feat: improve runtime visibility and browser interaction

This commit is contained in:
lofyer
2026-08-07 17:53:14 +08:00
parent 2c715e5e81
commit 53d18e2b06
36 changed files with 1680 additions and 907 deletions
@@ -127,6 +127,7 @@ describe('BrowserModelTools', () => {
})
expect(first.scopeKey).not.toBe(second.scopeKey)
expect(first.allowPermanent).toBe(false)
expect(first.description).toContain('包括密码字段')
expect(JSON.stringify(first)).not.toContain('top-secret')
const result = await tools.callTool(
+2 -2
View File
@@ -84,7 +84,7 @@ const definitions = [
type: 'string',
minLength: 1,
maxLength: 8_192,
description: '完整的公开 HTTP(S) URL'
description: '当前设备可连接的完整 HTTP 或 HTTPS URL'
}
},
required: ['url'],
@@ -267,7 +267,7 @@ export class BrowserModelTools {
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
} else if (name === 'browser_type') {
const input = browserTypeInputSchema.parse(argumentsValue)
description = `${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件隐藏字段会被拒绝`
description = `${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本,包括密码字段;文件隐藏、禁用和只读字段不支持输入`
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
// A session approval must never authorize a later value, even for the
// same element. The nonce intentionally makes this invocation-only.
+200 -9
View File
@@ -6,6 +6,7 @@ import {
type BrowserSessionLike
} from './browser-service'
import type { BrowserWebContents } from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
type HarnessSlot = {
currentOrigin?: string
@@ -30,19 +31,33 @@ function createHarness(options: {
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
sessionGate?: Promise<void>
captureScreenshot?: (
signal: AbortSignal
) => Promise<{
type: 'image'
mimeType: 'image/jpeg'
data: string
}>
driverScreenshot?: BrowserDriverLike['screenshot']
} = {}) {
const slots: HarnessSlot[] = []
const byContents = new Map<BrowserWebContents, HarnessSlot>()
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
await options.sessionGate
const webContents = {} as BrowserWebContents
const slot = {} as HarnessSlot
const webContents = {
getURL: () => `${slot.currentOrigin}/page`
} as BrowserWebContents
const session: BrowserSessionLike = {
webContents,
approveNavigation: vi.fn((target) => {
slot.approvedOrigin = target.origin
}),
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
openInteraction: vi.fn(async () => undefined),
...(options.captureScreenshot
? { captureScreenshot: vi.fn(options.captureScreenshot) }
: {}),
dispose: vi.fn(options.dispose ?? (async () => undefined))
}
const driver: BrowserDriverLike = {
@@ -67,11 +82,14 @@ function createHarness(options: {
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
return { url: target.url }
}),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
})),
screenshot: vi.fn(
options.driverScreenshot ??
(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
}))
),
dispose: vi.fn()
}
Object.assign(slot, { session, driver })
@@ -148,8 +166,8 @@ describe('BrowserService', () => {
it('does not publish ready after a session is stopped during frame capture', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: string[] = []
harness.service.onState((state) => states.push(state.status))
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
@@ -181,7 +199,128 @@ describe('BrowserService', () => {
await harness.service.releaseConversation('conversation')
await expect(click).rejects.toThrow('浏览器会话已释放')
expect(states.at(-1)).toBe('stopped')
expect(states.at(-1)?.status).toBe('stopped')
})
it('falls back to CDP when native capture cannot produce the live frame', async () => {
const nativeCapture = vi.fn(async () => {
throw new Error('native capture unavailable while hidden')
})
const harness = createHarness({
captureScreenshot: nativeCapture
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(nativeCapture).toHaveBeenCalledOnce()
expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce()
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
it('retries live capture while a newly committed page starts painting', async () => {
let attempts = 0
const harness = createHarness({
captureScreenshot: async () => {
attempts += 1
if (attempts === 1) {
throw new Error('page has not painted yet')
}
return {
type: 'image',
mimeType: 'image/jpeg',
data: '/9j/2Q=='
}
},
driverScreenshot: async () => {
throw new Error('CDP frame not ready')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(attempts).toBe(2)
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
it('reports a live-frame failure instead of waiting indefinitely', async () => {
const harness = createHarness({
captureScreenshot: async () => {
throw new Error('native capture failed')
},
driverScreenshot: async () => {
throw new Error('CDP capture failed')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(states.at(-1)).toMatchObject({
status: 'failed',
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
})
await harness.service.dispose()
})
it('keeps the last frame when a later refresh cannot capture a minimized window', async () => {
let nativeAttempts = 0
const harness = createHarness({
captureScreenshot: async () => {
nativeAttempts += 1
if (nativeAttempts === 1) {
return {
type: 'image',
mimeType: 'image/jpeg',
data: '/9j/2Q=='
}
}
throw new Error('minimized native capture unavailable')
},
driverScreenshot: async () => {
throw new Error('minimized CDP capture unavailable')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.click('conversation', 'button_ref', signal)
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
it('isolates browser state and drivers by conversation', async () => {
@@ -244,6 +383,58 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('pauses agent operations while the user interacts with the same session', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://a.example/',
signal
)
const interactionGate = deferred<
Awaited<ReturnType<BrowserSessionLike['openInteraction']>>
>()
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.session.openInteraction).mockReturnValueOnce(
interactionGate.promise
)
const interaction = harness.service.interact(
'conversation',
signal
)
await vi.waitFor(() =>
expect(slot.session.openInteraction).toHaveBeenCalledOnce()
)
const snapshot = harness.service.snapshot('conversation', signal)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(slot.driver.snapshot).not.toHaveBeenCalled()
interactionGate.resolve({
type: 'image',
mimeType: 'image/jpeg',
data: 'closing-frame'
})
await interaction
expect(states.slice(-2).map((state) => state.status)).toEqual([
'interactive',
'ready'
])
expect(states.at(-1)?.frameDataUrl).toBe(
'data:image/jpeg;base64,closing-frame'
)
expect(harness.service.getSessionCount()).toBe(1)
expect(slot.session.dispose).not.toHaveBeenCalled()
await snapshot
expect(slot.driver.snapshot).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('does not let a canceled queued waiter clear the active operation owner', async () => {
const harness = createHarness()
const signal = new AbortController().signal
+121 -35
View File
@@ -7,6 +7,7 @@ import {
import type { BrowserScreenshot } from './browser-screenshot'
import {
ElectronBrowserSession,
type BrowserParentWindowHandle,
type BrowserWebContents
} from './electron-browser-session'
import type { BrowserLiveState } from '../../shared/contracts'
@@ -20,6 +21,7 @@ export type BrowserSessionLike = {
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
): void
getCurrentOrigin(): string | undefined
openInteraction(): Promise<BrowserScreenshot | undefined>
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
dispose(): Promise<void>
}
@@ -45,6 +47,7 @@ export type BrowserServiceOptions = {
idleTimeoutMs?: number
cleanupTimeoutMs?: number
liveFrameDelayMs?: number
parentWindow?: BrowserParentWindowHandle
createSession?: (
policy: BrowserUrlPolicy,
signal: AbortSignal
@@ -112,9 +115,16 @@ async function boundedCleanup(
async function defaultCreateSession(
policy: BrowserUrlPolicy,
signal: AbortSignal
signal: AbortSignal,
parentWindow?: BrowserParentWindowHandle
): Promise<BrowserSessionLike> {
return ElectronBrowserSession.create({ policy }, signal)
return ElectronBrowserSession.create(
{
policy,
...(parentWindow ? { parentWindow } : {})
},
signal
)
}
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
@@ -152,7 +162,10 @@ export class BrowserService {
this.cleanupTimeoutMs =
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
this.createSession = options.createSession ?? defaultCreateSession
this.createSession =
options.createSession ??
((policy, signal) =>
defaultCreateSession(policy, signal, options.parentWindow))
this.createDriver = options.createDriver ?? defaultCreateDriver
if (
!Number.isSafeInteger(this.maximumSessions) ||
@@ -198,6 +211,9 @@ export class BrowserService {
conversationId,
status,
...(previous?.url ? { url: previous.url } : {}),
...(status !== 'stopped' && previous?.frameDataUrl
? { frameDataUrl: previous.frameDataUrl }
: {}),
...update,
updatedAt: Date.now()
}
@@ -235,40 +251,73 @@ export class BrowserService {
signal
)
}
const previewController = new AbortController()
const timeout = setTimeout(
() =>
previewController.abort(
new Error('浏览器实时画面捕获超时')
),
2_000
)
try {
const previewSignal = AbortSignal.any([
signal,
previewController.signal
])
frame = slot.session.captureScreenshot
? await slot.session.captureScreenshot(previewSignal)
: await slot.driver.screenshot(previewSignal)
} catch {
signal.throwIfAborted()
// Browser control succeeds even when the optional live frame fails.
} finally {
clearTimeout(timeout)
const captureDeadline = AbortSignal.any([
signal,
AbortSignal.timeout(6_000)
])
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
if (attempt > 0) {
try {
await waitFor(
new Promise<void>((resolve) =>
setTimeout(resolve, attempt * 150)
),
captureDeadline
)
} catch {
signal.throwIfAborted()
break
}
}
if (slot.session.captureScreenshot) {
try {
frame = await slot.session.captureScreenshot(
AbortSignal.any([
captureDeadline,
AbortSignal.timeout(1_500)
])
)
} catch {
signal.throwIfAborted()
}
}
if (!frame && !captureDeadline.aborted) {
try {
frame = await slot.driver.screenshot(
AbortSignal.any([
captureDeadline,
AbortSignal.timeout(1_500)
])
)
} catch {
signal.throwIfAborted()
}
}
}
}
signal.throwIfAborted()
if (slot.released || this.slots.get(conversationId) !== slot) {
return
}
if (!frame) {
const previousFrame =
this.liveStates.get(conversationId)?.frameDataUrl
if (previousFrame) {
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
frameDataUrl: previousFrame
})
return
}
this.emitState(conversationId, 'failed', {
...(url ? { url } : {}),
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
})
return
}
this.emitState(conversationId, 'ready', {
...(url ? { url } : {}),
...(frame
? {
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
}
: {})
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
})
}
@@ -429,7 +478,7 @@ export class BrowserService {
slot: BrowserSlot,
signal: AbortSignal,
operation: (effectiveSignal: AbortSignal) => Promise<T>,
status?: 'loading' | 'acting'
status?: 'loading' | 'acting' | 'interactive'
): Promise<T> {
signal.throwIfAborted()
if (slot.released || this.disposed) {
@@ -496,7 +545,7 @@ export class BrowserService {
private async runInSession<T>(
conversationId: string,
signal: AbortSignal,
status: 'loading' | 'acting',
status: 'loading' | 'acting' | 'interactive',
failureStage: string,
operation: (
slot: BrowserSlot,
@@ -715,10 +764,17 @@ export class BrowserService {
'浏览器截图',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const screenshot =
slot.session.captureScreenshot
? await slot.session.captureScreenshot(effectiveSignal)
: await slot.driver.screenshot(effectiveSignal)
let screenshot: BrowserScreenshot | undefined
if (slot.session.captureScreenshot) {
try {
screenshot = await slot.session.captureScreenshot(
effectiveSignal
)
} catch {
effectiveSignal.throwIfAborted()
}
}
screenshot ??= await slot.driver.screenshot(effectiveSignal)
await this.captureFrame(
conversationId,
slot,
@@ -731,6 +787,36 @@ export class BrowserService {
)
}
async interact(
conversationId: string,
signal: AbortSignal
): Promise<void> {
await this.runInSession(
conversationId,
signal,
'interactive',
'浏览器交互',
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const closingFrame = await waitFor(
slot.session.openInteraction(),
effectiveSignal
)
const currentUrl = canonicalizeBrowserUrl(
slot.session.webContents.getURL()
)
slot.origin = currentUrl.origin
await this.captureFrame(
conversationId,
slot,
effectiveSignal,
currentUrl.href,
closingFrame
)
}
)
}
async releaseConversation(conversationId: string): Promise<void> {
this.releaseRequests.add(conversationId)
let releasedSlot = false
+6 -5
View File
@@ -536,7 +536,7 @@ describe('CdpBrowserDriver', () => {
driver.dispose()
})
it('rejects password, file, hidden, and stale typing targets', async () => {
it('allows password typing while keeping the password value redacted', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
@@ -545,15 +545,16 @@ describe('CdpBrowserDriver', () => {
throw new Error('password missing')
}
await expect(
driver.type(password.ref, 'never-send', new AbortController().signal)
).rejects.toThrow('受保护')
driver.type(password.ref, 'login-secret', new AbortController().signal)
).resolves.toBeUndefined()
expect(
harness.sendCommand.mock.calls.some(
([method, parameters]) =>
method === 'Input.insertText' &&
parameters?.text === 'never-send'
parameters?.text === 'login-secret'
)
).toBe(false)
).toBe(true)
expect(JSON.stringify(snapshot)).not.toContain('secret')
driver.dispose()
})
+2 -6
View File
@@ -88,7 +88,6 @@ type RefBinding = {
backendNodeId: number
generation: number
role: string
protected: boolean
}
export type CdpBrowserDriverOptions = {
@@ -591,8 +590,7 @@ export class CdpBrowserDriver {
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
role
})
output.push(item)
}
@@ -648,7 +646,6 @@ export class CdpBrowserDriver {
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
const blocked =
binding.protected ||
attributeMap.has('hidden') ||
attributeMap.has('disabled') ||
attributeMap.has('inert') ||
@@ -656,10 +653,9 @@ export class CdpBrowserDriver {
attributeMap.get('aria-hidden') === 'true' ||
attributeMap.get('aria-disabled') === 'true' ||
inputType === 'hidden' ||
inputType === 'password' ||
inputType === 'file'
if (blocked) {
throw new Error('浏览器拒绝操作受保护、隐藏禁用字段')
throw new Error('浏览器拒绝操作隐藏禁用、只读或文件字段')
}
if (
action === 'type' &&
@@ -21,6 +21,7 @@ function createHarness() {
const debuggerEvents = new EventEmitter()
const contentEvents = new EventEmitter()
const partitionEvents = new EventEmitter()
const windowEvents = new EventEmitter()
let currentUrl = ''
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
const sendCommand = vi.fn(async () => ({}))
@@ -71,6 +72,21 @@ function createHarness() {
loadURL: vi.fn(async (url: string) => {
currentUrl = url
}),
show: vi.fn(),
minimize: vi.fn(),
restore: vi.fn(),
isMinimized: vi.fn(() => false),
focus: vi.fn(),
on: (event, listener) =>
windowEvents.on(
event,
listener as (...argumentsValue: unknown[]) => void
),
off: (event, listener) =>
windowEvents.off(
event,
listener as (...argumentsValue: unknown[]) => void
),
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
}
@@ -125,6 +141,7 @@ function createHarness() {
contentEvents,
debuggerEvents,
partitionEvents,
windowEvents,
partition,
proxy,
policy,
@@ -276,6 +293,74 @@ describe('ElectronBrowserSession', () => {
await session.dispose()
})
it('restores the browser for interaction and minimizes it on close', async () => {
const harness = createHarness()
const parentWindow = {
setEnabled: vi.fn(),
focus: vi.fn(),
isDestroyed: vi.fn(() => false)
}
let createdWindowOptions: Record<string, unknown> | undefined
const createWindow = vi.fn(
async (options: Record<string, unknown>) => {
createdWindowOptions = options
return harness.window
}
)
const session = await ElectronBrowserSession.create({
policy: harness.policy,
parentWindow,
createPartition: async () => harness.partition,
createWindow,
createProxy: () => harness.proxy
})
expect(createWindow).toHaveBeenCalledWith(
expect.objectContaining({
parent: parentWindow,
show: false,
title: 'GoodBuddy 浏览器交互'
})
)
expect(createdWindowOptions?.modal).toBeUndefined()
const interaction = session.openInteraction()
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
expect(harness.window.show).toHaveBeenCalledOnce()
expect(harness.window.focus).toHaveBeenCalledOnce()
const closeEvent = { preventDefault: vi.fn() }
harness.windowEvents.emit('close', closeEvent)
await expect(interaction).resolves.toEqual({
type: 'image',
mimeType: 'image/jpeg',
data: '/9j/2Q=='
})
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
expect(harness.window.minimize).toHaveBeenCalledOnce()
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
expect(parentWindow.focus).toHaveBeenCalledOnce()
expect(
vi.mocked(harness.webContents.capturePage!).mock
.invocationCallOrder[0]
).toBeLessThan(
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
Number.POSITIVE_INFINITY
)
const repeatedCloseEvent = { preventDefault: vi.fn() }
harness.windowEvents.emit('close', repeatedCloseEvent)
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
expect(harness.window.destroy).not.toHaveBeenCalled()
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
const reopenedInteraction = session.openInteraction()
expect(harness.window.restore).toHaveBeenCalledOnce()
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
await reopenedInteraction
await session.dispose()
expect(harness.window.destroy).toHaveBeenCalledOnce()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
+132 -2
View File
@@ -49,10 +49,23 @@ export type BrowserWebContents = {
export type BrowserWindowHandle = {
webContents: BrowserWebContents
loadURL(url: string): Promise<unknown>
show(): void
minimize(): void
restore(): void
isMinimized(): boolean
focus(): void
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
destroy(): void
isDestroyed(): boolean
}
export type BrowserParentWindowHandle = {
setEnabled?(enabled: boolean): void
focus?(): void
isDestroyed?(): boolean
}
export type BrowserPartitionSession = {
setPermissionCheckHandler(
handler: (...argumentsValue: never[]) => boolean
@@ -100,6 +113,7 @@ export type ElectronBrowserSessionOptions = {
options: Record<string, unknown>
) => Promise<BrowserWindowHandle>
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
parentWindow?: BrowserParentWindowHandle
}
type Listener = {
@@ -211,6 +225,11 @@ export class ElectronBrowserSession {
readonly webContents: BrowserWebContents
private approvedOrigin?: string
private readonly listeners: Listener[] = []
private interaction?: {
promise: Promise<BrowserScreenshot | undefined>
resolve(frame?: BrowserScreenshot): void
}
private interactionClosing?: Promise<void>
private disposed = false
private constructor(
@@ -219,7 +238,8 @@ export class ElectronBrowserSession {
private readonly window: BrowserWindowHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number
private readonly cleanupTimeoutMs: number,
private readonly parentWindow?: BrowserParentWindowHandle
) {
this.partition = partition
this.webContents = window.webContents
@@ -301,6 +321,13 @@ export class ElectronBrowserSession {
show: false,
width: 1280,
height: 900,
title: 'GoodBuddy 浏览器交互',
autoHideMenuBar: true,
...(options.parentWindow
? {
parent: options.parentWindow
}
: {}),
webPreferences: {
partition,
sandbox: true,
@@ -308,6 +335,7 @@ export class ElectronBrowserSession {
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
backgroundThrottling: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
@@ -337,7 +365,8 @@ export class ElectronBrowserSession {
window,
managedProxy,
partition,
cleanupTimeoutMs
cleanupTimeoutMs,
options.parentWindow
)
setupStage = '初始化浏览器协议'
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
@@ -383,6 +412,21 @@ export class ElectronBrowserSession {
private async initialize(): Promise<void> {
const contents = this.webContents
this.listen(
this.window,
'close',
(event: { preventDefault(): void }) => {
if (this.disposed) {
return
}
event.preventDefault()
if (this.interaction) {
void this.captureAndFinishInteraction()
} else {
this.window.minimize()
}
}
)
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
const url = typeof details === 'string' ? details : details.url
@@ -513,12 +557,98 @@ export class ElectronBrowserSession {
this.approvedOrigin = target.origin
}
openInteraction(): Promise<BrowserScreenshot | undefined> {
this.assertOpen()
if (this.interaction) {
this.setParentEnabled(false)
if (this.window.isMinimized()) {
this.window.restore()
}
this.window.show()
this.window.focus()
return this.interaction.promise
}
let resolve!: (frame?: BrowserScreenshot) => void
const promise = new Promise<BrowserScreenshot | undefined>(
(resolvePromise) => {
resolve = resolvePromise
}
)
this.interaction = { promise, resolve }
this.setParentEnabled(false)
try {
if (this.window.isMinimized()) {
this.window.restore()
}
this.window.show()
this.window.focus()
} catch (error) {
this.finishInteraction()
throw error
}
return promise
}
private captureAndFinishInteraction(): Promise<void> {
if (this.interactionClosing) {
return this.interactionClosing
}
const operation = (async (): Promise<void> => {
let frame: BrowserScreenshot | undefined
try {
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
} catch {
// The session remains usable even if the final visible frame fails.
}
try {
if (!this.disposed && !this.window.isDestroyed()) {
this.window.minimize()
}
} catch {
// Resolving interaction must not depend on native minimize success.
}
this.finishInteraction(frame)
})()
this.interactionClosing = operation
void operation.finally(() => {
if (this.interactionClosing === operation) {
this.interactionClosing = undefined
}
})
return operation
}
private finishInteraction(frame?: BrowserScreenshot): void {
const interaction = this.interaction
this.interaction = undefined
this.setParentEnabled(true)
interaction?.resolve(frame)
}
private setParentEnabled(enabled: boolean): void {
try {
if (
!this.parentWindow ||
this.parentWindow.isDestroyed?.() === true
) {
return
}
this.parentWindow.setEnabled?.(enabled)
if (enabled) {
this.parentWindow.focus?.()
}
} catch {
// Parent-window state must not break browser-session cleanup.
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
this.approvedOrigin = undefined
this.finishInteraction()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}