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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A host that exists only to explain why the browser you asked for is not here.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * When the browser setting names a browser explicitly (Chrome or Edge rather than
6
+ * "bundled" or "automatic"), the plugin used to log a warning and quietly fall back
7
+ * to its own Electron window. That is worse than failing: you asked for Chrome, you
8
+ * got an Electron window, and the error that eventually surfaced talked about
9
+ * Electron — pointing at something you never chose and never wanted to install. The
10
+ * actual problem ("no Chrome on this machine") never reached you.
11
+ *
12
+ * So the two choices are treated as the different requests they are:
13
+ * - `auto` means "any browser will do", and falling back to the bundled one is the
14
+ * behaviour it asks for;
15
+ * - `chrome` / `edge` are explicit, and an explicit request that cannot be met is
16
+ * reported as such, with the ways out of it.
17
+ *
18
+ * `available()` reports true on purpose. Reporting false would make the provider pick
19
+ * a different carrier and hide the problem again, which is exactly the bug.
20
+ */
21
+ import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
22
+ import type { BrowserChannel } from './system-browser.js'
23
+
24
+ /**
25
+ * How each product is named in the message, and what to install.
26
+ *
27
+ * Only chrome and edge can appear: the constructor's type excludes `bundled` and `auto`,
28
+ * and `BrowserChannel` has no `brave`. A brave entry here would be unreachable code.
29
+ */
30
+ const PRODUCT: Record<string, { label: string; install: string; variable: string }> = {
31
+ chrome: {
32
+ label: 'Google Chrome',
33
+ install: 'install Google Chrome',
34
+ variable: 'DSH_BROWSER_CHROME_PATH',
35
+ },
36
+ edge: {
37
+ label: 'Microsoft Edge',
38
+ install: 'install Microsoft Edge',
39
+ variable: 'DSH_BROWSER_EDGE_PATH',
40
+ },
41
+ }
42
+
43
+ /** How the setting is named to the user. */
44
+ const SETTING_LABEL: Record<string, string> = {
45
+ chrome: '本机 Chrome / installed Chrome',
46
+ edge: '本机 Edge / installed Edge',
47
+ auto: '自动 / automatic',
48
+ bundled: '内置 / bundled',
49
+ }
50
+
51
+ /**
52
+ * A carrier that cannot carry anything: every command explains the real problem.
53
+ *
54
+ * The message is written to be acted on, not just read: it says which browser was
55
+ * requested, where the plugin looked, and the three concrete ways to resolve it.
56
+ */
57
+ export class MissingSystemBrowserHost implements ElectronBrowserViewHost {
58
+ /**
59
+ * @param kind - the browser the user asked for.
60
+ * @param searched - the names and locations that were checked, for the report.
61
+ */
62
+ constructor(private readonly kind: Exclude<BrowserChannel, 'bundled' | 'auto'>, private readonly searched: readonly string[]) {}
63
+
64
+ /** @returns always true: see the module comment — reporting false would hide this. */
65
+ available(): boolean {
66
+ return true
67
+ }
68
+
69
+ /**
70
+ * The message every command fails with.
71
+ * @returns a multi-line explanation with the fixes.
72
+ */
73
+ private explain(): string {
74
+ const product = PRODUCT[this.kind] ?? { label: this.kind, install: `install ${this.kind}`, variable: 'DSH_BROWSER_<KIND>_PATH' }
75
+ const searched = this.searched.length > 0 ? this.searched.join(', ') : 'PATH and the usual install locations'
76
+ return [
77
+ `dsh-builtin-browser: the browser you selected is not installed — no ${product.label} was found.`,
78
+ ``,
79
+ ` Setting: browser carrier = "${SETTING_LABEL[this.kind] ?? this.kind}"`,
80
+ ` Looked for: ${searched}`,
81
+ ``,
82
+ `Nothing else is wrong: the plugin falls back to its own bundled browser only when the`,
83
+ `setting is "bundled" or "automatic". Because this one names a browser explicitly, it`,
84
+ `reports the missing browser instead of silently using a different one.`,
85
+ ``,
86
+ `To fix it, any ONE of these is enough:`,
87
+ ` 1. ${product.install}, then restart DSH;`,
88
+ ` 2. point ${product.variable} at its executable;`,
89
+ ` 3. set the browser carrier back to "内置 / bundled" (or "自动 / automatic" to let it`,
90
+ ` fall back on its own next time).`,
91
+ ].join('\n')
92
+ }
93
+
94
+ /**
95
+ * Create a handle whose every command fails with the explanation.
96
+ * @returns a handle that never reaches a page.
97
+ */
98
+ createView(): ElectronViewHandle {
99
+ const message = this.explain()
100
+ return {
101
+ id: `missing-${this.kind}`,
102
+ sendCommand: async () => { throw new Error(message) },
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Accept a handle for destruction. Nothing was created, so nothing is released.
108
+ * @param handle - the handle returned by {@link createView}.
109
+ */
110
+ destroyView(handle: ElectronViewHandle): void {
111
+ void handle
112
+ }
113
+ }
@@ -242,6 +242,15 @@ export interface ElectronViewHandle {
242
242
  * @returns the CDP `result` object.
243
243
  */
244
244
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
245
+ /**
246
+ * Whether this view's CDP can encode JPEG and honour a quality setting.
247
+ *
248
+ * Electron 43's CDP hangs on `Page.captureScreenshot` with `format: 'jpeg'`, so a view
249
+ * backed by Electron must leave this unset and the provider keeps to PNG. A view backed
250
+ * by an installed Chrome or Edge is the real browser, where JPEG works, and sets it so
251
+ * the requested format is not silently downgraded.
252
+ */
253
+ readonly supportsCdpJpeg?: boolean
245
254
  /**
246
255
  * Give the backing view web focus so keyboard input reaches its page.
247
256
  * Optional: an adapter that cannot focus a view omits it, and the provider
@@ -344,6 +353,114 @@ export interface CdpEvaluateParams {
344
353
  /** CDP method for a full-page screenshot capture. */
345
354
  export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
346
355
 
356
+ /** One cookie as CDP reports it (`Storage.getCookies`). */
357
+ interface CdpCookie {
358
+ readonly name?: unknown
359
+ readonly value?: unknown
360
+ readonly domain?: unknown
361
+ readonly path?: unknown
362
+ readonly expires?: unknown
363
+ readonly httpOnly?: unknown
364
+ readonly secure?: unknown
365
+ readonly sameSite?: unknown
366
+ }
367
+
368
+ /**
369
+ * The document size out of a `Page.getLayoutMetrics` response.
370
+ *
371
+ * CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
372
+ * visible); the newer spellings `contentSize` / `layoutViewport` appear on some
373
+ * versions, so both are accepted. Returns undefined when neither is usable, leaving the
374
+ * caller to capture unscaled rather than guess.
375
+ * @param metrics - the raw CDP response.
376
+ * @returns the document and viewport size in CSS pixels.
377
+ */
378
+ export function layoutSize(metrics: Record<string, unknown>): { width: number; height: number; viewportHeight: number } | undefined {
379
+ const pick = (candidates: readonly unknown[]): { width: number; height: number } | undefined => {
380
+ for (const candidate of candidates) {
381
+ if (typeof candidate !== 'object' || candidate === null) continue
382
+ const box = candidate as { width?: unknown; height?: unknown }
383
+ if (typeof box.width === 'number' && typeof box.height === 'number' && box.width > 0 && box.height > 0) {
384
+ return { width: box.width, height: box.height }
385
+ }
386
+ }
387
+ return undefined
388
+ }
389
+ const content = pick([metrics.cssContentSize, metrics.contentSize])
390
+ const viewport = pick([metrics.cssLayoutViewport, metrics.layoutViewport])
391
+ if (content === undefined) return undefined
392
+ return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height }
393
+ }
394
+
395
+ /**
396
+ * Map CDP cookies onto the shape `browser_auth` exports.
397
+ *
398
+ * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
399
+ * milliseconds, so both are converted. Entries missing a name, value or domain are
400
+ * dropped rather than exported as broken cookies the caller cannot restore.
401
+ * @param raw - the `cookies` array from a CDP response, of unknown shape.
402
+ * @returns the mappable cookies.
403
+ */
404
+ export function toExportedCookies(raw: unknown): ExportedCookie[] {
405
+ if (!Array.isArray(raw)) return []
406
+ const out: ExportedCookie[] = []
407
+ for (const entry of raw) {
408
+ if (typeof entry !== 'object' || entry === null) continue
409
+ const cookie = entry as CdpCookie
410
+ // An empty domain is as unusable as a missing one: it cannot form a cookie URL, so
411
+ // exporting it would hand the caller something that fails to restore.
412
+ if (typeof cookie.name !== 'string' || cookie.name === '') continue
413
+ if (typeof cookie.value !== 'string') continue
414
+ if (typeof cookie.domain !== 'string' || cookie.domain === '') continue
415
+ const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
416
+ // A leading dot means "this domain and its subdomains"; a cookie URL must not keep it.
417
+ const host = cookie.domain.replace(/^\./, '')
418
+ out.push({
419
+ url: `${cookie.secure === true ? 'https' : 'http'}://${host}${path}`,
420
+ name: cookie.name,
421
+ value: cookie.value,
422
+ domain: cookie.domain,
423
+ path,
424
+ ...cookie.secure === true ? { secure: true } : {},
425
+ ...cookie.httpOnly === true ? { httpOnly: true } : {},
426
+ ...typeof cookie.expires === 'number' && cookie.expires > 0 ? { expirationDate: cookie.expires } : {},
427
+ })
428
+ }
429
+ return out
430
+ }
431
+
432
+ /**
433
+ * Map an exported cookie onto the fields `Storage.setCookies` expects.
434
+ *
435
+ * An exported cookie may carry only a URL, so the domain and path are recovered from it
436
+ * when they are absent; without either, CDP cannot place the cookie and it is skipped.
437
+ * @param cookie - the cookie to convert.
438
+ * @returns the CDP cookie, or undefined when it lacks a usable domain.
439
+ */
440
+ export function toCdpCookie(cookie: ExportedCookie): Record<string, unknown> | undefined {
441
+ let domain = cookie.domain
442
+ let path = cookie.path
443
+ if (domain === undefined || domain === '') {
444
+ try {
445
+ const parsed = new URL(cookie.url)
446
+ domain = parsed.hostname
447
+ if (path === undefined || path === '') path = parsed.pathname === '' ? '/' : parsed.pathname
448
+ } catch {
449
+ return undefined
450
+ }
451
+ }
452
+ if (typeof domain !== 'string' || domain === '') return undefined
453
+ return {
454
+ name: cookie.name,
455
+ value: cookie.value,
456
+ domain,
457
+ path: path === undefined || path === '' ? '/' : path,
458
+ ...cookie.secure === true ? { secure: true } : {},
459
+ ...cookie.httpOnly === true ? { httpOnly: true } : {},
460
+ ...typeof cookie.expirationDate === 'number' ? { expires: cookie.expirationDate } : {},
461
+ }
462
+ }
463
+
347
464
  /** Native capture options the self-hosted view handle understands. */
348
465
  export interface ScreenshotOptions {
349
466
  readonly format?: 'png' | 'jpeg'
@@ -566,10 +683,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
566
683
  const wanted = stripTabPrefix(tabId)
567
684
  const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
568
685
  if (own >= 0) return { s, index: own }
569
- for (const other of this.sessions.values()) {
570
- const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
571
- if (index >= 0) return { s: other, index }
572
- }
686
+ // Deliberately NOT searched across other sessions. Accepting a bare uuid and
687
+ // searching elsewhere are different things: ids are globally unique, so the first is
688
+ // harmless convenience, but the second lets a stale id close or switch a tab that
689
+ // belongs to another task — or to the human — while reporting success, which
690
+ // contradicts the isolation the tools promise.
573
691
  const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
574
692
  throw new BrowserError(
575
693
  `browser: tab "${tabId}" is not open in this session (session tabs: ${ownIds})`,
@@ -630,6 +748,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
630
748
  // Record as soon as the navigation has committed: history bookkeeping must
631
749
  // not wait on the best-effort settle below (which only delays what the
632
750
  // NEXT observation sees, not whether this action happened).
751
+ // The page has already moved by now, so a caller that aborted must be told that rather
752
+ // than handed a success: settleDocument returns quietly on abort by design, so without
753
+ // this the whole path completes exception-free — success reported, success recorded, and
754
+ // the visit written to the persistent history for a navigation nobody awaits any more.
755
+ signal?.throwIfAborted()
633
756
  this.record(s, 'navigate', { url }, true)
634
757
  await this.settleDocument(handle, before, signal)
635
758
  this.recordVisit(s, handle)
@@ -767,7 +890,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
767
890
  private async focusView(handle: ElectronViewHandle): Promise<void> {
768
891
  if (handle.focus === undefined) return
769
892
  try {
770
- await handle.focus()
893
+ // Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
894
+ // and this runs before typing, so an unresponsive child would hang browser_key for
895
+ // good. A timeout is caught below exactly like any other focus failure, so the
896
+ // best-effort contract is unchanged.
897
+ await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out')
771
898
  } catch {
772
899
  // Best-effort: a view that refuses focus must not fail the caller's key
773
900
  // press; the dispatch below reports a real channel failure itself.
@@ -826,6 +953,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
826
953
  }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
827
954
  return { ok: true, value }
828
955
  } catch (error) {
956
+ // A caller that aborted is not a failure of the page: report its own reason, or the
957
+ // cancellation surfaces as "execute failed: AbortError" and — if the caller's reason
958
+ // happens to be named TimeoutError — as "timed out after 30000ms", quoting a budget
959
+ // that belongs to the caller rather than to this call.
960
+ if (signal?.aborted) throw signal.reason
829
961
  if (error instanceof Error && error.name === 'TimeoutError') {
830
962
  throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
831
963
  }
@@ -900,10 +1032,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
900
1032
  return { ready: false, reason: misses.join('; ') }
901
1033
  }
902
1034
  // Sleep between polls, abortable by the caller.
903
- await new Promise<void>((resolve, reject) => {
904
- const timer = setTimeout(resolve, 200)
905
- signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason ?? new Error('aborted')) }, { once: true })
906
- })
1035
+ // Sleep between polls; delay() removes its abort listener on both paths, which the
1036
+ // hand-rolled version here did not — a 30s wait leaked about 150 of them.
1037
+ await delay(200, signal)
907
1038
  }
908
1039
  }
909
1040
 
@@ -1641,7 +1772,31 @@ export class ElectronBrowserProvider implements BrowserProvider {
1641
1772
  continue
1642
1773
  }
1643
1774
  if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
1644
- const el = els[0]
1775
+ let el = els[0]
1776
+ // The caller may name a kind. Honor it: the branches below read the element's own
1777
+ // tag/type, so without this a selector matching two different controls acted on
1778
+ // whichever came first — usually right, which is what hid the mistake.
1779
+ if (spec.kind) {
1780
+ const matchesKind = (node) => {
1781
+ const nodeTag = node.tagName
1782
+ const nodeType = (node.type || '').toLowerCase()
1783
+ if (spec.kind === 'select') return nodeTag === 'SELECT'
1784
+ if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
1785
+ if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
1786
+ return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
1787
+ }
1788
+ const wanted = els.find(matchesKind)
1789
+ if (wanted === undefined) {
1790
+ out.push({ ok: false, error: 'no "' + spec.kind + '" control matched', target: describe(spec) })
1791
+ continue
1792
+ }
1793
+ els.length = 0
1794
+ els.push(wanted)
1795
+ // el was bound before the filter narrowed els, so the branches below would still
1796
+ // read the first match — the very silent wrong-element behaviour this filter exists
1797
+ // to remove.
1798
+ el = els[0]
1799
+ }
1645
1800
  const tag = el.tagName
1646
1801
  const type = (el.type || '').toLowerCase()
1647
1802
  try {
@@ -2057,7 +2212,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2057
2212
  })()`
2058
2213
  const result = await withTimeout(
2059
2214
  handleSendEvaluate(tab.handle, script),
2060
- timeoutMs,
2215
+ timeoutMs + TARGET_SCRIPT_GRACE_MS,
2061
2216
  signal,
2062
2217
  `browser: scrape timed out after ${timeoutMs}ms`,
2063
2218
  () => terminatePage(tab.handle),
@@ -2165,29 +2320,65 @@ export class ElectronBrowserProvider implements BrowserProvider {
2165
2320
  * Self-hosted only; the desktop shell's embedded views use the real profile.
2166
2321
  */
2167
2322
  async flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]> {
2323
+ // The settings panel offers "allow the agent to read cookies / export login state".
2324
+ // Until now nothing read that switch, so turning it off changed nothing at all —
2325
+ // the tool exported every cookie regardless.
2326
+ if (this.settingsSource?.().credentials.allowRead === false) {
2327
+ throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED')
2328
+ }
2168
2329
  const s = this.session(session)
2169
2330
  const { handle } = this.activeTab(s)
2170
- const host = handle as { flushAuth?(): Promise<ExportedCookie[]> }
2171
- if (typeof host.flushAuth !== 'function') {
2172
- throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
2173
- }
2174
2331
  const timeoutMs = 30_000
2175
- const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
2176
- this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
2332
+ // The self-hosted carrier has a native path (the child reads its own session), which
2333
+ // is worth preferring: it reports exactly what the browser persisted.
2334
+ const native = handle as { flushAuth?(): Promise<readonly ExportedCookie[]> }
2335
+ if (typeof native.flushAuth === 'function') {
2336
+ const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
2337
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
2338
+ return cookies
2339
+ }
2340
+ // Every other carrier drives a real browser over CDP, and cookies are part of CDP —
2341
+ // the sidebar goes through the shell's webContents.debugger, an installed Chrome or
2342
+ // Edge is the browser itself. So this works everywhere rather than only self-hosted.
2343
+ const result = await withTimeout(
2344
+ handle.sendCommand('Storage.getCookies', {}),
2345
+ timeoutMs,
2346
+ undefined,
2347
+ `browser: auth export timed out after ${timeoutMs}ms`,
2348
+ )
2349
+ const cookies = toExportedCookies(result.cookies)
2350
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` })
2177
2351
  return cookies
2178
2352
  }
2179
2353
 
2180
2354
  /** Import cookies into the session (restore login state). Self-hosted only. */
2181
2355
  async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
2356
+ // The settings panel offers "allow the agent to read cookies / export login state".
2357
+ // Until now nothing read that switch, so turning it off changed nothing at all —
2358
+ // the tool exported every cookie regardless.
2359
+ if (this.settingsSource?.().credentials.allowRead === false) {
2360
+ throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED')
2361
+ }
2182
2362
  const s = this.session(session)
2183
2363
  const { handle } = this.activeTab(s)
2184
- const host = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2185
- if (typeof host.restoreAuth !== 'function') {
2186
- throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
2187
- }
2188
2364
  const timeoutMs = 30_000
2189
- const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2190
- this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
2365
+ const native = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2366
+ if (typeof native.restoreAuth === 'function') {
2367
+ const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2368
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
2369
+ return restored
2370
+ }
2371
+ // CDP again, for the same reason as the export: any carrier that drives a real
2372
+ // browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
2373
+ // the CDP way, including the sameSite spelling and seconds rather than milliseconds.
2374
+ const result = await withTimeout(
2375
+ handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }),
2376
+ timeoutMs,
2377
+ undefined,
2378
+ `browser: auth restore timed out after ${timeoutMs}ms`,
2379
+ )
2380
+ const restored = toExportedCookies(result.cookies).length
2381
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` })
2191
2382
  return restored
2192
2383
  }
2193
2384
 
@@ -2227,16 +2418,50 @@ export class ElectronBrowserProvider implements BrowserProvider {
2227
2418
  }
2228
2419
  return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png')
2229
2420
  }
2230
- // Fallback: a desktop-shell handle (no capture()) or a full-page capture
2231
- // uses CDP; full-page needs `captureBeyondViewport` which capturePage
2232
- // lacks. PNG only — the JPEG hang is a CDP-on-Electron-43 defect.
2421
+ // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
2422
+ // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
2423
+ //
2424
+ // JPEG: Electron 43's CDP hangs on it, which is why the sidebar stays PNG. An
2425
+ // installed Chrome or Edge is the real browser and has no such defect, so it says so
2426
+ // through `supportsCdpJpeg` and gets the format it asked for, and the same for
2427
+ // downscaling, which CDP performs through a full-document clip.
2428
+ const timeoutMs = 30_000
2233
2429
  const params: Record<string, unknown> = {}
2234
2430
  if (request?.fullPage === true) {
2235
2431
  // `captureBeyondViewport` captures the full scrollable content; without
2236
2432
  // a clip this yields the full-page image (CDP default is the viewport).
2237
2433
  params.captureBeyondViewport = true
2238
2434
  }
2239
- const timeoutMs = 30_000
2435
+ const supportsJpeg = (handle as { supportsCdpJpeg?: boolean }).supportsCdpJpeg === true
2436
+ if (format === 'jpeg' && supportsJpeg) {
2437
+ params.format = 'jpeg'
2438
+ // CDP range is 0-100 and defaults to 80 when omitted.
2439
+ if (request?.quality !== undefined) params.quality = request.quality
2440
+ }
2441
+ // Downscaling on this path: CDP scales through `clip.scale`, and a clip only applies
2442
+ // to the region it names, so it has to cover the whole document. The page's own size
2443
+ // is read first; when that is unavailable the capture proceeds unscaled rather than
2444
+ // failing — an image the caller can shrink itself beats no image at all.
2445
+ if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
2446
+ const metrics = await withTimeout(
2447
+ handle.sendCommand('Page.getLayoutMetrics', {}),
2448
+ timeoutMs,
2449
+ signal,
2450
+ `browser: screenshot timed out after ${timeoutMs}ms`,
2451
+ )
2452
+ const size = layoutSize(metrics)
2453
+ if (size !== undefined) {
2454
+ const height = request.fullPage === true ? size.height : size.viewportHeight
2455
+ const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1
2456
+ const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1
2457
+ const scale = Math.min(1, byWidth, byHeight)
2458
+ if (scale < 1) {
2459
+ params.clip = { x: 0, y: 0, width: size.width, height, scale }
2460
+ // The clip defines the captured area; captureBeyondViewport must not also apply.
2461
+ delete params.captureBeyondViewport
2462
+ }
2463
+ }
2464
+ }
2240
2465
  const result = await withTimeout(
2241
2466
  handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
2242
2467
  timeoutMs,
@@ -2583,17 +2808,23 @@ export class ElectronBrowserProvider implements BrowserProvider {
2583
2808
  if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
2584
2809
  }
2585
2810
 
2586
- /** Ask the host to show the active tab's view, carrying the session label. */
2811
+ /**
2812
+ * Ask the host to show the active tab's view, carrying the session label.
2813
+ *
2814
+ * The two halves are sequential, not alternatives. `showView` makes the view the
2815
+ * visible one — which is what keeps what the human sees in step with what the agent
2816
+ * drives, and what lets `capturePage` return an image at all (a hidden view captures
2817
+ * empty) — while `collapse` folds the carrier's own chrome away when the user asked
2818
+ * for no auto-expand. Making them exclusive meant that with auto-expand off this
2819
+ * method did nothing at all on carriers without `collapse` (the self-hosted one),
2820
+ * silently desynchronising the two and failing every screenshot.
2821
+ */
2587
2822
  private showActive(s: Session): void {
2588
- // §3: whether the agent's page takes over the screen is the user's choice, not
2589
- // a fixed behaviour. With auto-expand off the page still exists and is still
2590
- // driven — it is simply folded away, so the agent works without claiming the
2591
- // sidebar. Carriers that own no presentation (self-hosted) ignore the call.
2592
- if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
2593
- void this.host.collapse?.().catch(() => undefined)
2594
- return
2595
- }
2596
2823
  this.host.showView?.(this.activeTab(s).handle, s.label)
2824
+ const autoExpand = this.settingsSource === undefined || this.settingsSource().ui.autoExpandOnce
2825
+ if (autoExpand) return
2826
+ // Best effort: a carrier that owns no presentation (self-hosted) simply has none.
2827
+ void this.host.collapse?.().catch(() => undefined)
2597
2828
  }
2598
2829
 
2599
2830
  /** Read the current URL of a view through CDP. */
@@ -25,7 +25,7 @@
25
25
  * @module dsh-browser/browser-electron/remote-host
26
26
  */
27
27
 
28
- import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_process'
28
+ import { execFileSync, spawn } from 'node:child_process'
29
29
  import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
30
30
  import { randomBytes } from 'node:crypto'
31
31
  import { createRequire } from 'node:module'
@@ -907,8 +907,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
907
907
 
908
908
  /** The child died: tear down so the next use starts a fresh child. */
909
909
  private onChildExit(): void {
910
- if (this.disposed) return
911
- process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
910
+ // A disposed host still has to drop these: returning here left a child that had
911
+ // already been replaced during a dispose race in no map at all, so nothing would
912
+ // ever kill it. Only the restart is suppressed, not the cleanup.
913
+ const wasDisposed = this.disposed
914
+ if (!wasDisposed) process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
912
915
  this.client = undefined
913
916
  this.server?.close()
914
917
  this.server = undefined
@@ -202,11 +202,18 @@ export class SettingsStore {
202
202
  try {
203
203
  mkdirSync(dirname(this.file), { recursive: true })
204
204
  writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
205
- } catch {
206
- // Keep the merged value in memory even when the write fails.
205
+ } catch (error) {
206
+ // Do NOT publish the new value as cached, and do not pretend the file changed: a
207
+ // reader compares the file's mtime against this stamp, so stamping "now" on a failed
208
+ // write made the next get() re-read the file and silently revert the user's change.
209
+ // Clear the cache before failing: the value on disk is still the old one, so leaving
210
+ // the merged document cached would hand the next reader something that does not exist —
211
+ // and the stamp would make it look fresh until the mtime comparison rolled it back.
212
+ this.cached = undefined
213
+ this.cachedMtimeMs = -1
214
+ throw new Error(`dsh-builtin-browser: settings could not be saved: ${error instanceof Error ? error.message : String(error)}`)
215
+
207
216
  }
208
- this.cached = merged
209
- this.cachedMtimeMs = Date.now()
210
217
  return merged
211
218
  }
212
219
  }