dsh-builtin-browser 0.4.1 → 0.4.5
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 +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -25,15 +25,57 @@
|
|
|
25
25
|
* @module dsh-browser/browser-electron/system-browser
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { readFileSync, rmSync } from 'node:fs'
|
|
28
|
+
import { readFileSync, rmSync, statSync } from 'node:fs'
|
|
29
29
|
import { existsSync } from 'node:fs'
|
|
30
30
|
import { join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long a killed browser is given to actually exit before the launch proceeds.
|
|
34
|
+
*
|
|
35
|
+
* `kill()` only sends the signal — on Windows it is TerminateProcess, which is asynchronous —
|
|
36
|
+
* and the browser may hold this profile's singleton lock for a while afterwards. Spawning
|
|
37
|
+
* immediately meant the replacement saw a live instance, handed its command line over and
|
|
38
|
+
* exited, and that exit was reported as a bad browser path.
|
|
39
|
+
*/
|
|
40
|
+
const EXIT_GRACE_MS = 3_000
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ceiling on one attempt to ask a discovered CDP port who it is.
|
|
44
|
+
*
|
|
45
|
+
* The launch loop below polls: it reads `DevToolsActivePort`, tries `/json/version` on the port
|
|
46
|
+
* inside it, and waits 250 ms between attempts under a 30 s deadline. An unbounded fetch can
|
|
47
|
+
* park inside one iteration for the whole budget, so a bound is needed — but it has to be
|
|
48
|
+
* small enough that the loop still behaves like a loop.
|
|
49
|
+
*
|
|
50
|
+
* The usual case is NOT a slow answer, it is NO answer: a browser that has written the file but
|
|
51
|
+
* is not listening yet refuses the connection instantly, and that immediacy is what lets the
|
|
52
|
+
* loop spin. Any timeout added here is paid on every iteration in which the port is not ready,
|
|
53
|
+
* so an earlier attempt at this used 2000 ms — eight times the poll interval — and cut the
|
|
54
|
+
* attempts available in the budget from roughly 120 to 15. This value is the same size as the
|
|
55
|
+
* interval, so a worst-case iteration costs 500 ms and the budget still affords ~60 attempts.
|
|
56
|
+
*/
|
|
57
|
+
const PORT_PROBE_MS = 250
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Starts a browser process and returns a handle to it.
|
|
61
|
+
*
|
|
62
|
+
* Injectable so tests can supply something that speaks CDP — the seam a recovery test needs
|
|
63
|
+
* and could not have while the launch was hardcoded to a stub that rejects Chromium's args.
|
|
64
|
+
* @param path - the executable to run.
|
|
65
|
+
* @param args - its arguments.
|
|
66
|
+
* @returns the child process.
|
|
67
|
+
*/
|
|
68
|
+
export type BrowserLauncher = (path: string, args: readonly string[]) => ReturnType<typeof spawn>
|
|
69
|
+
|
|
70
|
+
/** The real launcher: spawn a detached-free child with pipes closed. */
|
|
71
|
+
const defaultLauncher: BrowserLauncher = (path, args) =>
|
|
72
|
+
spawn(path, [...args], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
|
|
31
73
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
32
74
|
import { randomUUID } from 'node:crypto'
|
|
33
75
|
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
34
76
|
|
|
35
77
|
/** Which browser the user asked for. */
|
|
36
|
-
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto'
|
|
78
|
+
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto' | 'sidebar'
|
|
37
79
|
|
|
38
80
|
/** A resolved browser installation. */
|
|
39
81
|
export interface DetectedBrowser {
|
|
@@ -83,6 +125,11 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
83
125
|
win32: [
|
|
84
126
|
String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
85
127
|
String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
128
|
+
// Where a per-user install lands, which is what Chrome's own installer chooses when it
|
|
129
|
+
// cannot (or should not) write to Program Files. Missing this meant the plugin reported
|
|
130
|
+
// "no browser found" on machines that plainly had one.
|
|
131
|
+
String.raw`%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe`,
|
|
132
|
+
String.raw`%LOCALAPPDATA%\Chromium\Application\chrome.exe`,
|
|
86
133
|
],
|
|
87
134
|
darwin: [
|
|
88
135
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
@@ -100,6 +147,7 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
100
147
|
win32: [
|
|
101
148
|
String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
102
149
|
String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
150
|
+
String.raw`%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe`,
|
|
103
151
|
],
|
|
104
152
|
darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
105
153
|
linux: [
|
|
@@ -112,12 +160,29 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
112
160
|
win32: [
|
|
113
161
|
String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
114
162
|
String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
163
|
+
String.raw`%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
115
164
|
],
|
|
116
165
|
darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
|
|
117
166
|
linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
|
|
118
167
|
},
|
|
119
168
|
}
|
|
120
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the environment references in one install location.
|
|
172
|
+
*
|
|
173
|
+
* The list is static strings, but a per-user install lands under %LOCALAPPDATA%, so the entry
|
|
174
|
+
* has to be resolved against the environment detection was HANDED rather than process.env — a
|
|
175
|
+
* caller that supplies a platform and an environment (which is what makes detection testable)
|
|
176
|
+
* must not end up reading the developer's real machine.
|
|
177
|
+
* @param location - one entry from the install-location table.
|
|
178
|
+
* @param env - the environment to resolve against.
|
|
179
|
+
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
180
|
+
*/
|
|
181
|
+
function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
|
|
182
|
+
return location.replace(/%([^%]+)%/g, (whole, name: string) =>
|
|
183
|
+
env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
|
|
184
|
+
}
|
|
185
|
+
|
|
121
186
|
/**
|
|
122
187
|
* Find an executable by name on PATH.
|
|
123
188
|
*
|
|
@@ -172,7 +237,8 @@ export function detectBrowser(
|
|
|
172
237
|
if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
|
|
173
238
|
const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform)
|
|
174
239
|
if (onPath !== undefined) return { kind, path: onPath }
|
|
175
|
-
for (const
|
|
240
|
+
for (const raw of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
|
|
241
|
+
const candidate = expandLocation(raw, env)
|
|
176
242
|
if (existsSync(candidate)) return { kind, path: candidate }
|
|
177
243
|
}
|
|
178
244
|
}
|
|
@@ -189,9 +255,15 @@ export function detectBrowser(
|
|
|
189
255
|
* @param platform - the platform whose names and locations apply.
|
|
190
256
|
* @returns the launcher names (as PATH lookups) and the fixed locations checked.
|
|
191
257
|
*/
|
|
192
|
-
export function searchSummary(
|
|
258
|
+
export function searchSummary(
|
|
259
|
+
kind: DetectedBrowser['kind'],
|
|
260
|
+
platform: NodeJS.Platform = process.platform,
|
|
261
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
262
|
+
): string[] {
|
|
193
263
|
const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`)
|
|
194
|
-
|
|
264
|
+
// Expanded, so the report names the location that was actually tested rather than a template
|
|
265
|
+
// the user cannot paste into a file manager.
|
|
266
|
+
const located = (INSTALL_LOCATIONS[kind]?.[platform] ?? []).map(entry => expandLocation(entry, env))
|
|
195
267
|
return [...onPath, ...located]
|
|
196
268
|
}
|
|
197
269
|
|
|
@@ -310,7 +382,19 @@ class CdpClient {
|
|
|
310
382
|
|
|
311
383
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
312
384
|
export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
313
|
-
|
|
385
|
+
/**
|
|
386
|
+
* viewId -> the session that view rides, and the client that issued it.
|
|
387
|
+
*
|
|
388
|
+
* The client is part of the value on purpose. A session id means nothing to a browser other
|
|
389
|
+
* than the one that handed it out, and this host replaces browsers routinely: the process
|
|
390
|
+
* dies, or its debugging socket closes while the process lives on. Keeping only the id meant
|
|
391
|
+
* the host could start a NEW browser successfully and then send every command to it carrying
|
|
392
|
+
* a session the old one had issued — `Session with given id not found`, on every call, with a
|
|
393
|
+
* browser that answers /json/version perfectly well and nothing the user can do short of
|
|
394
|
+
* restarting DSH. Comparing identity catches it with no round trip; validating each cached
|
|
395
|
+
* session against the browser would put one on every page command.
|
|
396
|
+
*/
|
|
397
|
+
private readonly views = new Map<string, { session: string; client: CdpClient }>()
|
|
314
398
|
private disposed = false
|
|
315
399
|
/**
|
|
316
400
|
* The browser is started on FIRST USE, never on construction.
|
|
@@ -321,21 +405,47 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
321
405
|
* spawned when a view is actually needed.
|
|
322
406
|
*/
|
|
323
407
|
private client: CdpClient | undefined
|
|
408
|
+
/**
|
|
409
|
+
* Set when a command went unanswered rather than being refused.
|
|
410
|
+
*
|
|
411
|
+
* A timeout says nothing about the connection: the page may simply be slow. It marks the
|
|
412
|
+
* client for a cheap check before the next command trusts it, which keeps a slow page
|
|
413
|
+
* from being mistaken for a dead browser while still catching one that has truly stopped
|
|
414
|
+
* answering.
|
|
415
|
+
*/
|
|
416
|
+
private clientSuspect = false
|
|
324
417
|
private child: ChildProcess | undefined
|
|
325
418
|
private starting: Promise<CdpClient> | undefined
|
|
326
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Browsers that ignored a kill.
|
|
422
|
+
*
|
|
423
|
+
* They are no longer this host's child — a replacement has taken that slot — but they
|
|
424
|
+
* are still running, still holding the profile, and still something release must try to
|
|
425
|
+
* stop. Forgetting them is what allowed a launch this host could not undo.
|
|
426
|
+
*/
|
|
427
|
+
private readonly stubborn: { kill: () => void; exitCode: number | null; signalCode: NodeJS.Signals | null }[] = []
|
|
428
|
+
|
|
327
429
|
/**
|
|
328
430
|
* @param browser - the detected installation to launch on first use.
|
|
329
431
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
330
432
|
* @param extraArgs - additional Chromium switches.
|
|
331
433
|
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
332
434
|
* persistence off so no login state outlives the session.
|
|
435
|
+
* @param launcher - how to start the browser, for tests.
|
|
436
|
+
*
|
|
437
|
+
* Recovery after the browser dies was untestable without this: the suite spawns a stub
|
|
438
|
+
* with Chromium's arguments, and a stub that is not a browser rejects them and exits, so
|
|
439
|
+
* no CDP endpoint ever appears and no session is ever created. Verified by mutation —
|
|
440
|
+
* removing every sessions.clear() left the recovery tests green. A launcher lets a test
|
|
441
|
+
* provide something that actually speaks CDP, so the state those tests describe exists.
|
|
333
442
|
*/
|
|
334
443
|
constructor(
|
|
335
444
|
private readonly browser: DetectedBrowser,
|
|
336
445
|
private readonly profileDir: string,
|
|
337
446
|
private readonly extraArgs: readonly string[] = [],
|
|
338
447
|
private readonly ephemeralDir?: string,
|
|
448
|
+
private readonly launcher: BrowserLauncher = defaultLauncher,
|
|
339
449
|
) {}
|
|
340
450
|
|
|
341
451
|
/** Which product this host would drive (for diagnostics). */
|
|
@@ -352,6 +462,49 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
352
462
|
* The CDP client, starting the browser on first use.
|
|
353
463
|
* @returns the connected client.
|
|
354
464
|
*/
|
|
465
|
+
/**
|
|
466
|
+
* Whether a client that reports itself open is actually answering.
|
|
467
|
+
*
|
|
468
|
+
* An open socket proves nothing: a browser can hold the connection and never reply, and
|
|
469
|
+
* that state must not be cached, or every later command waits out its own timeout. Asking
|
|
470
|
+
* the browser something cheap is a fact; reading the failure text is a guess, and the
|
|
471
|
+
* guess was wrong in both directions (it killed healthy browsers on a slow page, and it
|
|
472
|
+
* mistook the browser's own free text for a verdict).
|
|
473
|
+
*
|
|
474
|
+
* This is only reached when the socket is open, so the common path costs nothing.
|
|
475
|
+
* @param client - the client to question.
|
|
476
|
+
* @returns true when it answered.
|
|
477
|
+
*/
|
|
478
|
+
private async probeClient(client: CdpClient): Promise<boolean> {
|
|
479
|
+
try {
|
|
480
|
+
// The command budget (30s) on purpose, not a shorter "probe" value, because of what the
|
|
481
|
+
// answer is used for: a false verdict discards the client and replaces the browser,
|
|
482
|
+
// which KILLS the window the human is looking at. A page that is merely slow must not
|
|
483
|
+
// lose its browser, so this errs toward waiting. The price is that a truly wedged
|
|
484
|
+
// browser takes the full budget to be declared dead — the tool call that triggered the
|
|
485
|
+
// probe answers first, and its timeout is the error the caller sees. Trading a later
|
|
486
|
+
// error for a window that is not destroyed is the right way round.
|
|
487
|
+
await client.send('Browser.getVersion', {})
|
|
488
|
+
return true
|
|
489
|
+
} catch {
|
|
490
|
+
// No answer within the budget, or the socket failed while asking: unusable either way.
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Forget which browser issued what, after that browser is gone.
|
|
497
|
+
*
|
|
498
|
+
* The two maps must move together: `views` maps a view id to a session id and `sessions`
|
|
499
|
+
* maps a session id to a target id, so clearing one alone leaves a mapping the next command
|
|
500
|
+
* will act on. Every path that abandons a browser calls this rather than spelling the pair
|
|
501
|
+
* out — four separate copies is how the third one came to be missing.
|
|
502
|
+
*/
|
|
503
|
+
private forgetBrowserState(): void {
|
|
504
|
+
this.views.clear()
|
|
505
|
+
this.sessions.clear()
|
|
506
|
+
}
|
|
507
|
+
|
|
355
508
|
private async ensureClient(): Promise<CdpClient> {
|
|
356
509
|
// A disposed host must never start a browser: doing so would spawn a process
|
|
357
510
|
// nobody owns and nobody will kill.
|
|
@@ -360,9 +513,25 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
360
513
|
// (the browser closing its debugging endpoint, a dropped connection). Checking the
|
|
361
514
|
// socket's own state costs nothing and turns a 30s hang into a restart.
|
|
362
515
|
if (this.client !== undefined) {
|
|
363
|
-
|
|
364
|
-
|
|
516
|
+
// A closed socket means the browser is gone; an OPEN socket proves nothing, because
|
|
517
|
+
// a browser can hold the connection and never answer. Only probe when the socket is
|
|
518
|
+
// closed or a previous command went unanswered without being refused — probing every
|
|
519
|
+
// call would add a round trip to the common path for nothing.
|
|
520
|
+
const current = this.client
|
|
521
|
+
if (current.isAlive() && !this.clientSuspect) return current
|
|
522
|
+
if (current.isAlive() && await this.probeClient(current)) {
|
|
523
|
+
this.clientSuspect = false
|
|
524
|
+
return current
|
|
525
|
+
}
|
|
526
|
+
current.close()
|
|
365
527
|
this.client = undefined
|
|
528
|
+
this.clientSuspect = false
|
|
529
|
+
// Everything this client issued dies with it. `start()` clears these too, but only
|
|
530
|
+
// inside its "kill the old child" branch — and the shape of this failure usually has no
|
|
531
|
+
// child left to kill: the process already exited, its exit listener already set
|
|
532
|
+
// `this.child` to undefined, so start() skips that branch and publishes a new browser
|
|
533
|
+
// while the views still point at the dead one. That is the state that never recovers.
|
|
534
|
+
this.forgetBrowserState()
|
|
366
535
|
}
|
|
367
536
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
368
537
|
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
@@ -391,7 +560,60 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
391
560
|
...this.extraArgs,
|
|
392
561
|
'about:blank',
|
|
393
562
|
]
|
|
394
|
-
|
|
563
|
+
// A browser from a previous attempt may still be running with this same
|
|
564
|
+
// --user-data-dir. On Windows the new one would merely hand off to it and exit, and that
|
|
565
|
+
// exit would then be treated as the launch failing.
|
|
566
|
+
if (this.child !== undefined) {
|
|
567
|
+
const dying = this.child
|
|
568
|
+
try { dying.kill() } catch { /* already gone */ }
|
|
569
|
+
this.child = undefined
|
|
570
|
+
|
|
571
|
+
// Whatever the old browser issued is worthless now, and its exit listener cannot clean
|
|
572
|
+
// up after itself: this.child is reassigned below, so the old child's exit event returns
|
|
573
|
+
// early and the session ids it handed out would be reused against a browser that never
|
|
574
|
+
// heard of them — the "Session with given id not found" loop, which never clears.
|
|
575
|
+
this.client?.close()
|
|
576
|
+
this.client = undefined
|
|
577
|
+
this.forgetBrowserState()
|
|
578
|
+
|
|
579
|
+
// Wait for it to actually die. kill() only sends the signal: on Windows it is
|
|
580
|
+
// TerminateProcess, which is asynchronous, and the browser may still hold this profile's
|
|
581
|
+
// singleton lock for a while. Spawning immediately meant the replacement saw an existing
|
|
582
|
+
// instance, handed its command line over and exited — and that exit was then reported as
|
|
583
|
+
// "check that the path is a runnable browser", blaming the user's install for a race this
|
|
584
|
+
// code created. Bounded, because a process that refuses to die must not hang the call.
|
|
585
|
+
await new Promise<void>(resolve => {
|
|
586
|
+
if (dying.exitCode !== null || dying.signalCode !== null) { resolve(); return }
|
|
587
|
+
const done = (): void => { clearTimeout(timer); resolve() }
|
|
588
|
+
const timer = setTimeout(done, EXIT_GRACE_MS)
|
|
589
|
+
dying.once('exit', done)
|
|
590
|
+
// It did not exit in time. Dropping the reference here was how this host could
|
|
591
|
+
// launch a browser it could never kill again: dispose() only knows about this.child,
|
|
592
|
+
// and this process still holds the profile's singleton lock. Remember it so release
|
|
593
|
+
// still reaches it.
|
|
594
|
+
if (dying.exitCode === null && dying.signalCode === null) this.stubborn.push(dying)
|
|
595
|
+
dying.once('exit', () => {
|
|
596
|
+
const at2 = this.stubborn.indexOf(dying)
|
|
597
|
+
if (at2 !== -1) this.stubborn.splice(at2, 1)
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
// The port file belongs to the browser that just died, and it is NOT removed when the
|
|
602
|
+
// process is killed: nothing gives a terminated browser a chance to clean up. The launch
|
|
603
|
+
// loop below reads it to learn the port, so a stale file means connecting to whatever else
|
|
604
|
+
// holds that port — or to the previous instance — and publishing a client this host does
|
|
605
|
+
// not own and dispose() can never kill.
|
|
606
|
+
try { rmSync(join(this.profileDir, 'DevToolsActivePort'), { force: true }) } catch { /* nothing to remove */ }
|
|
607
|
+
const spawnedAt = Date.now()
|
|
608
|
+
let child: ReturnType<typeof spawn>
|
|
609
|
+
try {
|
|
610
|
+
child = this.launcher(this.browser.path, args)
|
|
611
|
+
} catch (error) {
|
|
612
|
+
// spawn throws synchronously for some targets (a .cmd on Windows, a path that cannot
|
|
613
|
+
// be executed). Without this the exception escaped start() and skipped every
|
|
614
|
+
// diagnostic below it.
|
|
615
|
+
throw new Error(`dsh-builtin-browser: could not launch ${this.browser.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
616
|
+
}
|
|
395
617
|
this.child = child
|
|
396
618
|
/**
|
|
397
619
|
* A launch that fails (ENOENT, no permission, a broken binary) is reported by an
|
|
@@ -410,8 +632,30 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
410
632
|
this.client?.close()
|
|
411
633
|
this.client = undefined
|
|
412
634
|
this.child = undefined
|
|
635
|
+
// The pages this client opened belong to the process that just died; keeping their
|
|
636
|
+
// session ids means every later command is sent to a browser that never issued them.
|
|
637
|
+
// Clearing these is what makes the restart usable rather than merely alive.
|
|
638
|
+
this.forgetBrowserState()
|
|
413
639
|
})
|
|
414
640
|
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
641
|
+
/**
|
|
642
|
+
* The port this launch wrote, or undefined while it has not written one yet.
|
|
643
|
+
*
|
|
644
|
+
* A file left by the PREVIOUS browser is not evidence about this one. Its contents
|
|
645
|
+
* name a port that may now belong to something else entirely — another Chromium, a
|
|
646
|
+
* node --inspect — and connecting there publishes a client this host does not own and
|
|
647
|
+
* dispose() can never kill. Only a file written after the spawn counts.
|
|
648
|
+
*/
|
|
649
|
+
const freshPort = (): number | undefined => {
|
|
650
|
+
try {
|
|
651
|
+
if (!existsSync(portFile)) return undefined
|
|
652
|
+
if (statSync(portFile).mtimeMs < spawnedAt) return undefined
|
|
653
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
|
|
654
|
+
return Number.isInteger(value) && value > 0 ? value : undefined
|
|
655
|
+
} catch {
|
|
656
|
+
return undefined
|
|
657
|
+
}
|
|
658
|
+
}
|
|
415
659
|
const deadline = Date.now() + 30_000
|
|
416
660
|
/**
|
|
417
661
|
* Why the loop stopped, so the failure can say what actually happened. Reporting
|
|
@@ -428,14 +672,29 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
428
672
|
if (this.disposed) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'released'; break }
|
|
429
673
|
if (launchError !== undefined) { if (this.child === child) this.child = undefined; stopped = 'launch-failed'; break }
|
|
430
674
|
if (Date.now() > deadline) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'timeout'; break }
|
|
431
|
-
|
|
432
|
-
|
|
675
|
+
// signalCode too: a browser killed by a signal never sets exitCode, so checking exitCode
|
|
676
|
+
// alone made a killed browser look alive and the launch run out its full 30s deadline
|
|
677
|
+
// before reporting "did not expose CDP" — a timeout for something that died at once.
|
|
678
|
+
// The sibling check on the release path already looked at both.
|
|
679
|
+
if (child.exitCode !== null || child.signalCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
|
|
680
|
+
// Only a port THIS launch wrote: reading whatever file happened to be there is how a
|
|
681
|
+
// restart could adopt the previous browser, or any process now holding that port.
|
|
682
|
+
const port = freshPort()
|
|
683
|
+
if (port !== undefined) {
|
|
433
684
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
685
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
686
|
+
// Bounded: an unanswered loopback request would otherwise park inside this
|
|
687
|
+
// iteration for the rest of the budget, and the deadline above is only re-checked
|
|
688
|
+
// between iterations. Deliberately no larger than the 250 ms sleep — see
|
|
689
|
+
// PORT_PROBE_MS.
|
|
690
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
691
|
+
})).json() as { webSocketDebuggerUrl?: string }
|
|
437
692
|
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
438
693
|
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
694
|
+
// `whenReady` has its own 30 s ceiling, and it is reached only after the port
|
|
695
|
+
// answered above — so the browser is listening and this is a handshake, not a
|
|
696
|
+
// discovery. A handshake that outlives the launch is reported as the timeout it
|
|
697
|
+
// is, by the same deadline that governs everything else here.
|
|
439
698
|
await client.whenReady()
|
|
440
699
|
// Still wanted? A dispose() during the handshake has already killed the
|
|
441
700
|
// process, so publishing the client would leave a dangling connection.
|
|
@@ -443,7 +702,6 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
443
702
|
this.client = client
|
|
444
703
|
return client
|
|
445
704
|
}
|
|
446
|
-
}
|
|
447
705
|
} catch {
|
|
448
706
|
// The file can exist a moment before the port answers; poll again.
|
|
449
707
|
}
|
|
@@ -458,7 +716,24 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
458
716
|
throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`)
|
|
459
717
|
}
|
|
460
718
|
if (stopped === 'exited') {
|
|
461
|
-
|
|
719
|
+
// Only exit code 0 is the hand-off story. A non-zero code or a signal means the browser
|
|
720
|
+
// died on its own — a crash, a missing library, a sandbox refusal — and asserting the
|
|
721
|
+
// profile story for those sent people looking for a leftover process that was not there,
|
|
722
|
+
// which is the same mistake this message was written to correct, pointing the other way.
|
|
723
|
+
const code = child.exitCode
|
|
724
|
+
const signal = child.signalCode
|
|
725
|
+
const clean = code === 0 && signal === null
|
|
726
|
+
throw new Error(
|
|
727
|
+
clean
|
|
728
|
+
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
729
|
+
+ 'That means it handed the request to an instance already using this profile '
|
|
730
|
+
+ 'directory and quit — look for a leftover browser process from an earlier run, then retry.'
|
|
731
|
+
: `dsh-builtin-browser: ${target} exited immediately after launch `
|
|
732
|
+
+ `(${signal !== null ? `killed by ${signal}` : `exit code ${String(code)}`}), so it did not `
|
|
733
|
+
+ 'hand the request anywhere — the browser failed to start rather than deferring to another '
|
|
734
|
+
+ 'instance. Check that it can run at all: run it by hand, and look for a crash or a missing '
|
|
735
|
+
+ 'dependency rather than a leftover process.',
|
|
736
|
+
)
|
|
462
737
|
}
|
|
463
738
|
if (stopped === 'released') {
|
|
464
739
|
throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`)
|
|
@@ -489,24 +764,65 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
489
764
|
// socket can stay OPEN while nothing comes back, which is the form the reporter saw with
|
|
490
765
|
// a live, healthy-looking Edge. Dropping the client here means the next call starts a new
|
|
491
766
|
// browser rather than hanging again, and the error names the cause instead of a timeout.
|
|
767
|
+
const client = await this.ensureClient()
|
|
768
|
+
const cachedView = this.views.get(viewId)
|
|
769
|
+
// A session belongs to the browser that issued it. Clearing the cache whenever a client is
|
|
770
|
+
// dropped covers the ordinary case, but not this one: another call can be inside
|
|
771
|
+
// ensureSession() holding the OLD client while this one discards it and clears, and that
|
|
772
|
+
// call's `views.set` then lands afterwards — re-adding an entry naming a browser that is
|
|
773
|
+
// gone. The identity check is what refuses it, and it costs a comparison rather than the
|
|
774
|
+
// round trip that validating every cached session against the browser would cost.
|
|
775
|
+
//
|
|
776
|
+
// (Not covered by the tests: the race needs two interleaved commands, and the suite's fake
|
|
777
|
+
// browser is synchronous enough that they never interleave. Verified by mutation instead —
|
|
778
|
+
// removing this check alone leaves every case green, which is why the comment says what it
|
|
779
|
+
// protects rather than claiming a case proves it.)
|
|
780
|
+
const session = cachedView !== undefined && cachedView.client === client
|
|
781
|
+
? cachedView.session
|
|
782
|
+
: await this.ensureSession(viewId)
|
|
492
783
|
try {
|
|
493
|
-
const client = await this.ensureClient()
|
|
494
|
-
const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
|
|
495
784
|
const result = await client.send(method, params, session)
|
|
496
785
|
return (result ?? {}) as Record<string, unknown>
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
786
|
+
} catch (error) {
|
|
787
|
+
// A CDP protocol error means the browser ANSWERED and refused this one command, so the
|
|
788
|
+
// connection is healthy and must be kept. Deciding by the error TEXT misfires both
|
|
789
|
+
// ways: the inner timeout rejects with "… timed out", so a slow but healthy page read
|
|
790
|
+
// as a dead browser, the client was dropped, and the next call's start() killed the
|
|
791
|
+
// running child — the human's tabs and half-filled forms gone, which is worse than the
|
|
792
|
+
// bug that change was fixing. The browser's own free text is interpolated into
|
|
793
|
+
// protocol errors too, so any message containing those words misfired the same way.
|
|
794
|
+
// Unanswered versus refused. 'timed out' is the inner client's own budget expiring,
|
|
795
|
+
// which says the page was slow, not that the browser died — so it only marks the client
|
|
796
|
+
// for a probe. Being wrong here costs a two-second check; being wrong about the
|
|
797
|
+
// browser being dead cost the user their open tabs.
|
|
798
|
+
if (error instanceof Error && /timed out/.test(error.message)) this.clientSuspect = true
|
|
799
|
+
if (!client.isAlive()) {
|
|
800
|
+
// Only THIS call's client. Under concurrency the field may already hold a
|
|
801
|
+
// connection another call just published, and closing that would kill a healthy one.
|
|
802
|
+
if (this.client === client) {
|
|
803
|
+
this.client = undefined
|
|
804
|
+
// This is the third of the three paths that discard a client, and it was the one
|
|
805
|
+
// that did not retire what the client had issued. A view holding a session id
|
|
806
|
+
// from the dead browser kept handing it to the replacement, which had never heard
|
|
807
|
+
// of it: every page command then failed with "Session with given id not found"
|
|
808
|
+
// and no later call could clear it, because the cached session was returned
|
|
809
|
+
// unconditionally. Gated on the same identity check as the field itself, so a
|
|
810
|
+
// concurrent call that just published a healthy client does not lose its maps.
|
|
811
|
+
this.forgetBrowserState()
|
|
812
|
+
}
|
|
813
|
+
client.close()
|
|
814
|
+
}
|
|
815
|
+
throw error
|
|
501
816
|
}
|
|
502
817
|
},
|
|
503
818
|
}
|
|
504
819
|
}
|
|
505
820
|
|
|
506
821
|
destroyView(handle: ElectronViewHandle): void {
|
|
507
|
-
const
|
|
822
|
+
const cachedView = this.views.get(handle.id)
|
|
508
823
|
this.views.delete(handle.id)
|
|
509
|
-
if (
|
|
824
|
+
if (cachedView === undefined) return
|
|
825
|
+
const session = cachedView.session
|
|
510
826
|
// A session id is not a target id: closing the page needs the target the session
|
|
511
827
|
// was attached to, which is why they are tracked separately.
|
|
512
828
|
const targetId = this.sessions.get(session)
|
|
@@ -521,11 +837,32 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
521
837
|
/** Nothing to show: the browser owns its own windows. */
|
|
522
838
|
showView(): void {}
|
|
523
839
|
|
|
524
|
-
/**
|
|
525
|
-
|
|
840
|
+
/**
|
|
841
|
+
* Bring the target's page to the front, then report success.
|
|
842
|
+
*
|
|
843
|
+
* The comment on `focus` below used to say this was impossible — that raising the page needs
|
|
844
|
+
* `Page.bringToFront` on a target session, which is a view-level concern the host cannot
|
|
845
|
+
* reach. The handle this method is handed IS that view, and it can send commands, so the
|
|
846
|
+
* capability was here all along; it just was not used. Without it `presentView` was an empty
|
|
847
|
+
* async method, so the provider's whole presentation barrier — the one that stops
|
|
848
|
+
* `Input.*` from being silently dropped for a view with no display surface, and reports
|
|
849
|
+
* `BROWSER_VIEW_NOT_PRESENTED` when it cannot present — never ran on this carrier.
|
|
850
|
+
*
|
|
851
|
+
* A failure is swallowed rather than propagated: a real Chrome window that refuses to come
|
|
852
|
+
* forward is not a reason to refuse the click that follows. The barrier exists to order the
|
|
853
|
+
* two, and the order is honoured either way.
|
|
854
|
+
* @param handle - the view to present.
|
|
855
|
+
*/
|
|
856
|
+
async presentView(handle: ElectronViewHandle): Promise<void> {
|
|
857
|
+
try {
|
|
858
|
+
await handle.sendCommand('Page.bringToFront')
|
|
859
|
+
} catch {
|
|
860
|
+
// Backgrounded, minimised, or a target that went away between the snapshot and the
|
|
861
|
+
// click. The command that called this will report its own failure if the page is gone.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
526
865
|
|
|
527
|
-
/** The browser owns window grouping. */
|
|
528
|
-
groupView(): void {}
|
|
529
866
|
|
|
530
867
|
/**
|
|
531
868
|
* Best effort at bringing the browser's window forward.
|
|
@@ -543,11 +880,7 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
543
880
|
try { this.child?.kill('SIGCONT') } catch { /* Windows has no SIGCONT; nothing to do */ }
|
|
544
881
|
}
|
|
545
882
|
|
|
546
|
-
/** The browser reports its own window lifecycle. */
|
|
547
|
-
onUserAction(): void {}
|
|
548
883
|
|
|
549
|
-
/** The browser reports its own window lifecycle. */
|
|
550
|
-
onViewClosed(): void {}
|
|
551
884
|
|
|
552
885
|
/**
|
|
553
886
|
* Close the browser we launched, if we ever launched one.
|
|
@@ -559,25 +892,58 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
559
892
|
dispose(): void {
|
|
560
893
|
if (this.disposed) return
|
|
561
894
|
this.disposed = true
|
|
562
|
-
this.
|
|
563
|
-
this.sessions.clear()
|
|
895
|
+
this.forgetBrowserState()
|
|
564
896
|
this.client?.close()
|
|
565
897
|
try { this.child?.kill() } catch { /* already gone */ }
|
|
898
|
+
// And anything that ignored an earlier kill. They hold this profile, so leaving them
|
|
899
|
+
// running means the next launch contends with a browser nobody owns.
|
|
900
|
+
for (const leftover of this.stubborn.splice(0)) {
|
|
901
|
+
try { leftover.kill() } catch { /* already gone */ }
|
|
902
|
+
}
|
|
566
903
|
const ephemeral = this.ephemeralDir
|
|
567
904
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
568
905
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
569
906
|
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
570
|
-
|
|
907
|
+
//
|
|
908
|
+
// The timer is unref'd, so it never holds the process open — which also means that a
|
|
909
|
+
// process which exits inside this 3s window (a CLI probe, a DSH restart) leaves the
|
|
910
|
+
// directory behind for good, silently contradicting the user's "keep no profile"
|
|
911
|
+
// choice. The exit hook below is the same delete, run synchronously as a last resort;
|
|
912
|
+
// the callback is idempotent, so whichever runs first wins and the other is a no-op.
|
|
913
|
+
const removeEphemeral = (): void => {
|
|
914
|
+
try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
915
|
+
}
|
|
916
|
+
setTimeout(removeEphemeral, 3_000).unref?.()
|
|
917
|
+
process.once('exit', removeEphemeral)
|
|
571
918
|
} else if (ephemeral !== undefined) {
|
|
572
919
|
// Never started, so nothing can be holding it open.
|
|
573
920
|
try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
574
921
|
}
|
|
575
922
|
}
|
|
576
923
|
|
|
577
|
-
/**
|
|
924
|
+
/**
|
|
925
|
+
* The flattened session for a view, creating its page on first use.
|
|
926
|
+
*
|
|
927
|
+
* No cache check here: the only caller has already established that the cached session does
|
|
928
|
+
* not belong to the client it is about to use. Re-checking would mean either a round trip or
|
|
929
|
+
* a second copy of that rule.
|
|
930
|
+
*/
|
|
578
931
|
private async ensureSession(viewId: string): Promise<string> {
|
|
579
|
-
|
|
580
|
-
|
|
932
|
+
// One target per view, even when two commands arrive together. The two CDP round trips
|
|
933
|
+
// below are awaits, so without this a second caller entering during the first's flight saw
|
|
934
|
+
// no session in `views` and created its own target — leaving an orphan page that nothing
|
|
935
|
+
// closes, because only the last id survives in the map.
|
|
936
|
+
const inFlight = this.sessionBuilding.get(viewId)
|
|
937
|
+
if (inFlight !== undefined) return await inFlight
|
|
938
|
+
const building = this.buildSession(viewId).finally(() => {
|
|
939
|
+
this.sessionBuilding.delete(viewId)
|
|
940
|
+
})
|
|
941
|
+
this.sessionBuilding.set(viewId, building)
|
|
942
|
+
return await building
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
946
|
+
private async buildSession(viewId: string): Promise<string> {
|
|
581
947
|
const client = await this.ensureClient()
|
|
582
948
|
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
583
949
|
const targetId = created?.targetId
|
|
@@ -587,10 +953,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
587
953
|
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
588
954
|
// The session id is what page commands ride; the target id is what closes it.
|
|
589
955
|
this.sessions.set(sessionId, targetId)
|
|
590
|
-
this.views.set(viewId, sessionId)
|
|
956
|
+
this.views.set(viewId, { session: sessionId, client })
|
|
591
957
|
return sessionId
|
|
592
958
|
}
|
|
593
959
|
|
|
960
|
+
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
961
|
+
private readonly sessionBuilding = new Map<string, Promise<string>>()
|
|
962
|
+
|
|
594
963
|
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
595
964
|
private readonly sessions = new Map<string, string>()
|
|
596
965
|
}
|