dsh-builtin-browser 0.3.1 → 0.4.1

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.
Files changed (34) hide show
  1. package/README.en.md +84 -39
  2. package/README.md +78 -38
  3. package/client.js +2 -3
  4. package/cordis.patch.yml +13 -7
  5. package/desktop-bridge/install.mjs +274 -12
  6. package/desktop-bridge/plugin-browser-bridge.js +55 -15
  7. package/docs/architecture.md +1 -1
  8. package/docs/tool-reference.md +8 -8
  9. package/docs/user-guide.md +4 -2
  10. package/docs/why-browser.md +1 -1
  11. package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
  12. package/lib/browser-electron/desktop-bridge-host.js +10 -2
  13. package/lib/browser-electron/entry.js +22 -2
  14. package/lib/browser-electron/history-store.js +5 -1
  15. package/lib/browser-electron/missing-system-browser.d.ts +54 -0
  16. package/lib/browser-electron/missing-system-browser.js +89 -0
  17. package/lib/browser-electron/provider.d.ts +54 -1
  18. package/lib/browser-electron/provider.js +241 -37
  19. package/lib/browser-electron/remote-host.js +6 -3
  20. package/lib/browser-electron/settings-store.js +10 -4
  21. package/lib/browser-electron/system-browser.d.ts +27 -6
  22. package/lib/browser-electron/system-browser.js +274 -37
  23. package/lib/tool-browser/index.d.ts +14 -0
  24. package/lib/tool-browser/index.js +143 -58
  25. package/package.json +1 -1
  26. package/src/browser-electron/desktop-bridge-host.ts +11 -2
  27. package/src/browser-electron/entry.ts +22 -2
  28. package/src/browser-electron/history-store.ts +5 -1
  29. package/src/browser-electron/missing-system-browser.ts +113 -0
  30. package/src/browser-electron/provider.ts +267 -36
  31. package/src/browser-electron/remote-host.ts +6 -3
  32. package/src/browser-electron/settings-store.ts +11 -4
  33. package/src/browser-electron/system-browser.ts +256 -31
  34. package/src/tool-browser/index.ts +149 -63
@@ -44,47 +44,157 @@ export interface DetectedBrowser {
44
44
  }
45
45
 
46
46
  /**
47
- * Where each Chromium product installs on Windows, plus the registry paths that
48
- * survive a non-default install location. Only paths that exist are returned, so a
49
- * caller can tell "not installed" from "installed elsewhere".
47
+ * Launcher names to look for on PATH, per product and per platform.
48
+ *
49
+ * The names genuinely differ: Linux distributions package the launcher under a
50
+ * versioned name (`microsoft-edge-stable`, `google-chrome-stable`), Windows puts
51
+ * `chrome.exe` in an Application directory that may or may not be on PATH, and macOS
52
+ * ships the executable inside the .app bundle. PATH is still consulted first on every
53
+ * platform — it is what reflects how the user actually installed the browser.
54
+ */
55
+ const PATH_NAMES: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.Platform, string[]>>> = {
56
+ chrome: {
57
+ win32: ['chrome'],
58
+ darwin: ['Google Chrome', 'Chromium'],
59
+ linux: ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'],
60
+ },
61
+ edge: {
62
+ win32: ['msedge'],
63
+ darwin: ['Microsoft Edge'],
64
+ linux: ['microsoft-edge-stable', 'microsoft-edge', 'microsoft-edge-dev'],
65
+ },
66
+ brave: {
67
+ win32: ['brave'],
68
+ darwin: ['Brave Browser'],
69
+ linux: ['brave-browser', 'brave'],
70
+ },
71
+ }
72
+
73
+ /**
74
+ * Where each product installs when its launcher is not on PATH.
75
+ *
76
+ * Windows keeps the executable inside a version-less Application directory; macOS
77
+ * keeps it inside the .app bundle; Linux distributions that do not use a launcher
78
+ * put it under /usr/bin or /opt. All are checked, and anything missed can be named
79
+ * explicitly with DSH_BROWSER_<KIND>_PATH.
50
80
  */
51
- const WINDOWS_LOCATIONS: Record<DetectedBrowser['kind'], string[]> = {
52
- chrome: [
53
- String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
54
- String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
55
- ],
56
- edge: [
57
- String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
58
- String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
59
- ],
60
- brave: [
61
- String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
62
- String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
63
- ],
81
+ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.Platform, string[]>>> = {
82
+ chrome: {
83
+ win32: [
84
+ String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
85
+ String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
86
+ ],
87
+ darwin: [
88
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
89
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
90
+ ],
91
+ linux: [
92
+ '/usr/bin/google-chrome-stable',
93
+ '/usr/bin/google-chrome',
94
+ '/usr/bin/chromium',
95
+ '/opt/google/chrome/chrome',
96
+ '/snap/bin/chromium',
97
+ ],
98
+ },
99
+ edge: {
100
+ win32: [
101
+ String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
102
+ String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
103
+ ],
104
+ darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
105
+ linux: [
106
+ '/usr/bin/microsoft-edge-stable',
107
+ '/usr/bin/microsoft-edge',
108
+ '/usr/bin/microsoft-edge-dev',
109
+ ],
110
+ },
111
+ brave: {
112
+ win32: [
113
+ String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
114
+ String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
115
+ ],
116
+ darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
117
+ linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
118
+ },
119
+ }
120
+
121
+ /**
122
+ * Find an executable by name on PATH.
123
+ *
124
+ * PATH is read from the supplied environment rather than the process's own, so the
125
+ * detection stays testable. On Windows a bare name needs its extension; elsewhere
126
+ * the launcher is the file itself.
127
+ * @param names - candidate launcher names, in preference order.
128
+ * @param env - the environment to read PATH from.
129
+ * @param platform - the platform whose PATH and extension conventions apply.
130
+ * @returns the first existing absolute path, or undefined.
131
+ */
132
+ function findOnPath(names: readonly string[], env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | undefined {
133
+ // Windows spells it Path as often as PATH; the separator differs too.
134
+ const raw = env.PATH ?? env.Path ?? env.path ?? ''
135
+ const separator = platform === 'win32' ? ';' : ':'
136
+ const directories = raw.split(separator).map(entry => entry.trim()).filter(entry => entry !== '')
137
+ const suffixes = platform === 'win32' ? ['.exe', '.cmd', ''] : ['']
138
+ for (const name of names) {
139
+ for (const directory of directories) {
140
+ for (const suffix of suffixes) {
141
+ const candidate = join(directory, `${name}${suffix}`)
142
+ if (existsSync(candidate)) return candidate
143
+ }
144
+ }
145
+ }
146
+ return undefined
64
147
  }
65
148
 
66
149
  /**
67
150
  * Find an installed Chromium browser.
68
151
  *
69
152
  * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
70
- * is to be the browser a user actually chose rather than one Windows shipped.
153
+ * is to be the browser a user actually chose rather than one the system shipped.
154
+ * Each candidate is looked up in three places, most specific first: an explicit
155
+ * environment override, then PATH, then the platform's conventional install
156
+ * locations. PATH comes before the fixed paths because it is what actually reflects
157
+ * how the browser was installed.
71
158
  * @param channel - the configured choice; `bundled` never resolves to a system browser.
72
159
  * @param env - environment lookup, injected so tests need no real machine.
160
+ * @param platform - the platform to use conventions for; injected for the same reason.
73
161
  * @returns the detected browser, or undefined when the choice is unavailable.
74
162
  */
75
- export function detectBrowser(channel: BrowserChannel, env: NodeJS.ProcessEnv = process.env): DetectedBrowser | undefined {
163
+ export function detectBrowser(
164
+ channel: BrowserChannel,
165
+ env: NodeJS.ProcessEnv = process.env,
166
+ platform: NodeJS.Platform = process.platform,
167
+ ): DetectedBrowser | undefined {
76
168
  if (channel === 'bundled') return undefined
77
169
  const order: DetectedBrowser['kind'][] = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel as DetectedBrowser['kind']]
78
170
  for (const kind of order) {
79
171
  const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined)
80
172
  if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
81
- for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
173
+ const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform)
174
+ if (onPath !== undefined) return { kind, path: onPath }
175
+ for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
82
176
  if (existsSync(candidate)) return { kind, path: candidate }
83
177
  }
84
178
  }
85
179
  return undefined
86
180
  }
87
181
 
182
+ /**
183
+ * Everything that was checked for one product, for reporting a miss.
184
+ *
185
+ * Detection returns undefined with no explanation, which left the caller able to say
186
+ * only "not found". This gives the user the actual list so they can see whether the
187
+ * plugin looked somewhere their browser is not.
188
+ * @param kind - the product to describe.
189
+ * @param platform - the platform whose names and locations apply.
190
+ * @returns the launcher names (as PATH lookups) and the fixed locations checked.
191
+ */
192
+ export function searchSummary(kind: DetectedBrowser['kind'], platform: NodeJS.Platform = process.platform): string[] {
193
+ const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`)
194
+ const located = INSTALL_LOCATIONS[kind]?.[platform] ?? []
195
+ return [...onPath, ...located]
196
+ }
197
+
88
198
  /** One CDP connection to a browser-level WebSocket, with flattened sessions. */
89
199
  class CdpClient {
90
200
  private nextId = 1
@@ -94,7 +204,7 @@ class CdpClient {
94
204
  /**
95
205
  * @param url - the browser's `webSocketDebuggerUrl`.
96
206
  */
97
- constructor(private readonly url: string) {
207
+ constructor(url: string) {
98
208
  // Node 22 provides WebSocket globally; no dependency is added for this.
99
209
  const socket = new WebSocket(url)
100
210
  this.socket = socket
@@ -108,9 +218,44 @@ class CdpClient {
108
218
 
109
219
  private readonly socket: WebSocket
110
220
 
111
- /** Resolve once the socket is open. */
221
+ /**
222
+ * Resolve once the socket is open.
223
+ *
224
+ * Bounded on purpose. `ready` settles on the socket's own `open` or `error` events, and
225
+ * a connection that is dropped by something in between may deliver neither — in which
226
+ * case an unbounded await here would hang the tool call forever, because the caller's
227
+ * own deadline is only checked between polls and this await happens inside one of them.
228
+ * @returns when the handshake completed.
229
+ */
230
+ /**
231
+ * Whether the socket is still usable.
232
+ *
233
+ * A dropped connection is not reported to the caller of send(); the pending command
234
+ * would simply never complete. Asking the socket directly is what lets a cached client
235
+ * be discarded instead of reused, which is the difference between a restart and a 30s
236
+ * hang on every subsequent call.
237
+ * @returns true while the socket is open.
238
+ */
239
+ isAlive(): boolean {
240
+ return this.socket.readyState === WebSocket.OPEN
241
+ }
242
+
112
243
  async whenReady(): Promise<void> {
113
- await this.ready
244
+ const timeoutMs = 30_000
245
+ let timer: NodeJS.Timeout | undefined
246
+ try {
247
+ await Promise.race([
248
+ this.ready,
249
+ new Promise<never>((_resolve, reject) => {
250
+ timer = setTimeout(
251
+ () => reject(new Error(`dsh-builtin-browser: the browser did not complete the CDP handshake within ${timeoutMs / 1000}s`)),
252
+ timeoutMs,
253
+ )
254
+ }),
255
+ ])
256
+ } finally {
257
+ if (timer !== undefined) clearTimeout(timer)
258
+ }
114
259
  }
115
260
 
116
261
  /**
@@ -121,6 +266,11 @@ class CdpClient {
121
266
  * @returns the CDP result.
122
267
  */
123
268
  async send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown> {
269
+ // Fail fast and say why. Waiting out the timeout on a closed socket produced
270
+ // only "timed out", which reads as a slow page and hides the real cause.
271
+ if (!this.isAlive()) {
272
+ throw new Error('dsh-builtin-browser: the browser connection is closed (the browser window was probably closed); the next command will start it again')
273
+ }
124
274
  await this.ready
125
275
  const id = this.nextId++
126
276
  const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} }
@@ -203,7 +353,17 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
203
353
  * @returns the connected client.
204
354
  */
205
355
  private async ensureClient(): Promise<CdpClient> {
206
- if (this.client !== undefined) return this.client
356
+ // A disposed host must never start a browser: doing so would spawn a process
357
+ // nobody owns and nobody will kill.
358
+ if (this.disposed) throw new Error('dsh-builtin-browser: this browser host was released')
359
+ // The exit listener clears this, but a socket can also die without the process doing so
360
+ // (the browser closing its debugging endpoint, a dropped connection). Checking the
361
+ // socket's own state costs nothing and turns a 30s hang into a restart.
362
+ if (this.client !== undefined) {
363
+ if (this.client.isAlive()) return this.client
364
+ this.client.close()
365
+ this.client = undefined
366
+ }
207
367
  // Concurrent first calls share one startup rather than racing two browsers.
208
368
  this.starting ??= this.start().finally(() => { this.starting = undefined })
209
369
  return await this.starting
@@ -233,11 +393,42 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
233
393
  ]
234
394
  const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
235
395
  this.child = child
396
+ /**
397
+ * A launch that fails (ENOENT, no permission, a broken binary) is reported by an
398
+ * asynchronous 'error' event on the child, NOT by a throw here and NOT by exitCode.
399
+ * Without this listener Node treats it as unhandled and takes the whole host process
400
+ * down — one bad browser path would kill DSH, not just fail a tool call.
401
+ */
402
+ let launchError: Error | undefined
403
+ child.on('error', error => { launchError = error })
404
+ // A browser that exits later is as unusable as one that never started, and the failure
405
+ // has to reach the NEXT call rather than being swallowed: clearing the client here is
406
+ // what makes ensureClient() start a fresh browser instead of returning a socket to a
407
+ // dead process and letting every command time out.
408
+ child.on('exit', () => {
409
+ if (this.child !== child) return
410
+ this.client?.close()
411
+ this.client = undefined
412
+ this.child = undefined
413
+ })
236
414
  const portFile = join(this.profileDir, 'DevToolsActivePort')
237
415
  const deadline = Date.now() + 30_000
416
+ /**
417
+ * Why the loop stopped, so the failure can say what actually happened. Reporting
418
+ * every ending as "did not expose CDP within 30s" was wrong three ways: the cause,
419
+ * the words, and the elapsed time — a browser that cannot be launched says so in
420
+ * half a second, and the caller then looks for a CDP problem that does not exist.
421
+ */
422
+ let stopped: 'released' | 'timeout' | 'exited' | 'launch-failed' = 'timeout'
238
423
  for (;;) {
239
- if (Date.now() > deadline) { child.kill(); this.child = undefined; break }
240
- if (child.exitCode !== null) { this.child = undefined; break }
424
+ // Released while we were starting: stop here rather than keep polling for a
425
+ // browser that dispose() has already killed, which would leave a connection
426
+ // to a dead process behind. The child is killed here too — dispose() may have
427
+ // run before this child was recorded, leaving nobody else to do it.
428
+ if (this.disposed) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'released'; break }
429
+ if (launchError !== undefined) { if (this.child === child) this.child = undefined; stopped = 'launch-failed'; break }
430
+ if (Date.now() > deadline) { child.kill(); if (this.child === child) this.child = undefined; stopped = 'timeout'; break }
431
+ if (child.exitCode !== null) { if (this.child === child) this.child = undefined; stopped = 'exited'; break }
241
432
  if (existsSync(portFile)) {
242
433
  try {
243
434
  const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
@@ -246,6 +437,9 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
246
437
  if (typeof version.webSocketDebuggerUrl === 'string') {
247
438
  const client = new CdpClient(version.webSocketDebuggerUrl)
248
439
  await client.whenReady()
440
+ // Still wanted? A dispose() during the handshake has already killed the
441
+ // process, so publishing the client would leave a dangling connection.
442
+ if (this.disposed) { client.close(); break }
249
443
  this.client = client
250
444
  return client
251
445
  }
@@ -256,7 +450,20 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
256
450
  }
257
451
  await new Promise(resolve => setTimeout(resolve, 250))
258
452
  }
259
- throw new Error(`dsh-builtin-browser: ${this.browser.kind} did not expose CDP within 30s (${this.browser.path})`)
453
+ // Each ending gets its own message. The old single message blamed a CDP timeout for
454
+ // all of them, which sent the reader looking for a port problem when the browser had
455
+ // simply failed to launch.
456
+ const target = `${this.browser.kind} (${this.browser.path})`
457
+ if (stopped === 'launch-failed') {
458
+ throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`)
459
+ }
460
+ if (stopped === 'exited') {
461
+ throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`)
462
+ }
463
+ if (stopped === 'released') {
464
+ throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`)
465
+ }
466
+ throw new Error(`dsh-builtin-browser: ${target} did not expose CDP within 30s`)
260
467
  }
261
468
 
262
469
  /**
@@ -274,11 +481,24 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
274
481
  const viewId = randomUUID()
275
482
  return {
276
483
  id: viewId,
484
+ // This is the real browser, not Electron: CDP's JPEG encoder works here, so the
485
+ // provider may pass format and quality through instead of falling back to PNG.
486
+ supportsCdpJpeg: true,
277
487
  sendCommand: async (method: string, params?: Record<string, unknown>) => {
488
+ // A command that never answered is the only signal that a client has wedged — the
489
+ // socket can stay OPEN while nothing comes back, which is the form the reporter saw with
490
+ // a live, healthy-looking Edge. Dropping the client here means the next call starts a new
491
+ // browser rather than hanging again, and the error names the cause instead of a timeout.
492
+ try {
278
493
  const client = await this.ensureClient()
279
494
  const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
280
495
  const result = await client.send(method, params, session)
281
496
  return (result ?? {}) as Record<string, unknown>
497
+ } catch (error) {
498
+ this.client?.close()
499
+ this.client = undefined
500
+ throw error
501
+ }
282
502
  },
283
503
  }
284
504
  }
@@ -308,14 +528,19 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
308
528
  groupView(): void {}
309
529
 
310
530
  /**
311
- * Bring the browser's window forward.
531
+ * Best effort at bringing the browser's window forward.
312
532
  *
313
- * The browser is a separate application, so there is no view to raise; focusing
314
- * the process is the closest honest equivalent, and failing to do so (a minimized
315
- * window, a locked session) is never worth an error.
533
+ * The browser is a separate application, so there is no view to raise. What this can
534
+ * actually do is ask its process to continue from a stopped state — which is a no-op on
535
+ * Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
536
+ * because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
537
+ * resumes), but it must not be read as a promise that the window comes to the front;
538
+ * importantly, it does NOT raise a window that is merely behind another. Raising the
539
+ * page properly would need `Page.bringToFront` on a specific target session, which is a
540
+ * view-level concern and not available to the host.
316
541
  */
317
542
  async focus(): Promise<void> {
318
- try { this.child?.kill('SIGCONT') } catch { /* not supported on Windows; harmless */ }
543
+ try { this.child?.kill('SIGCONT') } catch { /* Windows has no SIGCONT; nothing to do */ }
319
544
  }
320
545
 
321
546
  /** The browser reports its own window lifecycle. */