import { randomUUID } from 'node:crypto' import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy' import { CdpBrowserDriver, type BrowserHistoryTarget, type BrowserNavigationMetadata, type BrowserSnapshot } from './cdp-browser-driver' import type { BrowserScreenshot } from './browser-screenshot' import { ElectronBrowserSession, type BrowserParentWindowHandle, type BrowserWindowBounds, type BrowserWebContents } from './electron-browser-session' import type { BrowserLiveState, BrowserTabId, BrowserTabSummary } from '../../shared/contracts' const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000 const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000 const DEFAULT_MAXIMUM_TABS_PER_CONVERSATION = 8 const DEFAULT_MAXIMUM_TABS_PER_WINDOW = 16 export class BrowserNavigationStoppedError extends Error { constructor() { super('浏览器导航已停止,可继续使用当前页面') this.name = 'BrowserNavigationStoppedError' } } export const BROWSER_TAB_IN_USE_ERROR = '浏览器标签页正在被活动请求使用,无法关闭' export type BrowserTabUsageLease = { readonly conversationId: string readonly tabId: BrowserTabId readonly owner: string readonly signal: AbortSignal release(): void } export type BrowserSessionLike = { readonly webContents: BrowserWebContents approveNavigation( target: Awaited> ): void createTab?(signal: AbortSignal): Promise getCurrentOrigin(): string | undefined isLoading(): boolean onLoadingChange(listener: (isLoading: boolean) => void): () => void onNavigationChange?(listener: (url: string) => void): () => void setViewport?(bounds?: BrowserWindowBounds): void stopLoading(): void captureScreenshot?(signal: AbortSignal): Promise dispose(): Promise } export type BrowserDriverLike = { navigate(url: string, signal: AbortSignal): Promise<{ url: string }> reload(signal: AbortSignal): Promise<{ url: string }> snapshot(signal: AbortSignal): Promise click(ref: string, signal: AbortSignal): Promise type(ref: string, text: string, signal: AbortSignal): Promise select(ref: string, value: string, signal: AbortSignal): Promise getBackTarget(signal: AbortSignal): Promise backTo( target: BrowserHistoryTarget, signal: AbortSignal ): Promise<{ url: string }> getNavigationMetadata( signal: AbortSignal ): Promise screenshot(signal: AbortSignal): Promise dispose(): void } export type BrowserServiceOptions = { policy?: BrowserUrlPolicy maximumSessions?: number maximumTabsPerConversation?: number maximumTabsPerWindow?: number idleTimeoutMs?: number cleanupTimeoutMs?: number parentWindow?: BrowserParentWindowHandle createSession?: ( policy: BrowserUrlPolicy, signal: AbortSignal ) => Promise createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike } type ActiveOperation = { controller: AbortController stopLoadingAllowed: boolean status: 'loading' | 'acting' | 'interactive' } type BrowserTabSlot = { conversationId: string tabId: BrowserTabId session: BrowserSessionLike driver: BrowserDriverLike createdAt: number origin?: string tail: Promise active?: ActiveOperation isLoading: boolean stopLoadingRequested: boolean removeLoadingListener: () => void removeNavigationListener: () => void released: boolean workbarInstanceId?: string usageLeases: Map } type BrowserConversationSlot = { conversationId: string ownerWindowId?: number tabs: Map primaryTabId: BrowserTabId tabCreationTail: Promise lifecycle: AbortController ownedTabs: Map idleTimer?: ReturnType lastUsedAt: number released: boolean } type ConversationCreation = { tabId: BrowserTabId controller: AbortController promise: Promise waiters: Set ownerWindowId?: number } type RequestTabReservation = Pick< BrowserTabSlot, 'conversationId' | 'tabId' | 'usageLeases' > & { ownerWindowId?: number } type ViewportLease = { leaseToken: string ownerWindowId?: number conversationId?: string tabId?: BrowserTabId bounds?: BrowserWindowBounds } type OwnedTabCreation = { conversationId: string controller: AbortController promise: Promise waiters: Set } function waitFor(promise: Promise, signal: AbortSignal): Promise { signal.throwIfAborted() return new Promise((resolve, reject) => { const abort = (): void => reject(signal.reason) signal.addEventListener('abort', abort, { once: true }) void promise.then( (value) => { signal.removeEventListener('abort', abort) resolve(value) }, (error: unknown) => { signal.removeEventListener('abort', abort) reject(error) } ) }) } async function boundedCleanup( cleanup: Promise, timeoutMs: number ): Promise { let timer: ReturnType | undefined try { await Promise.race([ cleanup, new Promise((_resolve, reject) => { timer = setTimeout( () => reject(new Error('浏览器会话清理超时')), timeoutMs ) }) ]) } finally { if (timer) clearTimeout(timer) } } async function defaultCreateSession( policy: BrowserUrlPolicy, signal: AbortSignal, parentWindow?: BrowserParentWindowHandle ): Promise { return ElectronBrowserSession.create( { policy, ...(parentWindow ? { parentWindow } : {}) }, signal ) } function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike { return new CdpBrowserDriver(webContents) } function createTabId(): BrowserTabId { return randomUUID() as BrowserTabId } function stateKey(conversationId: string, tabId: BrowserTabId): string { return `${conversationId}\u0000${tabId}` } function ownershipKey(conversationId: string, workbarInstanceId: string): string { return `${conversationId}\u0000${workbarInstanceId}` } export class BrowserService { private readonly policy: BrowserUrlPolicy private readonly maximumSessions: number private readonly maximumTabsPerConversation: number private readonly maximumTabsPerWindow: number private readonly idleTimeoutMs: number private readonly cleanupTimeoutMs: number private readonly createSession: NonNullable private readonly createDriver: NonNullable private readonly conversations = new Map() private readonly creations = new Map() private readonly releaseRequests = new Set() private readonly pendingConversationTabs = new Map() private readonly pendingWindowTabs = new Map() private readonly stateListeners = new Set<(state: BrowserLiveState) => void>() private readonly liveStates = new Map() private readonly ownedTabCreations = new Map() private readonly requestTabs = new Map() private viewport?: ViewportLease private lifecycle = new AbortController() private clearOperation?: Promise private clearing = false private disposed = false constructor(options: BrowserServiceOptions = {}) { this.policy = options.policy ?? new BrowserUrlPolicy() this.maximumSessions = options.maximumSessions ?? 3 this.maximumTabsPerConversation = options.maximumTabsPerConversation ?? DEFAULT_MAXIMUM_TABS_PER_CONVERSATION this.maximumTabsPerWindow = options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS this.createSession = options.createSession ?? ((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow)) this.createDriver = options.createDriver ?? defaultCreateDriver for (const value of [ this.maximumSessions, this.maximumTabsPerConversation, this.maximumTabsPerWindow, this.idleTimeoutMs, this.cleanupTimeoutMs ]) { if (!Number.isSafeInteger(value) || value < 1) { throw new Error('浏览器服务限制配置无效') } } } getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined { const conversation = this.conversations.get(conversationId) return conversation?.tabs.get(tabId ?? conversation.primaryTabId)?.origin } getSessionCount(): number { return this.conversations.size } getTabCount(conversationId?: string): number { if (conversationId) return this.conversations.get(conversationId)?.tabs.size ?? 0 let count = 0 for (const conversation of this.conversations.values()) count += conversation.tabs.size return count } getOwnerWindowId(conversationId: string): number | undefined { return this.conversations.get(conversationId)?.ownerWindowId ?? this.creations.get(conversationId)?.ownerWindowId ?? this.requestTabs.get(conversationId)?.ownerWindowId } onState(listener: (state: BrowserLiveState) => void): () => void { this.stateListeners.add(listener) for (const state of this.liveStates.values()) listener(state) return () => this.stateListeners.delete(listener) } setViewport( conversationId?: string, bounds?: BrowserWindowBounds, tabId?: BrowserTabId, leaseToken?: string, ownerWindowId?: number ): boolean { if (!leaseToken) { throw new Error('浏览器视口租约无效') } if (!conversationId && this.viewport?.leaseToken !== leaseToken) return false if (!conversationId && ownerWindowId !== undefined && this.viewport?.ownerWindowId !== ownerWindowId) return false let effectiveTabId = tabId if (conversationId && bounds) { const conversation = this.conversations.get(conversationId) if (conversation) { this.assertWindowOwner(conversation, ownerWindowId) effectiveTabId ??= conversation.primaryTabId if (!conversation.tabs.has(effectiveTabId)) { throw new Error('浏览器标签页不存在或不属于当前对话') } } } else if (conversationId || bounds || tabId) { throw new Error('浏览器视口参数不完整') } this.viewport = { leaseToken, ...(ownerWindowId !== undefined ? { ownerWindowId } : {}), ...(conversationId ? { conversationId } : {}), ...(effectiveTabId ? { tabId: effectiveTabId } : {}), ...(bounds ? { bounds } : {}) } this.applyViewport() return true } private applyViewport(): void { for (const conversation of this.conversations.values()) { for (const tab of conversation.tabs.values()) { const visible = this.viewport?.conversationId === conversation.conversationId && (this.viewport.tabId ?? conversation.primaryTabId) === tab.tabId tab.session.setViewport?.(visible ? this.viewport?.bounds : undefined) } } } private emitState( tab: Pick, status: BrowserLiveState['status'], update: Partial> = {} ): void { const key = stateKey(tab.conversationId, tab.tabId) const previous = this.liveStates.get(key) const current = this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId) const state: BrowserLiveState = { conversationId: tab.conversationId, tabId: tab.tabId, ...((tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId) ? { workbarInstanceId: tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId } : {}), ...(current ? this.conversations.get(tab.conversationId)?.ownerWindowId !== undefined ? { ownerWindowId: this.conversations.get(tab.conversationId)!.ownerWindowId } : {} : previous?.ownerWindowId !== undefined ? { ownerWindowId: previous.ownerWindowId } : this.creations.get(tab.conversationId)?.ownerWindowId !== undefined ? { ownerWindowId: this.creations.get(tab.conversationId)!.ownerWindowId } : {}), status, sessionActive: status !== 'creating' && status !== 'stopped' && Boolean(current && !current.released), isLoading: update.isLoading ?? (status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false), canGoBack: update.canGoBack ?? previous?.canGoBack ?? false, ...(previous?.url ? { url: previous.url } : {}), ...update, updatedAt: Date.now() } if (status !== 'failed') delete state.error if (status === 'stopped') this.liveStates.delete(key) else this.liveStates.set(key, state) for (const listener of this.stateListeners) { try { listener(state) } catch { // UI observers cannot interrupt browser control. } } } private assertWindowOwner( conversation: { ownerWindowId?: number }, ownerWindowId?: number ): void { if (ownerWindowId === undefined) return if (conversation.ownerWindowId === undefined) { conversation.ownerWindowId = ownerWindowId return } if (conversation.ownerWindowId !== ownerWindowId) { throw new Error('浏览器对话不属于当前窗口') } } private countWindowTabs(ownerWindowId: number): number { let count = 0 for (const conversation of this.conversations.values()) { if (conversation.ownerWindowId === ownerWindowId) count += conversation.tabs.size } return count } private assertTabCapacity( conversation: BrowserConversationSlot | undefined, ownerWindowId?: number ): void { if ( conversation && conversation.tabs.size + (this.pendingConversationTabs.get(conversation.conversationId) ?? 0) >= this.maximumTabsPerConversation ) { throw new Error(`当前对话的浏览器标签页已达到 ${this.maximumTabsPerConversation} 个上限`) } if ( ownerWindowId !== undefined && this.countWindowTabs(ownerWindowId) + (this.pendingWindowTabs.get(ownerWindowId) ?? 0) >= this.maximumTabsPerWindow ) { throw new Error(`当前窗口的浏览器标签页已达到 ${this.maximumTabsPerWindow} 个上限`) } } private reserveTab(conversationId: string, ownerWindowId?: number): () => void { const conversation = this.conversations.get(conversationId) this.assertTabCapacity(conversation, ownerWindowId) this.pendingConversationTabs.set( conversationId, (this.pendingConversationTabs.get(conversationId) ?? 0) + 1 ) if (ownerWindowId !== undefined) { this.pendingWindowTabs.set( ownerWindowId, (this.pendingWindowTabs.get(ownerWindowId) ?? 0) + 1 ) } return () => { const conversationPending = (this.pendingConversationTabs.get(conversationId) ?? 1) - 1 if (conversationPending > 0) { this.pendingConversationTabs.set(conversationId, conversationPending) } else { this.pendingConversationTabs.delete(conversationId) } if (ownerWindowId !== undefined) { const windowPending = (this.pendingWindowTabs.get(ownerWindowId) ?? 1) - 1 if (windowPending > 0) { this.pendingWindowTabs.set(ownerWindowId, windowPending) } else { this.pendingWindowTabs.delete(ownerWindowId) } } } } private async createTabSlot( conversation: BrowserConversationSlot | undefined, conversationId: string, session: BrowserSessionLike, tabId: BrowserTabId, workbarInstanceId?: string ): Promise { let driver: BrowserDriverLike try { driver = this.createDriver(session.webContents) } catch (error) { await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(() => undefined) throw error } const tab: BrowserTabSlot = { conversationId, tabId, session, driver, createdAt: Date.now(), tail: Promise.resolve(), isLoading: session.isLoading(), stopLoadingRequested: false, removeLoadingListener: () => undefined, removeNavigationListener: () => undefined, released: false, usageLeases: new Map(), ...(workbarInstanceId ? { workbarInstanceId } : {}) } tab.removeLoadingListener = session.onLoadingChange((isLoading) => { if (!isLoading) tab.stopLoadingRequested = false if (tab.released || tab.isLoading === isLoading || !this.hasTab(tab)) return tab.isLoading = isLoading const current = this.liveStates.get(stateKey(conversationId, tabId)) if (current) { const status = tab.active?.status ?? (isLoading ? 'loading' : current.status === 'loading' ? 'ready' : current.status) this.emitState(tab, status, { isLoading }) } }) tab.removeNavigationListener = session.onNavigationChange?.((url) => { if (!this.hasTab(tab)) return try { const target = canonicalizeBrowserUrl(url) tab.origin = target.origin this.touchConversation(conversationId) this.emitState(tab, tab.active?.status ?? 'ready', { url: target.href }) void tab.driver .getNavigationMetadata(AbortSignal.timeout(2_000)) .then((metadata) => { if (!this.hasTab(tab) || canonicalizeBrowserUrl(metadata.url).href !== target.href) return this.emitState(tab, tab.active?.status ?? 'ready', { url: target.href, canGoBack: metadata.canGoBack }) }) .catch(() => undefined) } catch { // Unsupported top-level URLs are rejected by the session. } }) ?? (() => undefined) if (conversation) conversation.tabs.set(tabId, tab) return tab } private hasTab(tab: BrowserTabSlot): boolean { return !tab.released && this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId) === tab } private async getOrCreateConversation( conversationId: string, signal: AbortSignal, ownerWindowId?: number, workbarInstanceId?: string, requestTabId?: BrowserTabId ): Promise { signal.throwIfAborted() if (this.disposed || this.clearing || this.lifecycle.signal.aborted) { throw new Error('浏览器服务已关闭') } const requestTab = this.requestTabs.get(conversationId) if (requestTab) { this.assertWindowOwner(requestTab, ownerWindowId) ownerWindowId ??= requestTab.ownerWindowId } const existing = this.conversations.get(conversationId) if (existing && !existing.released) { this.assertWindowOwner(existing, ownerWindowId) return existing } let creation = this.creations.get(conversationId) const pendingCreations = [...this.creations.keys()].filter((id) => !this.conversations.has(id)).length if (!creation && this.conversations.size + pendingCreations >= this.maximumSessions) { throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`) } if (creation && ownerWindowId !== undefined && creation.ownerWindowId !== undefined && creation.ownerWindowId !== ownerWindowId) { throw new Error('浏览器对话不属于当前窗口') } if (!creation) { const releaseReservation = this.reserveTab(conversationId, ownerWindowId) const controller = new AbortController() const waiters = new Set() const tabId = requestTabId ?? createTabId() const instanceId = workbarInstanceId ?? tabId const promise = this.createConversation( conversationId, tabId, ownerWindowId, AbortSignal.any([this.lifecycle.signal, controller.signal]), () => waiters.size > 0, instanceId ) void promise.finally(releaseReservation).catch(() => undefined) creation = { tabId, controller, promise, waiters, ownerWindowId } this.creations.set(conversationId, creation) this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'creating') const current = creation const remove = (): void => { if (this.creations.get(conversationId) === current) this.creations.delete(conversationId) } void promise.then(remove, () => { remove() if (this.liveStates.get(stateKey(conversationId, tabId))?.status === 'creating') { this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'stopped') } }) } const waiter = Symbol(conversationId) creation.waiters.add(waiter) try { const result = await waitFor(creation.promise, signal) this.assertWindowOwner(result, ownerWindowId) return result } finally { creation.waiters.delete(waiter) if (creation.waiters.size === 0 && this.creations.get(conversationId) === creation) { this.creations.delete(conversationId) creation.controller.abort(new Error('浏览器会话创建已取消')) } } } private async createConversation( conversationId: string, tabId: BrowserTabId, ownerWindowId: number | undefined, signal: AbortSignal, hasWaiters: () => boolean, workbarInstanceId: string ): Promise { const session = await this.createSession(this.policy, signal) if (this.disposed || signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) { await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) throw new Error('浏览器服务已关闭') } const conversation: BrowserConversationSlot = { conversationId, ...(ownerWindowId !== undefined ? { ownerWindowId } : {}), tabs: new Map(), primaryTabId: tabId, tabCreationTail: Promise.resolve(), lifecycle: new AbortController(), ownedTabs: new Map(), lastUsedAt: Date.now(), released: false } const tab = await this.createTabSlot(undefined, conversationId, session, tabId, workbarInstanceId) if (signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) { tab.removeLoadingListener() tab.removeNavigationListener() tab.driver.dispose() await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) throw signal.reason ?? new Error('浏览器会话创建已取消') } this.adoptRequestTab(conversation, tab) conversation.ownedTabs.set(workbarInstanceId, tabId) conversation.tabs.set(tabId, tab) this.conversations.set(conversationId, conversation) this.emitState(tab, 'ready') this.scheduleIdleExpiry(conversation) this.applyViewport() return conversation } private adoptRequestTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): void { const reservation = this.requestTabs.get(conversation.conversationId) if (reservation?.tabId !== tab.tabId) return tab.usageLeases = reservation.usageLeases conversation.ownerWindowId ??= reservation.ownerWindowId this.requestTabs.delete(conversation.conversationId) } async createTab( conversationId: string, ownerWindowId?: number, signal: AbortSignal = new AbortController().signal, workbarInstanceId?: string ): Promise { if (workbarInstanceId) { const key = ownershipKey(conversationId, workbarInstanceId) const existingConversation = this.conversations.get(conversationId) if (existingConversation) this.assertWindowOwner(existingConversation, ownerWindowId) const pendingConversation = this.creations.get(conversationId) if (pendingConversation) this.assertWindowOwner(pendingConversation, ownerWindowId) const existingTabId = existingConversation?.ownedTabs.get(workbarInstanceId) const existingTab = existingTabId ? existingConversation?.tabs.get(existingTabId) : undefined if (existingTab && !existingTab.released) return this.summarizeTab(existingTab) let creation = this.ownedTabCreations.get(key) if (!creation) { const controller = new AbortController() const waiters = new Set() const promise = this.createTabInternal( conversationId, ownerWindowId, AbortSignal.any([this.lifecycle.signal, controller.signal]), workbarInstanceId ) creation = { conversationId, controller, promise, waiters } this.ownedTabCreations.set(key, creation) const current = creation void promise.finally(() => { if (this.ownedTabCreations.get(key) === current) this.ownedTabCreations.delete(key) }).catch(() => undefined) } const waiter = Symbol(workbarInstanceId) creation.waiters.add(waiter) try { return await waitFor(creation.promise, signal) } finally { creation.waiters.delete(waiter) if (creation.waiters.size === 0 && this.ownedTabCreations.get(key) === creation) { creation.controller.abort(new Error('浏览器标签页创建已取消')) } } } return this.createTabInternal(conversationId, ownerWindowId, signal) } private async createTabInternal( conversationId: string, ownerWindowId: number | undefined, signal: AbortSignal, workbarInstanceId?: string ): Promise { const existed = this.conversations.has(conversationId) || this.creations.has(conversationId) const reservation = this.requestTabs.get(conversationId) const requestTabId = reservation && reservation.tabId === workbarInstanceId ? reservation.tabId : undefined const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId, workbarInstanceId, requestTabId) if (workbarInstanceId) { const ownedTabId = conversation.ownedTabs.get(workbarInstanceId) const ownedTab = ownedTabId ? conversation.tabs.get(ownedTabId) : undefined if (ownedTab && !ownedTab.released) return this.summarizeTab(ownedTab) } else if (!existed) { return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!) } this.assertWindowOwner(conversation, ownerWindowId) const releaseReservation = this.reserveTab(conversationId, ownerWindowId) let releaseGate!: () => void const gate = new Promise((resolve) => { releaseGate = resolve }) const predecessor = conversation.tabCreationTail.catch(() => undefined) conversation.tabCreationTail = predecessor.then(() => gate) try { await waitFor(predecessor, signal) const primary = conversation.tabs.get(conversation.primaryTabId) if (!primary || conversation.released) throw new Error('浏览器会话已关闭') const effectiveSignal = AbortSignal.any([signal, this.lifecycle.signal, conversation.lifecycle.signal]) const session = primary.session.createTab ? await primary.session.createTab(effectiveSignal) : await this.createSession(this.policy, effectiveSignal) if (effectiveSignal.aborted || conversation.released || this.conversations.get(conversationId) !== conversation) { await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) throw effectiveSignal.reason ?? new Error('浏览器会话已关闭') } const tabId = requestTabId ?? createTabId() this.emitState({ conversationId, tabId, workbarInstanceId }, 'creating') const tab = await this.createTabSlot(conversation, conversationId, session, tabId, workbarInstanceId) if (effectiveSignal.aborted || conversation.released) { await this.releaseTab(conversation, tab) throw effectiveSignal.reason ?? new Error('浏览器会话已关闭') } this.adoptRequestTab(conversation, tab) if (workbarInstanceId) conversation.ownedTabs.set(workbarInstanceId, tabId) this.touchConversation(conversationId) this.emitState(tab, 'ready') this.applyViewport() return this.summarizeTab(tab) } finally { releaseGate() releaseReservation() } } listTabs(conversationId: string, ownerWindowId?: number): BrowserTabSummary[] { const conversation = this.conversations.get(conversationId) if (!conversation || conversation.released) return [] this.assertWindowOwner(conversation, ownerWindowId) return [...conversation.tabs.values()].map((tab) => this.summarizeTab(tab)) } getVisibleTabId( conversationId: string, ownerWindowId?: number ): BrowserTabId | undefined { const conversation = this.conversations.get(conversationId) if (!conversation || conversation.released) return undefined this.assertWindowOwner(conversation, ownerWindowId) if ( this.viewport?.conversationId !== conversationId || (ownerWindowId !== undefined && this.viewport.ownerWindowId !== ownerWindowId) ) { return undefined } const tabId = this.viewport.tabId ?? conversation.primaryTabId return conversation.tabs.has(tabId) ? tabId : undefined } reserveRequestTab( conversationId: string, owner: string, ownerWindowId?: number ): BrowserTabUsageLease { if (this.disposed || this.clearing || this.releaseRequests.has(conversationId)) { throw new Error('浏览器服务已关闭') } if (!owner || owner.length > 500) { throw new Error('浏览器标签页使用租约所有者无效') } const conversation = this.conversations.get(conversationId) let reservation = this.requestTabs.get(conversationId) if (!reservation && conversation && !conversation.released) { return this.acquireTabUsage(conversationId, conversation.primaryTabId, owner, ownerWindowId) } if (!reservation) { const creation = this.creations.get(conversationId) if (creation) this.assertWindowOwner(creation, ownerWindowId) reservation = { conversationId, tabId: creation?.tabId ?? createTabId(), ownerWindowId: ownerWindowId ?? creation?.ownerWindowId, usageLeases: new Map() } this.requestTabs.set(conversationId, reservation) } return this.acquireTabUsage(conversationId, reservation.tabId, owner, ownerWindowId) } acquireTabUsage( conversationId: string, tabId: BrowserTabId, owner: string, ownerWindowId?: number ): BrowserTabUsageLease { if (!owner || owner.length > 500) { throw new Error('浏览器标签页使用租约所有者无效') } const conversation = this.conversations.get(conversationId) const reservation = this.requestTabs.get(conversationId) if (conversation) this.assertWindowOwner(conversation, ownerWindowId) if (reservation) this.assertWindowOwner(reservation, ownerWindowId) const tab = conversation?.tabs.get(tabId) ?? (reservation?.tabId === tabId ? reservation : undefined) if (!tab || conversation?.released) { throw new Error('浏览器标签页不存在或不属于当前对话') } const token = Symbol(owner) const controller = new AbortController() tab.usageLeases.set(token, { owner, controller }) if (conversation?.idleTimer) { clearTimeout(conversation.idleTimer) conversation.idleTimer = undefined } let released = false return { conversationId, tabId, owner, signal: controller.signal, release: (): void => { if (released) return released = true if (tab.usageLeases.delete(token)) { if (this.conversations.get(conversationId)?.tabs.has(tabId)) { this.touchConversation(conversationId) } else if ( tab.usageLeases.size === 0 && this.requestTabs.get(conversationId) === tab ) { this.requestTabs.delete(conversationId) const creation = this.creations.get(conversationId) if (creation?.tabId === tabId) { creation.controller.abort(new Error('浏览器会话创建已取消')) } this.ownedTabCreations.get(ownershipKey(conversationId, tabId))?.controller.abort( new Error('浏览器标签页创建已取消') ) } } controller.abort(new Error('浏览器标签页使用租约已终止')) } } } private summarizeTab(tab: BrowserTabSlot): BrowserTabSummary { const state = this.liveStates.get(stateKey(tab.conversationId, tab.tabId)) return { conversationId: tab.conversationId, tabId: tab.tabId, ...(tab.workbarInstanceId ? { workbarInstanceId: tab.workbarInstanceId } : {}), primary: this.conversations.get(tab.conversationId)?.primaryTabId === tab.tabId, status: state?.status && state.status !== 'stopped' ? state.status : 'ready', isLoading: tab.isLoading, canGoBack: state?.canGoBack ?? false, ...(state?.url ? { url: state.url } : {}), createdAt: tab.createdAt, updatedAt: state?.updatedAt ?? tab.createdAt } } async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise { const conversation = this.conversations.get(conversationId) if (conversation) this.assertWindowOwner(conversation, ownerWindowId) const reservation = this.requestTabs.get(conversationId) if (reservation) this.assertWindowOwner(reservation, ownerWindowId) const creation = this.creations.get(conversationId) if (creation) this.assertWindowOwner(creation, ownerWindowId) const tab = conversation?.tabs.get(tabId) if (!tab) { if ( [...this.conversations.values()].some((slot) => slot.tabs.has(tabId)) || [...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) || [...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId) ) throw new Error('浏览器标签页不存在或不属于当前对话') if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) { throw new Error(BROWSER_TAB_IN_USE_ERROR) } return } if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR) await this.releaseTab(conversation!, tab) } private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot { if (this.disposed) throw new Error('浏览器服务已关闭') const conversation = this.conversations.get(conversationId) if (!conversation || conversation.released) throw new Error('当前对话尚未建立浏览器会话,请先导航') this.assertWindowOwner(conversation, ownerWindowId) const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) if (!tab || tab.released || !tab.origin) throw new Error('当前浏览器标签页尚未导航') return tab } private scheduleIdleExpiry(conversation: BrowserConversationSlot): void { if (conversation.idleTimer) clearTimeout(conversation.idleTimer) if (conversation.released || this.disposed) return conversation.idleTimer = setTimeout(() => { if (Date.now() - conversation.lastUsedAt < this.idleTimeoutMs) { this.scheduleIdleExpiry(conversation) return } if ( [...conversation.tabs.values()].some( (tab) => tab.usageLeases.size > 0 ) ) { this.scheduleIdleExpiry(conversation) return } void this.releaseConversationSlot(conversation).catch(() => undefined) }, this.idleTimeoutMs) } private touchConversation(conversationId: string): void { const conversation = this.conversations.get(conversationId) if (!conversation) return conversation.lastUsedAt = Date.now() if ([...conversation.tabs.values()].some((tab) => tab.active)) { if (conversation.idleTimer) clearTimeout(conversation.idleTimer) conversation.idleTimer = undefined return } this.scheduleIdleExpiry(conversation) } private async serialize( tab: BrowserTabSlot, signal: AbortSignal, operation: (effectiveSignal: AbortSignal) => Promise, status: ActiveOperation['status'], stopLoadingAllowed: boolean ): Promise { signal.throwIfAborted() if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭') let releaseGate!: () => void const gate = new Promise((resolve) => { releaseGate = resolve }) const predecessor = tab.tail.catch(() => undefined) tab.tail = predecessor.then(() => gate) let operationController: AbortController | undefined try { await waitFor(predecessor, signal) if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭') const conversation = this.conversations.get(tab.conversationId) if (conversation?.idleTimer) { clearTimeout(conversation.idleTimer) conversation.idleTimer = undefined } this.emitState(tab, status) tab.stopLoadingRequested = false operationController = new AbortController() tab.active = { controller: operationController, stopLoadingAllowed, status } return await operation(AbortSignal.any([signal, this.lifecycle.signal, operationController.signal])) } finally { if (operationController && tab.active?.controller === operationController) tab.active = undefined releaseGate() if (operationController) this.touchConversation(tab.conversationId) } } private verifyCurrentOrigin(tab: BrowserTabSlot): string { const current = tab.session.getCurrentOrigin() if (!tab.origin || current !== tab.origin) throw new Error('浏览器页面来源已改变,会话已被拒绝') return current } private async verifyCurrentOriginOrRelease(tab: BrowserTabSlot): Promise { try { return this.verifyCurrentOrigin(tab) } catch (error) { const conversation = this.conversations.get(tab.conversationId) if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw error } } private shouldEmitFailure(conversationId: string, signal: AbortSignal, error: unknown): boolean { if (error instanceof BrowserNavigationStoppedError || signal.aborted || this.releaseRequests.has(conversationId)) return false const message = error instanceof Error ? error.message : '' return !['浏览器会话已释放', '浏览器会话已清除', '浏览器会话已关闭', '浏览器服务已关闭'].some((reason) => message.includes(reason)) } private emitFailure(tab: Pick, stage: string, error: unknown): void { const detail = error instanceof Error && error.message ? error.message.slice(0, 180) : '未知错误' this.emitState(tab, 'failed', { error: `${stage}失败:${detail}`.slice(0, 240) }) } private async runInTab( conversationId: string, signal: AbortSignal, status: ActiveOperation['status'], stopLoadingAllowed: boolean, failureStage: string, operation: (tab: BrowserTabSlot, effectiveSignal: AbortSignal) => Promise, tabId?: BrowserTabId, ownerWindowId?: number ): Promise { let tab: BrowserTabSlot | undefined try { tab = this.requireTab(conversationId, tabId, ownerWindowId) return await this.serialize(tab, signal, (effectiveSignal) => operation(tab!, effectiveSignal), status, stopLoadingAllowed) } catch (error) { if (error instanceof BrowserNavigationStoppedError && tab && status !== 'loading') this.recoverStoppedNavigation(tab) if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, failureStage, error) throw error } } async navigate( conversationId: string, url: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number ): Promise<{ url: string; origin: string }> { let tab: BrowserTabSlot | undefined try { if (tabId) { const knownTab = this.conversations.get(conversationId)?.tabs.has(tabId) || this.requestTabs.get(conversationId)?.tabId === tabId || this.creations.get(conversationId)?.tabId === tabId if (!knownTab) throw new Error('浏览器标签页不存在或不属于当前对话') if (this.requestTabs.get(conversationId)?.tabId === tabId) { await this.createTab(conversationId, ownerWindowId, signal, tabId) } } const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId) tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) if (!tab || tab.released) throw new Error('浏览器标签页不存在或不属于当前对话') return await this.serialize(tab, signal, async (effectiveSignal) => { const target = await this.policy.validate(url, effectiveSignal) tab!.session.approveNavigation(target) return this.completeNavigation(tab!, effectiveSignal, () => tab!.driver.navigate(target.url.href, effectiveSignal), '浏览器导航结果来源不一致') }, 'loading', true) } catch (error) { if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, '浏览器导航', error) throw error } } async snapshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { return this.runInTab(conversationId, signal, 'acting', false, '读取浏览器页面', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) const snapshot = await tab.driver.snapshot(effectiveSignal) const target = canonicalizeBrowserUrl(snapshot.url) if (target.origin !== tab.origin) { const conversation = this.conversations.get(conversationId) if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw new Error('浏览器快照来源与当前会话不一致') } await this.refreshNavigationState(tab, effectiveSignal, target.href) return { ...snapshot, url: target.href } }, tabId, ownerWindowId) } async click(conversationId: string, ref: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.click(ref, effectiveSignal) await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } async type(conversationId: string, ref: string, text: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.type(ref, text, effectiveSignal) await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } async select(conversationId: string, ref: string, value: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) await tab.driver.select(ref, value, effectiveSignal) await this.refreshNavigationState(tab, effectiveSignal) }, tabId, ownerWindowId) } async back(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> { return this.runInTab(conversationId, signal, 'loading', true, '浏览器返回', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) const historyTarget = await tab.driver.getBackTarget(effectiveSignal) const target = await this.policy.validate(historyTarget.url, effectiveSignal) tab.session.approveNavigation(target) return this.completeNavigation(tab, effectiveSignal, () => tab.driver.backTo(historyTarget, effectiveSignal), '浏览器返回结果来源不一致') }, tabId, ownerWindowId) } async reload(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> { return this.runInTab(conversationId, signal, 'loading', true, '浏览器刷新', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) return this.completeNavigation(tab, effectiveSignal, () => tab.driver.reload(effectiveSignal), '浏览器刷新结果来源不一致') }, tabId, ownerWindowId) } async screenshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { return this.runInTab(conversationId, signal, 'acting', false, '浏览器截图', async (tab, effectiveSignal) => { await this.verifyCurrentOriginOrRelease(tab) let screenshot: BrowserScreenshot | undefined if (tab.session.captureScreenshot) { try { screenshot = await tab.session.captureScreenshot(effectiveSignal) } catch { effectiveSignal.throwIfAborted() } } screenshot ??= await tab.driver.screenshot(effectiveSignal) await this.refreshNavigationState(tab, effectiveSignal) return screenshot }, tabId, ownerWindowId) } async stopLoading(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): Promise { const conversation = this.conversations.get(conversationId) if (!conversation) return false this.assertWindowOwner(conversation, ownerWindowId) const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) const active = tab?.active if (!tab || tab.released || tab.stopLoadingRequested) return false if (active) { if (!active.stopLoadingAllowed || active.controller.signal.aborted || (active.status !== 'loading' && !tab.isLoading && !tab.session.isLoading())) return false tab.isLoading = false tab.stopLoadingRequested = true tab.session.stopLoading() active.controller.abort(new BrowserNavigationStoppedError()) return true } if (!tab.isLoading && !tab.session.isLoading()) return false tab.isLoading = false tab.stopLoadingRequested = true tab.session.stopLoading() this.recoverStoppedNavigation(tab) return true } private recoverStoppedNavigation(tab: BrowserTabSlot): void { if (!this.hasTab(tab)) return tab.isLoading = false try { const target = canonicalizeBrowserUrl(tab.session.webContents.getURL()) if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器停止导航后的页面来源不一致') tab.origin = target.origin this.emitState(tab, 'ready', { url: target.href, isLoading: false }) } catch { if (this.hasTab(tab)) this.emitState(tab, 'ready', { isLoading: false }) } } private async completeNavigation( tab: BrowserTabSlot, signal: AbortSignal, operation: () => Promise<{ url: string }>, originMismatchMessage: string ): Promise<{ url: string; origin: string }> { try { const result = await operation() const finalTarget = await this.policy.validateRedirect(result.url, signal) if (tab.session.getCurrentOrigin() !== finalTarget.origin) throw new Error(originMismatchMessage) tab.origin = finalTarget.origin await this.refreshNavigationState(tab, signal, finalTarget.url.href) return { url: finalTarget.url.href, origin: finalTarget.origin } } catch (error) { if (error instanceof BrowserNavigationStoppedError) { this.recoverStoppedNavigation(tab) throw error } const conversation = this.conversations.get(tab.conversationId) if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw error } } private async refreshNavigationState( tab: BrowserTabSlot, signal: AbortSignal, url?: string ): Promise { let committedUrl = url const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId)) let canGoBack = previous?.canGoBack ?? false try { const metadata = await tab.driver.getNavigationMetadata(signal) const target = canonicalizeBrowserUrl(metadata.url) if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器当前页面来源不一致') tab.origin = target.origin committedUrl = target.href canGoBack = metadata.canGoBack } catch { signal.throwIfAborted() } signal.throwIfAborted() if (!this.hasTab(tab)) return this.emitState(tab, 'ready', { ...(committedUrl ? { url: committedUrl } : {}), canGoBack }) } async releaseConversation(conversationId: string, ownerWindowId?: number): Promise { const ownedConversation = this.conversations.get(conversationId) if (ownedConversation) this.assertWindowOwner(ownedConversation, ownerWindowId) const reservation = this.requestTabs.get(conversationId) if (reservation) this.assertWindowOwner(reservation, ownerWindowId) const creation = this.creations.get(conversationId) if ( ownerWindowId !== undefined && creation?.ownerWindowId !== undefined && creation.ownerWindowId !== ownerWindowId ) { throw new Error('浏览器对话不属于当前窗口') } this.releaseRequests.add(conversationId) this.releaseRequestTab(conversationId) let emitted = false try { const pendingCreation = this.creations.get(conversationId) if (pendingCreation) { pendingCreation.controller.abort(new Error('浏览器会话已释放')) const created = await pendingCreation.promise.catch(() => undefined) if (created) { await this.releaseConversationSlot(created) emitted = true } } const conversation = this.conversations.get(conversationId) if (conversation) { await this.releaseConversationSlot(conversation) emitted = true } const ownedCreations = [...this.ownedTabCreations.values()].filter( (creation) => creation.conversationId === conversationId ) for (const creation of ownedCreations) { creation.controller.abort(new Error('浏览器会话已释放')) } await Promise.allSettled(ownedCreations.map((creation) => creation.promise)) if (!emitted) { for (const state of [...this.liveStates.values()].filter((item) => item.conversationId === conversationId)) { if (state.tabId) this.emitState({ ...state, tabId: state.tabId }, 'stopped') } } } finally { if (!this.disposed) this.releaseRequests.delete(conversationId) } } private releaseRequestTab(conversationId: string): void { const reservation = this.requestTabs.get(conversationId) if (!reservation) return this.requestTabs.delete(conversationId) for (const lease of reservation.usageLeases.values()) { lease.controller.abort(new Error('浏览器标签页使用租约已终止')) } reservation.usageLeases.clear() } private async releaseTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): Promise { if (tab.released) return tab.released = true conversation.tabs.delete(tab.tabId) tab.removeLoadingListener() tab.removeNavigationListener() tab.active?.controller.abort(new Error('浏览器会话已释放')) for (const lease of tab.usageLeases.values()) { lease.controller.abort(new Error('浏览器标签页使用租约已终止')) } tab.usageLeases.clear() if (tab.workbarInstanceId) conversation.ownedTabs.delete(tab.workbarInstanceId) if (conversation.tabs.size === 0) { conversation.released = true conversation.lifecycle.abort(new Error('浏览器会话已释放')) if (conversation.idleTimer) clearTimeout(conversation.idleTimer) this.conversations.delete(conversation.conversationId) } else if (conversation.primaryTabId === tab.tabId) { conversation.primaryTabId = conversation.tabs.keys().next().value! } if (this.viewport?.conversationId === conversation.conversationId && this.viewport.tabId === tab.tabId) { try { tab.session.setViewport?.(undefined) } catch { // The service state is still cleared when the native view is already gone. } this.viewport = undefined this.applyViewport() } let disposalError: unknown try { tab.driver.dispose() } catch (error) { disposalError = error } try { await boundedCleanup(tab.session.dispose(), this.cleanupTimeoutMs) } catch (error) { disposalError ??= error } finally { this.emitState(tab, 'stopped') } if (disposalError) throw disposalError } private async releaseConversationSlot(conversation: BrowserConversationSlot): Promise { if (conversation.released) return conversation.released = true conversation.lifecycle.abort(new Error('浏览器会话已释放')) this.conversations.delete(conversation.conversationId) if (conversation.idleTimer) clearTimeout(conversation.idleTimer) const tabs = [...conversation.tabs.values()] await Promise.allSettled(tabs.map((tab) => this.releaseTab(conversation, tab))).then((results) => { const failure = results.find((result) => result.status === 'rejected') if (failure?.status === 'rejected') throw failure.reason }) } clearSessions(): Promise { if (this.disposed) return Promise.resolve() if (this.clearOperation) return this.clearOperation const operation = this.performClearSessions() this.clearOperation = operation void operation.finally(() => { if (this.clearOperation === operation) this.clearOperation = undefined }).catch(() => undefined) return operation } private async performClearSessions(): Promise { this.clearing = true this.lifecycle.abort(new Error('浏览器会话已清除')) for (const id of this.requestTabs.keys()) this.releaseRequestTab(id) const requested = new Set(this.creations.keys()) for (const id of requested) this.releaseRequests.add(id) try { await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise)) await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise)) await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation))) this.conversations.clear() } finally { if (!this.disposed) { for (const id of requested) this.releaseRequests.delete(id) this.lifecycle = new AbortController() this.clearing = false } } } async dispose(): Promise { if (this.disposed) return this.disposed = true this.clearing = true this.lifecycle.abort(new Error('浏览器服务已关闭')) for (const id of this.requestTabs.keys()) this.releaseRequestTab(id) for (const [id, creation] of this.creations) { this.releaseRequests.add(id) creation.controller.abort(new Error('浏览器服务已关闭')) } await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise)) await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise)) await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation))) this.conversations.clear() this.stateListeners.clear() this.liveStates.clear() } }