dsh-builtin-browser 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,38 +38,80 @@ export interface DetectedBrowser {
38
38
  * Find an installed Chromium browser.
39
39
  *
40
40
  * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
41
- * is to be the browser a user actually chose rather than one Windows shipped.
41
+ * is to be the browser a user actually chose rather than one the system shipped.
42
+ * Each candidate is looked up in three places, most specific first: an explicit
43
+ * environment override, then PATH, then the platform's conventional install
44
+ * locations. PATH comes before the fixed paths because it is what actually reflects
45
+ * how the browser was installed.
42
46
  * @param channel - the configured choice; `bundled` never resolves to a system browser.
43
47
  * @param env - environment lookup, injected so tests need no real machine.
48
+ * @param platform - the platform to use conventions for; injected for the same reason.
44
49
  * @returns the detected browser, or undefined when the choice is unavailable.
45
50
  */
46
- export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv): DetectedBrowser | undefined;
51
+ export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): DetectedBrowser | undefined;
52
+ /**
53
+ * Everything that was checked for one product, for reporting a miss.
54
+ *
55
+ * Detection returns undefined with no explanation, which left the caller able to say
56
+ * only "not found". This gives the user the actual list so they can see whether the
57
+ * plugin looked somewhere their browser is not.
58
+ * @param kind - the product to describe.
59
+ * @param platform - the platform whose names and locations apply.
60
+ * @returns the launcher names (as PATH lookups) and the fixed locations checked.
61
+ */
62
+ export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform): string[];
47
63
  /** A system browser driven over CDP, presented as a browser view host. */
48
64
  export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
49
- private readonly child;
50
- private readonly client;
51
- readonly kind: DetectedBrowser['kind'];
65
+ private readonly browser;
66
+ private readonly profileDir;
67
+ private readonly extraArgs;
52
68
  private readonly ephemeralDir?;
53
69
  private readonly views;
54
70
  private disposed;
55
71
  /**
56
- * @param child - the launched browser process.
57
- * @param client - the CDP connection to it.
58
- * @param kind - which product this is (for diagnostics).
59
- * @param ephemeralDir - a throwaway profile to remove on release, when the user
60
- * has turned persistence off and no login state should outlive the session.
72
+ * The browser is started on FIRST USE, never on construction.
73
+ *
74
+ * A plugin that launches Chrome the moment its host loads is a plugin that takes
75
+ * over the machine before anyone asked it to: simply starting DSH would spawn a
76
+ * browser window. Registration must therefore be inert, and the process is only
77
+ * spawned when a view is actually needed.
61
78
  */
62
- private constructor();
79
+ private client;
80
+ private child;
81
+ private starting;
63
82
  /**
64
- * @param browser - the detected installation to launch.
83
+ * @param browser - the detected installation to launch on first use.
65
84
  * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
66
85
  * @param extraArgs - additional Chromium switches.
67
- * @param ephemeralDir - a directory to delete when the browser is released, used
68
- * when persistence is switched off so no login state outlives the session.
69
- * @returns the host, or undefined when the browser refuses to come up.
86
+ * @param ephemeralDir - a directory to delete on release, when the user has turned
87
+ * persistence off so no login state outlives the session.
88
+ */
89
+ constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
90
+ /** Which product this host would drive (for diagnostics). */
91
+ get kind(): DetectedBrowser['kind'];
92
+ /** Whether the browser has actually been started yet (diagnostics and tests). */
93
+ get started(): boolean;
94
+ /**
95
+ * The CDP client, starting the browser on first use.
96
+ * @returns the connected client.
97
+ */
98
+ private ensureClient;
99
+ /**
100
+ * Launch the browser with a private profile and connect over CDP.
101
+ *
102
+ * `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
103
+ * reliable way to learn the port: a fixed port collides with whatever else the
104
+ * machine is running, and parsing stderr is fragile across versions.
105
+ * @returns the connected client.
106
+ */
107
+ private start;
108
+ /**
109
+ * Whether this host can back views.
110
+ *
111
+ * True while the host is usable — including before the browser has been started,
112
+ * since the first command is what starts it. Reporting "unavailable" here would
113
+ * make the plugin fall back for no reason.
70
114
  */
71
- static launch(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string): Promise<SystemBrowserViewHost | undefined>;
72
- /** Whether this host can back views: the CDP connection is live. */
73
115
  available(): boolean;
74
116
  createView(): ElectronViewHandle;
75
117
  destroyView(handle: ElectronViewHandle): void;
@@ -80,18 +122,29 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
80
122
  /** The browser owns window grouping. */
81
123
  groupView(): void;
82
124
  /**
83
- * Bring the browser's window forward.
125
+ * Best effort at bringing the browser's window forward.
84
126
  *
85
- * The browser is a separate application, so there is no view to raise; focusing
86
- * the process is the closest honest equivalent, and failing to do so (a minimized
87
- * window, a locked session) is never worth an error.
127
+ * The browser is a separate application, so there is no view to raise. What this can
128
+ * actually do is ask its process to continue from a stopped state — which is a no-op on
129
+ * Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
130
+ * because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
131
+ * resumes), but it must not be read as a promise that the window comes to the front;
132
+ * importantly, it does NOT raise a window that is merely behind another. Raising the
133
+ * page properly would need `Page.bringToFront` on a specific target session, which is a
134
+ * view-level concern and not available to the host.
88
135
  */
89
136
  focus(): Promise<void>;
90
137
  /** The browser reports its own window lifecycle. */
91
138
  onUserAction(): void;
92
139
  /** The browser reports its own window lifecycle. */
93
140
  onViewClosed(): void;
94
- /** Close the browser we launched. The user's own windows are a different process. */
141
+ /**
142
+ * Close the browser we launched, if we ever launched one.
143
+ *
144
+ * The user's own windows belong to a different process and are never touched. A
145
+ * host whose browser was never started (the common case when nobody used the
146
+ * browser) simply tears down its bookkeeping.
147
+ */
95
148
  dispose(): void;
96
149
  /** The flattened session for a view, creating its page on first use. */
97
150
  private ensureSession;
@@ -30,34 +30,121 @@ import { join } from 'node:path';
30
30
  import { spawn } from 'node:child_process';
31
31
  import { randomUUID } from 'node:crypto';
32
32
  /**
33
- * Where each Chromium product installs on Windows, plus the registry paths that
34
- * survive a non-default install location. Only paths that exist are returned, so a
35
- * caller can tell "not installed" from "installed elsewhere".
33
+ * Launcher names to look for on PATH, per product and per platform.
34
+ *
35
+ * The names genuinely differ: Linux distributions package the launcher under a
36
+ * versioned name (`microsoft-edge-stable`, `google-chrome-stable`), Windows puts
37
+ * `chrome.exe` in an Application directory that may or may not be on PATH, and macOS
38
+ * ships the executable inside the .app bundle. PATH is still consulted first on every
39
+ * platform — it is what reflects how the user actually installed the browser.
40
+ */
41
+ const PATH_NAMES = {
42
+ chrome: {
43
+ win32: ['chrome'],
44
+ darwin: ['Google Chrome', 'Chromium'],
45
+ linux: ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'],
46
+ },
47
+ edge: {
48
+ win32: ['msedge'],
49
+ darwin: ['Microsoft Edge'],
50
+ linux: ['microsoft-edge-stable', 'microsoft-edge', 'microsoft-edge-dev'],
51
+ },
52
+ brave: {
53
+ win32: ['brave'],
54
+ darwin: ['Brave Browser'],
55
+ linux: ['brave-browser', 'brave'],
56
+ },
57
+ };
58
+ /**
59
+ * Where each product installs when its launcher is not on PATH.
60
+ *
61
+ * Windows keeps the executable inside a version-less Application directory; macOS
62
+ * keeps it inside the .app bundle; Linux distributions that do not use a launcher
63
+ * put it under /usr/bin or /opt. All are checked, and anything missed can be named
64
+ * explicitly with DSH_BROWSER_<KIND>_PATH.
36
65
  */
37
- const WINDOWS_LOCATIONS = {
38
- chrome: [
39
- String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
40
- String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
41
- ],
42
- edge: [
43
- String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
44
- String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
45
- ],
46
- brave: [
47
- String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
48
- String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
49
- ],
66
+ const INSTALL_LOCATIONS = {
67
+ chrome: {
68
+ win32: [
69
+ String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
70
+ String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
71
+ ],
72
+ darwin: [
73
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
74
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
75
+ ],
76
+ linux: [
77
+ '/usr/bin/google-chrome-stable',
78
+ '/usr/bin/google-chrome',
79
+ '/usr/bin/chromium',
80
+ '/opt/google/chrome/chrome',
81
+ '/snap/bin/chromium',
82
+ ],
83
+ },
84
+ edge: {
85
+ win32: [
86
+ String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
87
+ String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
88
+ ],
89
+ darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
90
+ linux: [
91
+ '/usr/bin/microsoft-edge-stable',
92
+ '/usr/bin/microsoft-edge',
93
+ '/usr/bin/microsoft-edge-dev',
94
+ ],
95
+ },
96
+ brave: {
97
+ win32: [
98
+ String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
99
+ String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
100
+ ],
101
+ darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
102
+ linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
103
+ },
50
104
  };
105
+ /**
106
+ * Find an executable by name on PATH.
107
+ *
108
+ * PATH is read from the supplied environment rather than the process's own, so the
109
+ * detection stays testable. On Windows a bare name needs its extension; elsewhere
110
+ * the launcher is the file itself.
111
+ * @param names - candidate launcher names, in preference order.
112
+ * @param env - the environment to read PATH from.
113
+ * @param platform - the platform whose PATH and extension conventions apply.
114
+ * @returns the first existing absolute path, or undefined.
115
+ */
116
+ function findOnPath(names, env, platform) {
117
+ // Windows spells it Path as often as PATH; the separator differs too.
118
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
119
+ const separator = platform === 'win32' ? ';' : ':';
120
+ const directories = raw.split(separator).map(entry => entry.trim()).filter(entry => entry !== '');
121
+ const suffixes = platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
122
+ for (const name of names) {
123
+ for (const directory of directories) {
124
+ for (const suffix of suffixes) {
125
+ const candidate = join(directory, `${name}${suffix}`);
126
+ if (existsSync(candidate))
127
+ return candidate;
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ }
51
133
  /**
52
134
  * Find an installed Chromium browser.
53
135
  *
54
136
  * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
55
- * is to be the browser a user actually chose rather than one Windows shipped.
137
+ * is to be the browser a user actually chose rather than one the system shipped.
138
+ * Each candidate is looked up in three places, most specific first: an explicit
139
+ * environment override, then PATH, then the platform's conventional install
140
+ * locations. PATH comes before the fixed paths because it is what actually reflects
141
+ * how the browser was installed.
56
142
  * @param channel - the configured choice; `bundled` never resolves to a system browser.
57
143
  * @param env - environment lookup, injected so tests need no real machine.
144
+ * @param platform - the platform to use conventions for; injected for the same reason.
58
145
  * @returns the detected browser, or undefined when the choice is unavailable.
59
146
  */
60
- export function detectBrowser(channel, env = process.env) {
147
+ export function detectBrowser(channel, env = process.env, platform = process.platform) {
61
148
  if (channel === 'bundled')
62
149
  return undefined;
63
150
  const order = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel];
@@ -65,16 +152,33 @@ export function detectBrowser(channel, env = process.env) {
65
152
  const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined);
66
153
  if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv))
67
154
  return { kind, path: fromEnv };
68
- for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
155
+ const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform);
156
+ if (onPath !== undefined)
157
+ return { kind, path: onPath };
158
+ for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
69
159
  if (existsSync(candidate))
70
160
  return { kind, path: candidate };
71
161
  }
72
162
  }
73
163
  return undefined;
74
164
  }
165
+ /**
166
+ * Everything that was checked for one product, for reporting a miss.
167
+ *
168
+ * Detection returns undefined with no explanation, which left the caller able to say
169
+ * only "not found". This gives the user the actual list so they can see whether the
170
+ * plugin looked somewhere their browser is not.
171
+ * @param kind - the product to describe.
172
+ * @param platform - the platform whose names and locations apply.
173
+ * @returns the launcher names (as PATH lookups) and the fixed locations checked.
174
+ */
175
+ export function searchSummary(kind, platform = process.platform) {
176
+ const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`);
177
+ const located = INSTALL_LOCATIONS[kind]?.[platform] ?? [];
178
+ return [...onPath, ...located];
179
+ }
75
180
  /** One CDP connection to a browser-level WebSocket, with flattened sessions. */
76
181
  class CdpClient {
77
- url;
78
182
  nextId = 1;
79
183
  pending = new Map();
80
184
  ready;
@@ -82,7 +186,6 @@ class CdpClient {
82
186
  * @param url - the browser's `webSocketDebuggerUrl`.
83
187
  */
84
188
  constructor(url) {
85
- this.url = url;
86
189
  // Node 22 provides WebSocket globally; no dependency is added for this.
87
190
  const socket = new WebSocket(url);
88
191
  this.socket = socket;
@@ -94,9 +197,30 @@ class CdpClient {
94
197
  socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')));
95
198
  }
96
199
  socket;
97
- /** Resolve once the socket is open. */
200
+ /**
201
+ * Resolve once the socket is open.
202
+ *
203
+ * Bounded on purpose. `ready` settles on the socket's own `open` or `error` events, and
204
+ * a connection that is dropped by something in between may deliver neither — in which
205
+ * case an unbounded await here would hang the tool call forever, because the caller's
206
+ * own deadline is only checked between polls and this await happens inside one of them.
207
+ * @returns when the handshake completed.
208
+ */
98
209
  async whenReady() {
99
- await this.ready;
210
+ const timeoutMs = 30_000;
211
+ let timer;
212
+ try {
213
+ await Promise.race([
214
+ this.ready,
215
+ new Promise((_resolve, reject) => {
216
+ timer = setTimeout(() => reject(new Error(`dsh-builtin-browser: the browser did not complete the CDP handshake within ${timeoutMs / 1000}s`)), timeoutMs);
217
+ }),
218
+ ]);
219
+ }
220
+ finally {
221
+ if (timer !== undefined)
222
+ clearTimeout(timer);
223
+ }
100
224
  }
101
225
  /**
102
226
  * Send one CDP command, optionally on an attached session.
@@ -155,37 +279,71 @@ class CdpClient {
155
279
  }
156
280
  /** A system browser driven over CDP, presented as a browser view host. */
157
281
  export class SystemBrowserViewHost {
158
- child;
159
- client;
160
- kind;
282
+ browser;
283
+ profileDir;
284
+ extraArgs;
161
285
  ephemeralDir;
162
286
  views = new Map();
163
287
  disposed = false;
164
288
  /**
165
- * @param child - the launched browser process.
166
- * @param client - the CDP connection to it.
167
- * @param kind - which product this is (for diagnostics).
168
- * @param ephemeralDir - a throwaway profile to remove on release, when the user
169
- * has turned persistence off and no login state should outlive the session.
289
+ * The browser is started on FIRST USE, never on construction.
290
+ *
291
+ * A plugin that launches Chrome the moment its host loads is a plugin that takes
292
+ * over the machine before anyone asked it to: simply starting DSH would spawn a
293
+ * browser window. Registration must therefore be inert, and the process is only
294
+ * spawned when a view is actually needed.
170
295
  */
171
- constructor(child, client, kind, ephemeralDir) {
172
- this.child = child;
173
- this.client = client;
174
- this.kind = kind;
175
- this.ephemeralDir = ephemeralDir;
176
- }
296
+ client;
297
+ child;
298
+ starting;
177
299
  /**
178
- * @param browser - the detected installation to launch.
300
+ * @param browser - the detected installation to launch on first use.
179
301
  * @param profileDir - a plugin-owned directory; the user's own profile is never touched.
180
302
  * @param extraArgs - additional Chromium switches.
181
- * @param ephemeralDir - a directory to delete when the browser is released, used
182
- * when persistence is switched off so no login state outlives the session.
183
- * @returns the host, or undefined when the browser refuses to come up.
303
+ * @param ephemeralDir - a directory to delete on release, when the user has turned
304
+ * persistence off so no login state outlives the session.
305
+ */
306
+ constructor(browser, profileDir, extraArgs = [], ephemeralDir) {
307
+ this.browser = browser;
308
+ this.profileDir = profileDir;
309
+ this.extraArgs = extraArgs;
310
+ this.ephemeralDir = ephemeralDir;
311
+ }
312
+ /** Which product this host would drive (for diagnostics). */
313
+ get kind() {
314
+ return this.browser.kind;
315
+ }
316
+ /** Whether the browser has actually been started yet (diagnostics and tests). */
317
+ get started() {
318
+ return this.child !== undefined;
319
+ }
320
+ /**
321
+ * The CDP client, starting the browser on first use.
322
+ * @returns the connected client.
184
323
  */
185
- static async launch(browser, profileDir, extraArgs = [], ephemeralDir) {
324
+ async ensureClient() {
325
+ // A disposed host must never start a browser: doing so would spawn a process
326
+ // nobody owns and nobody will kill.
327
+ if (this.disposed)
328
+ throw new Error('dsh-builtin-browser: this browser host was released');
329
+ if (this.client !== undefined)
330
+ return this.client;
331
+ // Concurrent first calls share one startup rather than racing two browsers.
332
+ this.starting ??= this.start().finally(() => { this.starting = undefined; });
333
+ return await this.starting;
334
+ }
335
+ /**
336
+ * Launch the browser with a private profile and connect over CDP.
337
+ *
338
+ * `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
339
+ * reliable way to learn the port: a fixed port collides with whatever else the
340
+ * machine is running, and parsing stderr is fragile across versions.
341
+ * @returns the connected client.
342
+ */
343
+ async start() {
186
344
  const args = [
187
345
  '--remote-debugging-port=0',
188
- `--user-data-dir=${profileDir}`,
346
+ `--user-data-dir=${this.profileDir}`,
189
347
  // Behave like a fresh, unattended browser: no first-run UI, no default-browser
190
348
  // prompt, no restore bubble, and no "Chrome is being controlled" infobar.
191
349
  '--no-first-run',
@@ -193,19 +351,59 @@ export class SystemBrowserViewHost {
193
351
  '--disable-features=Translate,MediaRouter',
194
352
  '--disable-session-crashed-bubble',
195
353
  '--hide-crash-restore-bubble',
196
- ...extraArgs,
354
+ ...this.extraArgs,
197
355
  'about:blank',
198
356
  ];
199
- const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
200
- const portFile = join(profileDir, 'DevToolsActivePort');
357
+ const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
358
+ this.child = child;
359
+ /**
360
+ * A launch that fails (ENOENT, no permission, a broken binary) is reported by an
361
+ * asynchronous 'error' event on the child, NOT by a throw here and NOT by exitCode.
362
+ * Without this listener Node treats it as unhandled and takes the whole host process
363
+ * down — one bad browser path would kill DSH, not just fail a tool call.
364
+ */
365
+ let launchError;
366
+ child.on('error', error => { launchError = error; });
367
+ const portFile = join(this.profileDir, 'DevToolsActivePort');
201
368
  const deadline = Date.now() + 30_000;
369
+ /**
370
+ * Why the loop stopped, so the failure can say what actually happened. Reporting
371
+ * every ending as "did not expose CDP within 30s" was wrong three ways: the cause,
372
+ * the words, and the elapsed time — a browser that cannot be launched says so in
373
+ * half a second, and the caller then looks for a CDP problem that does not exist.
374
+ */
375
+ let stopped = 'timeout';
202
376
  for (;;) {
377
+ // Released while we were starting: stop here rather than keep polling for a
378
+ // browser that dispose() has already killed, which would leave a connection
379
+ // to a dead process behind. The child is killed here too — dispose() may have
380
+ // run before this child was recorded, leaving nobody else to do it.
381
+ if (this.disposed) {
382
+ child.kill();
383
+ if (this.child === child)
384
+ this.child = undefined;
385
+ stopped = 'released';
386
+ break;
387
+ }
388
+ if (launchError !== undefined) {
389
+ if (this.child === child)
390
+ this.child = undefined;
391
+ stopped = 'launch-failed';
392
+ break;
393
+ }
203
394
  if (Date.now() > deadline) {
204
395
  child.kill();
205
- return undefined;
396
+ if (this.child === child)
397
+ this.child = undefined;
398
+ stopped = 'timeout';
399
+ break;
400
+ }
401
+ if (child.exitCode !== null) {
402
+ if (this.child === child)
403
+ this.child = undefined;
404
+ stopped = 'exited';
405
+ break;
206
406
  }
207
- if (child.exitCode !== null)
208
- return undefined;
209
407
  if (existsSync(portFile)) {
210
408
  try {
211
409
  const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '';
@@ -214,7 +412,14 @@ export class SystemBrowserViewHost {
214
412
  if (typeof version.webSocketDebuggerUrl === 'string') {
215
413
  const client = new CdpClient(version.webSocketDebuggerUrl);
216
414
  await client.whenReady();
217
- return new SystemBrowserViewHost(child, client, browser.kind, ephemeralDir);
415
+ // Still wanted? A dispose() during the handshake has already killed the
416
+ // process, so publishing the client would leave a dangling connection.
417
+ if (this.disposed) {
418
+ client.close();
419
+ break;
420
+ }
421
+ this.client = client;
422
+ return client;
218
423
  }
219
424
  }
220
425
  }
@@ -224,8 +429,28 @@ export class SystemBrowserViewHost {
224
429
  }
225
430
  await new Promise(resolve => setTimeout(resolve, 250));
226
431
  }
432
+ // Each ending gets its own message. The old single message blamed a CDP timeout for
433
+ // all of them, which sent the reader looking for a port problem when the browser had
434
+ // simply failed to launch.
435
+ const target = `${this.browser.kind} (${this.browser.path})`;
436
+ if (stopped === 'launch-failed') {
437
+ throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`);
438
+ }
439
+ if (stopped === 'exited') {
440
+ throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`);
441
+ }
442
+ if (stopped === 'released') {
443
+ throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`);
444
+ }
445
+ throw new Error(`dsh-builtin-browser: ${target} did not expose CDP within 30s`);
227
446
  }
228
- /** Whether this host can back views: the CDP connection is live. */
447
+ /**
448
+ * Whether this host can back views.
449
+ *
450
+ * True while the host is usable — including before the browser has been started,
451
+ * since the first command is what starts it. Reporting "unavailable" here would
452
+ * make the plugin fall back for no reason.
453
+ */
229
454
  available() {
230
455
  return !this.disposed;
231
456
  }
@@ -233,9 +458,13 @@ export class SystemBrowserViewHost {
233
458
  const viewId = randomUUID();
234
459
  return {
235
460
  id: viewId,
461
+ // This is the real browser, not Electron: CDP's JPEG encoder works here, so the
462
+ // provider may pass format and quality through instead of falling back to PNG.
463
+ supportsCdpJpeg: true,
236
464
  sendCommand: async (method, params) => {
465
+ const client = await this.ensureClient();
237
466
  const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
238
- const result = await this.client.send(method, params, session);
467
+ const result = await client.send(method, params, session);
239
468
  return (result ?? {});
240
469
  },
241
470
  };
@@ -249,7 +478,8 @@ export class SystemBrowserViewHost {
249
478
  // was attached to, which is why they are tracked separately.
250
479
  const targetId = this.sessions.get(session);
251
480
  this.sessions.delete(session);
252
- if (targetId !== undefined) {
481
+ // Nothing to close if the browser was never started.
482
+ if (targetId !== undefined && this.client !== undefined) {
253
483
  // Best-effort: a page that is already gone is not an error.
254
484
  void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined);
255
485
  }
@@ -261,36 +491,47 @@ export class SystemBrowserViewHost {
261
491
  /** The browser owns window grouping. */
262
492
  groupView() { }
263
493
  /**
264
- * Bring the browser's window forward.
494
+ * Best effort at bringing the browser's window forward.
265
495
  *
266
- * The browser is a separate application, so there is no view to raise; focusing
267
- * the process is the closest honest equivalent, and failing to do so (a minimized
268
- * window, a locked session) is never worth an error.
496
+ * The browser is a separate application, so there is no view to raise. What this can
497
+ * actually do is ask its process to continue from a stopped state — which is a no-op on
498
+ * Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
499
+ * because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
500
+ * resumes), but it must not be read as a promise that the window comes to the front;
501
+ * importantly, it does NOT raise a window that is merely behind another. Raising the
502
+ * page properly would need `Page.bringToFront` on a specific target session, which is a
503
+ * view-level concern and not available to the host.
269
504
  */
270
505
  async focus() {
271
506
  try {
272
- this.child.kill('SIGCONT');
507
+ this.child?.kill('SIGCONT');
273
508
  }
274
- catch { /* not supported on Windows; harmless */ }
509
+ catch { /* Windows has no SIGCONT; nothing to do */ }
275
510
  }
276
511
  /** The browser reports its own window lifecycle. */
277
512
  onUserAction() { }
278
513
  /** The browser reports its own window lifecycle. */
279
514
  onViewClosed() { }
280
- /** Close the browser we launched. The user's own windows are a different process. */
515
+ /**
516
+ * Close the browser we launched, if we ever launched one.
517
+ *
518
+ * The user's own windows belong to a different process and are never touched. A
519
+ * host whose browser was never started (the common case when nobody used the
520
+ * browser) simply tears down its bookkeeping.
521
+ */
281
522
  dispose() {
282
523
  if (this.disposed)
283
524
  return;
284
525
  this.disposed = true;
285
526
  this.views.clear();
286
527
  this.sessions.clear();
287
- this.client.close();
528
+ this.client?.close();
288
529
  try {
289
- this.child.kill();
530
+ this.child?.kill();
290
531
  }
291
532
  catch { /* already gone */ }
292
533
  const ephemeral = this.ephemeralDir;
293
- if (ephemeral !== undefined) {
534
+ if (ephemeral !== undefined && this.child !== undefined) {
294
535
  // The browser flushes its profile while shutting down, so deleting later avoids
295
536
  // racing it. Leftovers are only a stray temp directory, never a live credential.
296
537
  setTimeout(() => { try {
@@ -298,17 +539,25 @@ export class SystemBrowserViewHost {
298
539
  }
299
540
  catch { /* best effort */ } }, 3_000).unref?.();
300
541
  }
542
+ else if (ephemeral !== undefined) {
543
+ // Never started, so nothing can be holding it open.
544
+ try {
545
+ rmSync(ephemeral, { recursive: true, force: true });
546
+ }
547
+ catch { /* best effort */ }
548
+ }
301
549
  }
302
550
  /** The flattened session for a view, creating its page on first use. */
303
551
  async ensureSession(viewId) {
304
552
  const existing = this.views.get(viewId);
305
553
  if (existing !== undefined)
306
554
  return existing;
307
- const created = await this.client.send('Target.createTarget', { url: 'about:blank' });
555
+ const client = await this.ensureClient();
556
+ const created = await client.send('Target.createTarget', { url: 'about:blank' });
308
557
  const targetId = created?.targetId;
309
558
  if (typeof targetId !== 'string')
310
559
  throw new Error('dsh-builtin-browser: the browser did not create a page');
311
- const attached = await this.client.send('Target.attachToTarget', { targetId, flatten: true });
560
+ const attached = await client.send('Target.attachToTarget', { targetId, flatten: true });
312
561
  const sessionId = attached?.sessionId;
313
562
  if (typeof sessionId !== 'string')
314
563
  throw new Error('dsh-builtin-browser: the browser did not attach to the new page');