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.
- package/CHANGELOG.md +166 -0
- package/LICENSE +22 -0
- package/NOTICE.md +7 -0
- package/README.en.md +100 -0
- package/README.md +99 -2
- package/assets/dsh-browser-plus-256.png +0 -0
- package/assets/dsh-browser-plus-512.png +0 -0
- package/assets/dsh-browser-plus-small.svg +9 -0
- package/assets/dsh-browser-plus.ico +0 -0
- package/assets/dsh-browser-plus.svg +11 -0
- package/assets/readme-workspace.png +0 -0
- package/cordis.patch.yml +17 -0
- package/docs/MIGRATION.md +48 -0
- package/docs/README.md +22 -0
- package/docs/SOAK-CHECKLIST.md +98 -0
- package/docs/architecture.md +88 -0
- package/docs/tool-reference.md +124 -0
- package/docs/user-guide.md +121 -0
- package/docs/why-browser.md +45 -0
- package/lib/browser/runtime.d.ts +225 -0
- package/lib/browser/runtime.js +302 -0
- package/lib/browser/types.d.ts +668 -0
- package/lib/browser/types.js +18 -0
- package/lib/browser-electron/auth-cookies.d.ts +54 -0
- package/lib/browser-electron/auth-cookies.js +83 -0
- package/lib/browser-electron/chrome-state.d.ts +187 -0
- package/lib/browser-electron/chrome-state.js +12 -0
- package/lib/browser-electron/entry.d.ts +66 -0
- package/lib/browser-electron/entry.js +62 -0
- package/lib/browser-electron/fingerprint.d.ts +29 -0
- package/lib/browser-electron/fingerprint.js +42 -0
- package/lib/browser-electron/host-main.d.ts +18 -0
- package/lib/browser-electron/host-main.js +2494 -0
- package/lib/browser-electron/icon.d.ts +11 -0
- package/lib/browser-electron/icon.js +23 -0
- package/lib/browser-electron/page-chrome.d.ts +21 -0
- package/lib/browser-electron/page-chrome.js +2034 -0
- package/lib/browser-electron/provider.d.ts +709 -0
- package/lib/browser-electron/provider.js +2575 -0
- package/lib/browser-electron/remote-host.d.ts +143 -0
- package/lib/browser-electron/remote-host.js +952 -0
- package/lib/browser-electron/task-summary.d.ts +2 -0
- package/lib/browser-electron/task-summary.js +12 -0
- package/lib/browser-electron/task-thumbnail.d.ts +11 -0
- package/lib/browser-electron/task-thumbnail.js +9 -0
- package/lib/browser-electron/write-guard.d.ts +41 -0
- package/lib/browser-electron/write-guard.js +123 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +14 -0
- package/lib/tool-browser/index.d.ts +31 -0
- package/lib/tool-browser/index.js +1931 -0
- package/package.json +95 -4
- package/screenshots.json +3 -0
- package/scripts/build-icons.mjs +80 -0
- package/scripts/capture-window.ps1 +79 -0
- package/scripts/crop-image.ps1 +20 -0
- package/scripts/smoke-browser-tools.mjs +1968 -0
- package/scripts/smoke-chrome-world.mjs +63 -0
- package/scripts/smoke-electron-host.mjs +50 -0
- package/src/browser/runtime.ts +470 -0
- package/src/browser/types.ts +649 -0
- package/src/browser-electron/auth-cookies.ts +125 -0
- package/src/browser-electron/chrome-state.ts +174 -0
- package/src/browser-electron/entry.ts +115 -0
- package/src/browser-electron/fingerprint.ts +45 -0
- package/src/browser-electron/host-main.ts +2330 -0
- package/src/browser-electron/icon.ts +26 -0
- package/src/browser-electron/page-chrome.ts +2046 -0
- package/src/browser-electron/provider.ts +3088 -0
- package/src/browser-electron/remote-host.ts +1004 -0
- package/src/browser-electron/task-summary.ts +10 -0
- package/src/browser-electron/task-thumbnail.ts +17 -0
- package/src/browser-electron/write-guard.ts +134 -0
- package/src/index.ts +52 -0
- package/src/tool-browser/index.ts +1974 -0
- 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
|
+
}
|