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.
- package/README.en.md +22 -10
- package/README.md +22 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser/runtime.js +9 -1
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +157 -15
- package/lib/browser-electron/provider.d.ts +107 -12
- package/lib/browser-electron/provider.js +340 -50
- package/lib/browser-electron/remote-host.d.ts +46 -6
- package/lib/browser-electron/remote-host.js +147 -33
- package/lib/tool-browser/index.js +2 -2
- package/package.json +8 -6
- package/src/browser/runtime.ts +15 -1
- package/src/browser-electron/host-main.ts +163 -9
- package/src/browser-electron/provider.ts +385 -55
- package/src/browser-electron/remote-host.ts +149 -31
- package/src/tool-browser/index.ts +2 -2
|
@@ -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
|
-
/**
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
join(
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
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 ===
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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 } } },
|