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
|
@@ -28,67 +28,79 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
28
28
|
import { randomBytes } from 'node:crypto';
|
|
29
29
|
import { createRequire } from 'node:module';
|
|
30
30
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
31
|
-
import { basename, dirname, join } from 'node:path';
|
|
31
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
32
32
|
import { createServer } from 'node:net';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
/** How long to wait for the child to signal readiness before failing. */
|
|
35
35
|
const READY_TIMEOUT_MS = 20_000;
|
|
36
36
|
/** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
|
|
37
37
|
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
38
|
+
function resolveElectronPath() {
|
|
39
|
+
return resolveElectronPathImpl({
|
|
40
|
+
override: process.env.ELECTRON_PATH,
|
|
41
|
+
inElectron: typeof process.versions.electron === 'string',
|
|
42
|
+
execPath: process.execPath,
|
|
43
|
+
bundled: bundledElectronBinary,
|
|
44
|
+
anchored: anchoredElectronBinary,
|
|
45
|
+
ancestry: hostElectronViaProcessTree,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
38
48
|
/**
|
|
39
|
-
*
|
|
40
|
-
* 0.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* 1. require('electron') from this module (bundled dependency — the
|
|
44
|
-
* electron package ships with the plugin),
|
|
45
|
-
* 2. ELECTRON_PATH (explicit override),
|
|
46
|
-
* 3. every DSH install anchor + pnpm virtual store candidate, choosing the
|
|
47
|
-
* NEWEST version found. Older Electron releases (e.g. 33.x) have a
|
|
48
|
-
* compositor defect that intermittently breaks page capture, so prefer
|
|
49
|
-
* the newest binary available in the environment,
|
|
50
|
-
* 4. last resort: the process ancestry, for hosts that run this plugin in a
|
|
51
|
-
* child Node process of their own Electron main process (DSH Desktop).
|
|
52
|
-
* @returns the path to the Electron executable.
|
|
49
|
+
* Pure selection over the discovery probes, in order:
|
|
50
|
+
* 0. override (user intent) → 1. bundled package → 2. anchors (newest) →
|
|
51
|
+
* 3. in-process BARE host → 4. process ancestry → throw.
|
|
52
|
+
* Packaged app executables are never reused (steps 3/4 skip them).
|
|
53
53
|
*/
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const exe = process.execPath;
|
|
60
|
-
if (typeof exe === 'string' && exe.length > 0 && existsSync(exe))
|
|
61
|
-
return exe;
|
|
62
|
-
}
|
|
63
|
-
// 1. Bundled dependency — but ONLY when its binary is already on disk.
|
|
64
|
-
// Electron 44+ downloads the binary lazily on the first require()
|
|
65
|
-
// (it has no postinstall anymore), so probing here must never trigger a
|
|
66
|
-
// synchronous network download: available() would block DSH startup for
|
|
67
|
-
// the download (or fail outright offline), surfacing as a broken plugin.
|
|
68
|
-
// The download still happens on the first real spawn; this guard only
|
|
69
|
-
// keeps the probe side-effect free.
|
|
70
|
-
try {
|
|
71
|
-
const entry = require.resolve('electron');
|
|
72
|
-
if (typeof entry === 'string' && entry.length > 0) {
|
|
73
|
-
const pkgRoot = dirname(entry);
|
|
74
|
-
const binaryReady = existsSync(join(pkgRoot, 'path.txt'))
|
|
75
|
-
|| existsSync(join(pkgRoot, 'dist', 'electron.exe'))
|
|
76
|
-
|| existsSync(join(pkgRoot, 'dist', 'electron'));
|
|
77
|
-
if (binaryReady) {
|
|
78
|
-
const resolved = require('electron');
|
|
79
|
-
if (typeof resolved === 'string' && resolved.length > 0)
|
|
80
|
-
return resolved;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// continue probing
|
|
86
|
-
}
|
|
87
|
-
// 2. Explicit override (user intent wins).
|
|
88
|
-
const override = process.env.ELECTRON_PATH;
|
|
54
|
+
function resolveElectronPathImpl(inputs) {
|
|
55
|
+
// 0. Explicit override first: a user-set ELECTRON_PATH is a deliberate
|
|
56
|
+
// choice and must beat every auto-discovery (including the bundled
|
|
57
|
+
// package) — auto-discovery is a fallback, not an override.
|
|
58
|
+
const override = inputs.override;
|
|
89
59
|
if (typeof override === 'string' && override.length > 0 && existsSync(override))
|
|
90
60
|
return override;
|
|
91
|
-
//
|
|
61
|
+
// 1. The plugin's own electron package (filesystem probe). Never require():
|
|
62
|
+
// inside an Electron main process require('electron') is the built-in
|
|
63
|
+
// API module, not the npm package, and electron 44+ downloads its binary
|
|
64
|
+
// on first require — probing must stay side-effect free (available()
|
|
65
|
+
// would otherwise block DSH startup on the download).
|
|
66
|
+
const bundled = inputs.bundled();
|
|
67
|
+
if (bundled !== undefined)
|
|
68
|
+
return bundled;
|
|
69
|
+
// 2. DSH install anchors, newest wins. (The plugin's own tree — plain and
|
|
70
|
+
// pnpm store layouts — was already scanned in step 1; the anchors cover
|
|
71
|
+
// electron installed separately, e.g. `dsh plugin add electron` into the
|
|
72
|
+
// profile or a global prefix.)
|
|
73
|
+
const anchored = inputs.anchored();
|
|
74
|
+
if (anchored !== undefined)
|
|
75
|
+
return anchored;
|
|
76
|
+
// 3. The current process IS a bare Electron (dev hosts, e.g. `electron .`):
|
|
77
|
+
// reuse the host binary — it cannot be missing because we are running on
|
|
78
|
+
// it. A PACKAGED app executable (resources/app.asar beside it, e.g. DSH
|
|
79
|
+
// Desktop.exe) is NOT spawnable as bare electron: it ignores the script
|
|
80
|
+
// argument, launches the app itself, and typically exits immediately
|
|
81
|
+
// (single-instance lock) — so it is never reused.
|
|
82
|
+
if (inputs.inElectron) {
|
|
83
|
+
const exe = inputs.execPath;
|
|
84
|
+
if (typeof exe === 'string' && exe.length > 0 && existsSync(exe) && isBareElectron(exe))
|
|
85
|
+
return exe;
|
|
86
|
+
}
|
|
87
|
+
// 4. Last resort for bare-Electron hosts: walk the process ancestry (the
|
|
88
|
+
// plugin may run in a child Node process of an Electron main process).
|
|
89
|
+
const hostExe = inputs.ancestry();
|
|
90
|
+
if (hostExe !== undefined)
|
|
91
|
+
return hostExe;
|
|
92
|
+
throw new Error('dsh-builtin-browser: cannot locate a spawnable Electron binary. ' +
|
|
93
|
+
'The electron package ships with the plugin (0.1.18+); Electron 44+ downloads its binary on first use, ' +
|
|
94
|
+
'so if the package is installed but the binary is missing, run `npx install-electron` once (needs network) ' +
|
|
95
|
+
'in the profile that installed the plugin, then retry. Alternatively set ELECTRON_PATH to an electron ' +
|
|
96
|
+
'executable. On installs from before electron became a dependency, add it to the active profile ' +
|
|
97
|
+
'(`dsh plugin --profile <your-profile> add electron`; on DSH Desktop the profile is `desktop`). ' +
|
|
98
|
+
'Note: the host\'s own executable cannot be reused when it is a packaged Electron app (e.g. DSH Desktop.exe) ' +
|
|
99
|
+
'— only a real electron binary can be spawned.');
|
|
100
|
+
}
|
|
101
|
+
/** DSH install anchors: electron installed separately (profile / global prefix), newest wins. */
|
|
102
|
+
function anchoredElectronBinary() {
|
|
103
|
+
const require = createRequire(import.meta.url);
|
|
92
104
|
const candidates = [];
|
|
93
105
|
const add = (version, path) => {
|
|
94
106
|
if (path !== undefined)
|
|
@@ -106,7 +118,10 @@ function resolveElectronPath() {
|
|
|
106
118
|
for (const anchor of anchors) {
|
|
107
119
|
try {
|
|
108
120
|
const resolved = require.resolve('electron', { paths: [anchor] });
|
|
109
|
-
|
|
121
|
+
// Inside an Electron main process resolve('electron') yields the
|
|
122
|
+
// built-in module NAME (not a path) — requiring an absolute path keeps
|
|
123
|
+
// electronExeBeside from probing cwd-relative garbage like ./dist/.
|
|
124
|
+
if (typeof resolved === 'string' && resolved.length > 0 && isAbsolute(resolved)) {
|
|
110
125
|
add(versionOf(resolved), electronExeBeside(resolved));
|
|
111
126
|
}
|
|
112
127
|
}
|
|
@@ -114,66 +129,89 @@ function resolveElectronPath() {
|
|
|
114
129
|
// continue probing
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
return pickNewest(candidates);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The plugin's own electron package binary, found by a pure filesystem walk
|
|
136
|
+
* (never require(), whose semantics differ inside an Electron main process —
|
|
137
|
+
* there `require('electron')` is the built-in API module, not the npm package
|
|
138
|
+
* — and which would trigger electron 44+'s lazy binary download when the dist
|
|
139
|
+
* is missing). Checks every `node_modules/electron` above this module,
|
|
140
|
+
* including pnpm virtual-store layouts, and returns the NEWEST ready binary.
|
|
141
|
+
* Scan ONLY the plugin's own install tree: never cwd or the executable's
|
|
142
|
+
* directory, which can sit inside an unrelated project whose node_modules may
|
|
143
|
+
* hold a different electron (executing the newest random binary in the
|
|
144
|
+
* filesystem is a supply-chain hazard with no benefit).
|
|
145
|
+
*/
|
|
146
|
+
function bundledElectronBinary() {
|
|
147
|
+
const candidates = [];
|
|
148
|
+
const consider = (pkgRoot, versionHint) => {
|
|
149
|
+
const exe = electronDistExe(pkgRoot);
|
|
150
|
+
if (exe !== undefined)
|
|
151
|
+
candidates.push({ version: versionHint || pkgVersion(pkgRoot), path: exe });
|
|
152
|
+
};
|
|
153
|
+
let dir = fileURLToPath(new URL('.', import.meta.url));
|
|
154
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
155
|
+
// Plain (npm/yarn, hoisted) layout: <ancestor>/node_modules/electron.
|
|
156
|
+
const plain = join(dir, 'node_modules', 'electron');
|
|
157
|
+
if (existsSync(join(plain, 'package.json')))
|
|
158
|
+
consider(plain, '');
|
|
159
|
+
// pnpm layout: <ancestor>/node_modules/.pnpm or <ancestor>/.pnpm, with
|
|
160
|
+
// electron@<version>/node_modules/electron entries.
|
|
161
|
+
for (const storeRoot of [join(dir, 'node_modules', '.pnpm'), join(dir, '.pnpm')]) {
|
|
162
|
+
if (!existsSync(storeRoot))
|
|
163
|
+
continue;
|
|
164
|
+
for (const entry of readdirSync(storeRoot)) {
|
|
165
|
+
if (!entry.startsWith('electron@'))
|
|
166
|
+
continue;
|
|
167
|
+
const pkg = join(storeRoot, entry, 'node_modules', 'electron');
|
|
168
|
+
// Strip pnpm's peer-dependency suffix (electron@44.0.0_@types+node@22).
|
|
169
|
+
if (existsSync(join(pkg, 'package.json')))
|
|
170
|
+
consider(pkg, entry.slice('electron@'.length).split('_')[0]);
|
|
136
171
|
}
|
|
137
|
-
const parent = join(dir, '..');
|
|
138
|
-
if (parent === dir)
|
|
139
|
-
break;
|
|
140
|
-
dir = parent;
|
|
141
172
|
}
|
|
173
|
+
const parent = join(dir, '..');
|
|
174
|
+
if (parent === dir)
|
|
175
|
+
break;
|
|
176
|
+
dir = parent;
|
|
142
177
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
178
|
+
return pickNewest(candidates);
|
|
179
|
+
}
|
|
180
|
+
/** The electron version from a package root's package.json, or '0.0.0'. */
|
|
181
|
+
function pkgVersion(pkgRoot) {
|
|
182
|
+
try {
|
|
183
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
184
|
+
return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : '0.0.0';
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return '0.0.0';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** The highest-versioned candidate's path, or undefined when none. */
|
|
191
|
+
function pickNewest(candidates) {
|
|
192
|
+
let best;
|
|
193
|
+
for (const c of candidates) {
|
|
194
|
+
if (best === undefined || compareVersions(c.version, best.version) > 0)
|
|
195
|
+
best = c;
|
|
196
|
+
}
|
|
197
|
+
return best?.path;
|
|
161
198
|
}
|
|
162
199
|
/**
|
|
163
200
|
* Last-resort Electron discovery: walk this process's ancestry looking for a
|
|
164
|
-
* parent process whose executable is
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* so reusing it needs no extra
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
201
|
+
* parent process whose executable is a BARE Electron binary (only bare
|
|
202
|
+
* electron can be spawned with a script argument). Covers hosts that run the
|
|
203
|
+
* plugin in a child Node process of their own Electron main process: the host
|
|
204
|
+
* binary is already on disk and running, so reusing it needs no extra
|
|
205
|
+
* install. Filesystem-only on POSIX (/proc); on Windows a best-effort
|
|
206
|
+
* PowerShell CIM query is used — this path only runs when every other lookup
|
|
207
|
+
* failed, so its latency never touches the happy path, and a
|
|
208
|
+
* missing/blocked powershell simply yields nothing.
|
|
171
209
|
*/
|
|
172
210
|
function hostElectronViaProcessTree() {
|
|
173
211
|
let pid = process.ppid;
|
|
174
212
|
for (let depth = 0; depth < 6 && pid !== undefined && pid > 1; depth++) {
|
|
175
213
|
const exe = processExecutable(pid);
|
|
176
|
-
if (exe !== undefined &&
|
|
214
|
+
if (exe !== undefined && isBareElectron(exe))
|
|
177
215
|
return exe;
|
|
178
216
|
pid = processParent(pid);
|
|
179
217
|
}
|
|
@@ -235,6 +273,33 @@ function isElectronBinary(exe) {
|
|
|
235
273
|
|| existsSync(join(resources, 'app.asar'))
|
|
236
274
|
|| existsSync(join(resources, 'default_app.asar'));
|
|
237
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* True when the executable is a BARE (unpackaged) Electron that can be
|
|
278
|
+
* spawned with a script argument (`exe script.js --rpc-port N`). Packaged
|
|
279
|
+
* apps carry their code inside a resources dir (`app.asar`, or an unpacked
|
|
280
|
+
* `app` dir) and are NOT spawnable as bare electron: they ignore the script
|
|
281
|
+
* argument and launch the app itself, typically exiting immediately via the
|
|
282
|
+
* single-instance lock — spawning e.g. DSH Desktop.exe is exactly the
|
|
283
|
+
* "browser host exited (code=0)" failure. A portable single-file build (no
|
|
284
|
+
* resources dir at all) likewise cannot run a script argument.
|
|
285
|
+
*/
|
|
286
|
+
function isBareElectron(exe) {
|
|
287
|
+
const dir = dirname(exe);
|
|
288
|
+
// Windows/Linux keep resources beside the binary; macOS app bundles keep
|
|
289
|
+
// them in Contents/Resources, one level above the binary's MacOS dir.
|
|
290
|
+
const resourcesList = process.platform === 'darwin'
|
|
291
|
+
? [join(dir, '..', 'Resources'), join(dir, 'resources')]
|
|
292
|
+
: [join(dir, 'resources')];
|
|
293
|
+
for (const resources of resourcesList) {
|
|
294
|
+
if (existsSync(join(resources, 'app.asar')) || existsSync(join(resources, 'app')))
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
// A real electron always ships a resources dir somewhere; requiring one
|
|
298
|
+
// keeps every not-actually-spawnable layout out.
|
|
299
|
+
if (!resourcesList.some(p => existsSync(p)))
|
|
300
|
+
return false;
|
|
301
|
+
return isElectronBinary(exe);
|
|
302
|
+
}
|
|
238
303
|
/** Extract an electron version like "43.4.0" from a path containing it. */
|
|
239
304
|
function versionOf(path) {
|
|
240
305
|
const match = /electron@(\d+\.\d+\.\d+)/.exec(path);
|
|
@@ -273,6 +338,19 @@ function electronDistExe(pkgRoot) {
|
|
|
273
338
|
}
|
|
274
339
|
return undefined;
|
|
275
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Thrown whenever the browser host (the Electron child) is gone — it exited,
|
|
343
|
+
* failed to start, or its RPC connection dropped. Operations against a dead
|
|
344
|
+
* host can never succeed, so the view layer treats this as "rebuild against a
|
|
345
|
+
* fresh host" instead of a page-level error. Kept module-private: callers
|
|
346
|
+
* observe the behavior (auto-restart), not the class.
|
|
347
|
+
*/
|
|
348
|
+
class HostGoneError extends Error {
|
|
349
|
+
constructor(message) {
|
|
350
|
+
super(message);
|
|
351
|
+
this.name = 'HostGoneError';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
276
354
|
/**
|
|
277
355
|
* Line-delimited JSON-RPC client over a local TCP socket. Electron's main
|
|
278
356
|
* process on Windows does not receive piped stdin, so the parent listens on a
|
|
@@ -285,6 +363,7 @@ class ElectronChildClient {
|
|
|
285
363
|
token;
|
|
286
364
|
onExit;
|
|
287
365
|
onUserAction;
|
|
366
|
+
executable;
|
|
288
367
|
child;
|
|
289
368
|
pending = new Map();
|
|
290
369
|
nextId = 1;
|
|
@@ -301,13 +380,17 @@ class ElectronChildClient {
|
|
|
301
380
|
*/
|
|
302
381
|
awaitingHello = true;
|
|
303
382
|
helloTimer;
|
|
304
|
-
constructor(hostMainPath, port, token, onExit, onUserAction
|
|
383
|
+
constructor(hostMainPath, port, token, onExit, onUserAction,
|
|
384
|
+
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
385
|
+
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
386
|
+
executable) {
|
|
305
387
|
this.hostMainPath = hostMainPath;
|
|
306
388
|
this.port = port;
|
|
307
389
|
this.token = token;
|
|
308
390
|
this.onExit = onExit;
|
|
309
391
|
this.onUserAction = onUserAction;
|
|
310
|
-
|
|
392
|
+
this.executable = executable;
|
|
393
|
+
const electron = this.executable ?? resolveElectronPath();
|
|
311
394
|
process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
|
|
312
395
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
313
396
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
@@ -353,8 +436,11 @@ class ElectronChildClient {
|
|
|
353
436
|
return;
|
|
354
437
|
this.dead = true;
|
|
355
438
|
this.connected = false;
|
|
439
|
+
// Tag every failure as "host gone" so the view layer can tell a dead host
|
|
440
|
+
// apart from a page-level error and rebuild against a fresh child.
|
|
441
|
+
const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message);
|
|
356
442
|
for (const pending of this.pending.values())
|
|
357
|
-
pending.reject(
|
|
443
|
+
pending.reject(wrapped);
|
|
358
444
|
this.pending.clear();
|
|
359
445
|
this.outbox = [];
|
|
360
446
|
this.onExit?.();
|
|
@@ -468,14 +554,23 @@ class ElectronChildClient {
|
|
|
468
554
|
/** Send one command and await the reply. */
|
|
469
555
|
call(op, payload = {}) {
|
|
470
556
|
if (this.dead) {
|
|
471
|
-
return Promise.reject(new
|
|
557
|
+
return Promise.reject(new HostGoneError('dsh-builtin-browser: browser host is not running'));
|
|
472
558
|
}
|
|
473
559
|
const id = this.nextId++;
|
|
474
560
|
const line = JSON.stringify({ id, op, ...payload });
|
|
475
561
|
return new Promise((resolve, reject) => {
|
|
476
562
|
this.pending.set(id, { resolve: resolve, reject });
|
|
477
563
|
if (this.connected && this.socket !== undefined) {
|
|
478
|
-
|
|
564
|
+
try {
|
|
565
|
+
this.socket.write(line + '\n');
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// The socket died between the dead check and the write; the 'close'
|
|
569
|
+
// handler fails the rest. Reject THIS call as host-gone so the
|
|
570
|
+
// caller can recover immediately instead of hanging.
|
|
571
|
+
this.pending.delete(id);
|
|
572
|
+
reject(new HostGoneError('dsh-builtin-browser: browser host connection closed'));
|
|
573
|
+
}
|
|
479
574
|
}
|
|
480
575
|
else {
|
|
481
576
|
// Not connected yet: queue; attach() flushes on the child's arrival.
|
|
@@ -534,6 +629,7 @@ class RemoteView {
|
|
|
534
629
|
*/
|
|
535
630
|
export class RemoteElectronViewHost {
|
|
536
631
|
hostMainPath;
|
|
632
|
+
spawnExecutable;
|
|
537
633
|
client;
|
|
538
634
|
server;
|
|
539
635
|
pendingSocket;
|
|
@@ -547,8 +643,12 @@ export class RemoteElectronViewHost {
|
|
|
547
643
|
groups = new Map();
|
|
548
644
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
549
645
|
userActionHandler;
|
|
550
|
-
constructor(hostMainPath
|
|
646
|
+
constructor(hostMainPath,
|
|
647
|
+
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
648
|
+
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
649
|
+
spawnExecutable) {
|
|
551
650
|
this.hostMainPath = hostMainPath;
|
|
651
|
+
this.spawnExecutable = spawnExecutable;
|
|
552
652
|
}
|
|
553
653
|
/**
|
|
554
654
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
@@ -563,14 +663,23 @@ export class RemoteElectronViewHost {
|
|
|
563
663
|
resolveElectronPath();
|
|
564
664
|
this.electronAvailable = true;
|
|
565
665
|
}
|
|
566
|
-
catch {
|
|
666
|
+
catch (error) {
|
|
567
667
|
this.electronAvailable = false;
|
|
668
|
+
// The provider will be reported unavailable, so the detailed
|
|
669
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
670
|
+
// otherwise never reach the user — surface it on stderr.
|
|
671
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
568
672
|
}
|
|
569
673
|
}
|
|
570
674
|
return this.electronAvailable;
|
|
571
675
|
}
|
|
572
676
|
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
573
677
|
ready() {
|
|
678
|
+
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
679
|
+
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
680
|
+
// nobody will ever kill.
|
|
681
|
+
if (this.disposed)
|
|
682
|
+
throw new HostGoneError('browser host disposed');
|
|
574
683
|
if (this.readyPromise !== undefined)
|
|
575
684
|
return this.readyPromise;
|
|
576
685
|
const started = this.start();
|
|
@@ -583,6 +692,7 @@ export class RemoteElectronViewHost {
|
|
|
583
692
|
this.client = undefined;
|
|
584
693
|
this.server?.close();
|
|
585
694
|
this.server = undefined;
|
|
695
|
+
this.pendingSocket?.destroy();
|
|
586
696
|
this.pendingSocket = undefined;
|
|
587
697
|
}
|
|
588
698
|
throw error;
|
|
@@ -626,11 +736,25 @@ export class RemoteElectronViewHost {
|
|
|
626
736
|
// Random per-spawn token: the child must prove knowledge of it (via
|
|
627
737
|
// stdin hello) before any command is written to the socket.
|
|
628
738
|
const token = randomBytes(24).toString('hex');
|
|
629
|
-
this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action));
|
|
739
|
+
this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), this.spawnExecutable);
|
|
630
740
|
if (this.pendingSocket !== undefined) {
|
|
631
741
|
this.client.attach(this.pendingSocket);
|
|
632
742
|
this.pendingSocket = undefined;
|
|
633
743
|
}
|
|
744
|
+
// dispose() can race this spawn (it may run while start() is suspended
|
|
745
|
+
// between the listen and the client creation): re-check here so a host
|
|
746
|
+
// that was torn down mid-start does not leak a child nobody will kill.
|
|
747
|
+
if (this.disposed) {
|
|
748
|
+
this.client.kill();
|
|
749
|
+
this.client = undefined;
|
|
750
|
+
try {
|
|
751
|
+
server.close();
|
|
752
|
+
}
|
|
753
|
+
catch { /* already closed */ }
|
|
754
|
+
if (this.server === server)
|
|
755
|
+
this.server = undefined;
|
|
756
|
+
throw new HostGoneError('browser host disposed');
|
|
757
|
+
}
|
|
634
758
|
// Wait for the child's connection + authentication + readiness ping. The
|
|
635
759
|
// ping is queued until the hello authenticates, so a spoofed socket
|
|
636
760
|
// never observes it.
|
|
@@ -640,13 +764,17 @@ export class RemoteElectronViewHost {
|
|
|
640
764
|
onChildExit() {
|
|
641
765
|
if (this.disposed)
|
|
642
766
|
return;
|
|
767
|
+
process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
|
|
643
768
|
this.client = undefined;
|
|
644
769
|
this.server?.close();
|
|
645
770
|
this.server = undefined;
|
|
771
|
+
this.pendingSocket?.destroy();
|
|
646
772
|
this.pendingSocket = undefined;
|
|
647
773
|
this.readyPromise = undefined;
|
|
648
|
-
// Keep the views map: handles still resolve to ids
|
|
649
|
-
//
|
|
774
|
+
// Keep the views map: handles still resolve to ids, and
|
|
775
|
+
// DeferredRemoteView re-materializes them against the fresh child on
|
|
776
|
+
// their next use — sessions opened before the crash stay usable instead
|
|
777
|
+
// of going stale (only the page state is lost).
|
|
650
778
|
}
|
|
651
779
|
createView() {
|
|
652
780
|
// The seam is synchronous; the provider uses the handle immediately, so
|
|
@@ -657,10 +785,18 @@ export class RemoteElectronViewHost {
|
|
|
657
785
|
return view;
|
|
658
786
|
}
|
|
659
787
|
async ensureView(id) {
|
|
788
|
+
// A rebuild in flight during dispose() must not spawn a fresh child that
|
|
789
|
+
// would then outlive the host (a zombie window nobody disposes).
|
|
790
|
+
if (this.disposed)
|
|
791
|
+
throw new HostGoneError('browser host disposed');
|
|
660
792
|
await this.ready();
|
|
793
|
+
// dispose() may have landed while the host was starting (ready() resolved
|
|
794
|
+
// after dispose cleared its state): never materialize into a dead host.
|
|
795
|
+
if (this.disposed)
|
|
796
|
+
throw new HostGoneError('browser host disposed');
|
|
661
797
|
const client = this.client;
|
|
662
798
|
if (client === undefined)
|
|
663
|
-
throw new
|
|
799
|
+
throw new HostGoneError('browser host unavailable');
|
|
664
800
|
// Re-send the window group before creating the view: the child routes the
|
|
665
801
|
// view into its session's own window. Re-sending every materialization
|
|
666
802
|
// also covers a child restart (the new child has no assignments yet).
|
|
@@ -722,6 +858,8 @@ export class RemoteElectronViewHost {
|
|
|
722
858
|
this.client = undefined;
|
|
723
859
|
this.server?.close();
|
|
724
860
|
this.server = undefined;
|
|
861
|
+
this.pendingSocket?.destroy();
|
|
862
|
+
this.pendingSocket = undefined;
|
|
725
863
|
this.readyPromise = undefined;
|
|
726
864
|
this.views.clear();
|
|
727
865
|
}
|
|
@@ -736,7 +874,7 @@ class DeferredRemoteView {
|
|
|
736
874
|
this.materialize = materialize;
|
|
737
875
|
}
|
|
738
876
|
/**
|
|
739
|
-
* Materialize once and cache: every
|
|
877
|
+
* Materialize once and cache: every operation on the same handle must
|
|
740
878
|
* target the SAME child view (re-materializing would re-run createView and
|
|
741
879
|
* duplicate the window). A FAILED materialization is reset so a later call
|
|
742
880
|
* (e.g. after the host restarted) can retry instead of being poisoned.
|
|
@@ -752,25 +890,48 @@ class DeferredRemoteView {
|
|
|
752
890
|
}
|
|
753
891
|
return this.materialized;
|
|
754
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* Run one operation against this view's backing child, automatically
|
|
895
|
+
* recovering from a dead host. The cached materialization is bound to the
|
|
896
|
+
* child it was created on; once that child is gone (exited, crashed, or its
|
|
897
|
+
* connection dropped — e.g. the DSH process that spawned it was restarted),
|
|
898
|
+
* any further operation on the same handle would otherwise fail forever
|
|
899
|
+
* with "browser host is not running". Instead: drop the cache, rebuild the
|
|
900
|
+
* view against a freshly spawned child, and retry the operation exactly
|
|
901
|
+
* once — so sessions that survive a host death self-heal on their FIRST
|
|
902
|
+
* call after it, without a manual browser_reset_session. Page-level errors
|
|
903
|
+
* are never retried (the view itself is fine).
|
|
904
|
+
*/
|
|
905
|
+
async withRecovery(op) {
|
|
906
|
+
try {
|
|
907
|
+
const view = await this.materializeOnce();
|
|
908
|
+
return await op(view);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
if (!(error instanceof HostGoneError))
|
|
912
|
+
throw error;
|
|
913
|
+
// The backing child is gone: a retry on the same materialization can
|
|
914
|
+
// never succeed. Rebuild against a fresh child and retry once; a second
|
|
915
|
+
// failure (fresh host also failing to start) surfaces to the caller.
|
|
916
|
+
this.materialized = undefined;
|
|
917
|
+
const fresh = await this.materializeOnce();
|
|
918
|
+
return op(fresh);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
755
921
|
async sendCommand(method, params) {
|
|
756
|
-
|
|
757
|
-
return view.sendCommand(method, params);
|
|
922
|
+
return this.withRecovery(view => view.sendCommand(method, params));
|
|
758
923
|
}
|
|
759
924
|
async download(url, savePath) {
|
|
760
|
-
|
|
761
|
-
return view.download(url, savePath);
|
|
925
|
+
return this.withRecovery(view => view.download(url, savePath));
|
|
762
926
|
}
|
|
763
927
|
async capture(opts) {
|
|
764
|
-
|
|
765
|
-
return view.capture(opts);
|
|
928
|
+
return this.withRecovery(view => view.capture(opts));
|
|
766
929
|
}
|
|
767
930
|
async flushAuth() {
|
|
768
|
-
|
|
769
|
-
return view.flushAuth();
|
|
931
|
+
return this.withRecovery(view => view.flushAuth());
|
|
770
932
|
}
|
|
771
933
|
async restoreAuth(cookies) {
|
|
772
|
-
|
|
773
|
-
return view.restoreAuth(cookies);
|
|
934
|
+
return this.withRecovery(view => view.restoreAuth(cookies));
|
|
774
935
|
}
|
|
775
936
|
}
|
|
776
937
|
/** Reject a promise if it does not settle within the budget. */
|
|
@@ -784,3 +945,13 @@ function withTimeout(promise, ms, message) {
|
|
|
784
945
|
export function defaultHostMainPath() {
|
|
785
946
|
return fileURLToPath(new URL('./host-main.js', import.meta.url));
|
|
786
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* Test/diagnostic hooks (mirrors tool-browser's `internals` convention).
|
|
950
|
+
* `isBareElectron` is the packaged-app discriminator behind the host-reuse
|
|
951
|
+
* steps; `resolveElectronPath` is the full resolution order.
|
|
952
|
+
*/
|
|
953
|
+
export const internals = {
|
|
954
|
+
isBareElectron,
|
|
955
|
+
resolveElectronPath,
|
|
956
|
+
resolveElectronPathImpl,
|
|
957
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|