dsh-builtin-browser 0.4.7 → 0.4.9
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/CHANGELOG.md +74 -0
- package/README.en.md +62 -491
- package/README.md +44 -470
- package/client.js +10 -10
- package/cordis.patch.yml +4 -4
- package/desktop-bridge/plugin-browser-bridge.js +1 -1
- package/docs/README.md +8 -4
- package/docs/architecture.md +82 -11
- package/docs/development.md +38 -0
- package/docs/limitations.md +49 -0
- package/docs/tool-reference.md +42 -42
- package/docs/user-guide.md +93 -37
- package/docs/why-browser.md +3 -3
- package/lib/browser/types.d.ts +2 -2
- package/lib/browser-electron/entry.d.ts +2 -2
- package/lib/browser-electron/entry.js +1 -1
- package/lib/browser-electron/history-store.d.ts +1 -1
- package/lib/browser-electron/history-store.js +1 -1
- package/lib/browser-electron/host-main.js +3 -3
- package/lib/browser-electron/provider.d.ts +9 -9
- package/lib/browser-electron/provider.js +14 -14
- package/lib/browser-electron/remote-host.js +1 -1
- package/lib/browser-electron/settings-store.d.ts +4 -4
- package/lib/browser-electron/system-browser.d.ts +60 -0
- package/lib/browser-electron/system-browser.js +257 -23
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/tool-browser/element-fields.d.ts +4 -4
- package/lib/tool-browser/element-fields.js +6 -6
- package/lib/tool-browser/index.d.ts +6 -6
- package/lib/tool-browser/index.js +161 -139
- package/package.json +2 -2
- package/scripts/verify-host-compat.mjs +6 -6
- package/src/browser/types.ts +2 -2
- package/src/browser-electron/entry.ts +2 -2
- package/src/browser-electron/history-store.ts +1 -1
- package/src/browser-electron/host-main.ts +3 -3
- package/src/browser-electron/provider.ts +15 -15
- package/src/browser-electron/remote-host.ts +1 -1
- package/src/browser-electron/settings-store.ts +4 -4
- package/src/browser-electron/system-browser.ts +244 -16
- package/src/index.ts +1 -1
- package/src/tool-browser/element-fields.ts +6 -6
- package/src/tool-browser/index.ts +164 -140
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (
|
|
3
|
+
"version": "0.4.9",
|
|
4
|
+
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -169,20 +169,20 @@ try {
|
|
|
169
169
|
|
|
170
170
|
const exec = { agent: { id: 'verify-host-compat' } }
|
|
171
171
|
try {
|
|
172
|
-
const value = await registered.get('
|
|
173
|
-
ok('
|
|
172
|
+
const value = await registered.get('dsh_browser_session').execute({}, exec)
|
|
173
|
+
ok('dsh_browser_session ran', value !== undefined, JSON.stringify(value).slice(0, 100))
|
|
174
174
|
} catch (error) {
|
|
175
|
-
ok('
|
|
175
|
+
ok('dsh_browser_session ran', false, String(error && error.message))
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
try {
|
|
179
|
-
const tool = registered.get('
|
|
179
|
+
const tool = registered.get('dsh_browser_a11y')
|
|
180
180
|
const value = await tool.execute({}, exec)
|
|
181
181
|
const text = JSON.stringify(tool.output.render({}, value))
|
|
182
|
-
ok('
|
|
182
|
+
ok('dsh_browser_a11y ran and rendered', value?.nodes?.length === 1, '')
|
|
183
183
|
ok('its renderer emits the element selector', text.includes('#probe'), text.slice(0, 120))
|
|
184
184
|
} catch (error) {
|
|
185
|
-
ok('
|
|
185
|
+
ok('dsh_browser_a11y ran', false, String(error && error.message))
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
console.log('')
|
package/src/browser/types.ts
CHANGED
|
@@ -65,7 +65,7 @@ export interface BrowserScrollRequest {
|
|
|
65
65
|
|
|
66
66
|
/** Press one named key (Enter, Tab, arrows, …). */
|
|
67
67
|
export interface BrowserKeyRequest {
|
|
68
|
-
/** Key name from the supported set (see the
|
|
68
|
+
/** Key name from the supported set (see the dsh_browser_key tool). */
|
|
69
69
|
readonly key: string
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -361,7 +361,7 @@ export interface BrowserSnapshotElement {
|
|
|
361
361
|
/**
|
|
362
362
|
* True when the element lives inside an (same-origin) iframe: coordinates
|
|
363
363
|
* are still top-document, but DOM selectors are frame-scoped — reach the
|
|
364
|
-
* element via `iframe.contentDocument` in
|
|
364
|
+
* element via `iframe.contentDocument` in dsh_browser_execute.
|
|
365
365
|
*/
|
|
366
366
|
readonly frame?: boolean
|
|
367
367
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `ctx.electronViewHost`, that host is used (embedded, human-machine shared
|
|
6
6
|
* view). Otherwise the plugin self-hosts: it spawns its own Electron child
|
|
7
7
|
* (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
|
|
8
|
-
* installing the plugin is enough for `
|
|
8
|
+
* installing the plugin is enough for `dsh_browser_*` tools to work on any
|
|
9
9
|
* surface.
|
|
10
10
|
* @module dsh-browser/browser-electron
|
|
11
11
|
*/
|
|
@@ -48,7 +48,7 @@ export interface Config {
|
|
|
48
48
|
/** Allow navigation only to HTTP(S) URLs. Default true. */
|
|
49
49
|
readonly httpOnly?: boolean
|
|
50
50
|
/**
|
|
51
|
-
* Directory `
|
|
51
|
+
* Directory `dsh_browser_download` save paths must resolve inside (prevents a
|
|
52
52
|
* prompt-injected agent from writing arbitrary machine paths). Default:
|
|
53
53
|
* the user's Downloads folder; override to confine downloads elsewhere.
|
|
54
54
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent browsing history: which pages the shared browser actually visited,
|
|
3
|
-
* kept independently from any session's operation log (`
|
|
3
|
+
* kept independently from any session's operation log (`dsh_browser_history`) and
|
|
4
4
|
* from the browser process's lifetime.
|
|
5
5
|
*
|
|
6
6
|
* It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
|
|
@@ -41,7 +41,7 @@ import { dirname, join } from 'node:path'
|
|
|
41
41
|
// several Electron instances sharing Roaming\Electron fight over the GPU
|
|
42
42
|
// cache/session locks, which can leave the window without a display surface
|
|
43
43
|
// (capturePage then fails). A dedicated userData also persists cookies across
|
|
44
|
-
// host restarts (on top of
|
|
44
|
+
// host restarts (on top of dsh_browser_auth). Must run before app is ready.
|
|
45
45
|
try {
|
|
46
46
|
const base = process.env.DSH_HOME ?? app.getPath('appData')
|
|
47
47
|
app.setPath('userData', join(base, 'dsh-builtin-browser-host'))
|
|
@@ -769,7 +769,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
769
769
|
const entry = win?.views.get(viewId)
|
|
770
770
|
if (win === undefined || entry === undefined) throw new Error(`focus: unknown view ${viewId}`)
|
|
771
771
|
// Keys go to the view that holds web focus. A view that was never
|
|
772
|
-
// clicked holds none, so the FIRST
|
|
772
|
+
// clicked holds none, so the FIRST dsh_browser_key of a session was dropped
|
|
773
773
|
// by the renderer while CDP still answered the dispatch with success —
|
|
774
774
|
// and `document.hasFocus()` inside the page even read true.
|
|
775
775
|
const contents = entry.webContentsView.webContents
|
|
@@ -881,7 +881,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
881
881
|
throw new Error('capture produced no image (view not painted)')
|
|
882
882
|
}
|
|
883
883
|
// The measured size travels with the image. Without it the native path — which is the
|
|
884
|
-
// default self-hosted one — could never answer `
|
|
884
|
+
// default self-hosted one — could never answer `dsh_browser_screenshot`'s width/height,
|
|
885
885
|
// because the seam's only channel was {base64, mime} and the provider had nothing to
|
|
886
886
|
// forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
|
|
887
887
|
// place that knows what came back.
|
|
@@ -393,7 +393,7 @@ export interface ElectronBrowserProviderConfig {
|
|
|
393
393
|
*/
|
|
394
394
|
readonly contentMaxChars?: number
|
|
395
395
|
/**
|
|
396
|
-
* Directory `
|
|
396
|
+
* Directory `dsh_browser_download` save paths must resolve inside (prevents a
|
|
397
397
|
* prompt-injected agent from writing arbitrary machine paths). Default:
|
|
398
398
|
* `~/Downloads`. NOTE: this default is a convenience, not a security
|
|
399
399
|
* boundary — on Windows it may resolve to OneDrive/redirected paths;
|
|
@@ -477,7 +477,7 @@ export function layoutSize(metrics: Record<string, unknown>): { width: number; h
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
/**
|
|
480
|
-
* Map CDP cookies onto the shape `
|
|
480
|
+
* Map CDP cookies onto the shape `dsh_browser_auth` exports.
|
|
481
481
|
*
|
|
482
482
|
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
483
483
|
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
@@ -955,9 +955,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
955
955
|
/**
|
|
956
956
|
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
957
957
|
* is already current (so location.href and document.title are the new page's)
|
|
958
|
-
* while its DOM is still being parsed.
|
|
958
|
+
* while its DOM is still being parsed. dsh_browser_open snapshots immediately
|
|
959
959
|
* after navigating, which is why it could report the right title with zero
|
|
960
|
-
* interactive elements — and why a separate
|
|
960
|
+
* interactive elements — and why a separate dsh_browser_snapshot right after
|
|
961
961
|
* always found them.
|
|
962
962
|
*
|
|
963
963
|
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
@@ -1077,7 +1077,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1077
1077
|
*
|
|
1078
1078
|
* A renderer only delivers key events to the view that holds web focus. A
|
|
1079
1079
|
* view that was created but never clicked holds none — and `Input` events
|
|
1080
|
-
* injected over CDP do not grant it — so the FIRST `
|
|
1080
|
+
* injected over CDP do not grant it — so the FIRST `dsh_browser_key` of a fresh
|
|
1081
1081
|
* session vanished inside the renderer while the command still answered
|
|
1082
1082
|
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
1083
1083
|
* adapter) simply have no `focus`, and nothing changes for them.
|
|
@@ -1086,7 +1086,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1086
1086
|
if (handle.focus === undefined) return
|
|
1087
1087
|
try {
|
|
1088
1088
|
// Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
|
|
1089
|
-
// and this runs before typing, so an unresponsive child would hang
|
|
1089
|
+
// and this runs before typing, so an unresponsive child would hang dsh_browser_key for
|
|
1090
1090
|
// good. A timeout is caught below exactly like any other focus failure, so the
|
|
1091
1091
|
// best-effort contract is unchanged.
|
|
1092
1092
|
await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out')
|
|
@@ -1328,12 +1328,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1328
1328
|
// A timeout leaves the wedged view in the session, so every later read would pay the same
|
|
1329
1329
|
// 30 seconds again — and `terminatePage` only interrupts the running script, it does not
|
|
1330
1330
|
// make the view readable. Replacing it is what turns a permanent loss into one bad call:
|
|
1331
|
-
// the tab keeps the id the caller holds, and the next
|
|
1331
|
+
// the tab keeps the id the caller holds, and the next dsh_browser_open with the real URL
|
|
1332
1332
|
// repopulates it.
|
|
1333
1333
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1334
1334
|
this.replaceTabWithBlank(s, tab)
|
|
1335
1335
|
throw new BrowserError(
|
|
1336
|
-
'browser: snapshot timed out; the stuck tab was reset to blank. Call
|
|
1336
|
+
'browser: snapshot timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.',
|
|
1337
1337
|
'BROWSER_SNAPSHOT_TIMEOUT',
|
|
1338
1338
|
{ cause: error },
|
|
1339
1339
|
)
|
|
@@ -1536,7 +1536,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1536
1536
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1537
1537
|
this.replaceTabWithBlank(s, tab)
|
|
1538
1538
|
throw new BrowserError(
|
|
1539
|
-
'browser: a11y timed out; the stuck tab was reset to blank. Call
|
|
1539
|
+
'browser: a11y timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.',
|
|
1540
1540
|
'BROWSER_A11Y_TIMEOUT',
|
|
1541
1541
|
{ cause: error },
|
|
1542
1542
|
)
|
|
@@ -2003,7 +2003,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2003
2003
|
placeholder: f.placeholder ?? null,
|
|
2004
2004
|
// A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
|
|
2005
2005
|
// it to 'text' here made `if (spec.kind)` above always true, so every unspecified
|
|
2006
|
-
// field was narrowed to an <input> —
|
|
2006
|
+
// field was narrowed to an <input> — dsh_browser_fill on a <textarea> reported
|
|
2007
2007
|
// `no "text" control matched`, and a contenteditable could not match any kind at all.
|
|
2008
2008
|
// The value stays a string or null (never undefined): the in-page check tests truthiness,
|
|
2009
2009
|
// and JSON drops an undefined member entirely, which is the same thing here.
|
|
@@ -2138,7 +2138,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2138
2138
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
2139
2139
|
out.push({ ok: true, method: 'select', target: describe(spec) })
|
|
2140
2140
|
} else if (type === 'file') {
|
|
2141
|
-
out.push({ ok: false, error: 'file inputs cannot be set from script; use
|
|
2141
|
+
out.push({ ok: false, error: 'file inputs cannot be set from script; use dsh_browser_download or ask the human', target: describe(spec) })
|
|
2142
2142
|
} else if (type === 'checkbox') {
|
|
2143
2143
|
const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
|
|
2144
2144
|
if (el.checked !== want) el.click()
|
|
@@ -2592,7 +2592,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2592
2592
|
* Refuse an action the settings have switched off.
|
|
2593
2593
|
*
|
|
2594
2594
|
* These switches belong to the OPERATOR. The settings document is written through the
|
|
2595
|
-
* plugin's own panel and no tool can reach it, so — unlike `
|
|
2595
|
+
* plugin's own panel and no tool can reach it, so — unlike `dsh_browser_restrict`, which the
|
|
2596
2596
|
* model owns and can lift at will — a refusal here stands for as long as the setting does.
|
|
2597
2597
|
* That is the point of them: a deployment can take page-script execution, downloads, or
|
|
2598
2598
|
* login-state writes off the table without relying on the model's cooperation.
|
|
@@ -2619,7 +2619,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2619
2619
|
|
|
2620
2620
|
/**
|
|
2621
2621
|
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
2622
|
-
* ONE gate for both `
|
|
2622
|
+
* ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
|
|
2623
2623
|
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
2624
2624
|
* exist. Without it a prompt-injected agent could write — or silently
|
|
2625
2625
|
* replace — any file the DSH process can reach, which also escapes the file
|
|
@@ -3053,7 +3053,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3053
3053
|
}
|
|
3054
3054
|
|
|
3055
3055
|
/**
|
|
3056
|
-
* List persisted visits, newest first — what `
|
|
3056
|
+
* List persisted visits, newest first — what `dsh_browser_visited` reads back.
|
|
3057
3057
|
* @param options - result cap and an optional hostname filter.
|
|
3058
3058
|
* @returns the matching visits, or an empty list when history is disabled.
|
|
3059
3059
|
*/
|
|
@@ -3200,7 +3200,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3200
3200
|
*
|
|
3201
3201
|
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
3202
3202
|
* whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
|
|
3203
|
-
* callers hold stays valid, and the next `
|
|
3203
|
+
* callers hold stays valid, and the next `dsh_browser_open` with the real URL re-populates it.
|
|
3204
3204
|
*
|
|
3205
3205
|
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
3206
3206
|
* session holding a handle that is already gone.
|
|
@@ -1327,7 +1327,7 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1327
1327
|
* with "browser host is not running". Instead: drop the cache, rebuild the
|
|
1328
1328
|
* view against a freshly spawned child, and retry the operation exactly
|
|
1329
1329
|
* once — so sessions that survive a host death self-heal on their FIRST
|
|
1330
|
-
* call after it, without a manual
|
|
1330
|
+
* call after it, without a manual dsh_browser_reset_session. Page-level errors
|
|
1331
1331
|
* are never retried (the view itself is fine).
|
|
1332
1332
|
*/
|
|
1333
1333
|
private async withRecovery<T>(op: (view: RemoteView) => Promise<T>): Promise<T> {
|
|
@@ -55,15 +55,15 @@ export interface BrowserSettings {
|
|
|
55
55
|
*
|
|
56
56
|
* These are OPERATOR switches: the settings panel is not reachable from the model, so a
|
|
57
57
|
* value here refuses whatever the model asks for, and no tool can lift it. That is what
|
|
58
|
-
* separates them from `
|
|
58
|
+
* separates them from `dsh_browser_restrict`, which is the model's own soft guardrail. All
|
|
59
59
|
* three default to on, which is the documented out-of-the-box behaviour.
|
|
60
60
|
*/
|
|
61
61
|
readonly actions: {
|
|
62
|
-
/** Run arbitrary page scripts in the active tab (`
|
|
62
|
+
/** Run arbitrary page scripts in the active tab (`dsh_browser_execute`). */
|
|
63
63
|
readonly allowExecute: boolean
|
|
64
|
-
/** Download a URL to disk (`
|
|
64
|
+
/** Download a URL to disk (`dsh_browser_download`). */
|
|
65
65
|
readonly allowDownload: boolean
|
|
66
|
-
/** Write cookies — importing login state (`
|
|
66
|
+
/** Write cookies — importing login state (`dsh_browser_auth` restore). */
|
|
67
67
|
readonly allowCredentialWrite: boolean
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -38,6 +38,15 @@ import { join } from 'node:path'
|
|
|
38
38
|
* exited, and that exit was reported as a bad browser path.
|
|
39
39
|
*/
|
|
40
40
|
const EXIT_GRACE_MS = 3_000
|
|
41
|
+
/**
|
|
42
|
+
* How long a browser is given to close itself after being asked, before it is killed.
|
|
43
|
+
*
|
|
44
|
+
* Long enough for Chrome to flush its profile, which is what keeps the next launch from reading
|
|
45
|
+
* the profile as crashed and offering a session restore. Deliberately not the same knob as
|
|
46
|
+
* {@link EXIT_GRACE_MS}: that one waits for a browser to die because a NEW one cannot start until
|
|
47
|
+
* it does, this one merely prefers a graceful exit to a kill.
|
|
48
|
+
*/
|
|
49
|
+
const CLOSE_GRACE_MS = 6_000
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Ceiling on one attempt to ask a discovered CDP port who it is.
|
|
@@ -178,6 +187,21 @@ const INSTALL_LOCATIONS: Record<DetectedBrowser['kind'], Partial<Record<NodeJS.P
|
|
|
178
187
|
* @param env - the environment to resolve against.
|
|
179
188
|
* @returns the path with %NAME% references replaced, or the entry unchanged when unknown.
|
|
180
189
|
*/
|
|
190
|
+
/**
|
|
191
|
+
* Whether a target URL is one of the blanks a browser opens by itself.
|
|
192
|
+
*
|
|
193
|
+
* Deliberately narrow: `about:blank` and Chrome's own new-tab pages, and nothing else. A wider
|
|
194
|
+
* test ("has no path", "starts with chrome://") would sooner or later match a page the user is
|
|
195
|
+
* reading, and the caller closes what this returns.
|
|
196
|
+
* @param url - the target's reported URL.
|
|
197
|
+
*/
|
|
198
|
+
function isBlankUrl(url: unknown): boolean {
|
|
199
|
+
if (typeof url !== 'string') return false
|
|
200
|
+
return url === 'about:blank'
|
|
201
|
+
|| url.startsWith('chrome://newtab')
|
|
202
|
+
|| url.startsWith('chrome://new-tab-page')
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
function expandLocation(location: string, env: NodeJS.ProcessEnv): string {
|
|
182
206
|
return location.replace(/%([^%]+)%/g, (whole, name: string) =>
|
|
183
207
|
env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()] ?? whole)
|
|
@@ -415,6 +439,16 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
415
439
|
*/
|
|
416
440
|
private clientSuspect = false
|
|
417
441
|
private child: ChildProcess | undefined
|
|
442
|
+
/**
|
|
443
|
+
* True when the browser in use was adopted from a previous run rather than launched here.
|
|
444
|
+
*
|
|
445
|
+
* Worth recording because the two differ on teardown: a browser this host spawned is its to
|
|
446
|
+
* kill, while one it merely took over belongs to a process that is already gone — closing it is
|
|
447
|
+
* still right (it holds the profile nobody else can use), but it is a decision, not cleanup.
|
|
448
|
+
*/
|
|
449
|
+
private adoptedRunning = false
|
|
450
|
+
/** Whether this connection's surplus blank pages have been collapsed already. */
|
|
451
|
+
private blanksNormalized = false
|
|
418
452
|
private starting: Promise<CdpClient> | undefined
|
|
419
453
|
|
|
420
454
|
/**
|
|
@@ -503,6 +537,10 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
503
537
|
private forgetBrowserState(): void {
|
|
504
538
|
this.views.clear()
|
|
505
539
|
this.sessions.clear()
|
|
540
|
+
// A new browser comes with its own surplus pages, so the tidy-up runs again for it. Without
|
|
541
|
+
// this, a restart would inherit the previous connection's "already handled" and leave the new
|
|
542
|
+
// browser's extra blank page in place for the rest of the session.
|
|
543
|
+
this.blanksNormalized = false
|
|
506
544
|
}
|
|
507
545
|
|
|
508
546
|
private async ensureClient(): Promise<CdpClient> {
|
|
@@ -535,7 +573,70 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
535
573
|
}
|
|
536
574
|
// Concurrent first calls share one startup rather than racing two browsers.
|
|
537
575
|
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
538
|
-
|
|
576
|
+
const client = await this.starting
|
|
577
|
+
// Once per connection. A browser opens more than one page when it starts, and the surplus is
|
|
578
|
+
// invisible to the plugin's tab model — see normalizeBlankPages for why that matters.
|
|
579
|
+
if (!this.blanksNormalized) {
|
|
580
|
+
this.blanksNormalized = true
|
|
581
|
+
await this.normalizeBlankPages(client)
|
|
582
|
+
}
|
|
583
|
+
return client
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Take over a browser already serving this profile, instead of racing it.
|
|
588
|
+
*
|
|
589
|
+
* A previous DSH process leaves its browser running: the child handle died with that process, so
|
|
590
|
+
* nothing here knows about it, and `start()`'s cleanup branch is skipped because it keys on
|
|
591
|
+
* `this.child`. Launching then does something worse than fail — on Windows Chrome hands the
|
|
592
|
+
* request to the instance already holding the profile and exits 0, and that exit is read as a
|
|
593
|
+
* launch failure. The user is told to go kill the leftovers by hand, on every restart, which is
|
|
594
|
+
* exactly what was reported.
|
|
595
|
+
*
|
|
596
|
+
* The launch loop deliberately refuses to trust a `<profile>/DevToolsActivePort` left by a
|
|
597
|
+
* previous launch, and it is right to: a stale port number can be reused by something that is
|
|
598
|
+
* not a browser at all. What makes adoption safe HERE is that this runs BEFORE launching, on a
|
|
599
|
+
* file that lives inside this plugin's own profile directory — only a browser started with this
|
|
600
|
+
* `--user-data-dir` writes it — and the endpoint is then asked to identify itself.
|
|
601
|
+
* @param why - a short phrase for the log, naming which attempt this is.
|
|
602
|
+
* @returns a connected client for the running browser, or undefined when there is none.
|
|
603
|
+
*/
|
|
604
|
+
private async adoptRunningBrowser(why: string): Promise<CdpClient | undefined> {
|
|
605
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
606
|
+
let port: number
|
|
607
|
+
try {
|
|
608
|
+
if (!existsSync(portFile)) return undefined
|
|
609
|
+
const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
|
|
610
|
+
if (!Number.isInteger(value) || value <= 0) return undefined
|
|
611
|
+
port = value
|
|
612
|
+
} catch {
|
|
613
|
+
return undefined
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
617
|
+
signal: AbortSignal.timeout(PORT_PROBE_MS),
|
|
618
|
+
})).json() as { webSocketDebuggerUrl?: string; Browser?: string }
|
|
619
|
+
if (typeof version.webSocketDebuggerUrl !== 'string') return undefined
|
|
620
|
+
// The port number alone proves nothing — it can be reused once the browser that owned it
|
|
621
|
+
// exits. The endpoint's own answer is the evidence, and it has to name a browser.
|
|
622
|
+
if (typeof version.Browser !== 'string' || !/chrome|chromium|edge/i.test(version.Browser)) return undefined
|
|
623
|
+
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
624
|
+
await client.whenReady()
|
|
625
|
+
// Asked through the protocol too, so a websocket that merely accepts connections is not
|
|
626
|
+
// mistaken for a browser that answers.
|
|
627
|
+
const info = await client.send('Browser.getVersion', {}) as { product?: string }
|
|
628
|
+
if (typeof info?.product !== 'string' || !/chrome|chromium|edge/i.test(info.product)) {
|
|
629
|
+
client.close()
|
|
630
|
+
return undefined
|
|
631
|
+
}
|
|
632
|
+
if (this.disposed) { client.close(); return undefined }
|
|
633
|
+
this.client = client
|
|
634
|
+
this.adoptedRunning = true
|
|
635
|
+
return client
|
|
636
|
+
} catch {
|
|
637
|
+
// No endpoint, nothing listening, or not a browser: all of them mean "launch one".
|
|
638
|
+
return undefined
|
|
639
|
+
}
|
|
539
640
|
}
|
|
540
641
|
|
|
541
642
|
/**
|
|
@@ -547,6 +648,11 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
547
648
|
* @returns the connected client.
|
|
548
649
|
*/
|
|
549
650
|
private async start(): Promise<CdpClient> {
|
|
651
|
+
// Before spawning anything: is a browser for THIS profile already running? A DSH restart
|
|
652
|
+
// leaves one behind, and launching into it is the failure reported as "exited immediately
|
|
653
|
+
// after launch (exit code 0)" that required killing seven processes by hand.
|
|
654
|
+
const adopted = await this.adoptRunningBrowser('before launch')
|
|
655
|
+
if (adopted !== undefined) return adopted
|
|
550
656
|
const args = [
|
|
551
657
|
'--remote-debugging-port=0',
|
|
552
658
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -598,12 +704,14 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
598
704
|
})
|
|
599
705
|
})
|
|
600
706
|
}
|
|
601
|
-
// The port file
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
707
|
+
// The port file is deliberately NOT deleted here, and that is a reversal.
|
|
708
|
+
//
|
|
709
|
+
// It used to be removed so the launch loop below could not read a previous browser's port. But
|
|
710
|
+
// the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
|
|
711
|
+
// deletion never protected it — while it DID destroy the only record of a browser that may
|
|
712
|
+
// still be running for this profile. That record is what `adoptRunningBrowser` needs when the
|
|
713
|
+
// launch comes back as a hand-off: the browser was alive the whole time, and without the file
|
|
714
|
+
// there is no way to find it again.
|
|
607
715
|
const spawnedAt = Date.now()
|
|
608
716
|
let child: ReturnType<typeof spawn>
|
|
609
717
|
try {
|
|
@@ -723,6 +831,13 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
723
831
|
const code = child.exitCode
|
|
724
832
|
const signal = child.signalCode
|
|
725
833
|
const clean = code === 0 && signal === null
|
|
834
|
+
// Exit code 0 IS the hand-off: the request went to an instance already holding this profile,
|
|
835
|
+
// so that instance is our browser and it is running right now. Adopting it turns the
|
|
836
|
+
// reported "kill the leftovers by hand, on every restart" into a call that just works.
|
|
837
|
+
if (clean) {
|
|
838
|
+
const recovered = await this.adoptRunningBrowser('after a hand-off')
|
|
839
|
+
if (recovered !== undefined) return recovered
|
|
840
|
+
}
|
|
726
841
|
throw new Error(
|
|
727
842
|
clean
|
|
728
843
|
? `dsh-builtin-browser: ${target} exited immediately after launch (exit code 0). `
|
|
@@ -892,13 +1007,34 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
892
1007
|
dispose(): void {
|
|
893
1008
|
if (this.disposed) return
|
|
894
1009
|
this.disposed = true
|
|
1010
|
+
const client = this.client
|
|
895
1011
|
this.forgetBrowserState()
|
|
896
|
-
this.client
|
|
897
|
-
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
1012
|
+
this.client = undefined
|
|
1013
|
+
// Ask before killing. `kill()` on Windows is TerminateProcess, which gives the browser no
|
|
1014
|
+
// chance to flush its profile — and Chrome records how it ended in that profile, so a
|
|
1015
|
+
// hard kill leaves it marked as a crash and the NEXT launch offers to restore a session.
|
|
1016
|
+
// That is a visible symptom (an extra window) traced all the way back to this line.
|
|
1017
|
+
if (client !== undefined) {
|
|
1018
|
+
try { void client.send('Browser.close', {}).catch(() => { /* it is closing, not talking */ }) } catch { /* already gone */ }
|
|
1019
|
+
}
|
|
1020
|
+
// Both the browser we launched and anything that ignored an earlier kill. They hold this
|
|
1021
|
+
// profile, so leaving them running means the next launch contends with a browser nobody owns.
|
|
1022
|
+
const doomed = [this.child, ...this.stubborn.splice(0)].filter(
|
|
1023
|
+
(entry): entry is ChildProcess => entry !== undefined,
|
|
1024
|
+
)
|
|
1025
|
+
this.child = undefined
|
|
1026
|
+
for (const dying of doomed) {
|
|
1027
|
+
/**
|
|
1028
|
+
* A bounded reprieve, then the kill we used to do immediately.
|
|
1029
|
+
*
|
|
1030
|
+
* The timer is unref'd so it never holds the process open: a DSH shutdown that is itself
|
|
1031
|
+
* ending does not wait for this, and the browser — having been asked to close — generally
|
|
1032
|
+
* gets there on its own. If it does not, the kill still lands while the process lives.
|
|
1033
|
+
*/
|
|
1034
|
+
const force = (): void => { try { dying.kill() } catch { /* already gone */ } }
|
|
1035
|
+
const timer = setTimeout(force, CLOSE_GRACE_MS)
|
|
1036
|
+
timer.unref?.()
|
|
1037
|
+
dying.once('exit', () => { clearTimeout(timer) })
|
|
902
1038
|
}
|
|
903
1039
|
const ephemeral = this.ephemeralDir
|
|
904
1040
|
if (ephemeral !== undefined && this.child !== undefined) {
|
|
@@ -945,9 +1081,15 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
945
1081
|
/** Create and attach the target for one view. Serialised by {@link ensureSession}. */
|
|
946
1082
|
private async buildSession(viewId: string): Promise<string> {
|
|
947
1083
|
const client = await this.ensureClient()
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1084
|
+
// Adopt a blank page the browser opened on its own before creating another one.
|
|
1085
|
+
//
|
|
1086
|
+
// Chrome starts with more than one page when handed a URL on top of its own start page, so
|
|
1087
|
+
// creating a target here discarded the original and left it behind as a tab that is not in
|
|
1088
|
+
// the plugin's tab model — invisible to `dsh_browser_list_tabs`, and therefore unclosable
|
|
1089
|
+
// through the tools. Taking the blank instead makes the page the browser already opened the
|
|
1090
|
+
// one the caller gets.
|
|
1091
|
+
const adopted = await this.adoptBlankTarget(client)
|
|
1092
|
+
const targetId = adopted ?? await this.createTarget(client)
|
|
951
1093
|
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
952
1094
|
const sessionId = attached?.sessionId
|
|
953
1095
|
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
@@ -957,6 +1099,92 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
957
1099
|
return sessionId
|
|
958
1100
|
}
|
|
959
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Take an unclaimed blank page, if the browser has one to spare.
|
|
1104
|
+
*
|
|
1105
|
+
* "Unclaimed" is what matters: a blank page the plugin is already driving belongs to a view, and
|
|
1106
|
+
* handing the same target to a second view would make two views share one page. The page kept by
|
|
1107
|
+
* {@link normalizeBlankPages} is exactly the unclaimed one this is looking for.
|
|
1108
|
+
* @param client - the connected browser.
|
|
1109
|
+
* @returns the target id to reuse, or undefined when a new page has to be created.
|
|
1110
|
+
*/
|
|
1111
|
+
private async adoptBlankTarget(client: CdpClient): Promise<string | undefined> {
|
|
1112
|
+
try {
|
|
1113
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1114
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1115
|
+
}
|
|
1116
|
+
const claimed = new Set(this.sessions.values())
|
|
1117
|
+
for (const entry of listed?.targetInfos ?? []) {
|
|
1118
|
+
if (entry.type !== 'page' || typeof entry.targetId !== 'string') continue
|
|
1119
|
+
if (!isBlankUrl(entry.url)) continue
|
|
1120
|
+
if (claimed.has(entry.targetId)) continue
|
|
1121
|
+
return entry.targetId
|
|
1122
|
+
}
|
|
1123
|
+
return undefined
|
|
1124
|
+
} catch {
|
|
1125
|
+
// Discovery is best-effort; creating a page is the behaviour this replaced.
|
|
1126
|
+
return undefined
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Create one page, refusing to go on without an id. */
|
|
1131
|
+
private async createTarget(client: CdpClient): Promise<string> {
|
|
1132
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
1133
|
+
const targetId = created?.targetId
|
|
1134
|
+
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
1135
|
+
return targetId
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Collapse the blank pages a freshly started browser comes with down to one.
|
|
1140
|
+
*
|
|
1141
|
+
* Chrome opens more than one page when it is given a URL on top of its own start page, and this
|
|
1142
|
+
* is the browser's behaviour rather than a session restore: measured on a cleanly exited profile
|
|
1143
|
+
* as well as a crashed one, both pages appear within a second of launch. There is nothing to
|
|
1144
|
+
* configure away — by the time a client can talk to the browser, both already exist.
|
|
1145
|
+
*
|
|
1146
|
+
* Left alone the surplus page is invisible to the plugin's tab model and therefore unclosable
|
|
1147
|
+
* through the tools, so it survives every navigation and accumulates for as long as the window
|
|
1148
|
+
* lives. One is kept deliberately: {@link buildSession} adopts it instead of creating a third.
|
|
1149
|
+
*
|
|
1150
|
+
* `Target.setDiscoverTargets` has to come first. Without it the browser may omit pages that
|
|
1151
|
+
* existed before this client connected — which is precisely the page that needs closing, and the
|
|
1152
|
+
* reason the first attempt at this fix failed silently.
|
|
1153
|
+
* @param client - the connected browser.
|
|
1154
|
+
* @returns how many surplus blank pages were closed.
|
|
1155
|
+
*/
|
|
1156
|
+
private async normalizeBlankPages(client: CdpClient): Promise<number> {
|
|
1157
|
+
try {
|
|
1158
|
+
await client.send('Target.setDiscoverTargets', { discover: true })
|
|
1159
|
+
const listed = await client.send('Target.getTargets', {}) as {
|
|
1160
|
+
targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>
|
|
1161
|
+
}
|
|
1162
|
+
const blanks = (listed?.targetInfos ?? []).filter(
|
|
1163
|
+
entry => entry.type === 'page' && typeof entry.targetId === 'string' && isBlankUrl(entry.url),
|
|
1164
|
+
)
|
|
1165
|
+
const claimed = new Set(this.sessions.values())
|
|
1166
|
+
let closed = 0
|
|
1167
|
+
// Keep the first unclaimed blank (that is the one buildSession will adopt) and close the rest.
|
|
1168
|
+
let kept = false
|
|
1169
|
+
for (const entry of blanks) {
|
|
1170
|
+
const targetId = entry.targetId as string
|
|
1171
|
+
if (claimed.has(targetId)) continue
|
|
1172
|
+
if (!kept) { kept = true; continue }
|
|
1173
|
+
try {
|
|
1174
|
+
await client.send('Target.closeTarget', { targetId })
|
|
1175
|
+
closed += 1
|
|
1176
|
+
} catch {
|
|
1177
|
+
// It may already be gone; one failure must not stop the rest.
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return closed
|
|
1181
|
+
} catch {
|
|
1182
|
+
// A browser that will not answer Target commands has a real problem, and the next command
|
|
1183
|
+
// will report it. Turning a tidy-up into a functional error here would hide that.
|
|
1184
|
+
return 0
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
960
1188
|
/** Views whose session is being created right now, so a second caller waits for the first. */
|
|
961
1189
|
private readonly sessionBuilding = new Map<string, Promise<string>>()
|
|
962
1190
|
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
-
* - `dsh-builtin-browser/tool-browser` -> the model-facing
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
|
|
7
7
|
* The capability itself is composed by those rows; this root entry exists for
|
|
8
8
|
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
9
|
* package's browser half through a loader row whose specifier resolves to the
|