dsh-browser-plus 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +100 -0
  5. package/README.md +99 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/cordis.patch.yml +17 -0
  13. package/docs/MIGRATION.md +48 -0
  14. package/docs/README.md +22 -0
  15. package/docs/SOAK-CHECKLIST.md +98 -0
  16. package/docs/architecture.md +88 -0
  17. package/docs/tool-reference.md +124 -0
  18. package/docs/user-guide.md +121 -0
  19. package/docs/why-browser.md +45 -0
  20. package/lib/browser/runtime.d.ts +225 -0
  21. package/lib/browser/runtime.js +302 -0
  22. package/lib/browser/types.d.ts +668 -0
  23. package/lib/browser/types.js +18 -0
  24. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  25. package/lib/browser-electron/auth-cookies.js +83 -0
  26. package/lib/browser-electron/chrome-state.d.ts +187 -0
  27. package/lib/browser-electron/chrome-state.js +12 -0
  28. package/lib/browser-electron/entry.d.ts +66 -0
  29. package/lib/browser-electron/entry.js +62 -0
  30. package/lib/browser-electron/fingerprint.d.ts +29 -0
  31. package/lib/browser-electron/fingerprint.js +42 -0
  32. package/lib/browser-electron/host-main.d.ts +18 -0
  33. package/lib/browser-electron/host-main.js +2494 -0
  34. package/lib/browser-electron/icon.d.ts +11 -0
  35. package/lib/browser-electron/icon.js +23 -0
  36. package/lib/browser-electron/page-chrome.d.ts +21 -0
  37. package/lib/browser-electron/page-chrome.js +2034 -0
  38. package/lib/browser-electron/provider.d.ts +709 -0
  39. package/lib/browser-electron/provider.js +2575 -0
  40. package/lib/browser-electron/remote-host.d.ts +143 -0
  41. package/lib/browser-electron/remote-host.js +952 -0
  42. package/lib/browser-electron/task-summary.d.ts +2 -0
  43. package/lib/browser-electron/task-summary.js +12 -0
  44. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  45. package/lib/browser-electron/task-thumbnail.js +9 -0
  46. package/lib/browser-electron/write-guard.d.ts +41 -0
  47. package/lib/browser-electron/write-guard.js +123 -0
  48. package/lib/index.d.ts +16 -0
  49. package/lib/index.js +14 -0
  50. package/lib/tool-browser/index.d.ts +31 -0
  51. package/lib/tool-browser/index.js +1931 -0
  52. package/package.json +95 -4
  53. package/screenshots.json +3 -0
  54. package/scripts/build-icons.mjs +80 -0
  55. package/scripts/capture-window.ps1 +79 -0
  56. package/scripts/crop-image.ps1 +20 -0
  57. package/scripts/smoke-browser-tools.mjs +1968 -0
  58. package/scripts/smoke-chrome-world.mjs +63 -0
  59. package/scripts/smoke-electron-host.mjs +50 -0
  60. package/src/browser/runtime.ts +470 -0
  61. package/src/browser/types.ts +649 -0
  62. package/src/browser-electron/auth-cookies.ts +125 -0
  63. package/src/browser-electron/chrome-state.ts +174 -0
  64. package/src/browser-electron/entry.ts +115 -0
  65. package/src/browser-electron/fingerprint.ts +45 -0
  66. package/src/browser-electron/host-main.ts +2330 -0
  67. package/src/browser-electron/icon.ts +26 -0
  68. package/src/browser-electron/page-chrome.ts +2046 -0
  69. package/src/browser-electron/provider.ts +3088 -0
  70. package/src/browser-electron/remote-host.ts +1004 -0
  71. package/src/browser-electron/task-summary.ts +10 -0
  72. package/src/browser-electron/task-thumbnail.ts +17 -0
  73. package/src/browser-electron/write-guard.ts +134 -0
  74. package/src/index.ts +52 -0
  75. package/src/tool-browser/index.ts +1974 -0
  76. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * Self-hosted Electron browser host (parent side): an
3
+ * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
+ * Electron child process (host-main.js) and drives it over line-delimited
5
+ * JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
6
+ * surfaces without a desktop shell's electronViewHost (plain dsh web):
7
+ * installing the plugin is enough — the browser window appears on first use.
8
+ *
9
+ * Protocol (one JSON object per line, both directions):
10
+ * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
11
+ * { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
12
+ * <- { id, ok: true, result? } | { id, ok: false, err }
13
+ *
14
+ * The child is Electron's main process; host-main.js owns the BrowserWindow,
15
+ * WebContentsViews, and webContents.debugger (CDP).
16
+ * @module dsh-browser-plus/browser-electron/remote-host
17
+ */
18
+
19
+ import { spawn, type ChildProcessByStdio } from 'node:child_process'
20
+ import { createRequire } from 'node:module'
21
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { createServer, type Server, type Socket } from 'node:net'
24
+ import { fileURLToPath } from 'node:url'
25
+ import type { ChromeHostEvent, ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts'
26
+ import { BrowserError } from '../browser/types.ts'
27
+ import type { BrowserTaskInfo, BrowserTaskUpdate, ExportedCookie } from '../browser/types.ts'
28
+
29
+ /** How long to wait for the child to signal readiness before failing. */
30
+ const READY_TIMEOUT_MS = 20_000
31
+
32
+ /**
33
+ * Safety cap on a single RPC reply line (base64 downloads are the big ones).
34
+ *
35
+ * Derived from the child's download cap (host-main.ts MAX_DOWNLOAD_BYTES,
36
+ * lowered to 64 MiB = 67,108,864 bytes by T1): the child ships the body as
37
+ * base64 inside ONE JSON line, which inflates it by 4/3 —
38
+ * 64 MiB * 4 / 3 = 67,108,864 * 4 / 3 = 89,478,485 bytes ≈ 85.33 MiB
39
+ * — plus the JSON envelope and room for a base64 capture PNG. 128 MiB =
40
+ * Downloads no longer cross this channel — the child writes them and reports a
41
+ * byte count — so the largest replies left are screenshot payloads. The cap
42
+ * still bounds what a pathological child can make the parent buffer.
43
+ */
44
+ const MAX_RPC_BUFFER_BYTES = 128 * 1024 * 1024
45
+ /** Bounded RPC budgets prevent a dead child from wedging model-facing tools. */
46
+ const RPC_QUERY_TIMEOUT_MS = 8_000
47
+ const RPC_COMMAND_TIMEOUT_MS = 35_000
48
+ const RPC_TRANSFER_TIMEOUT_MS = 120_000
49
+
50
+ /**
51
+ * A recycled Electron child can report document-ready before its compositor
52
+ * owns a paintable surface. Delay only the first capture after self-healing.
53
+ */
54
+ const RECOVERY_CAPTURE_SETTLE_MS = 3_000
55
+
56
+ /** Electron 43.x is known to trigger compositor faults in this host. */
57
+ const SUPPORTED_ELECTRON_VERSION = '42.9.3'
58
+
59
+ /**
60
+ * CDP methods that must NOT be replayed onto a freshly materialized view. Input
61
+ * dispatched at a blank document does nothing yet still resolves, so retrying it
62
+ * after a host death reported success for a click that never happened.
63
+ */
64
+ const UNREPLAYABLE_METHOD_PREFIX = 'Input.'
65
+
66
+ /**
67
+ * Locate the one supported Electron binary. Candidates may come from the
68
+ * plugin, DSH anchors, an explicit override, or pnpm stores, but only the
69
+ * pinned version is admitted. A newer binary is not a safe substitute.
70
+ */
71
+ function resolveElectronPath(): string {
72
+ const require = createRequire(import.meta.url)
73
+ const candidates: Array<{ version: string; path: string }> = []
74
+ const add = (version: string | undefined, path: string | undefined): void => {
75
+ if (version === undefined || path === undefined) return
76
+ candidates.push({ version, path })
77
+ }
78
+ const addResolvedModule = (resolved: string): void => {
79
+ const packageJson = join(dirname(resolved), 'package.json')
80
+ add(packageVersion(packageJson) ?? versionOf(resolved), electronExeBeside(resolved))
81
+ }
82
+
83
+ // Prefer the package-local optional dependency when it is installed.
84
+ try { addResolvedModule(require.resolve('electron')) } catch { /* continue probing */ }
85
+
86
+ // An explicit path is admitted only after its package metadata verifies 42.9.3.
87
+ const override = process.env.ELECTRON_PATH
88
+ if (typeof override === 'string' && override.length > 0 && existsSync(override)) {
89
+ add(versionOfElectronExecutable(override), override)
90
+ }
91
+
92
+ const anchors: string[] = []
93
+ const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX
94
+ if (globalPrefix !== undefined) {
95
+ anchors.push(join(globalPrefix, 'node_modules'))
96
+ anchors.push(join(globalPrefix, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'))
97
+ }
98
+ if (process.env.DSH_HOME !== undefined) anchors.push(join(process.env.DSH_HOME, 'profiles', 'node_modules'))
99
+ for (const anchor of anchors) {
100
+ try { addResolvedModule(require.resolve('electron', { paths: [anchor] })) } catch { /* keep probing */ }
101
+ }
102
+
103
+ const roots = new Set<string>([
104
+ fileURLToPath(new URL('.', import.meta.url)),
105
+ process.cwd(),
106
+ dirname(process.execPath),
107
+ ])
108
+ for (const root of roots) {
109
+ let dir = root
110
+ for (let depth = 0; depth < 8; depth++) {
111
+ const store = join(dir, 'node_modules', '.pnpm')
112
+ if (existsSync(store)) {
113
+ for (const entry of readdirSync(store)) {
114
+ if (!entry.startsWith('electron@')) continue
115
+ const exe = electronDistExe(join(store, entry, 'node_modules', 'electron'))
116
+ add(entry.slice('electron@'.length), exe)
117
+ }
118
+ }
119
+ const parent = join(dir, '..')
120
+ if (parent === dir) break
121
+ dir = parent
122
+ }
123
+ }
124
+
125
+ return selectSupportedElectronPath(candidates)
126
+ }
127
+
128
+ /**
129
+ * Whether a usable Electron binary can be located right now. Cheap and local:
130
+ * it only probes package metadata and the filesystem (no spawn, no network).
131
+ * Exported with an injectable resolver so the failure branch stays testable
132
+ * without uninstalling Electron.
133
+ * @param resolve - the locator to probe; defaults to {@link resolveElectronPath}.
134
+ */
135
+ export function probeElectronAvailability(resolve: () => string = resolveElectronPath): boolean {
136
+ try {
137
+ resolve()
138
+ return true
139
+ } catch {
140
+ return false
141
+ }
142
+ }
143
+
144
+ /** Select the one Electron version this plugin supports; exported for behavior tests. */
145
+ export function selectSupportedElectronPath(candidates: ReadonlyArray<{ version: string; path: string }>): string {
146
+ const supported = candidates.find(candidate => candidate.version === SUPPORTED_ELECTRON_VERSION)
147
+ if (supported !== undefined) return supported.path
148
+ const available = [...new Set(candidates.map(candidate => candidate.version))].join(', ') || 'none'
149
+ throw new Error(
150
+ 'dsh-browser-plus requires Electron ' + SUPPORTED_ELECTRON_VERSION +
151
+ ' because Electron 43.x has a compositor fault; found: ' + available +
152
+ '. Install the plugin optional dependency electron@' + SUPPORTED_ELECTRON_VERSION + ' or set ELECTRON_PATH to that binary.',
153
+ )
154
+ }
155
+
156
+ function packageVersion(packageJson: string): string | undefined {
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(packageJson, 'utf8')) as { version?: unknown }
159
+ return typeof parsed.version === 'string' ? parsed.version : undefined
160
+ } catch {
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ function versionOfElectronExecutable(executable: string): string | undefined {
166
+ return packageVersion(join(dirname(dirname(executable)), 'package.json')) ?? versionOf(executable)
167
+ }
168
+
169
+ /** Extract an electron version like "42.9.3" from a pnpm path. */
170
+ function versionOf(path: string): string | undefined {
171
+ const match = /electron@(\d+\.\d+\.\d+)/.exec(path)
172
+ return match?.[1]
173
+ }
174
+ /** From an electron package entry file, find the dist executable beside it. */
175
+ function electronExeBeside(entry: string): string | undefined {
176
+ const candidates = [
177
+ join(dirname(entry), 'dist', 'electron.exe'),
178
+ join(dirname(entry), 'dist', 'electron'),
179
+ join(dirname(entry), '..', 'dist', 'electron.exe'),
180
+ join(dirname(entry), '..', 'dist', 'electron'),
181
+ ]
182
+ for (const candidate of candidates) {
183
+ if (existsSync(candidate)) return candidate
184
+ }
185
+ return undefined
186
+ }
187
+
188
+ /** From an electron package root, find its dist executable. */
189
+ function electronDistExe(pkgRoot: string): string | undefined {
190
+ for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
191
+ if (existsSync(candidate)) return candidate
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ /** dirname without importing node:path's dirname separately. */
197
+ function dirname(p: string): string {
198
+ const i = p.lastIndexOf('/')
199
+ const j = p.lastIndexOf('\\')
200
+ const k = Math.max(i, j)
201
+ return k < 0 ? p : p.slice(0, k)
202
+ }
203
+
204
+ /**
205
+ * Stable `error.code` for every rejection caused by the Electron child being
206
+ * gone. DeferredRemoteView.withView retries on this code instead of
207
+ * pattern-matching message text: a child can die in several ways — spawn
208
+ * failure, exit, socket close, or a call made after it already died — and each
209
+ * produces a different message.
210
+ */
211
+ export const BROWSER_HOST_DEAD_CODE = 'BROWSER_HOST_DEAD'
212
+
213
+ /** Tag an error with {@link BROWSER_HOST_DEAD_CODE} without touching its message. */
214
+ function markBrowserHostDead<T extends Error>(error: T): T {
215
+ const tagged = error as Error & { code?: string }
216
+ tagged.code = BROWSER_HOST_DEAD_CODE
217
+ return error
218
+ }
219
+
220
+ /** Build a dead-host error carrying the stable code. */
221
+ function browserHostDeadError(message: string): Error {
222
+ return markBrowserHostDead(new Error(message))
223
+ }
224
+
225
+ /**
226
+ * True when an error means the child is gone and ONE self-heal retry is
227
+ * allowed. The stable code is authoritative; the message check is a legacy
228
+ * backstop for errors raised outside ElectronChildClient (an externally
229
+ * supplied host shim, or an older `Error` that only carries the old text), so
230
+ * the pre-existing "browser host is not running" retry contract keeps working.
231
+ */
232
+ export function isBrowserHostDead(error: unknown): boolean {
233
+ if (!(error instanceof Error)) return false
234
+ if ((error as { code?: unknown }).code === BROWSER_HOST_DEAD_CODE) return true
235
+ return error.message.includes('browser host is not running')
236
+ }
237
+
238
+ /** One RPC round-trip with the child. */
239
+ interface Pending {
240
+ resolve(result: unknown): void
241
+ reject(err: Error): void
242
+ readonly timer: ReturnType<typeof setTimeout>
243
+ }
244
+
245
+ /** Spawn arguments for the User-Agent masking options. */
246
+ function fingerprintArgs(options: { readonly userAgent?: string; readonly maskAutomation?: boolean }): string[] {
247
+ return [
248
+ ...options.maskAutomation === false ? ['--no-mask-automation'] : [],
249
+ ...options.userAgent === undefined ? [] : ['--user-agent', options.userAgent],
250
+ ]
251
+ }
252
+
253
+ /**
254
+ * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
255
+ * process on Windows does not receive piped stdin, so the parent listens on a
256
+ * loopback port and passes it to the child via `--rpc-port`; the child
257
+ * connects back and speaks the same one-JSON-per-line protocol.
258
+ */
259
+ class ElectronChildClient {
260
+ private readonly child: ChildProcessByStdio<null, import('node:stream').Readable, import('node:stream').Readable>
261
+ private readonly pending = new Map<number, Pending>()
262
+ private readonly chromeWorld: 'main' | 'isolated' | undefined
263
+ private readonly fingerprintArgs: readonly string[]
264
+ /**
265
+ * Receives messages the child sends without a request id. Today that is only
266
+ * the chrome's own tab requests, raised when a human clicks the injected
267
+ * toolbar; a reply always carries the id of the call it answers.
268
+ */
269
+ private onEvent: ((event: unknown) => void) | undefined
270
+ private nextId = 1
271
+ private buffer = ''
272
+ private socket: import('node:net').Socket | undefined
273
+ private connected = false
274
+ private outbox: string[] = []
275
+ /** Set once the child has exited; further calls fail fast instead of queueing. */
276
+ private dead = false
277
+
278
+ constructor(
279
+ private readonly hostMainPath: string,
280
+ private readonly port: number,
281
+ private readonly onExit?: () => void,
282
+ chromeWorld?: 'main' | 'isolated',
283
+ fingerprintArgs: readonly string[] = [],
284
+ ) {
285
+ this.chromeWorld = chromeWorld
286
+ this.fingerprintArgs = fingerprintArgs
287
+ const electron = resolveElectronPath()
288
+ process.stderr.write(`[dsh-browser-plus host] spawning electron: ${electron}\n`)
289
+ // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
290
+ // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
291
+ // break the child. Rebuild the env without either.
292
+ const env: Record<string, string | undefined> = { ...process.env }
293
+ delete env.ELECTRON_RUN_AS_NODE
294
+ delete env.NODE_OPTIONS
295
+ // The chrome's world is the child's choice, so it travels as an argument.
296
+ const childArgs = [
297
+ ...this.chromeWorld === 'isolated' ? ['--chrome-world', 'isolated'] : [],
298
+ ...this.fingerprintArgs,
299
+ ]
300
+ this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port), ...childArgs], {
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ windowsHide: false,
303
+ env,
304
+ })
305
+ this.child.stderr.setEncoding('utf8')
306
+ this.child.stderr.on('data', chunk => {
307
+ // Diagnostics only; never parse stderr as protocol.
308
+ process.stderr.write(`[dsh-browser-plus host] ${String(chunk)}`)
309
+ })
310
+ // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
311
+ // that would crash the whole DSH process.
312
+ this.child.on('error', error => {
313
+ process.stderr.write(`[dsh-browser-plus host] spawn error: ${String(error)}\n`)
314
+ this.fail(new Error(`dsh-browser-plus: browser host failed to start: ${String(error)}`))
315
+ })
316
+ this.child.on('exit', (code, signal) => {
317
+ this.fail(new Error(`dsh-browser-plus: browser host exited (code=${String(code)} signal=${String(signal)})`))
318
+ })
319
+ }
320
+
321
+ /** Route unsolicited child messages (see {@link onEvent}). */
322
+ setEventListener(listener: (event: unknown) => void): void {
323
+ this.onEvent = listener
324
+ }
325
+
326
+ /** Reject everything in flight, mark the client dead, and notify the host. */
327
+ private fail(err: Error): void {
328
+ if (this.dead) return
329
+ this.dead = true
330
+ this.connected = false
331
+ // Everything rejected from here on means "this child is gone": tag it with
332
+ // the stable code so withView can self-heal without matching message text.
333
+ // Covers all three death paths that funnel through fail(): child 'exit',
334
+ // child 'error' (spawn failure), and socket 'close'.
335
+ markBrowserHostDead(err)
336
+ for (const pending of this.pending.values()) pending.reject(err)
337
+ this.pending.clear()
338
+ this.outbox = []
339
+ this.onExit?.()
340
+ }
341
+
342
+ /** Accept the child's connection (called by the server). */
343
+ attach(socket: import('node:net').Socket): void {
344
+ this.socket = socket
345
+ this.connected = true
346
+ socket.setEncoding('utf8')
347
+ // Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
348
+ // uncaught 'error' event and crashes the whole DSH process; 'close' below
349
+ // does the cleanup.
350
+ socket.on('error', error => {
351
+ process.stderr.write(`[dsh-browser-plus host] socket error: ${String(error)}\n`)
352
+ })
353
+ socket.on('data', chunk => this.onData(chunk))
354
+ socket.on('close', () => {
355
+ this.connected = false
356
+ if (!this.dead) {
357
+ this.fail(new Error('dsh-browser-plus: browser host connection closed'))
358
+ }
359
+ })
360
+ // Flush anything queued while disconnected.
361
+ if (this.outbox.length > 0) {
362
+ for (const line of this.outbox) socket.write(line + '\n')
363
+ this.outbox = []
364
+ }
365
+ }
366
+
367
+ private onData(chunk: string | Buffer): void {
368
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
369
+ // Safety net: a pathological child (or a reply larger than expected)
370
+ // must not grow the parent's memory without bound. The child caps
371
+ // downloads at 64 MiB (see MAX_RPC_BUFFER_BYTES), so a healthy stream
372
+ // never approaches this.
373
+ if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
374
+ this.buffer = ''
375
+ this.fail(new Error(`dsh-browser-plus: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`))
376
+ return
377
+ }
378
+ let nl: number
379
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
380
+ const line = this.buffer.slice(0, nl).trim()
381
+ this.buffer = this.buffer.slice(nl + 1)
382
+ if (line === '') continue
383
+ let msg: { id?: number; ok?: boolean; result?: unknown; err?: string; event?: unknown; action?: unknown }
384
+ try {
385
+ msg = JSON.parse(line) as typeof msg
386
+ } catch {
387
+ // Non-protocol line; ignore.
388
+ continue
389
+ }
390
+ if (typeof msg.id !== 'number') {
391
+ // The child speaks first only for chrome-originated tab requests. A
392
+ // listener that throws must not kill the socket, and an unknown event
393
+ // name is ignored like any other non-protocol line.
394
+ if (msg.event === 'chrome' && this.onEvent !== undefined) {
395
+ try {
396
+ this.onEvent(msg.action)
397
+ } catch { /* listener's problem, not the stream's */ }
398
+ }
399
+ continue
400
+ }
401
+ const pending = this.pending.get(msg.id)
402
+ if (pending === undefined) continue
403
+ this.pending.delete(msg.id)
404
+ if (msg.ok === true) pending.resolve(msg.result)
405
+ else pending.reject(new Error(msg.err ?? 'browser host command failed'))
406
+ }
407
+ }
408
+
409
+ /** Send one bounded command and await the reply. */
410
+ call<T = unknown>(op: string, payload: Record<string, unknown> = {}, timeoutMs = RPC_COMMAND_TIMEOUT_MS): Promise<T> {
411
+ if (this.dead) {
412
+ // Death path 1: called after the child already died. Message kept for
413
+ // diagnostics; the code is what withView keys on.
414
+ return Promise.reject(browserHostDeadError('dsh-browser-plus: browser host is not running'))
415
+ }
416
+ const id = this.nextId++
417
+ const line = JSON.stringify({ id, op, ...payload })
418
+ return new Promise<T>((resolve, reject) => {
419
+ const settle = <TValue>(callback: (value: TValue) => void, value: TValue): void => {
420
+ clearTimeout(timer)
421
+ callback(value)
422
+ }
423
+ const timer = setTimeout(() => {
424
+ const pending = this.pending.get(id)
425
+ if (pending === undefined) return
426
+ this.pending.delete(id)
427
+ this.outbox = this.outbox.filter(queued => queued !== line)
428
+ const error = new Error('dsh-browser-plus: RPC ' + op + ' timed out after ' + String(timeoutMs) + 'ms')
429
+ pending.reject(error)
430
+ // A child that stopped answering cannot safely serve later operations.
431
+ // Tear it down so the next call follows the existing self-heal path.
432
+ // fail() tags `error` with the dead code, so the timed-out call is
433
+ // retried once against the freshly started child (the provider's own
434
+ // deadlines bound the worst case) and the other in-flight calls, which
435
+ // never got to run, recover the same way.
436
+ this.fail(error)
437
+ try { this.child.kill() } catch { /* already exited */ }
438
+ }, timeoutMs)
439
+ this.pending.set(id, {
440
+ timer,
441
+ resolve: result => settle(resolve, result as T),
442
+ reject: error => settle(reject, error),
443
+ })
444
+ if (this.connected && this.socket !== undefined) {
445
+ this.socket.write(line + '\n')
446
+ } else {
447
+ // Not connected yet: queue; attach() flushes on the child's arrival.
448
+ this.outbox.push(line)
449
+ }
450
+ })
451
+ }
452
+
453
+ /** Terminate the child and its loopback connection. */
454
+ kill(): void {
455
+ try { this.socket?.destroy() } catch { /* already closed */ }
456
+ try { this.child.kill() } catch { /* already exited */ }
457
+ }
458
+ }
459
+
460
+ /** One view in the child: its id, used for every command. */
461
+ class RemoteView implements ElectronViewHandle {
462
+ constructor(readonly id: string, private readonly client: ElectronChildClient) {}
463
+
464
+ sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
465
+ return this.client.call<Record<string, unknown>>('command', {
466
+ viewId: this.id,
467
+ method,
468
+ params: params ?? {},
469
+ }, RPC_COMMAND_TIMEOUT_MS)
470
+ }
471
+
472
+ /** Ask the child to download a URL to a local file (keeps cookies/login). */
473
+ async download(url: string, savePath: string): Promise<void> {
474
+ // The child writes the file and reports its size, so the body never crosses
475
+ // the RPC socket.
476
+ await this.client.call<{ bytes: number }>('download', { viewId: this.id, url, savePath }, RPC_TRANSFER_TIMEOUT_MS)
477
+ }
478
+
479
+ /** Native capturePage snapshot of the view (PNG base64 + size). */
480
+ capture(): Promise<{ base64: string; width: number; height: number }> {
481
+ return this.client.call<{ base64: string; width: number; height: number }>('capture', { viewId: this.id }, RPC_TRANSFER_TIMEOUT_MS)
482
+ }
483
+
484
+ /** Export the session's cookies (login state). */
485
+ flushAuth(): Promise<ExportedCookie[]> {
486
+ return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(r => r.cookies)
487
+ }
488
+
489
+ /** Import cookies into the session (restore login state). */
490
+ restoreAuth(cookies: ExportedCookie[]): Promise<number> {
491
+ return this.client.call<{ restored: number }>('restoreAuth', { viewId: this.id, cookies }, RPC_COMMAND_TIMEOUT_MS).then(r => r.restored)
492
+ }
493
+
494
+ /** Remove cookies matching a site scope (stale challenge generations, logout). */
495
+ clearCookies(filter: { domain?: string; name?: string; all?: boolean }): Promise<{ removed: number; names: string[] }> {
496
+ return this.client.call<{ removed: number; names: string[] }>('clearCookies', {
497
+ viewId: this.id,
498
+ ...filter.domain !== undefined ? { domain: filter.domain } : {},
499
+ ...filter.name !== undefined ? { name: filter.name } : {},
500
+ ...filter.all === true ? { all: true } : {},
501
+ }, RPC_COMMAND_TIMEOUT_MS)
502
+ }
503
+
504
+ /** Read (and clear) the most recent auto-accepted JS dialog for the view. */
505
+ async clearDialog(): Promise<unknown> {
506
+ // client.call resolves the host's reply result directly (no wrapper), so
507
+ // the dialog object arrives as-is; a null reply means nothing was raised.
508
+ return this.client.call<unknown>('drainDialog', { viewId: this.id }, RPC_QUERY_TIMEOUT_MS)
509
+ }
510
+
511
+ /** Read the child's bounded console capture for this view. */
512
+ async readConsole(clear?: boolean): Promise<unknown> {
513
+ return this.client.call<unknown>('readConsole', { viewId: this.id, ...clear === true ? { clear: true } : {} }, RPC_QUERY_TIMEOUT_MS)
514
+ }
515
+
516
+ /** Read the child's bounded network capture for this view. */
517
+ async readNetwork(clear?: boolean): Promise<unknown> {
518
+ return this.client.call<unknown>('readNetwork', { viewId: this.id, ...clear === true ? { clear: true } : {} }, RPC_QUERY_TIMEOUT_MS)
519
+ }
520
+
521
+ /** Tell the child how to answer the next JS dialog on this view. */
522
+ async setDialogPolicy(policy: { behavior: 'accept' | 'dismiss'; promptText?: string }): Promise<unknown> {
523
+ return this.client.call<unknown>('setDialogPolicy', {
524
+ viewId: this.id,
525
+ behavior: policy.behavior,
526
+ ...policy.promptText === undefined ? {} : { promptText: policy.promptText },
527
+ }, RPC_QUERY_TIMEOUT_MS)
528
+ }
529
+
530
+ /** Ask the child to re-apply its token-aware chrome to the current document. */
531
+ async reinstallChrome(): Promise<void> {
532
+ await this.client.call('reinstallChrome', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS)
533
+ }
534
+
535
+ /** Set this view's browser-task label; selected task controls the shared title. */
536
+ async label(label: string): Promise<void> {
537
+ await this.client.call('label', { viewId: this.id, label }, RPC_COMMAND_TIMEOUT_MS)
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Self-hosted view host: spawns the plugin's Electron child on first use and
543
+ * keeps it alive until dispose(). Fallback when no desktop shell provides
544
+ * ctx.electronViewHost.
545
+ */
546
+ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
547
+ private client: ElectronChildClient | undefined
548
+ private server: Server | undefined
549
+ private pendingSocket: Socket | undefined
550
+ private readonly views = new Map<string, ElectronViewHandle>()
551
+ private readyPromise: Promise<void> | undefined
552
+ private disposed = false
553
+ /** Cached local-backend probe; locating Electron walks the filesystem. */
554
+ private availableProbe: boolean | undefined
555
+ /** Chrome listener; re-attached to every child this host spawns. */
556
+ private chromeEventListener: ((event: ChromeHostEvent) => void) | undefined
557
+
558
+ /**
559
+ * @param hostMainPath - the child entry script.
560
+ * @param options - `chromeWorld: 'isolated'` runs the injected chrome in its
561
+ * own JavaScript world, so visited pages cannot read its state or its
562
+ * binding token. `maskAutomation: false` leaves Electron's own User-Agent
563
+ * alone, and `userAgent` replaces it outright. Defaults to the proven
564
+ * main-world path with the automation fingerprint masked.
565
+ */
566
+ constructor(
567
+ private readonly hostMainPath: string,
568
+ private readonly options: {
569
+ readonly chromeWorld?: 'main' | 'isolated'
570
+ readonly userAgent?: string
571
+ readonly maskAutomation?: boolean
572
+ } = {},
573
+ ) {}
574
+
575
+ /**
576
+ * Cheap local usability probe, consulted by the provider's `available()`.
577
+ * Without it the provider reports itself usable unconditionally, so a missing
578
+ * Electron binary would surface only on the first browser tool call instead of
579
+ * at provider-selection time.
580
+ */
581
+ isAvailable(): boolean {
582
+ this.availableProbe ??= probeElectronAvailability()
583
+ return this.availableProbe
584
+ }
585
+
586
+ /**
587
+ * Forward the child's chrome tab requests to the provider.
588
+ *
589
+ * The child is respawned after a crash, so the listener is kept here and
590
+ * re-attached to each new client rather than handed to one client instance.
591
+ */
592
+ onChromeEvent(listener: (event: ChromeHostEvent) => void): void {
593
+ this.chromeEventListener = listener
594
+ this.client?.setEventListener(event => this.dispatchChromeEvent(event))
595
+ }
596
+
597
+ /**
598
+ * Validate one child-raised action before it reaches the provider.
599
+ *
600
+ * The child is trusted (it is our own process), but a malformed or truncated
601
+ * line must still not reach the tab model as a half-built request.
602
+ */
603
+ private dispatchChromeEvent(event: unknown): void {
604
+ const listener = this.chromeEventListener
605
+ if (listener === undefined) return
606
+ if (typeof event !== 'object' || event === null || Array.isArray(event)) return
607
+ const record = event as { type?: unknown; taskKey?: unknown; tabId?: unknown; toIndex?: unknown; url?: unknown }
608
+ const type = record.type
609
+ if (type !== 'new-tab' && type !== 'close-tab' && type !== 'activate-tab' && type !== 'move-tab') return
610
+ if (typeof record.taskKey !== 'string' || record.taskKey === '') return
611
+ listener({
612
+ type,
613
+ taskKey: record.taskKey,
614
+ ...typeof record.tabId === 'string' ? { tabId: record.tabId } : {},
615
+ // Drag-to-reorder: the index the tab was dropped at, after removal.
616
+ ...type === 'move-tab' && typeof record.toIndex === 'number' ? { toIndex: record.toIndex } : {},
617
+ // 点收藏来的新标签带着 url:这里也是**重建**事件的地方,漏一个字段它就被静默丢掉。
618
+ ...type === 'new-tab' && typeof record.url === 'string' && /^https?:[/][/]/i.test(record.url) ? { url: record.url } : {},
619
+ })
620
+ }
621
+
622
+ /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
623
+ private ready(): Promise<void> {
624
+ // The one case that must NOT self-heal: a disposed host is shutting down
625
+ // with its fiber, so respawning here (a late call, or a withView retry)
626
+ // would leave an orphaned Electron process. This error carries no dead
627
+ // code on purpose.
628
+ if (this.disposed) {
629
+ return Promise.reject(new Error('dsh-browser-plus: browser host is disposed'))
630
+ }
631
+ if (this.readyPromise !== undefined) return this.readyPromise
632
+ const started = this.start()
633
+ const wrapped = started.catch(error => {
634
+ // A failed startup must not poison the host forever: tear down whatever
635
+ // was half-created and let the next call retry from scratch.
636
+ if (this.readyPromise === wrapped) {
637
+ this.readyPromise = undefined
638
+ this.client?.kill()
639
+ this.client = undefined
640
+ this.server?.close()
641
+ this.server = undefined
642
+ this.pendingSocket = undefined
643
+ }
644
+ throw error
645
+ })
646
+ this.readyPromise = wrapped
647
+ return wrapped
648
+ }
649
+
650
+ private async start(): Promise<void> {
651
+ // Listen on an ephemeral loopback port; the child connects back.
652
+ const server = createServer(socket => {
653
+ if (this.client !== undefined) this.client.attach(socket)
654
+ else this.pendingSocket = socket
655
+ })
656
+ await new Promise<void>((resolve, reject) => {
657
+ server.once('error', reject)
658
+ server.listen(0, '127.0.0.1', () => resolve())
659
+ })
660
+ // A later server error (rare on a loopback ephemeral port) must not crash
661
+ // the process; the client's fail path handles the actual recovery.
662
+ server.on('error', error => {
663
+ process.stderr.write(`[dsh-browser-plus host] rpc server error: ${String(error)}\n`)
664
+ })
665
+ const address = server.address()
666
+ const port = typeof address === 'object' && address !== null ? address.port : 0
667
+ this.server = server
668
+ this.client = new ElectronChildClient(
669
+ this.hostMainPath,
670
+ port,
671
+ () => this.onChildExit(),
672
+ this.options.chromeWorld,
673
+ fingerprintArgs(this.options),
674
+ )
675
+ this.client.setEventListener(event => this.dispatchChromeEvent(event))
676
+ if (this.pendingSocket !== undefined) {
677
+ this.client.attach(this.pendingSocket)
678
+ this.pendingSocket = undefined
679
+ }
680
+ // Wait for the child's connection + readiness ping.
681
+ await withTimeout(this.client.call('ping', {}, RPC_QUERY_TIMEOUT_MS), READY_TIMEOUT_MS, 'browser host did not become ready')
682
+ }
683
+
684
+ /** The child died: tear down so the next use starts a fresh child. */
685
+ private onChildExit(): void {
686
+ if (this.disposed) return
687
+ this.client = undefined
688
+ this.server?.close()
689
+ this.server = undefined
690
+ this.pendingSocket = undefined
691
+ this.readyPromise = undefined
692
+ // Keep the views map: handles still resolve to ids; a fresh child simply
693
+ // has no such views yet, and reset_session reopens clean sessions.
694
+ }
695
+
696
+ createView(key?: string, label?: string): ElectronViewHandle {
697
+ // The seam is synchronous; the provider uses the handle immediately, so
698
+ // commands are deferred until the child is up and the view materialized.
699
+ const id = `view:${Math.random().toString(36).slice(2, 10)}`
700
+ const view = new DeferredRemoteView(id, label, currentLabel => this.ensureView(id, key, currentLabel))
701
+ this.views.set(id, view)
702
+ return view
703
+ }
704
+
705
+ private async ensureView(id: string, key?: string, label?: string): Promise<RemoteView> {
706
+ await this.ready()
707
+ const client = this.client
708
+ // Death path 5: ready() resolved but the child died before this read (a
709
+ // narrow race) — or the host was disposed, which ready() above already
710
+ // rejects. Tag it dead so withView retries once against a fresh child;
711
+ // materialization itself is safe to repeat because createView never ran.
712
+ if (client === undefined) throw browserHostDeadError('browser host unavailable')
713
+ await client.call('createView', {
714
+ viewId: id,
715
+ ...key !== undefined ? { key } : {},
716
+ ...label !== undefined ? { label } : {},
717
+ })
718
+ // If the view was destroyed while the createView RPC was in flight, do
719
+ // not re-insert a stale entry that would resurrect a dead child view.
720
+ if (this.views.get(id) === undefined) {
721
+ throw new Error('browser: view destroyed while starting')
722
+ }
723
+ const view = new RemoteView(id, client)
724
+ this.views.set(id, view)
725
+ return view
726
+ }
727
+
728
+ /**
729
+ * Send a CDP `Input.*` command to the host's chrome frame view.
730
+ *
731
+ * The frame is not a tab, so it has no view handle: this is a direct channel to
732
+ * it, used to drive the toolbar (the click tests, and anything that needs to
733
+ * exercise the chrome the way a human does).
734
+ */
735
+ async chromeInput(method: string, params: Record<string, unknown> = {}): Promise<void> {
736
+ await this.ready()
737
+ await this.client?.call('chromeInput', { method, params })
738
+ }
739
+
740
+ /**
741
+ * Evaluate an expression inside the chrome frame's document and return its value.
742
+ *
743
+ * The frame is not a tab, so nothing that targets the page can read it — without
744
+ * this, the toolbar's own animations could only be inferred from whatever the
745
+ * page's copy of the chrome logged. Used to assert the frame's motion directly.
746
+ */
747
+ async chromeEval(expression: string): Promise<unknown> {
748
+ await this.ready()
749
+ // call() already unwraps the reply's `result` field.
750
+ return await this.client?.call('chromeEval', { expression })
751
+ }
752
+
753
+ showView(handle: ElectronViewHandle): void {
754
+ // Fire-and-forget by design (visibility is best-effort), but a rejected
755
+ // promise must not become an unhandled rejection (crash on Node >= 15).
756
+ //
757
+ // Materialize BEFORE showing. createView is what tells the child the view
758
+ // exists, and it is sent lazily on first use; showView used to race ahead
759
+ // of it, so the child threw "unknown view", this catch swallowed it, and a
760
+ // brand-new tab's view was never made visible -- while createView had
761
+ // already marked it active. The result was a tab that could not be shown
762
+ // and, through the activeViewChanged guard, could not be switched to.
763
+ void this.ready()
764
+ .then(async () => {
765
+ if (handle instanceof DeferredRemoteView) await handle.materializeForShow()
766
+ await this.client?.call('showView', { viewId: handle.id })
767
+ })
768
+ .catch(() => { /* host unavailable */ })
769
+ }
770
+
771
+ destroyView(handle: ElectronViewHandle): void {
772
+ const view = this.views.get(handle.id)
773
+ if (view === undefined) return
774
+ this.views.delete(handle.id)
775
+ void this.ready()
776
+ .then(() => this.client?.call('destroyView', { viewId: handle.id }))
777
+ .catch(() => { /* child already gone */ })
778
+ }
779
+ /** Append one operation to the child's per-view trail. */
780
+ trace(viewId: string, entry: unknown): void {
781
+ void this.ready()
782
+ .then(() => this.client?.call('trace', { viewId, entry }))
783
+ .catch(() => { /* child gone */ })
784
+ }
785
+
786
+ /** List browser task keys with labels (legacy RPC name retained for compatibility). */
787
+ async listWindows(): Promise<Array<{ key: string; label: string }>> {
788
+ await this.ready()
789
+ const client = this.client
790
+ if (client === undefined) throw new Error('browser host unavailable')
791
+ const r = await client.call<{ windows: Array<{ key: string; label: string }> }>('listWindows', {}, RPC_QUERY_TIMEOUT_MS)
792
+ return r.windows
793
+ }
794
+
795
+ /** List task summaries from the self-hosted visible workspace. */
796
+ async listTasks(): Promise<readonly BrowserTaskInfo[]> {
797
+ await this.ready()
798
+ const client = this.client
799
+ if (client === undefined) throw new Error('browser host unavailable')
800
+ const result = await client.call<{ tasks: BrowserTaskInfo[] }>('listTasks', {}, RPC_QUERY_TIMEOUT_MS)
801
+ return result.tasks
802
+ }
803
+
804
+ /** Read one task summary from the self-hosted visible workspace. */
805
+ async getTask(key: string): Promise<BrowserTaskInfo | undefined> {
806
+ await this.ready()
807
+ const client = this.client
808
+ if (client === undefined) throw new Error('browser host unavailable')
809
+ const result = await client.call<{ task: BrowserTaskInfo | null }>('getTask', { key }, RPC_QUERY_TIMEOUT_MS)
810
+ return result.task ?? undefined
811
+ }
812
+
813
+ /** Update one task summary in the self-hosted visible workspace. */
814
+ async updateTask(key: string, task: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined> {
815
+ await this.ready()
816
+ const client = this.client
817
+ if (client === undefined) throw new Error('browser host unavailable')
818
+ const result = await client.call<{ task: BrowserTaskInfo | null }>('updateTask', { key, task }, RPC_QUERY_TIMEOUT_MS)
819
+ return result.task ?? undefined
820
+ }
821
+
822
+ /** Shut the child and the RPC server down. */
823
+ dispose(): void {
824
+ this.disposed = true
825
+ this.client?.kill()
826
+ this.client = undefined
827
+ this.server?.close()
828
+ this.server = undefined
829
+ this.readyPromise = undefined
830
+ this.views.clear()
831
+ }
832
+ }
833
+
834
+ /** @internal Deferred view recovery handle; exported for focused behavior tests. */
835
+ export class DeferredRemoteView implements ElectronViewHandle {
836
+ private materialized: Promise<RemoteView> | undefined
837
+ private recoveryCompositorSettle: Promise<void> | undefined
838
+ private taskLabel: string | undefined
839
+ private labelRevision = 0
840
+
841
+ constructor(
842
+ readonly id: string,
843
+ label: string | undefined,
844
+ private readonly materialize: (label: string | undefined) => Promise<RemoteView>,
845
+ ) {
846
+ this.taskLabel = label
847
+ }
848
+
849
+ /**
850
+ * Materialize once and cache: every sendCommand on the same handle must
851
+ * target the SAME child view (re-materializing would re-run createView and
852
+ * duplicate the view). A FAILED materialization is reset so a later call
853
+ * (e.g. after the host restarted) can retry instead of being poisoned.
854
+ */
855
+ /**
856
+ * Make sure the child knows this view exists. showView needs this: without
857
+ * it the showView RPC can reach the child before createView does, and the
858
+ * child then throws "unknown view" while the caller swallows the error.
859
+ */
860
+ async materializeForShow(): Promise<void> { await this.materializeOnce() }
861
+
862
+ private materializeOnce(): Promise<RemoteView> {
863
+ if (this.materialized === undefined) {
864
+ const pending = this.materialize(this.taskLabel)
865
+ this.materialized = pending.catch(error => {
866
+ if (this.materialized === pending) this.materialized = undefined
867
+ throw error
868
+ })
869
+ }
870
+ return this.materialized
871
+ }
872
+
873
+ private scheduleRecoveredCompositorSettle(): void {
874
+ this.recoveryCompositorSettle = new Promise<void>(resolve => {
875
+ setTimeout(resolve, RECOVERY_CAPTURE_SETTLE_MS)
876
+ })
877
+ }
878
+
879
+ /** Wait for a recovered child to acquire a paintable compositor surface. */
880
+ private async settleRecoveredCompositorForCapture(): Promise<void> {
881
+ // A second recovery can happen while a prior settle delay is resolving.
882
+ while (this.recoveryCompositorSettle !== undefined) {
883
+ const settle = this.recoveryCompositorSettle
884
+ await settle
885
+ if (this.recoveryCompositorSettle === settle) {
886
+ this.recoveryCompositorSettle = undefined
887
+ return
888
+ }
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Run an operation against the materialized view, with ONE self-heal
894
+ * retry: if the child died while this handle was cached (host restart or a
895
+ * recycle), dropping the cached materialization and re-materializing
896
+ * creates a fresh child view for the same session handle, so a session
897
+ * survives a host crash/recycle without a manual reset.
898
+ */
899
+ private async withView<T>(
900
+ run: (view: RemoteView) => Promise<T>,
901
+ afterRecovery?: () => Promise<void>,
902
+ method?: string,
903
+ ): Promise<T> {
904
+ try {
905
+ return await run(await this.materializeOnce())
906
+ } catch (error) {
907
+ // Judge by the stable code (see isBrowserHostDead), not by message text:
908
+ // a mid-call exit, spawn failure, or socket close used to escape this
909
+ // check because their messages differ from the dead early-exit's.
910
+ if (!isBrowserHostDead(error)) throw error
911
+ if (method !== undefined && method.startsWith(UNREPLAYABLE_METHOD_PREFIX)) {
912
+ // Re-materializing yields an about:blank view, so replaying input would
913
+ // act on an empty document and still report success. Name the loss.
914
+ throw new BrowserError(
915
+ `browser: the browser host restarted and the page was lost before ${method}; reopen the page and retry`,
916
+ 'BROWSER_HOST_RESTARTED',
917
+ )
918
+ }
919
+ // Stale child: forget the cached view, then re-create a fresh pair.
920
+ this.materialized = undefined
921
+ const view = await this.materializeOnce()
922
+ this.scheduleRecoveredCompositorSettle()
923
+ await afterRecovery?.()
924
+ return run(view)
925
+ }
926
+ }
927
+
928
+ async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
929
+ const settle = method === 'Page.captureScreenshot'
930
+ ? () => this.settleRecoveredCompositorForCapture()
931
+ : undefined
932
+ await settle?.()
933
+ return this.withView(view => view.sendCommand(method, params), settle, method)
934
+ }
935
+
936
+ async download(url: string, savePath: string): Promise<void> {
937
+ return this.withView(view => view.download(url, savePath))
938
+ }
939
+
940
+ async capture(): Promise<{ base64: string; width: number; height: number }> {
941
+ await this.settleRecoveredCompositorForCapture()
942
+ return this.withView(view => view.capture(), () => this.settleRecoveredCompositorForCapture())
943
+ }
944
+
945
+ async flushAuth(): Promise<ExportedCookie[]> {
946
+ return this.withView(view => view.flushAuth())
947
+ }
948
+
949
+ async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
950
+ return this.withView(view => view.restoreAuth(cookies))
951
+ }
952
+
953
+ async clearCookies(filter: { domain?: string; name?: string; all?: boolean }): Promise<{ removed: number; names: string[] }> {
954
+ return this.withView(view => view.clearCookies(filter))
955
+ }
956
+
957
+ async clearDialog(): Promise<unknown> {
958
+ return this.withView(view => view.clearDialog())
959
+ }
960
+
961
+ async setDialogPolicy(policy: { behavior: 'accept' | 'dismiss'; promptText?: string }): Promise<unknown> {
962
+ return this.withView(view => view.setDialogPolicy(policy))
963
+ }
964
+
965
+ async readConsole(clear?: boolean): Promise<unknown> {
966
+ return this.withView(view => view.readConsole(clear))
967
+ }
968
+
969
+ async readNetwork(clear?: boolean): Promise<unknown> {
970
+ return this.withView(view => view.readNetwork(clear))
971
+ }
972
+
973
+ async reinstallChrome(): Promise<void> {
974
+ return this.withView(view => view.reinstallChrome())
975
+ }
976
+
977
+ async label(label: string): Promise<void> {
978
+ const previousLabel = this.taskLabel
979
+ const revision = ++this.labelRevision
980
+ this.taskLabel = label
981
+ try {
982
+ await this.withView(view => view.label(label))
983
+ } catch (error) {
984
+ if (this.labelRevision === revision) this.taskLabel = previousLabel
985
+ throw error
986
+ }
987
+ }
988
+ }
989
+
990
+ /** Reject a promise if it does not settle within the budget. */
991
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
992
+ return new Promise<T>((resolve, reject) => {
993
+ const timer = setTimeout(() => reject(new Error(`${message} (${ms}ms)`)), ms)
994
+ promise.then(
995
+ value => { clearTimeout(timer); resolve(value) },
996
+ error => { clearTimeout(timer); reject(error) },
997
+ )
998
+ })
999
+ }
1000
+
1001
+ /** Default host-main path relative to this module's build output. */
1002
+ export function defaultHostMainPath(): string {
1003
+ return fileURLToPath(new URL('./host-main.js', import.meta.url))
1004
+ }