dsh-builtin-browser 0.1.21 → 0.1.23

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.
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_process'
29
+ import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
29
30
  import { randomBytes } from 'node:crypto'
30
31
  import { createRequire } from 'node:module'
31
32
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
@@ -38,6 +39,9 @@ import type { ExportedCookie } from '../browser/types.js'
38
39
  /** How long to wait for the child to signal readiness before failing. */
39
40
  const READY_TIMEOUT_MS = 20_000
40
41
 
42
+ /** Budget for the present/barrier round-trips (see presentView). */
43
+ const PRESENT_TIMEOUT_MS = 10_000
44
+
41
45
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
42
46
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
43
47
 
@@ -355,23 +359,32 @@ function compareVersions(a: string, b: string): number {
355
359
  return 0
356
360
  }
357
361
 
358
- /** From an electron package entry file, find the dist executable beside it. */
359
- function electronExeBeside(entry: string): string | undefined {
360
- const candidates = [
361
- join(dirname(entry), 'dist', 'electron.exe'),
362
- join(dirname(entry), 'dist', 'electron'),
363
- join(dirname(entry), '..', 'dist', 'electron.exe'),
364
- join(dirname(entry), '..', 'dist', 'electron'),
362
+ /** Candidate executable layouts used by the Electron npm package on one platform. */
363
+ function electronDistCandidates(pkgRoot: string, platform: NodeJS.Platform = process.platform): string[] {
364
+ // macOS keeps the runnable binary inside the Electron.app bundle; unlike
365
+ // Windows/Linux there is no dist/electron(.exe) sibling to probe.
366
+ if (platform === 'darwin') {
367
+ return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')]
368
+ }
369
+ return [
370
+ join(pkgRoot, 'dist', 'electron.exe'),
371
+ join(pkgRoot, 'dist', 'electron'),
365
372
  ]
366
- for (const candidate of candidates) {
367
- if (existsSync(candidate)) return candidate
373
+ }
374
+
375
+ /** From an electron package entry file, find the dist executable beside it. */
376
+ function electronExeBeside(entry: string, platform: NodeJS.Platform = process.platform): string | undefined {
377
+ const packageRoots = [dirname(entry), join(dirname(entry), '..')]
378
+ for (const packageRoot of packageRoots) {
379
+ const candidate = electronDistExe(packageRoot, platform)
380
+ if (candidate !== undefined) return candidate
368
381
  }
369
382
  return undefined
370
383
  }
371
384
 
372
385
  /** From an electron package root, find its dist executable. */
373
- function electronDistExe(pkgRoot: string): string | undefined {
374
- for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
386
+ function electronDistExe(pkgRoot: string, platform: NodeJS.Platform = process.platform): string | undefined {
387
+ for (const candidate of electronDistCandidates(pkgRoot, platform)) {
375
388
  if (existsSync(candidate)) return candidate
376
389
  }
377
390
  return undefined
@@ -452,8 +465,13 @@ class ElectronChildClient {
452
465
  })
453
466
  this.child.stderr.setEncoding('utf8')
454
467
  this.child.stderr.on('data', chunk => {
455
- // Diagnostics only; never parse stderr as protocol.
456
- process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
468
+ // Diagnostics only; never parse stderr as protocol. Mirror to a log file
469
+ // as well: under a plain `dsh web` self-host these lines only ever reach
470
+ // the DSH process's stderr, so a host that crashes in a restart loop left
471
+ // the user with a string of timeouts and no stack to act on.
472
+ const text = String(chunk)
473
+ process.stderr.write(`[dsh-browser host] ${text}`)
474
+ appendHostLog(text)
457
475
  })
458
476
  // Send the spawn token over stdin (first line). The child reads it from
459
477
  // stdin instead of argv so the token is never visible in the process
@@ -464,11 +482,17 @@ class ElectronChildClient {
464
482
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
465
483
  // that would crash the whole DSH process.
466
484
  this.child.on('error', error => {
467
- process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
485
+ const line = `spawn error: ${String(error)}`
486
+ process.stderr.write(`[dsh-browser host] ${line}\n`)
487
+ appendHostLog(`${line}\n`)
468
488
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
469
489
  })
470
490
  this.child.on('exit', (code, signal) => {
471
- this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
491
+ // The exit code/signal is the other half of a crash report; without it a
492
+ // restart loop is indistinguishable from a clean stop.
493
+ const line = `browser host exited (code=${String(code)} signal=${String(signal)})`
494
+ appendHostLog(`${line}\n`)
495
+ this.fail(new Error(`dsh-builtin-browser: ${line}`))
472
496
  })
473
497
  }
474
498
 
@@ -633,6 +657,11 @@ class RemoteView implements ElectronViewHandle {
633
657
  })
634
658
  }
635
659
 
660
+ /** Give this view web focus in the child, so keyboard input reaches the page. */
661
+ focus(): Promise<void> {
662
+ return this.client.call<void>('focus', { viewId: this.id })
663
+ }
664
+
636
665
  /** Ask the child to download a URL to a local file (keeps cookies/login). */
637
666
  async download(url: string, savePath: string): Promise<void> {
638
667
  // The child writes the file itself (temp + rename); only a small
@@ -657,6 +686,15 @@ class RemoteView implements ElectronViewHandle {
657
686
  }
658
687
  }
659
688
 
689
+ /**
690
+ * How long a FAILED Electron-binary probe is remembered before the binary
691
+ * search runs again (`available()`). A success is kept for the host's lifetime.
692
+ */
693
+ const PROBE_RETRY_MS = (() => {
694
+ const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS)
695
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30000
696
+ })()
697
+
660
698
  /**
661
699
  * Self-hosted view host: spawns the plugin's Electron child on first use and
662
700
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -669,8 +707,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
669
707
  private readonly views = new Map<string, ElectronViewHandle>()
670
708
  private readyPromise: Promise<void> | undefined
671
709
  private disposed = false
672
- /** Cached probe result so `available()` stays cheap after the first call. */
710
+ /** Last probe result. A success is kept for the host's lifetime; a failure
711
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
673
712
  private electronAvailable: boolean | undefined
713
+ /** Earliest time the next probe may run after a negative one. */
714
+ private nextProbeAt = 0
674
715
  /** Window groups (windowId per view), re-sent on every materialization so
675
716
  * a restarted child still places views in the right windows. */
676
717
  private readonly groups = new Map<string, { windowId: string; label?: string }>()
@@ -682,27 +723,37 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
682
723
  /** Test seam: the executable to spawn instead of the resolved Electron
683
724
  * binary. Absent -> resolveElectronPath() (production behavior). */
684
725
  private readonly spawnExecutable?: string,
726
+ /** Test seam: the binary probe behind `available()`. Absent ->
727
+ * resolveElectronPath(). */
728
+ private readonly probe: () => void = resolveElectronPath,
685
729
  ) {}
686
730
 
687
731
  /**
688
732
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
689
- * is filesystem-only (no network), per the seam's contract, and the result
690
- * is cached for the host's lifetime — a missing binary surfaces as
691
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
692
- * confusing spawn failure on first use.
733
+ * is filesystem-only (no network), per the seam's contract, so a missing
734
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
735
+ * instead of a confusing spawn failure on first use.
736
+ *
737
+ * A success is cached for the host's lifetime. A failure is NOT: an install
738
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
739
+ * long after DSH started, and provider selection happens once per process —
740
+ * caching "no" permanently reported "no usable browser provider is
741
+ * registered" until DSH restarted, with no way back short of that. Retrying
742
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
693
743
  */
694
744
  available(): boolean {
695
- if (this.electronAvailable === undefined) {
696
- try {
697
- resolveElectronPath()
698
- this.electronAvailable = true
699
- } catch (error) {
700
- this.electronAvailable = false
701
- // The provider will be reported unavailable, so the detailed
702
- // resolution error (install-electron / ELECTRON_PATH guidance) would
703
- // otherwise never reach the user — surface it on stderr.
704
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
705
- }
745
+ if (this.electronAvailable === true) return true
746
+ if (Date.now() < this.nextProbeAt) return false
747
+ this.nextProbeAt = Date.now() + PROBE_RETRY_MS
748
+ try {
749
+ this.probe()
750
+ this.electronAvailable = true
751
+ } catch (error) {
752
+ this.electronAvailable = false
753
+ // The provider will be reported unavailable, so the detailed
754
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
755
+ // otherwise never reach the user — surface it on stderr.
756
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
706
757
  }
707
758
  return this.electronAvailable
708
759
  }
@@ -860,6 +911,39 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
860
911
  .catch(() => { /* host unavailable */ })
861
912
  }
862
913
 
914
+ /**
915
+ * (Re)present a view and WAIT for the child to confirm it, so the provider
916
+ * can dispatch CDP `Input.*` knowing the view has a display surface.
917
+ *
918
+ * Two things this fixes, both silent before:
919
+ * - showing a view that does not exist yet is a no-op in the child, so the
920
+ * view must be materialized first (a fresh tab's createView races the
921
+ * provider's showActive);
922
+ * - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
923
+ * path, leaving the new renderer without a surface.
924
+ *
925
+ * The barrier ping is meaningful because the child handles messages strictly
926
+ * in order: its reply is queued behind the showView and so only arrives once
927
+ * the visibility change has been applied.
928
+ */
929
+ async presentView(handle: ElectronViewHandle, label?: string): Promise<void> {
930
+ await this.ready()
931
+ const client = this.client
932
+ if (client === undefined) throw new HostGoneError('browser host unavailable')
933
+ // A view created after the last createView does not exist in the child yet.
934
+ // ensureMaterialized also rebuilds it against a fresh child after a host
935
+ // death, which is the path that used to leave sessions timing out forever.
936
+ if (handle instanceof DeferredRemoteView) {
937
+ await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view')
938
+ }
939
+ await withTimeout(
940
+ client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }),
941
+ PRESENT_TIMEOUT_MS,
942
+ 'browser host did not confirm the view presentation',
943
+ )
944
+ await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering')
945
+ }
946
+
863
947
  /** Route a view into its session's own window (one window per session). */
864
948
  groupView(handle: ElectronViewHandle, windowId: string, label?: string): void {
865
949
  this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} })
@@ -970,6 +1054,10 @@ class DeferredRemoteView implements ElectronViewHandle {
970
1054
  return this.withRecovery(view => view.capture(opts))
971
1055
  }
972
1056
 
1057
+ async focus(): Promise<void> {
1058
+ return this.withRecovery(view => view.focus())
1059
+ }
1060
+
973
1061
  async flushAuth(): Promise<ExportedCookie[]> {
974
1062
  return this.withRecovery(view => view.flushAuth())
975
1063
  }
@@ -977,6 +1065,33 @@ class DeferredRemoteView implements ElectronViewHandle {
977
1065
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
978
1066
  return this.withRecovery(view => view.restoreAuth(cookies))
979
1067
  }
1068
+
1069
+ /** Materialize this view in the child (no-op when already there). */
1070
+ async ensureMaterialized(): Promise<void> {
1071
+ await this.withRecovery(async () => undefined)
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
1077
+ * Best-effort and self-limiting: diagnostics must never break the host or grow
1078
+ * without bound, so failures are swallowed and the file is truncated when it
1079
+ * gets large (a crash loop writes quickly).
1080
+ */
1081
+ const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
1082
+
1083
+ function appendHostLog(text: string): void {
1084
+ const home = process.env.DSH_HOME
1085
+ if (home === undefined || home === '') return
1086
+ try {
1087
+ const dir = join(home, 'logs')
1088
+ const file = join(dir, 'dsh-builtin-browser-host.log')
1089
+ try {
1090
+ if (statSync(file).size > HOST_LOG_MAX_BYTES) writeFileSync(file, '')
1091
+ } catch { /* first write, or unreadable: just append */ }
1092
+ mkdirSync(dir, { recursive: true })
1093
+ appendFileSync(file, text)
1094
+ } catch { /* diagnostics are best-effort only */ }
980
1095
  }
981
1096
 
982
1097
  /** Reject a promise if it does not settle within the budget. */
@@ -1001,6 +1116,9 @@ export function defaultHostMainPath(): string {
1001
1116
  * steps; `resolveElectronPath` is the full resolution order.
1002
1117
  */
1003
1118
  export const internals = {
1119
+ electronDistCandidates,
1120
+ electronDistExe,
1121
+ electronExeBeside,
1004
1122
  isBareElectron,
1005
1123
  resolveElectronPath,
1006
1124
  resolveElectronPathImpl,
@@ -1087,7 +1087,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1087
1087
  description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1088
1088
  parameters: {
1089
1089
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1090
- savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location).' },
1090
+ savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1091
1091
  format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg is self-hosted native path only).' },
1092
1092
  quality: { type: 'number', description: 'JPEG quality 1-100 (default 80); ignored for PNG.' },
1093
1093
  maxWidth: { type: 'number', description: 'Downscale to fit within this width (aspect preserved).' },
@@ -1354,7 +1354,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1354
1354
  description: 'Download a URL to a local file, keeping the browser session\'s cookies and login state. Use for fetching files behind authentication or from the current page context. Available on the self-hosted browser; the desktop shell delegates downloads to the real browser UI.',
1355
1355
  parameters: {
1356
1356
  url: { type: 'string', required: true, description: 'The URL to download.' },
1357
- savePath: { type: 'string', required: true, description: 'Absolute path of the file to write.' },
1357
+ savePath: { type: 'string', required: true, description: 'Absolute path of the file to write. Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1358
1358
  },
1359
1359
  output: {
1360
1360
  schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true } } },