dsh-builtin-browser 0.4.6 → 0.4.8
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 +76 -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/bridge-connection.d.ts +7 -1
- package/lib/browser-electron/bridge-connection.js +58 -3
- package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
- package/lib/browser-electron/desktop-bridge-host.js +21 -1
- package/lib/browser-electron/entry.d.ts +2 -2
- package/lib/browser-electron/entry.js +186 -103
- 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 +32 -8
- package/lib/browser-electron/provider.js +97 -16
- package/lib/browser-electron/remote-host.js +1 -1
- package/lib/browser-electron/settings-store.d.ts +14 -6
- package/lib/browser-electron/settings-store.js +29 -9
- 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 +196 -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/bridge-connection.ts +61 -3
- package/src/browser-electron/desktop-bridge-host.ts +22 -1
- package/src/browser-electron/entry.ts +101 -9
- package/src/browser-electron/history-store.ts +1 -1
- package/src/browser-electron/host-main.ts +3 -3
- package/src/browser-electron/provider.ts +135 -31
- package/src/browser-electron/remote-host.ts +1 -1
- package/src/browser-electron/settings-store.ts +32 -13
- package/src/index.ts +1 -1
- package/src/tool-browser/element-fields.ts +6 -6
- package/src/tool-browser/index.ts +197 -140
|
@@ -217,6 +217,17 @@ export interface ElectronBrowserViewHost {
|
|
|
217
217
|
* which is only correct from a teardown that is itself global.
|
|
218
218
|
*/
|
|
219
219
|
releasePage?(owner?: string): Promise<void>
|
|
220
|
+
/**
|
|
221
|
+
* Report that this carrier can no longer serve anything, so its owner may replace it.
|
|
222
|
+
*
|
|
223
|
+
* A carrier that borrows an outside process can lose it: the desktop shell exits, and every call
|
|
224
|
+
* after that would dial a port nobody owns. Without this signal the swap happens only at
|
|
225
|
+
* startup, so the choice made then is final for the process's life — which is what a user
|
|
226
|
+
* experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
|
|
227
|
+
* owns its own lifetime has nothing to report.
|
|
228
|
+
* @param listener - called once, when the carrier concludes it is gone.
|
|
229
|
+
*/
|
|
230
|
+
onLost?(listener: () => void): void
|
|
220
231
|
/**
|
|
221
232
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
222
233
|
*
|
|
@@ -312,10 +323,21 @@ export interface ElectronViewHandle {
|
|
|
312
323
|
capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
|
|
313
324
|
}
|
|
314
325
|
|
|
315
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* One tab inside a session: its view, a stable id, and the last URL this provider accepted.
|
|
328
|
+
*
|
|
329
|
+
* `url` does two jobs. It separates "never navigated" from "navigated, but the URL could not be
|
|
330
|
+
* read" — the two used to collapse into the same empty string — and it lets `listTabs` skip
|
|
331
|
+
* probing a tab it already knows is blank, where a probe is a round trip on an uncommitted view
|
|
332
|
+
* that can only answer nothing.
|
|
333
|
+
*
|
|
334
|
+
* `handle` is deliberately NOT readonly: a view that has wedged is replaced in place, and the tab
|
|
335
|
+
* keeps its public id across that replacement (see `replaceTabWithBlank`).
|
|
336
|
+
*/
|
|
316
337
|
interface Tab {
|
|
317
338
|
readonly id: string
|
|
318
|
-
|
|
339
|
+
handle: ElectronViewHandle
|
|
340
|
+
url: string
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
/** One live browser session: an ordered list of tabs, one active. */
|
|
@@ -371,7 +393,7 @@ export interface ElectronBrowserProviderConfig {
|
|
|
371
393
|
*/
|
|
372
394
|
readonly contentMaxChars?: number
|
|
373
395
|
/**
|
|
374
|
-
* Directory `
|
|
396
|
+
* Directory `dsh_browser_download` save paths must resolve inside (prevents a
|
|
375
397
|
* prompt-injected agent from writing arbitrary machine paths). Default:
|
|
376
398
|
* `~/Downloads`. NOTE: this default is a convenience, not a security
|
|
377
399
|
* boundary — on Windows it may resolve to OneDrive/redirected paths;
|
|
@@ -455,7 +477,7 @@ export function layoutSize(metrics: Record<string, unknown>): { width: number; h
|
|
|
455
477
|
}
|
|
456
478
|
|
|
457
479
|
/**
|
|
458
|
-
* Map CDP cookies onto the shape `
|
|
480
|
+
* Map CDP cookies onto the shape `dsh_browser_auth` exports.
|
|
459
481
|
*
|
|
460
482
|
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
461
483
|
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
@@ -717,7 +739,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
717
739
|
// session), carrying the label for the window title. Hosts without
|
|
718
740
|
// groupView keep the shared-window behavior.
|
|
719
741
|
this.host.groupView?.(handle, id, label)
|
|
720
|
-
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
742
|
+
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
721
743
|
return Promise.resolve(id)
|
|
722
744
|
}
|
|
723
745
|
|
|
@@ -737,9 +759,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
737
759
|
for (let i = 0; i < s.tabs.length; i++) {
|
|
738
760
|
const tab = s.tabs[i]
|
|
739
761
|
if (tab === undefined) continue // defensive: array can shift under concurrency
|
|
762
|
+
// A tab we know has never navigated is NOT probed: its view has committed no document, so
|
|
763
|
+
// the round trip can only answer nothing — and it is the round trip that costs. A tab that
|
|
764
|
+
// HAS navigated is re-read, because the page may have moved itself since; a read that fails
|
|
765
|
+
// keeps the last accepted URL rather than reporting the tab as blank.
|
|
766
|
+
const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url)
|
|
767
|
+
if (liveUrl !== '') tab.url = liveUrl
|
|
740
768
|
result.push({
|
|
741
769
|
id: tab.id,
|
|
742
|
-
url:
|
|
770
|
+
url: liveUrl,
|
|
743
771
|
active: i === s.activeIndex,
|
|
744
772
|
})
|
|
745
773
|
}
|
|
@@ -825,7 +853,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
825
853
|
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
826
854
|
async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
|
|
827
855
|
const s = this.session(session)
|
|
828
|
-
const
|
|
856
|
+
const tab = this.activeTab(s)
|
|
857
|
+
const { handle } = tab
|
|
829
858
|
const url = request.url
|
|
830
859
|
try {
|
|
831
860
|
if (this.httpOnly) {
|
|
@@ -870,6 +899,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
870
899
|
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
871
900
|
signal?.throwIfAborted()
|
|
872
901
|
this.record(s, 'navigate', { url }, true)
|
|
902
|
+
// The tab now holds a committed document, which is what the blank short-circuit in
|
|
903
|
+
// `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
|
|
904
|
+
// navigation HAS committed, and a tab wrongly treated as blank would return empty results
|
|
905
|
+
// for a page that is really there.
|
|
906
|
+
tab.url = url
|
|
873
907
|
await this.settleDocument(handle, before, signal)
|
|
874
908
|
this.recordVisit(s, handle)
|
|
875
909
|
this.showActive(s)
|
|
@@ -921,9 +955,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
921
955
|
/**
|
|
922
956
|
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
923
957
|
* is already current (so location.href and document.title are the new page's)
|
|
924
|
-
* while its DOM is still being parsed.
|
|
958
|
+
* while its DOM is still being parsed. dsh_browser_open snapshots immediately
|
|
925
959
|
* after navigating, which is why it could report the right title with zero
|
|
926
|
-
* interactive elements — and why a separate
|
|
960
|
+
* interactive elements — and why a separate dsh_browser_snapshot right after
|
|
927
961
|
* always found them.
|
|
928
962
|
*
|
|
929
963
|
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
@@ -1043,7 +1077,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1043
1077
|
*
|
|
1044
1078
|
* A renderer only delivers key events to the view that holds web focus. A
|
|
1045
1079
|
* view that was created but never clicked holds none — and `Input` events
|
|
1046
|
-
* 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
|
|
1047
1081
|
* session vanished inside the renderer while the command still answered
|
|
1048
1082
|
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
1049
1083
|
* adapter) simply have no `focus`, and nothing changes for them.
|
|
@@ -1052,7 +1086,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1052
1086
|
if (handle.focus === undefined) return
|
|
1053
1087
|
try {
|
|
1054
1088
|
// Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
|
|
1055
|
-
// 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
|
|
1056
1090
|
// good. A timeout is caught below exactly like any other focus failure, so the
|
|
1057
1091
|
// best-effort contract is unchanged.
|
|
1058
1092
|
await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out')
|
|
@@ -1205,6 +1239,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1205
1239
|
const s = this.session(session)
|
|
1206
1240
|
const tab = this.activeTab(s)
|
|
1207
1241
|
signal?.throwIfAborted()
|
|
1242
|
+
// A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
|
|
1243
|
+
// it — there is no document to commit — so the call would sit out its whole budget before
|
|
1244
|
+
// saying anything. Answering the empty page immediately is both faster and more truthful.
|
|
1245
|
+
if (tab.url === '') return { url: '', elements: [], truncated: false }
|
|
1208
1246
|
const script = `(() => {
|
|
1209
1247
|
const cap = ${String(this.snapshotMaxElements)}
|
|
1210
1248
|
const url = location.href
|
|
@@ -1277,13 +1315,31 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1277
1315
|
// Same hang guard as execute: a renderer that has not committed after
|
|
1278
1316
|
// navigate would otherwise block snapshot forever.
|
|
1279
1317
|
const timeoutMs = 30_000
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1318
|
+
let result: { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1319
|
+
try {
|
|
1320
|
+
result = await withTimeout(
|
|
1321
|
+
handleSendEvaluate(tab.handle, script),
|
|
1322
|
+
timeoutMs,
|
|
1323
|
+
signal,
|
|
1324
|
+
`browser: snapshot timed out after ${timeoutMs}ms`,
|
|
1325
|
+
() => terminatePage(tab.handle),
|
|
1326
|
+
) as { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
// A timeout leaves the wedged view in the session, so every later read would pay the same
|
|
1329
|
+
// 30 seconds again — and `terminatePage` only interrupts the running script, it does not
|
|
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 dsh_browser_open with the real URL
|
|
1332
|
+
// repopulates it.
|
|
1333
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1334
|
+
this.replaceTabWithBlank(s, tab)
|
|
1335
|
+
throw new BrowserError(
|
|
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
|
+
'BROWSER_SNAPSHOT_TIMEOUT',
|
|
1338
|
+
{ cause: error },
|
|
1339
|
+
)
|
|
1340
|
+
}
|
|
1341
|
+
throw error
|
|
1342
|
+
}
|
|
1287
1343
|
if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
|
|
1288
1344
|
const value = result.value as BrowserSnapshotResult
|
|
1289
1345
|
return value
|
|
@@ -1301,6 +1357,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1301
1357
|
const s = this.session(session)
|
|
1302
1358
|
const tab = this.activeTab(s)
|
|
1303
1359
|
signal?.throwIfAborted()
|
|
1360
|
+
// Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
|
|
1361
|
+
// the tree cannot be read and the call would spend its whole budget discovering that.
|
|
1362
|
+
if (tab.url === '') return { url: '', title: undefined, count: 0, nodes: [], truncated: false }
|
|
1304
1363
|
const includeHidden = request.includeHidden === true
|
|
1305
1364
|
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
1306
1365
|
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
@@ -1462,13 +1521,28 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1462
1521
|
return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
|
|
1463
1522
|
})()`
|
|
1464
1523
|
const timeoutMs = 30_000
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1524
|
+
let result: { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1525
|
+
try {
|
|
1526
|
+
result = await withTimeout(
|
|
1527
|
+
handleSendEvaluate(tab.handle, script),
|
|
1528
|
+
timeoutMs,
|
|
1529
|
+
signal,
|
|
1530
|
+
`browser: a11y timed out after ${timeoutMs}ms`,
|
|
1531
|
+
() => terminatePage(tab.handle),
|
|
1532
|
+
) as { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
// As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
|
|
1535
|
+
// charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
|
|
1536
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1537
|
+
this.replaceTabWithBlank(s, tab)
|
|
1538
|
+
throw new BrowserError(
|
|
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
|
+
'BROWSER_A11Y_TIMEOUT',
|
|
1541
|
+
{ cause: error },
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1544
|
+
throw error
|
|
1545
|
+
}
|
|
1472
1546
|
if (!result.ok) throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED')
|
|
1473
1547
|
const value = result.value as BrowserA11yResult
|
|
1474
1548
|
this.record(s, 'a11y', { includeHidden, maxNodes }, true, { result: `${value.count} nodes` })
|
|
@@ -1929,7 +2003,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1929
2003
|
placeholder: f.placeholder ?? null,
|
|
1930
2004
|
// A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
|
|
1931
2005
|
// it to 'text' here made `if (spec.kind)` above always true, so every unspecified
|
|
1932
|
-
// field was narrowed to an <input> —
|
|
2006
|
+
// field was narrowed to an <input> — dsh_browser_fill on a <textarea> reported
|
|
1933
2007
|
// `no "text" control matched`, and a contenteditable could not match any kind at all.
|
|
1934
2008
|
// The value stays a string or null (never undefined): the in-page check tests truthiness,
|
|
1935
2009
|
// and JSON drops an undefined member entirely, which is the same thing here.
|
|
@@ -2064,7 +2138,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2064
2138
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
2065
2139
|
out.push({ ok: true, method: 'select', target: describe(spec) })
|
|
2066
2140
|
} else if (type === 'file') {
|
|
2067
|
-
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) })
|
|
2068
2142
|
} else if (type === 'checkbox') {
|
|
2069
2143
|
const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
|
|
2070
2144
|
if (el.checked !== want) el.click()
|
|
@@ -2518,7 +2592,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2518
2592
|
* Refuse an action the settings have switched off.
|
|
2519
2593
|
*
|
|
2520
2594
|
* These switches belong to the OPERATOR. The settings document is written through the
|
|
2521
|
-
* 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
|
|
2522
2596
|
* model owns and can lift at will — a refusal here stands for as long as the setting does.
|
|
2523
2597
|
* That is the point of them: a deployment can take page-script execution, downloads, or
|
|
2524
2598
|
* login-state writes off the table without relying on the model's cooperation.
|
|
@@ -2545,7 +2619,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2545
2619
|
|
|
2546
2620
|
/**
|
|
2547
2621
|
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
2548
|
-
* ONE gate for both `
|
|
2622
|
+
* ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
|
|
2549
2623
|
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
2550
2624
|
* exist. Without it a prompt-injected agent could write — or silently
|
|
2551
2625
|
* replace — any file the DSH process can reach, which also escapes the file
|
|
@@ -2979,7 +3053,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2979
3053
|
}
|
|
2980
3054
|
|
|
2981
3055
|
/**
|
|
2982
|
-
* List persisted visits, newest first — what `
|
|
3056
|
+
* List persisted visits, newest first — what `dsh_browser_visited` reads back.
|
|
2983
3057
|
* @param options - result cap and an optional hostname filter.
|
|
2984
3058
|
* @returns the matching visits, or an empty list when history is disabled.
|
|
2985
3059
|
*/
|
|
@@ -3116,11 +3190,41 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3116
3190
|
const handle = this.host.createView(s.label)
|
|
3117
3191
|
// Same window as the session's other tabs.
|
|
3118
3192
|
this.host.groupView?.(handle, s.id, s.label)
|
|
3119
|
-
s.tabs.push({ id: `tab:${randomUUID()}`, handle })
|
|
3193
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' })
|
|
3120
3194
|
s.activeIndex = s.tabs.length - 1
|
|
3121
3195
|
this.showActive(s)
|
|
3122
3196
|
}
|
|
3123
3197
|
|
|
3198
|
+
/**
|
|
3199
|
+
* Swap a wedged view for a fresh blank one, keeping the tab's public id.
|
|
3200
|
+
*
|
|
3201
|
+
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
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 `dsh_browser_open` with the real URL re-populates it.
|
|
3204
|
+
*
|
|
3205
|
+
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
3206
|
+
* session holding a handle that is already gone.
|
|
3207
|
+
* @param s - the session owning the tab.
|
|
3208
|
+
* @param tab - the tab to reset.
|
|
3209
|
+
*/
|
|
3210
|
+
private replaceTabWithBlank(s: Session, tab: Tab): void {
|
|
3211
|
+
const index = s.tabs.indexOf(tab)
|
|
3212
|
+
if (index < 0) return
|
|
3213
|
+
let handle: ElectronViewHandle
|
|
3214
|
+
try {
|
|
3215
|
+
handle = this.host.createView(s.label)
|
|
3216
|
+
this.host.groupView?.(handle, s.id, s.label)
|
|
3217
|
+
} catch {
|
|
3218
|
+
// Creating the replacement failed: leaving the wedged tab in place is worse than nothing
|
|
3219
|
+
// only for the caller, who is about to be told the tab timed out either way.
|
|
3220
|
+
return
|
|
3221
|
+
}
|
|
3222
|
+
const wedged = tab.handle
|
|
3223
|
+
s.tabs[index] = { id: tab.id, handle, url: '' }
|
|
3224
|
+
if (index === s.activeIndex) this.showActive(s)
|
|
3225
|
+
this.host.destroyView(wedged)
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3124
3228
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
3125
3229
|
private tabByViewId(s: Session, viewId: string): Tab | undefined {
|
|
3126
3230
|
return s.tabs.find(tab => tab.handle.id === viewId)
|
|
@@ -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
|
}
|
|
@@ -129,16 +129,35 @@ const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
|
|
|
129
129
|
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The channels a settings document may name.
|
|
134
|
+
*
|
|
135
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
136
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
137
|
+
*/
|
|
138
|
+
export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'] as const
|
|
139
|
+
|
|
132
140
|
/**
|
|
133
141
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
134
142
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
143
|
+
*
|
|
144
|
+
* Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
|
|
145
|
+
* the same to whoever typed it, which is why every discarded value is reported.
|
|
135
146
|
* @param value - the raw value from the settings document.
|
|
147
|
+
* @param warn - where to report a discarded value, when the caller has somewhere to put it.
|
|
136
148
|
* @returns a valid channel.
|
|
137
149
|
*/
|
|
138
|
-
function resolveChannel(value: unknown): BrowserChannel {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
function resolveChannel(value: unknown, warn?: (message: string) => void): BrowserChannel {
|
|
151
|
+
// Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
|
|
152
|
+
// the default without a word.
|
|
153
|
+
if (value === undefined) return DEFAULT_SETTINGS.browser.channel
|
|
154
|
+
if ((BROWSER_CHANNELS as readonly unknown[]).includes(value)) return value as BrowserChannel
|
|
155
|
+
warn?.(
|
|
156
|
+
`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
|
|
157
|
+
+ `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
|
|
158
|
+
+ `${BROWSER_CHANNELS.join(', ')}.`,
|
|
159
|
+
)
|
|
160
|
+
return DEFAULT_SETTINGS.browser.channel
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
@@ -181,7 +200,7 @@ function section(source: Record<string, unknown>, key: string): Record<string, u
|
|
|
181
200
|
* @param raw - the parsed file contents.
|
|
182
201
|
* @returns the resolved settings.
|
|
183
202
|
*/
|
|
184
|
-
export function resolveSettings(raw: unknown): BrowserSettings {
|
|
203
|
+
export function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings {
|
|
185
204
|
const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
|
|
186
205
|
const history = section(source, 'history')
|
|
187
206
|
const cookies = section(source, 'cookies')
|
|
@@ -208,7 +227,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
208
227
|
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
209
228
|
},
|
|
210
229
|
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
211
|
-
browser: { channel: resolveChannel(browser?.channel) },
|
|
230
|
+
browser: { channel: resolveChannel(browser?.channel, warn) },
|
|
212
231
|
credentials: {
|
|
213
232
|
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
214
233
|
// is refused here for free: the section itself is the "present but wrong" value.
|
|
@@ -244,7 +263,7 @@ export class SettingsStore {
|
|
|
244
263
|
/**
|
|
245
264
|
* @param file - settings file path (defaults beside the browser profile).
|
|
246
265
|
*/
|
|
247
|
-
constructor(private readonly file: string = settingsPath()) {}
|
|
266
|
+
constructor(private readonly file: string = settingsPath(), private readonly log?: (message: string) => void) {}
|
|
248
267
|
|
|
249
268
|
/** Path this store reads and writes. */
|
|
250
269
|
path(): string {
|
|
@@ -291,7 +310,7 @@ export class SettingsStore {
|
|
|
291
310
|
// value in it: the document looked correct, and the plugin quietly ran on
|
|
292
311
|
// defaults — including switches the user had just turned off.
|
|
293
312
|
const body = text?.replace(/^\uFEFF/, '')
|
|
294
|
-
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
|
|
313
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m))
|
|
295
314
|
} catch {
|
|
296
315
|
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
297
316
|
// first is a corrupted or half-written file whose contents were the operator's decisions,
|
|
@@ -311,7 +330,7 @@ export class SettingsStore {
|
|
|
311
330
|
*/
|
|
312
331
|
update(patch: unknown): BrowserSettings {
|
|
313
332
|
const current = this.get()
|
|
314
|
-
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
|
|
333
|
+
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch), m => this.log?.(m))
|
|
315
334
|
try {
|
|
316
335
|
mkdirSync(dirname(this.file), { recursive: true })
|
|
317
336
|
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
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
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* and the tool's `output.schema` declared the properties. Nothing connected them, and the
|
|
9
9
|
* result is this project's most repeatable bug:
|
|
10
10
|
*
|
|
11
|
-
* - `
|
|
11
|
+
* - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
|
|
12
12
|
* printed by the renderer, and dropped by the projection. 134 tests stayed green.
|
|
13
|
-
* - `
|
|
13
|
+
* - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
|
|
14
14
|
* schema, and dropped by the projection. 172 tests stayed green. It was found a round
|
|
15
15
|
* later, by reading the three places that agree and noticing the fourth did not.
|
|
16
16
|
*
|
|
@@ -33,7 +33,7 @@ interface FieldSpec {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* The fields of one entry in `
|
|
36
|
+
* The fields of one entry in `dsh_browser_snapshot` / `dsh_browser_open`'s `elements`.
|
|
37
37
|
*
|
|
38
38
|
* `selector` is the reason this file exists; see the header. It was added to the schema and
|
|
39
39
|
* the projection separately, and only one of them landed.
|
|
@@ -49,7 +49,7 @@ const ELEMENT_FIELDS = {
|
|
|
49
49
|
} as const satisfies Record<string, FieldSpec>
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* The fields of one entry in `
|
|
52
|
+
* The fields of one entry in `dsh_browser_a11y`'s `nodes`.
|
|
53
53
|
*
|
|
54
54
|
* Different shape from an element on purpose: a role and an accessible name carry meaning a
|
|
55
55
|
* tag and a label string do not, and `depth` is what renders the tree.
|
|
@@ -91,7 +91,7 @@ function project(fields: Record<string, FieldSpec>, source: Record<string, unkno
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* The `elements` item schema for
|
|
94
|
+
* The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
|
|
95
95
|
*
|
|
96
96
|
* Written as a literal rather than generated, because `defineTool` validates the output schema
|
|
97
97
|
* against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
|
|
@@ -114,7 +114,7 @@ export const ELEMENT_ITEM_SCHEMA = {
|
|
|
114
114
|
} as const
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* The `nodes` item schema for
|
|
117
|
+
* The `nodes` item schema for dsh_browser_a11y.
|
|
118
118
|
*
|
|
119
119
|
* Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
|
|
120
120
|
* test.
|