feat: add persistent workbar browser tabs and scoped MCP routing

This commit is contained in:
mesalogo
2026-09-10 01:26:56 +08:00
parent 428b4c0d08
commit 80b0b18712
54 changed files with 5446 additions and 1468 deletions
+43 -6
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { browserTabIdSchema } from '../../shared/contracts'
import {
BrowserModelTools,
browserBackInputSchema,
@@ -42,12 +43,16 @@ function createService(): BrowserToolService {
const signal = new AbortController().signal
const ref = 'b_abcdefghijklmnop'
const browserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000101'
)
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const definitions = tools.listTools()
expect(definitions.map((definition) => definition.name)).toEqual([
@@ -93,7 +98,8 @@ describe('BrowserModelTools', () => {
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const approval = tools.getApproval('browser_navigate', {
url: 'https://example.com/path?token=top-secret'
@@ -115,7 +121,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const first = tools.getApproval('browser_type', {
ref,
@@ -139,7 +146,8 @@ describe('BrowserModelTools', () => {
'conversation',
ref,
'top-secret',
signal
signal,
browserTabId
)
expect(JSON.stringify(result)).not.toContain('top-secret')
expect(result.parts[0]).toMatchObject({
@@ -158,7 +166,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const navigate = await tools.callTool(
'browser_navigate',
@@ -189,12 +198,40 @@ describe('BrowserModelTools', () => {
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
expect(service.navigate).toHaveBeenCalledWith(
'conversation',
'https://example.com/',
signal,
browserTabId
)
expect(service.snapshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.click).toHaveBeenCalledWith(
'conversation',
ref,
signal,
browserTabId
)
expect(service.back).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.screenshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
expect(() =>
tools.getApproval('browser_click', { ref, extra: true })
+33 -8
View File
@@ -12,6 +12,7 @@ import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
import type { BrowserTabId } from '../../shared/contracts'
const MAX_REF_LENGTH = 64
@@ -195,6 +196,7 @@ export type BrowserToolService = Pick<
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
browserTabId: BrowserTabId
}
function createTextResult(value: unknown): ModelToolResult {
@@ -218,10 +220,12 @@ function navigationLabel(url: URL): string {
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
private readonly browserTabId: BrowserTabId
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
this.browserTabId = options.browserTabId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
@@ -249,7 +253,7 @@ export class BrowserModelTools {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
this.service.getOrigin(this.conversationId, this.browserTabId)
)
let description: string
let argumentSummary: string
@@ -320,18 +324,32 @@ export class BrowserModelTools {
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
await this.service.navigate(
this.conversationId,
input.url,
signal,
this.browserTabId
)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
await this.service.snapshot(
this.conversationId,
signal,
this.browserTabId
)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
await this.service.click(
this.conversationId,
input.ref,
signal,
this.browserTabId
)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
@@ -340,7 +358,8 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.text,
signal
signal,
this.browserTabId
)
return createTextResult({
typed: input.ref,
@@ -354,20 +373,26 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.value,
signal
signal,
this.browserTabId
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
await this.service.back(
this.conversationId,
signal,
this.browserTabId
)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
signal,
this.browserTabId
)
return {
parts: [
+314 -3
View File
@@ -31,6 +31,8 @@ function deferred<T>() {
function createHarness(options: {
maximumSessions?: number
maximumTabsPerConversation?: number
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
@@ -145,6 +147,8 @@ function createHarness(options: {
const service = new BrowserService({
policy: new BrowserUrlPolicy(dnsResolver),
maximumSessions: options.maximumSessions,
maximumTabsPerConversation: options.maximumTabsPerConversation,
maximumTabsPerWindow: options.maximumTabsPerWindow,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
@@ -165,12 +169,21 @@ afterEach(() => {
})
describe('BrowserService', () => {
const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
it('presents one session in the shared viewport and tracks user navigation', async () => {
const harness = createHarness()
const bounds = { x: 900, y: 120, width: 420, height: 640 }
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
harness.service.setViewport('conversation', bounds)
harness.service.setViewport(
'conversation',
bounds,
undefined,
firstLeaseToken
)
await harness.service.navigate(
'conversation',
@@ -196,7 +209,12 @@ describe('BrowserService', () => {
'https://example.com'
)
harness.service.setViewport()
harness.service.setViewport(
undefined,
undefined,
undefined,
firstLeaseToken
)
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
@@ -222,6 +240,7 @@ describe('BrowserService', () => {
expect(states.map((state) => state.status)).toEqual([
'creating',
'ready',
'loading',
'ready',
'acting',
@@ -242,7 +261,7 @@ describe('BrowserService', () => {
isLoading: false,
canGoBack: false
})
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
'data:image/jpeg;base64,/9j/2Q=='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
@@ -435,6 +454,253 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('owns independent tabs inside one conversation and closes only the requested tab', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://first.example/', signal)
const [primary] = harness.service.listTabs('conversation', 11)
const sibling = await harness.service.createTab('conversation', 11, signal)
await harness.service.navigate(
'conversation',
'https://second.example/',
signal,
sibling.tabId,
11
)
await harness.service.snapshot(
'conversation',
signal,
sibling.tabId,
11
)
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.service.getTabCount('conversation')).toBe(2)
expect(harness.slots[0]?.driver.snapshot).not.toHaveBeenCalled()
expect(harness.slots[1]?.driver.snapshot).toHaveBeenCalledOnce()
await harness.service.closeTab('conversation', sibling.tabId, 11)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
expect(harness.service.listTabs('conversation', 11)[0]?.tabId).toBe(
primary?.tabId
)
await harness.service.dispose()
})
it('blocks closing a leased tab until every request releases it', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal)
const sibling = await harness.service.createTab('conversation', 11, signal)
const first = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-one',
11
)
const second = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-two',
11
)
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
await expect(
harness.service.closeTab('conversation', sibling.tabId, 11)
).resolves.toBeUndefined()
first.release()
first.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
second.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).resolves.toBeUndefined()
await harness.service.dispose()
})
it('reports only the visible tab owned by the requesting window', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
expect(harness.service.getVisibleTabId('conversation', 21)).toBeUndefined()
harness.service.setViewport(
'conversation',
{ x: 0, y: 0, width: 320, height: 480 },
sibling.tabId,
firstLeaseToken,
21
)
expect(harness.service.getVisibleTabId('conversation', 21)).toBe(
sibling.tabId
)
expect(primary.tabId).not.toBe(sibling.tabId)
expect(() => harness.service.getVisibleTabId('conversation', 22)).toThrow(
'不属于当前窗口'
)
await harness.service.dispose()
})
it('aborts active tab leases when a conversation is force-released', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const lease = harness.service.acquireTabUsage(
'conversation',
tab.tabId,
'request',
21
)
await harness.service.releaseConversation('conversation', 21)
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getTabCount('conversation')).toBe(0)
lease.release()
await harness.service.dispose()
})
it('enforces conversation/window ownership, tab limits, and opaque viewport leases', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
const bounds = { x: 10, y: 20, width: 300, height: 400 }
expect(
harness.service.setViewport(
'conversation',
bounds,
sibling.tabId,
firstLeaseToken,
21
)
).toBe(true)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
expect(
harness.service.setViewport(
undefined,
undefined,
undefined,
secondLeaseToken,
21
)
).toBe(false)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
await expect(
harness.service.navigate(
'conversation',
'https://example.com/',
signal,
primary.tabId,
22
)
).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
const conversationLimited = createHarness({
maximumTabsPerConversation: 2
})
await conversationLimited.service.createTab('limited', 31, signal)
await conversationLimited.service.createTab('limited', 31, signal)
await expect(
conversationLimited.service.createTab('limited', 31, signal)
).rejects.toThrow('2 个上限')
await conversationLimited.service.dispose()
const windowLimited = createHarness({ maximumTabsPerWindow: 2 })
await windowLimited.service.createTab('first', 41, signal)
await windowLimited.service.createTab('second', 41, signal)
await expect(
windowLimited.service.createTab('third', 41, signal)
).rejects.toThrow('2 个上限')
await windowLimited.service.dispose()
})
it('returns one live tab for concurrent requests from the same workbar instance', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const signal = new AbortController().signal
const first = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
const duplicate = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
gate.resolve()
const [firstTab, duplicateTab] = await Promise.all([first, duplicate])
expect(duplicateTab.tabId).toBe(firstTab.tabId)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.service.getOwnerWindowId('conversation')).toBe(21)
await harness.service.dispose()
})
it('disposes a tab creation that resolves after its context is closed', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const gate = deferred<void>()
const implementation = harness.createSession.getMockImplementation()!
harness.createSession.mockImplementationOnce(async (...args) => {
await gate.promise
return implementation(...args)
})
const pending = harness.service.createTab(
'conversation',
21,
signal,
'c22e845b-4bc8-4246-9543-dd18dbf20782'
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledTimes(2))
await harness.service.closeTab('conversation', primary.tabId, 21)
gate.resolve()
await expect(pending).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('reserves window capacity while tab contexts are still being created', async () => {
const gate = deferred<void>()
const harness = createHarness({
maximumTabsPerWindow: 1,
sessionGate: gate.promise
})
const signal = new AbortController().signal
const first = harness.service.createTab('first', 51, signal)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
await expect(
harness.service.createTab('second', 51, signal)
).rejects.toThrow('1 个上限')
expect(harness.createSession).toHaveBeenCalledOnce()
gate.resolve()
await first
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
@@ -625,6 +891,42 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('does not expire a session while a serialized operation is active', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const gate = deferred<void>()
vi.mocked(harness.slots[0]!.driver.click).mockImplementationOnce(async () => gate.promise)
const click = harness.service.click('conversation', 'button_ref', signal)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
gate.resolve()
await click
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('ignores stale viewport cleanup tokens', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const bounds = { x: 1, y: 2, width: 300, height: 400 }
harness.service.setViewport('conversation', bounds, tab.tabId, firstLeaseToken, 21)
harness.service.setViewport('conversation', bounds, tab.tabId, secondLeaseToken, 21)
expect(
harness.service.setViewport(undefined, undefined, undefined, firstLeaseToken, 21)
).toBe(false)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(bounds)
expect(
harness.service.setViewport(undefined, undefined, undefined, secondLeaseToken, 21)
).toBe(true)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
@@ -905,10 +1207,19 @@ describe('BrowserService', () => {
'https://example.com/',
new AbortController().signal
)
const [tab] = harness.service.listTabs('conversation')
harness.service.setViewport(
'conversation',
{ x: 1, y: 2, width: 300, height: 400 },
tab!.tabId,
firstLeaseToken
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.listTabs('conversation')).toEqual([])
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,12 @@ import {
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve }
return { promise, resolve, reject }
}
function createHarness() {
@@ -348,6 +350,65 @@ describe('ElectronBrowserSession', () => {
)
})
it('creates sibling views in the same partition and cleans shared state after the final tab', async () => {
const first = createHarness()
const second = createHarness()
const createPartition = vi.fn(async () => first.partition)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition,
createView,
createProxy: () => first.proxy
})
const sibling = await root.createTab(new AbortController().signal)
expect(createPartition).toHaveBeenCalledOnce()
expect(sibling.partition).toBe(root.partition)
expect(
(createView.mock.calls[1]?.[0].webPreferences as Record<string, unknown>)
.partition
).toBe(root.partition)
await root.dispose()
expect(first.partition.clearData).not.toHaveBeenCalled()
expect(first.proxy.dispose).not.toHaveBeenCalled()
await sibling.dispose()
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('cleans shared state once when sibling creation fails after root disposal', async () => {
const first = createHarness()
const second = createHarness()
const setupGate = deferred<Record<string, never>>()
second.sendCommand.mockImplementationOnce(() => setupGate.promise)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition: async () => first.partition,
createView,
createProxy: () => first.proxy
})
const sibling = root.createTab(new AbortController().signal)
await vi.waitFor(() => expect(second.sendCommand).toHaveBeenCalled())
await root.dispose()
setupGate.reject(new Error('debugger failed'))
await expect(sibling).rejects.toThrow('无法创建浏览器标签页')
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
+125 -23
View File
@@ -126,6 +126,30 @@ type Listener = {
listener: BrowserEventListener
}
type SharedBrowserResources = {
partition: string
partitionSession: BrowserPartitionSession
proxy: FilteringProxyLike
createView: (options: Record<string, unknown>) => Promise<BrowserViewHandle>
setupTimeoutMs: number
cleanupTimeoutMs: number
references: number
cleanup?: Promise<void>
}
async function releaseSharedResources(
resources: SharedBrowserResources
): Promise<void> {
resources.references -= 1
if (resources.references !== 0) return
resources.cleanup ??= cleanupIsolatedState(
resources.partitionSession,
resources.proxy,
resources.cleanupTimeoutMs
)
await resources.cleanup
}
function managedBrowserUserAgent(): string {
const platform =
process.platform === 'win32'
@@ -243,14 +267,12 @@ export class ElectronBrowserSession {
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly view: BrowserViewHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number,
private readonly parentWindow?: BrowserParentWindowHandle
private readonly resources: SharedBrowserResources,
private readonly parentWindow?: BrowserParentWindowHandle,
private readonly listenForPartitionEvents = true
) {
this.partition = partition
this.partition = resources.partition
this.webContents = view.webContents
}
@@ -369,11 +391,16 @@ export class ElectronBrowserSession {
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
view,
managedProxy,
partition,
cleanupTimeoutMs,
{
partition,
partitionSession,
proxy: managedProxy,
createView,
setupTimeoutMs,
cleanupTimeoutMs,
references: 1
},
options.parentWindow
)
setupStage = '初始化浏览器协议'
@@ -480,14 +507,16 @@ export class ElectronBrowserSession {
this.emitNavigation(url)
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
if (this.listenForPartitionEvents) {
this.listen(
this.resources.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
}
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
@@ -625,6 +654,83 @@ export class ElectronBrowserSession {
this.approvedOrigin = target.origin
}
async createTab(signal: AbortSignal): Promise<ElectronBrowserSession> {
this.assertOpen()
const resources = this.resources
resources.references += 1
let view: BrowserViewHandle | undefined
let viewAttached = false
let tab: ElectronBrowserSession | undefined
try {
view = await boundedSetup(
resources.createView({
webPreferences: {
partition: resources.partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
backgroundThrottling: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
resources.setupTimeoutMs,
(lateView) => {
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.close?.({ waitForBeforeUnload: false })
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.destroy()
}
}
}
)
if (this.parentWindow?.contentView && view.nativeView) {
this.parentWindow.contentView.addChildView(view.nativeView)
viewAttached = true
}
if (!view.webContents.loadURL) {
throw new Error('浏览器视图导航不可用')
}
await boundedSetup(
view.webContents.loadURL('about:blank'),
signal,
resources.setupTimeoutMs
)
tab = new ElectronBrowserSession(
this.policy,
view,
resources,
this.parentWindow
)
await boundedSetup(tab.initialize(), signal, resources.setupTimeoutMs)
return tab
} catch (error) {
if (tab) {
await tab.dispose().catch(() => undefined)
} else {
if (view) {
if (viewAttached && view.nativeView) {
this.parentWindow?.contentView?.removeChildView(view.nativeView)
}
if (!view.webContents.isDestroyed()) {
view.webContents.close?.({ waitForBeforeUnload: false })
if (!view.webContents.isDestroyed()) {
view.webContents.destroy()
}
}
}
await releaseSharedResources(resources).catch(() => undefined)
}
throw new Error('无法创建浏览器标签页', { cause: error })
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
@@ -650,10 +756,6 @@ export class ElectronBrowserSession {
this.webContents.destroy()
}
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
await releaseSharedResources(this.resources)
}
}