dsh-builtin-browser 0.1.19 → 0.1.21
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/README.en.md +22 -8
- package/README.md +22 -8
- package/docs/architecture.md +2 -2
- package/docs/user-guide.md +3 -3
- package/lib/browser-electron/host-main.js +38 -7
- package/lib/browser-electron/remote-host.d.ts +76 -1
- package/lib/browser-electron/remote-host.js +294 -123
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +35 -7
- package/src/browser-electron/remote-host.ts +315 -115
|
@@ -29,7 +29,7 @@ import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_proces
|
|
|
29
29
|
import { randomBytes } from 'node:crypto'
|
|
30
30
|
import { createRequire } from 'node:module'
|
|
31
31
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
|
|
32
|
-
import { basename, dirname, join } from 'node:path'
|
|
32
|
+
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
33
33
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
35
|
import type { BrowserUserAction, ElectronBrowserViewHost, ElectronViewHandle, ScreenshotOptions } from './provider.js'
|
|
@@ -42,56 +42,110 @@ const READY_TIMEOUT_MS = 20_000
|
|
|
42
42
|
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Locate
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
45
|
+
* Locate a SPAWNABLE Electron binary — one that runs a script given as an
|
|
46
|
+
* argument, i.e. a BARE electron, never a packaged app executable:
|
|
47
|
+
* 0. ELECTRON_PATH (explicit override — user intent beats auto-discovery),
|
|
48
|
+
* 1. the plugin's own electron package (a real dependency since 0.1.18),
|
|
49
|
+
* found by a pure filesystem walk — never require(), whose semantics
|
|
50
|
+
* differ inside an Electron main process (there it is the built-in API
|
|
51
|
+
* module, not the npm package) and which would trigger electron 44+'s
|
|
52
|
+
* lazy binary download;
|
|
53
|
+
* 2. every DSH install anchor (electron installed separately, e.g.
|
|
54
|
+
* `dsh plugin add electron` into the profile or a global prefix),
|
|
55
|
+
* choosing the NEWEST version found. Older Electron releases (e.g.
|
|
56
|
+
* 33.x) have a compositor defect that intermittently breaks page
|
|
57
|
+
* capture, so prefer the newest binary available in the environment,
|
|
58
|
+
* 3. the current process, when it IS a bare Electron (dev hosts, e.g.
|
|
59
|
+
* `electron .`): reuse the host binary. Packaged app executables
|
|
60
|
+
* (resources/app.asar beside the exe) are never reused — they ignore
|
|
61
|
+
* the script argument and launch the app itself, typically exiting
|
|
62
|
+
* immediately via the single-instance lock, which is exactly the
|
|
63
|
+
* "browser host exited (code=0)" failure on DSH Desktop;
|
|
56
64
|
* 4. last resort: the process ancestry, for hosts that run this plugin in a
|
|
57
|
-
* child Node process of their own Electron main process
|
|
65
|
+
* child Node process of their own bare-Electron main process.
|
|
58
66
|
* @returns the path to the Electron executable.
|
|
59
67
|
*/
|
|
68
|
+
/** The discovery inputs {@link resolveElectronPathImpl} selects over, injectable for tests. */
|
|
69
|
+
interface ElectronPathInputs {
|
|
70
|
+
/** ELECTRON_PATH (may be unset, empty, or pointing at a missing file). */
|
|
71
|
+
readonly override: string | undefined
|
|
72
|
+
/** True when running inside an Electron main process. */
|
|
73
|
+
readonly inElectron: boolean
|
|
74
|
+
/** process.execPath (candidate for in-process host reuse). */
|
|
75
|
+
readonly execPath: string
|
|
76
|
+
/** The plugin's own electron package probe (bundled). */
|
|
77
|
+
readonly bundled: () => string | undefined
|
|
78
|
+
/** The DSH install-anchor probe. */
|
|
79
|
+
readonly anchored: () => string | undefined
|
|
80
|
+
/** The process-ancestry probe (last resort). */
|
|
81
|
+
readonly ancestry: () => string | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
function resolveElectronPath(): string {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|| existsSync(join(pkgRoot, 'dist', 'electron'))
|
|
83
|
-
if (binaryReady) {
|
|
84
|
-
const resolved: unknown = require('electron')
|
|
85
|
-
if (typeof resolved === 'string' && resolved.length > 0) return resolved
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// continue probing
|
|
90
|
-
}
|
|
91
|
-
// 2. Explicit override (user intent wins).
|
|
92
|
-
const override = process.env.ELECTRON_PATH
|
|
85
|
+
return resolveElectronPathImpl({
|
|
86
|
+
override: process.env.ELECTRON_PATH,
|
|
87
|
+
inElectron: typeof process.versions.electron === 'string',
|
|
88
|
+
execPath: process.execPath,
|
|
89
|
+
bundled: bundledElectronBinary,
|
|
90
|
+
anchored: anchoredElectronBinary,
|
|
91
|
+
ancestry: hostElectronViaProcessTree,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pure selection over the discovery probes, in order:
|
|
97
|
+
* 0. override (user intent) → 1. bundled package → 2. anchors (newest) →
|
|
98
|
+
* 3. in-process BARE host → 4. process ancestry → throw.
|
|
99
|
+
* Packaged app executables are never reused (steps 3/4 skip them).
|
|
100
|
+
*/
|
|
101
|
+
function resolveElectronPathImpl(inputs: ElectronPathInputs): string {
|
|
102
|
+
// 0. Explicit override first: a user-set ELECTRON_PATH is a deliberate
|
|
103
|
+
// choice and must beat every auto-discovery (including the bundled
|
|
104
|
+
// package) — auto-discovery is a fallback, not an override.
|
|
105
|
+
const override = inputs.override
|
|
93
106
|
if (typeof override === 'string' && override.length > 0 && existsSync(override)) return override
|
|
94
|
-
//
|
|
107
|
+
// 1. The plugin's own electron package (filesystem probe). Never require():
|
|
108
|
+
// inside an Electron main process require('electron') is the built-in
|
|
109
|
+
// API module, not the npm package, and electron 44+ downloads its binary
|
|
110
|
+
// on first require — probing must stay side-effect free (available()
|
|
111
|
+
// would otherwise block DSH startup on the download).
|
|
112
|
+
const bundled = inputs.bundled()
|
|
113
|
+
if (bundled !== undefined) return bundled
|
|
114
|
+
// 2. DSH install anchors, newest wins. (The plugin's own tree — plain and
|
|
115
|
+
// pnpm store layouts — was already scanned in step 1; the anchors cover
|
|
116
|
+
// electron installed separately, e.g. `dsh plugin add electron` into the
|
|
117
|
+
// profile or a global prefix.)
|
|
118
|
+
const anchored = inputs.anchored()
|
|
119
|
+
if (anchored !== undefined) return anchored
|
|
120
|
+
// 3. The current process IS a bare Electron (dev hosts, e.g. `electron .`):
|
|
121
|
+
// reuse the host binary — it cannot be missing because we are running on
|
|
122
|
+
// it. A PACKAGED app executable (resources/app.asar beside it, e.g. DSH
|
|
123
|
+
// Desktop.exe) is NOT spawnable as bare electron: it ignores the script
|
|
124
|
+
// argument, launches the app itself, and typically exits immediately
|
|
125
|
+
// (single-instance lock) — so it is never reused.
|
|
126
|
+
if (inputs.inElectron) {
|
|
127
|
+
const exe = inputs.execPath
|
|
128
|
+
if (typeof exe === 'string' && exe.length > 0 && existsSync(exe) && isBareElectron(exe)) return exe
|
|
129
|
+
}
|
|
130
|
+
// 4. Last resort for bare-Electron hosts: walk the process ancestry (the
|
|
131
|
+
// plugin may run in a child Node process of an Electron main process).
|
|
132
|
+
const hostExe = inputs.ancestry()
|
|
133
|
+
if (hostExe !== undefined) return hostExe
|
|
134
|
+
throw new Error(
|
|
135
|
+
'dsh-builtin-browser: cannot locate a spawnable Electron binary. ' +
|
|
136
|
+
'The electron package ships with the plugin (0.1.18+); Electron 44+ downloads its binary on first use, ' +
|
|
137
|
+
'so if the package is installed but the binary is missing, run `npx install-electron` once (needs network) ' +
|
|
138
|
+
'in the profile that installed the plugin, then retry. Alternatively set ELECTRON_PATH to an electron ' +
|
|
139
|
+
'executable. On installs from before electron became a dependency, add it to the active profile ' +
|
|
140
|
+
'(`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the profile is `desktop`). ' +
|
|
141
|
+
'Note: the host\'s own executable cannot be reused when it is a packaged Electron app (e.g. DSH Desktop.exe) ' +
|
|
142
|
+
'— only a real electron binary can be spawned.',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** DSH install anchors: electron installed separately (profile / global prefix), newest wins. */
|
|
147
|
+
function anchoredElectronBinary(): string | undefined {
|
|
148
|
+
const require = createRequire(import.meta.url)
|
|
95
149
|
const candidates: Array<{ version: string; path: string }> = []
|
|
96
150
|
const add = (version: string, path: string | undefined): void => {
|
|
97
151
|
if (path !== undefined) candidates.push({ version, path })
|
|
@@ -108,73 +162,95 @@ function resolveElectronPath(): string {
|
|
|
108
162
|
for (const anchor of anchors) {
|
|
109
163
|
try {
|
|
110
164
|
const resolved: unknown = require.resolve('electron', { paths: [anchor] })
|
|
111
|
-
|
|
165
|
+
// Inside an Electron main process resolve('electron') yields the
|
|
166
|
+
// built-in module NAME (not a path) — requiring an absolute path keeps
|
|
167
|
+
// electronExeBeside from probing cwd-relative garbage like ./dist/.
|
|
168
|
+
if (typeof resolved === 'string' && resolved.length > 0 && isAbsolute(resolved)) {
|
|
112
169
|
add(versionOf(resolved), electronExeBeside(resolved))
|
|
113
170
|
}
|
|
114
171
|
} catch {
|
|
115
172
|
// continue probing
|
|
116
173
|
}
|
|
117
174
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
return pickNewest(candidates)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The plugin's own electron package binary, found by a pure filesystem walk
|
|
180
|
+
* (never require(), whose semantics differ inside an Electron main process —
|
|
181
|
+
* there `require('electron')` is the built-in API module, not the npm package
|
|
182
|
+
* — and which would trigger electron 44+'s lazy binary download when the dist
|
|
183
|
+
* is missing). Checks every `node_modules/electron` above this module,
|
|
184
|
+
* including pnpm virtual-store layouts, and returns the NEWEST ready binary.
|
|
185
|
+
* Scan ONLY the plugin's own install tree: never cwd or the executable's
|
|
186
|
+
* directory, which can sit inside an unrelated project whose node_modules may
|
|
187
|
+
* hold a different electron (executing the newest random binary in the
|
|
188
|
+
* filesystem is a supply-chain hazard with no benefit).
|
|
189
|
+
*/
|
|
190
|
+
function bundledElectronBinary(): string | undefined {
|
|
191
|
+
const candidates: Array<{ version: string; path: string }> = []
|
|
192
|
+
const consider = (pkgRoot: string, versionHint: string): void => {
|
|
193
|
+
const exe = electronDistExe(pkgRoot)
|
|
194
|
+
if (exe !== undefined) candidates.push({ version: versionHint || pkgVersion(pkgRoot), path: exe })
|
|
195
|
+
}
|
|
196
|
+
let dir = fileURLToPath(new URL('.', import.meta.url))
|
|
197
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
198
|
+
// Plain (npm/yarn, hoisted) layout: <ancestor>/node_modules/electron.
|
|
199
|
+
const plain = join(dir, 'node_modules', 'electron')
|
|
200
|
+
if (existsSync(join(plain, 'package.json'))) consider(plain, '')
|
|
201
|
+
// pnpm layout: <ancestor>/node_modules/.pnpm or <ancestor>/.pnpm, with
|
|
202
|
+
// electron@<version>/node_modules/electron entries.
|
|
203
|
+
for (const storeRoot of [join(dir, 'node_modules', '.pnpm'), join(dir, '.pnpm')]) {
|
|
204
|
+
if (!existsSync(storeRoot)) continue
|
|
205
|
+
for (const entry of readdirSync(storeRoot)) {
|
|
206
|
+
if (!entry.startsWith('electron@')) continue
|
|
207
|
+
const pkg = join(storeRoot, entry, 'node_modules', 'electron')
|
|
208
|
+
// Strip pnpm's peer-dependency suffix (electron@44.0.0_@types+node@22).
|
|
209
|
+
if (existsSync(join(pkg, 'package.json'))) consider(pkg, entry.slice('electron@'.length).split('_')[0])
|
|
136
210
|
}
|
|
137
|
-
const parent = join(dir, '..')
|
|
138
|
-
if (parent === dir) break
|
|
139
|
-
dir = parent
|
|
140
211
|
}
|
|
212
|
+
const parent = join(dir, '..')
|
|
213
|
+
if (parent === dir) break
|
|
214
|
+
dir = parent
|
|
141
215
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
216
|
+
return pickNewest(candidates)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The electron version from a package root's package.json, or '0.0.0'. */
|
|
220
|
+
function pkgVersion(pkgRoot: string): string {
|
|
221
|
+
try {
|
|
222
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version?: unknown }
|
|
223
|
+
return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : '0.0.0'
|
|
224
|
+
} catch {
|
|
225
|
+
return '0.0.0'
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The highest-versioned candidate's path, or undefined when none. */
|
|
230
|
+
function pickNewest(candidates: Array<{ version: string; path: string }>): string | undefined {
|
|
231
|
+
let best: { version: string; path: string } | undefined
|
|
232
|
+
for (const c of candidates) {
|
|
233
|
+
if (best === undefined || compareVersions(c.version, best.version) > 0) best = c
|
|
234
|
+
}
|
|
235
|
+
return best?.path
|
|
161
236
|
}
|
|
162
237
|
|
|
163
238
|
/**
|
|
164
239
|
* Last-resort Electron discovery: walk this process's ancestry looking for a
|
|
165
|
-
* parent process whose executable is
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* so reusing it needs no extra
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
240
|
+
* parent process whose executable is a BARE Electron binary (only bare
|
|
241
|
+
* electron can be spawned with a script argument). Covers hosts that run the
|
|
242
|
+
* plugin in a child Node process of their own Electron main process: the host
|
|
243
|
+
* binary is already on disk and running, so reusing it needs no extra
|
|
244
|
+
* install. Filesystem-only on POSIX (/proc); on Windows a best-effort
|
|
245
|
+
* PowerShell CIM query is used — this path only runs when every other lookup
|
|
246
|
+
* failed, so its latency never touches the happy path, and a
|
|
247
|
+
* missing/blocked powershell simply yields nothing.
|
|
172
248
|
*/
|
|
173
249
|
function hostElectronViaProcessTree(): string | undefined {
|
|
174
250
|
let pid: number | undefined = process.ppid
|
|
175
251
|
for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
|
|
176
252
|
const exe = processExecutable(pid)
|
|
177
|
-
if (exe !== undefined &&
|
|
253
|
+
if (exe !== undefined && isBareElectron(exe)) return exe
|
|
178
254
|
pid = processParent(pid)
|
|
179
255
|
}
|
|
180
256
|
return undefined
|
|
@@ -236,6 +312,32 @@ function isElectronBinary(exe: string): boolean {
|
|
|
236
312
|
|| existsSync(join(resources, 'default_app.asar'))
|
|
237
313
|
}
|
|
238
314
|
|
|
315
|
+
/**
|
|
316
|
+
* True when the executable is a BARE (unpackaged) Electron that can be
|
|
317
|
+
* spawned with a script argument (`exe script.js --rpc-port N`). Packaged
|
|
318
|
+
* apps carry their code inside a resources dir (`app.asar`, or an unpacked
|
|
319
|
+
* `app` dir) and are NOT spawnable as bare electron: they ignore the script
|
|
320
|
+
* argument and launch the app itself, typically exiting immediately via the
|
|
321
|
+
* single-instance lock — spawning e.g. DSH Desktop.exe is exactly the
|
|
322
|
+
* "browser host exited (code=0)" failure. A portable single-file build (no
|
|
323
|
+
* resources dir at all) likewise cannot run a script argument.
|
|
324
|
+
*/
|
|
325
|
+
function isBareElectron(exe: string): boolean {
|
|
326
|
+
const dir = dirname(exe)
|
|
327
|
+
// Windows/Linux keep resources beside the binary; macOS app bundles keep
|
|
328
|
+
// them in Contents/Resources, one level above the binary's MacOS dir.
|
|
329
|
+
const resourcesList = process.platform === 'darwin'
|
|
330
|
+
? [join(dir, '..', 'Resources'), join(dir, 'resources')]
|
|
331
|
+
: [join(dir, 'resources')]
|
|
332
|
+
for (const resources of resourcesList) {
|
|
333
|
+
if (existsSync(join(resources, 'app.asar')) || existsSync(join(resources, 'app'))) return false
|
|
334
|
+
}
|
|
335
|
+
// A real electron always ships a resources dir somewhere; requiring one
|
|
336
|
+
// keeps every not-actually-spawnable layout out.
|
|
337
|
+
if (!resourcesList.some(p => existsSync(p))) return false
|
|
338
|
+
return isElectronBinary(exe)
|
|
339
|
+
}
|
|
340
|
+
|
|
239
341
|
/** Extract an electron version like "43.4.0" from a path containing it. */
|
|
240
342
|
function versionOf(path: string): string {
|
|
241
343
|
const match = /electron@(\d+\.\d+\.\d+)/.exec(path)
|
|
@@ -281,6 +383,20 @@ interface Pending {
|
|
|
281
383
|
reject(err: Error): void
|
|
282
384
|
}
|
|
283
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Thrown whenever the browser host (the Electron child) is gone — it exited,
|
|
388
|
+
* failed to start, or its RPC connection dropped. Operations against a dead
|
|
389
|
+
* host can never succeed, so the view layer treats this as "rebuild against a
|
|
390
|
+
* fresh host" instead of a page-level error. Kept module-private: callers
|
|
391
|
+
* observe the behavior (auto-restart), not the class.
|
|
392
|
+
*/
|
|
393
|
+
class HostGoneError extends Error {
|
|
394
|
+
constructor(message: string) {
|
|
395
|
+
super(message)
|
|
396
|
+
this.name = 'HostGoneError'
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
284
400
|
/**
|
|
285
401
|
* Line-delimited JSON-RPC client over a local TCP socket. Electron's main
|
|
286
402
|
* process on Windows does not receive piped stdin, so the parent listens on a
|
|
@@ -311,8 +427,11 @@ class ElectronChildClient {
|
|
|
311
427
|
private readonly token: string,
|
|
312
428
|
private readonly onExit?: () => void,
|
|
313
429
|
private readonly onUserAction?: (action: BrowserUserAction) => void,
|
|
430
|
+
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
431
|
+
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
432
|
+
private readonly executable?: string,
|
|
314
433
|
) {
|
|
315
|
-
const electron = resolveElectronPath()
|
|
434
|
+
const electron = this.executable ?? resolveElectronPath()
|
|
316
435
|
process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
|
|
317
436
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
318
437
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
@@ -358,7 +477,10 @@ class ElectronChildClient {
|
|
|
358
477
|
if (this.dead) return
|
|
359
478
|
this.dead = true
|
|
360
479
|
this.connected = false
|
|
361
|
-
|
|
480
|
+
// Tag every failure as "host gone" so the view layer can tell a dead host
|
|
481
|
+
// apart from a page-level error and rebuild against a fresh child.
|
|
482
|
+
const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
|
|
483
|
+
for (const pending of this.pending.values()) pending.reject(wrapped)
|
|
362
484
|
this.pending.clear()
|
|
363
485
|
this.outbox = []
|
|
364
486
|
this.onExit?.()
|
|
@@ -469,14 +591,22 @@ class ElectronChildClient {
|
|
|
469
591
|
/** Send one command and await the reply. */
|
|
470
592
|
call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
|
|
471
593
|
if (this.dead) {
|
|
472
|
-
return Promise.reject(new
|
|
594
|
+
return Promise.reject(new HostGoneError('dsh-builtin-browser: browser host is not running'))
|
|
473
595
|
}
|
|
474
596
|
const id = this.nextId++
|
|
475
597
|
const line = JSON.stringify({ id, op, ...payload })
|
|
476
598
|
return new Promise<T>((resolve, reject) => {
|
|
477
599
|
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
478
600
|
if (this.connected && this.socket !== undefined) {
|
|
479
|
-
|
|
601
|
+
try {
|
|
602
|
+
this.socket.write(line + '\n')
|
|
603
|
+
} catch {
|
|
604
|
+
// The socket died between the dead check and the write; the 'close'
|
|
605
|
+
// handler fails the rest. Reject THIS call as host-gone so the
|
|
606
|
+
// caller can recover immediately instead of hanging.
|
|
607
|
+
this.pending.delete(id)
|
|
608
|
+
reject(new HostGoneError('dsh-builtin-browser: browser host connection closed'))
|
|
609
|
+
}
|
|
480
610
|
} else {
|
|
481
611
|
// Not connected yet: queue; attach() flushes on the child's arrival.
|
|
482
612
|
this.outbox.push(line)
|
|
@@ -547,7 +677,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
547
677
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
548
678
|
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
549
679
|
|
|
550
|
-
constructor(
|
|
680
|
+
constructor(
|
|
681
|
+
private readonly hostMainPath: string,
|
|
682
|
+
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
683
|
+
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
684
|
+
private readonly spawnExecutable?: string,
|
|
685
|
+
) {}
|
|
551
686
|
|
|
552
687
|
/**
|
|
553
688
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
@@ -561,8 +696,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
561
696
|
try {
|
|
562
697
|
resolveElectronPath()
|
|
563
698
|
this.electronAvailable = true
|
|
564
|
-
} catch {
|
|
699
|
+
} catch (error) {
|
|
565
700
|
this.electronAvailable = false
|
|
701
|
+
// The provider will be reported unavailable, so the detailed
|
|
702
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
703
|
+
// otherwise never reach the user — surface it on stderr.
|
|
704
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
566
705
|
}
|
|
567
706
|
}
|
|
568
707
|
return this.electronAvailable
|
|
@@ -570,6 +709,10 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
570
709
|
|
|
571
710
|
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
572
711
|
private ready(): Promise<void> {
|
|
712
|
+
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
713
|
+
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
714
|
+
// nobody will ever kill.
|
|
715
|
+
if (this.disposed) throw new HostGoneError('browser host disposed')
|
|
573
716
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
574
717
|
const started = this.start()
|
|
575
718
|
const wrapped = started.catch(error => {
|
|
@@ -581,6 +724,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
581
724
|
this.client = undefined
|
|
582
725
|
this.server?.close()
|
|
583
726
|
this.server = undefined
|
|
727
|
+
this.pendingSocket?.destroy()
|
|
584
728
|
this.pendingSocket = undefined
|
|
585
729
|
}
|
|
586
730
|
throw error
|
|
@@ -631,11 +775,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
631
775
|
token,
|
|
632
776
|
() => this.onChildExit(),
|
|
633
777
|
action => this.userActionHandler?.(action),
|
|
778
|
+
this.spawnExecutable,
|
|
634
779
|
)
|
|
635
780
|
if (this.pendingSocket !== undefined) {
|
|
636
781
|
this.client.attach(this.pendingSocket)
|
|
637
782
|
this.pendingSocket = undefined
|
|
638
783
|
}
|
|
784
|
+
// dispose() can race this spawn (it may run while start() is suspended
|
|
785
|
+
// between the listen and the client creation): re-check here so a host
|
|
786
|
+
// that was torn down mid-start does not leak a child nobody will kill.
|
|
787
|
+
if (this.disposed) {
|
|
788
|
+
this.client.kill()
|
|
789
|
+
this.client = undefined
|
|
790
|
+
try { server.close() } catch { /* already closed */ }
|
|
791
|
+
if (this.server === server) this.server = undefined
|
|
792
|
+
throw new HostGoneError('browser host disposed')
|
|
793
|
+
}
|
|
639
794
|
// Wait for the child's connection + authentication + readiness ping. The
|
|
640
795
|
// ping is queued until the hello authenticates, so a spoofed socket
|
|
641
796
|
// never observes it.
|
|
@@ -645,13 +800,17 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
645
800
|
/** The child died: tear down so the next use starts a fresh child. */
|
|
646
801
|
private onChildExit(): void {
|
|
647
802
|
if (this.disposed) return
|
|
803
|
+
process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
|
|
648
804
|
this.client = undefined
|
|
649
805
|
this.server?.close()
|
|
650
806
|
this.server = undefined
|
|
807
|
+
this.pendingSocket?.destroy()
|
|
651
808
|
this.pendingSocket = undefined
|
|
652
809
|
this.readyPromise = undefined
|
|
653
|
-
// Keep the views map: handles still resolve to ids
|
|
654
|
-
//
|
|
810
|
+
// Keep the views map: handles still resolve to ids, and
|
|
811
|
+
// DeferredRemoteView re-materializes them against the fresh child on
|
|
812
|
+
// their next use — sessions opened before the crash stay usable instead
|
|
813
|
+
// of going stale (only the page state is lost).
|
|
655
814
|
}
|
|
656
815
|
|
|
657
816
|
createView(): ElectronViewHandle {
|
|
@@ -664,9 +823,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
664
823
|
}
|
|
665
824
|
|
|
666
825
|
private async ensureView(id: string): Promise<RemoteView> {
|
|
826
|
+
// A rebuild in flight during dispose() must not spawn a fresh child that
|
|
827
|
+
// would then outlive the host (a zombie window nobody disposes).
|
|
828
|
+
if (this.disposed) throw new HostGoneError('browser host disposed')
|
|
667
829
|
await this.ready()
|
|
830
|
+
// dispose() may have landed while the host was starting (ready() resolved
|
|
831
|
+
// after dispose cleared its state): never materialize into a dead host.
|
|
832
|
+
if (this.disposed) throw new HostGoneError('browser host disposed')
|
|
668
833
|
const client = this.client
|
|
669
|
-
if (client === undefined) throw new
|
|
834
|
+
if (client === undefined) throw new HostGoneError('browser host unavailable')
|
|
670
835
|
// Re-send the window group before creating the view: the child routes the
|
|
671
836
|
// view into its session's own window. Re-sending every materialization
|
|
672
837
|
// also covers a child restart (the new child has no assignments yet).
|
|
@@ -733,6 +898,8 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
733
898
|
this.client = undefined
|
|
734
899
|
this.server?.close()
|
|
735
900
|
this.server = undefined
|
|
901
|
+
this.pendingSocket?.destroy()
|
|
902
|
+
this.pendingSocket = undefined
|
|
736
903
|
this.readyPromise = undefined
|
|
737
904
|
this.views.clear()
|
|
738
905
|
}
|
|
@@ -748,7 +915,7 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
748
915
|
) {}
|
|
749
916
|
|
|
750
917
|
/**
|
|
751
|
-
* Materialize once and cache: every
|
|
918
|
+
* Materialize once and cache: every operation on the same handle must
|
|
752
919
|
* target the SAME child view (re-materializing would re-run createView and
|
|
753
920
|
* duplicate the window). A FAILED materialization is reset so a later call
|
|
754
921
|
* (e.g. after the host restarted) can retry instead of being poisoned.
|
|
@@ -764,29 +931,51 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
764
931
|
return this.materialized
|
|
765
932
|
}
|
|
766
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Run one operation against this view's backing child, automatically
|
|
936
|
+
* recovering from a dead host. The cached materialization is bound to the
|
|
937
|
+
* child it was created on; once that child is gone (exited, crashed, or its
|
|
938
|
+
* connection dropped — e.g. the DSH process that spawned it was restarted),
|
|
939
|
+
* any further operation on the same handle would otherwise fail forever
|
|
940
|
+
* with "browser host is not running". Instead: drop the cache, rebuild the
|
|
941
|
+
* view against a freshly spawned child, and retry the operation exactly
|
|
942
|
+
* once — so sessions that survive a host death self-heal on their FIRST
|
|
943
|
+
* call after it, without a manual browser_reset_session. Page-level errors
|
|
944
|
+
* are never retried (the view itself is fine).
|
|
945
|
+
*/
|
|
946
|
+
private async withRecovery<T>(op: (view: RemoteView) => Promise<T>): Promise<T> {
|
|
947
|
+
try {
|
|
948
|
+
const view = await this.materializeOnce()
|
|
949
|
+
return await op(view)
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if (!(error instanceof HostGoneError)) throw error
|
|
952
|
+
// The backing child is gone: a retry on the same materialization can
|
|
953
|
+
// never succeed. Rebuild against a fresh child and retry once; a second
|
|
954
|
+
// failure (fresh host also failing to start) surfaces to the caller.
|
|
955
|
+
this.materialized = undefined
|
|
956
|
+
const fresh = await this.materializeOnce()
|
|
957
|
+
return op(fresh)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
767
961
|
async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
768
|
-
|
|
769
|
-
return view.sendCommand(method, params)
|
|
962
|
+
return this.withRecovery(view => view.sendCommand(method, params))
|
|
770
963
|
}
|
|
771
964
|
|
|
772
965
|
async download(url: string, savePath: string): Promise<void> {
|
|
773
|
-
|
|
774
|
-
return view.download(url, savePath)
|
|
966
|
+
return this.withRecovery(view => view.download(url, savePath))
|
|
775
967
|
}
|
|
776
968
|
|
|
777
969
|
async capture(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string }> {
|
|
778
|
-
|
|
779
|
-
return view.capture(opts)
|
|
970
|
+
return this.withRecovery(view => view.capture(opts))
|
|
780
971
|
}
|
|
781
972
|
|
|
782
973
|
async flushAuth(): Promise<ExportedCookie[]> {
|
|
783
|
-
|
|
784
|
-
return view.flushAuth()
|
|
974
|
+
return this.withRecovery(view => view.flushAuth())
|
|
785
975
|
}
|
|
786
976
|
|
|
787
977
|
async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
|
|
788
|
-
|
|
789
|
-
return view.restoreAuth(cookies)
|
|
978
|
+
return this.withRecovery(view => view.restoreAuth(cookies))
|
|
790
979
|
}
|
|
791
980
|
}
|
|
792
981
|
|
|
@@ -805,3 +994,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promi
|
|
|
805
994
|
export function defaultHostMainPath(): string {
|
|
806
995
|
return fileURLToPath(new URL('./host-main.js', import.meta.url))
|
|
807
996
|
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Test/diagnostic hooks (mirrors tool-browser's `internals` convention).
|
|
1000
|
+
* `isBareElectron` is the packaged-app discriminator behind the host-reuse
|
|
1001
|
+
* steps; `resolveElectronPath` is the full resolution order.
|
|
1002
|
+
*/
|
|
1003
|
+
export const internals = {
|
|
1004
|
+
isBareElectron,
|
|
1005
|
+
resolveElectronPath,
|
|
1006
|
+
resolveElectronPathImpl,
|
|
1007
|
+
}
|