dsh-builtin-browser 0.1.23 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +91 -3
- package/README.md +98 -4
- package/client.js +334 -0
- package/cordis.patch.yml +24 -5
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +195 -11
- package/lib/browser-electron/history-store.d.ts +75 -0
- package/lib/browser-electron/history-store.js +162 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +122 -1
- package/lib/browser-electron/provider.js +233 -21
- package/lib/browser-electron/remote-host.d.ts +17 -1
- package/lib/browser-electron/remote-host.js +81 -9
- package/lib/browser-electron/settings-store.d.ts +92 -0
- package/lib/browser-electron/settings-store.js +183 -0
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +67 -0
- package/lib/browser-electron/virtual-cursor.js +141 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +122 -16
- package/package.json +14 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +217 -11
- package/src/browser-electron/history-store.ts +171 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +284 -21
- package/src/browser-electron/remote-host.ts +84 -7
- package/src/browser-electron/settings-store.ts +227 -0
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +152 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +121 -15
|
@@ -11,6 +11,9 @@ import { randomUUID } from 'node:crypto'
|
|
|
11
11
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { homedir } from 'node:os'
|
|
13
13
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
|
+
import { HistoryStore, type VisitedPage } from './history-store.js'
|
|
15
|
+
import type { BrowserSettings } from './settings-store.js'
|
|
16
|
+
import { forgetCursor, paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
14
17
|
import type {
|
|
15
18
|
BrowserA11yRequest,
|
|
16
19
|
BrowserA11yResult,
|
|
@@ -175,6 +178,38 @@ export interface ElectronBrowserViewHost {
|
|
|
175
178
|
* @param handler - called for every user action; must not throw.
|
|
176
179
|
*/
|
|
177
180
|
onUserAction?(handler: (action: BrowserUserAction) => void): void
|
|
181
|
+
/**
|
|
182
|
+
* Optional: receive notice that the human closed a browser window. Closing the
|
|
183
|
+
* interface ends the session it showed — the next call opens a clean one —
|
|
184
|
+
* while browsing history and login state survive on disk.
|
|
185
|
+
* @param handler - called with the group (session) key; must not throw.
|
|
186
|
+
*/
|
|
187
|
+
onViewClosed?(handler: (windowId: string) => void): void
|
|
188
|
+
/**
|
|
189
|
+
* Release the service's page(s) without ending the plugin's own lifetime.
|
|
190
|
+
*
|
|
191
|
+
* Only carriers that outlive a browser session need this. A self-hosted window
|
|
192
|
+
* dies with its view, but the desktop shell's sidebar keeps running no matter
|
|
193
|
+
* what the plugin does — so "release the browser when the session ends"
|
|
194
|
+
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
195
|
+
* @returns a promise that settles once the release was attempted.
|
|
196
|
+
*/
|
|
197
|
+
releasePage?(): Promise<void>
|
|
198
|
+
/**
|
|
199
|
+
* Fold the carrier's presentation away while keeping the page alive.
|
|
200
|
+
*
|
|
201
|
+
* The desktop sidebar keeps running regardless of the plugin, so "do not take
|
|
202
|
+
* over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
|
|
203
|
+
* asking the shell to fold it. A self-hosted window has no such state and simply
|
|
204
|
+
* does not implement this.
|
|
205
|
+
* @returns a promise that settles once the fold was attempted.
|
|
206
|
+
*/
|
|
207
|
+
collapse?(): Promise<void>
|
|
208
|
+
/**
|
|
209
|
+
* Release everything the carrier owns (a spawned browser, a socket, a child).
|
|
210
|
+
* Called when the plugin stops using it, never while it is still in service.
|
|
211
|
+
*/
|
|
212
|
+
dispose?(): void
|
|
178
213
|
}
|
|
179
214
|
|
|
180
215
|
/**
|
|
@@ -238,6 +273,28 @@ interface Session {
|
|
|
238
273
|
export interface ElectronBrowserProviderConfig {
|
|
239
274
|
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
240
275
|
readonly httpOnly?: boolean
|
|
276
|
+
/**
|
|
277
|
+
* Persistent browsing history (visited pages), independent of session
|
|
278
|
+
* lifetime. `enabled: false` keeps the browser working but records nothing;
|
|
279
|
+
* the retained limits ride along so a user's choice from the settings panel
|
|
280
|
+
* reaches the store without a second configuration path.
|
|
281
|
+
*/
|
|
282
|
+
readonly history?: {
|
|
283
|
+
readonly enabled?: boolean
|
|
284
|
+
readonly maxEntries?: number
|
|
285
|
+
readonly maxAgeDays?: number
|
|
286
|
+
/**
|
|
287
|
+
* Explicit history file path. Default: beside the browser profile
|
|
288
|
+
* (`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`).
|
|
289
|
+
*/
|
|
290
|
+
readonly file?: string
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Live settings source (the settings panel's document). When present it wins
|
|
294
|
+
* over the static `history` config, so a switch flipped in the UI takes effect
|
|
295
|
+
* without restarting DSH.
|
|
296
|
+
*/
|
|
297
|
+
readonly settings?: () => BrowserSettings
|
|
241
298
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
242
299
|
readonly snapshotMaxElements?: number
|
|
243
300
|
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
@@ -365,6 +422,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
365
422
|
private readonly snapshotMaxElements: number
|
|
366
423
|
private readonly contentMaxChars: number
|
|
367
424
|
private readonly downloadDir: string | undefined
|
|
425
|
+
/**
|
|
426
|
+
* Persistent browsing history, or `undefined` when the user turned it off.
|
|
427
|
+
* Sessions come and go; this record outlives all of them.
|
|
428
|
+
*/
|
|
429
|
+
private readonly historyStore: HistoryStore | undefined
|
|
430
|
+
/** Live settings source; absent when nothing owns a settings document. */
|
|
431
|
+
private readonly settingsSource: (() => BrowserSettings) | undefined
|
|
368
432
|
|
|
369
433
|
constructor(
|
|
370
434
|
private readonly host: ElectronBrowserViewHost,
|
|
@@ -379,9 +443,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
379
443
|
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
380
444
|
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
381
445
|
this.downloadDir = config.downloadDir ?? defaultDownloadDir()
|
|
446
|
+
this.settingsSource = config.settings
|
|
447
|
+
// Browsing history follows the browser profile's persistence rules (close
|
|
448
|
+
// the interface, lose nothing), and can be switched off from settings.
|
|
449
|
+
this.historyStore = config.history?.enabled === false
|
|
450
|
+
? undefined
|
|
451
|
+
: new HistoryStore(config.history?.file, {
|
|
452
|
+
...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
|
|
453
|
+
...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
|
|
454
|
+
})
|
|
382
455
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
383
456
|
// the agent then always drive the same tabs, history, and navigation.
|
|
384
457
|
this.host.onUserAction?.(action => { void this.handleUserAction(action) })
|
|
458
|
+
// A window the human closed ends the session it showed: the next call opens a
|
|
459
|
+
// clean one instead of driving a page nobody can see.
|
|
460
|
+
this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId) })
|
|
385
461
|
}
|
|
386
462
|
|
|
387
463
|
/**
|
|
@@ -556,6 +632,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
556
632
|
// NEXT observation sees, not whether this action happened).
|
|
557
633
|
this.record(s, 'navigate', { url }, true)
|
|
558
634
|
await this.settleDocument(handle, before, signal)
|
|
635
|
+
this.recordVisit(s, handle)
|
|
559
636
|
this.showActive(s)
|
|
560
637
|
} catch (error) {
|
|
561
638
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -618,6 +695,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
618
695
|
private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
|
|
619
696
|
const started = Date.now()
|
|
620
697
|
const deadline = started + SETTLE_TIMEOUT_MS
|
|
698
|
+
// The document is being replaced, so the synthetic pointer drawn into the old one
|
|
699
|
+
// is gone. Dropping the remembered position is what lets it be painted again:
|
|
700
|
+
// without this, a paint at coincidentally identical coordinates would be skipped
|
|
701
|
+
// and the pointer would never come back after a navigation.
|
|
702
|
+
forgetCursor(handle)
|
|
621
703
|
for (;;) {
|
|
622
704
|
if (signal?.aborted === true) return
|
|
623
705
|
// A probe failure is expected while the navigation commits (the context is
|
|
@@ -1128,21 +1210,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1128
1210
|
// iframes stay opaque — the browser forbids reading them).
|
|
1129
1211
|
let content = ''
|
|
1130
1212
|
if (fmt === 'txt') {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1213
|
+
// Prefer the browser's own rendered text.
|
|
1214
|
+
//
|
|
1215
|
+
// Walking text nodes and joining them is not equivalent: pages that wrap
|
|
1216
|
+
// every character in its own element — one-character spans for a
|
|
1217
|
+
// per-character animation, which is common — produce one text node per
|
|
1218
|
+
// letter, and joining those with spaces turns every word into a column of
|
|
1219
|
+
// letters. The rendered text reassembles them the way a reader sees them,
|
|
1220
|
+
// and it also honours visibility, so hidden text does not leak out.
|
|
1221
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
1222
|
+
const rendered = typeof root.innerText === 'string' ? root.innerText : ''
|
|
1223
|
+
if (rendered.trim() !== '') {
|
|
1224
|
+
content = rendered
|
|
1225
|
+
} else {
|
|
1226
|
+
// Fallback for nodes without layout (a detached root, or a fragment):
|
|
1227
|
+
// collect text directly, still piercing shadow roots and same-origin
|
|
1228
|
+
// iframes, and join without inventing separators beyond what the markup
|
|
1229
|
+
// already implies.
|
|
1230
|
+
const parts = []
|
|
1231
|
+
const textWalk = (node) => {
|
|
1232
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
1233
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
1234
|
+
const tag = node.tagName.toLowerCase()
|
|
1235
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
1236
|
+
if (tag === 'iframe') {
|
|
1237
|
+
try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
|
|
1241
|
+
for (const child of node.childNodes) textWalk(child)
|
|
1140
1242
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1243
|
+
textWalk(root)
|
|
1244
|
+
content = parts.join(' ')
|
|
1143
1245
|
}
|
|
1144
|
-
textWalk(root)
|
|
1145
|
-
content = parts.join(' ')
|
|
1146
1246
|
}
|
|
1147
1247
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
1148
1248
|
else if (fmt === 'json') content = JSON.stringify(root)
|
|
@@ -1218,6 +1318,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1218
1318
|
x = Number(out.x)
|
|
1219
1319
|
y = Number(out.y)
|
|
1220
1320
|
} else {
|
|
1321
|
+
// A coordinate only exists because somebody read a picture. Under the
|
|
1322
|
+
// non-visual strategy the plugin must not accept one: a model that cannot
|
|
1323
|
+
// see cannot have produced it, so the coordinates are either copied from an
|
|
1324
|
+
// earlier vision pass or invented, and honouring them turns a silent
|
|
1325
|
+
// mis-click into a mystery. Refusing here, with a message that says what to
|
|
1326
|
+
// do instead, converts that into a usable instruction.
|
|
1327
|
+
if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
|
|
1328
|
+
throw new BrowserError(
|
|
1329
|
+
'browser: the configured non-visual strategy refuses coordinate clicks — pass a semantic target instead, e.g. target { by: "text", value: "Sign in" }, which locates the element from the DOM and clicks its centre',
|
|
1330
|
+
'BROWSER_NON_VISUAL_COORDINATES',
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1221
1333
|
x = request.x
|
|
1222
1334
|
y = request.y
|
|
1223
1335
|
}
|
|
@@ -1244,6 +1356,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1244
1356
|
} catch (error) {
|
|
1245
1357
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1246
1358
|
}
|
|
1359
|
+
// Paint before the press so the ripple is already on screen when the click
|
|
1360
|
+
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
1361
|
+
// Name the operation in the overlay: the pointer is the "agent is driving this
|
|
1362
|
+
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1363
|
+
this.showCursor(handle, x, y, 'click', 'click', true)
|
|
1247
1364
|
const release = (): void => {
|
|
1248
1365
|
void handle
|
|
1249
1366
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -1293,7 +1410,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1293
1410
|
el.focus()
|
|
1294
1411
|
return { ok: true }
|
|
1295
1412
|
`)
|
|
1296
|
-
|
|
1413
|
+
const locateTab = this.activeTab(s)
|
|
1414
|
+
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
|
|
1415
|
+
const cursorPoint = scriptPoint(out)
|
|
1416
|
+
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true)
|
|
1297
1417
|
}
|
|
1298
1418
|
const text = 'text' in request ? request.text : ''
|
|
1299
1419
|
const timeoutMs = 30_000
|
|
@@ -1383,6 +1503,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1383
1503
|
)
|
|
1384
1504
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
|
|
1385
1505
|
await this.settleDocument(handle, before, signal)
|
|
1506
|
+
this.recordVisit(s, handle)
|
|
1386
1507
|
this.showActive(s)
|
|
1387
1508
|
}
|
|
1388
1509
|
|
|
@@ -1415,6 +1536,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1415
1536
|
)
|
|
1416
1537
|
this.record(s, 'reload', {}, true)
|
|
1417
1538
|
await this.settleDocument(handle, before, signal)
|
|
1539
|
+
this.recordVisit(s, handle)
|
|
1418
1540
|
this.showActive(s)
|
|
1419
1541
|
}
|
|
1420
1542
|
|
|
@@ -1664,7 +1786,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1664
1786
|
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1665
1787
|
await sleep(100)
|
|
1666
1788
|
}
|
|
1789
|
+
const __result = await (async () => {
|
|
1667
1790
|
${body}
|
|
1791
|
+
})()
|
|
1792
|
+
// Report the operated element's viewport center next to the script's own
|
|
1793
|
+
// verdict: the synthetic cursor (and any future feedback) needs the point,
|
|
1794
|
+
// and no individual body should have to compute it a second time. Kept
|
|
1795
|
+
// under a distinct key so a body that already returns x/y keeps them.
|
|
1796
|
+
const __rect = el.getBoundingClientRect()
|
|
1797
|
+
return {
|
|
1798
|
+
...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
|
|
1799
|
+
__point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
|
|
1800
|
+
}
|
|
1668
1801
|
})()`
|
|
1669
1802
|
}
|
|
1670
1803
|
|
|
@@ -1742,6 +1875,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1742
1875
|
`
|
|
1743
1876
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1744
1877
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue')
|
|
1878
|
+
const cursorPoint = scriptPoint(out)
|
|
1879
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1745
1880
|
this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method as string })
|
|
1746
1881
|
return { method: out.method as string, value: out.value as string }
|
|
1747
1882
|
}
|
|
@@ -1768,6 +1903,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1768
1903
|
`
|
|
1769
1904
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1770
1905
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check')
|
|
1906
|
+
const cursorPoint = scriptPoint(out)
|
|
1907
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1771
1908
|
this.record(s, 'check', { target: request.target, checked: want }, true)
|
|
1772
1909
|
return { checked: out.checked === true }
|
|
1773
1910
|
}
|
|
@@ -1800,6 +1937,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1800
1937
|
`
|
|
1801
1938
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1802
1939
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select')
|
|
1940
|
+
const cursorPoint = scriptPoint(out)
|
|
1941
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1803
1942
|
this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true)
|
|
1804
1943
|
return { value: out.value as string, text: out.text as string }
|
|
1805
1944
|
}
|
|
@@ -1833,7 +1972,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1833
1972
|
return { ok: true }
|
|
1834
1973
|
`
|
|
1835
1974
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1836
|
-
await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
|
|
1975
|
+
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
|
|
1976
|
+
const cursorPoint = scriptPoint(out)
|
|
1977
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1837
1978
|
this.record(s, 'clear', { target: request.target }, true)
|
|
1838
1979
|
return { cleared: true }
|
|
1839
1980
|
}
|
|
@@ -2160,6 +2301,63 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2160
2301
|
return this.session(session).history
|
|
2161
2302
|
}
|
|
2162
2303
|
|
|
2304
|
+
/**
|
|
2305
|
+
* Record one page visit in the persistent browsing history. Fire-and-forget by
|
|
2306
|
+
* design: a visit must never delay or fail the navigation that produced it,
|
|
2307
|
+
* and the title is only worth reading once the document has settled.
|
|
2308
|
+
* @param s - the session that drove the visit.
|
|
2309
|
+
* @param handle - the view whose document just loaded.
|
|
2310
|
+
*/
|
|
2311
|
+
private recordVisit(s: Session, handle: ElectronViewHandle): void {
|
|
2312
|
+
const store = this.historyStore
|
|
2313
|
+
if (store === undefined) return
|
|
2314
|
+
// The settings panel can switch recording off at runtime; the static config
|
|
2315
|
+
// only supplies the startup default.
|
|
2316
|
+
if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
|
|
2317
|
+
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
2318
|
+
.then(result => {
|
|
2319
|
+
if (!result.ok || typeof result.value !== 'string') return
|
|
2320
|
+
const [url = '', title = ''] = result.value.split('\u0000')
|
|
2321
|
+
// Internal documents are not visits: an untouched browser is not history.
|
|
2322
|
+
if (url === '' || url.startsWith('about:') || url.startsWith('devtools:')) return
|
|
2323
|
+
store.append({
|
|
2324
|
+
at: Date.now(),
|
|
2325
|
+
url,
|
|
2326
|
+
...title.trim() !== '' ? { title: title.trim() } : {},
|
|
2327
|
+
...s.label !== undefined ? { session: s.label } : {},
|
|
2328
|
+
})
|
|
2329
|
+
store.prune()
|
|
2330
|
+
})
|
|
2331
|
+
.catch(() => { /* best-effort: history is never worth surfacing an error */ })
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* List persisted visits, newest first — what `browser_visited` reads back.
|
|
2336
|
+
* @param options - result cap and an optional hostname filter.
|
|
2337
|
+
* @returns the matching visits, or an empty list when history is disabled.
|
|
2338
|
+
*/
|
|
2339
|
+
visited(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): readonly VisitedPage[] {
|
|
2340
|
+
return this.historyStore?.list(options) ?? []
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Show the synthetic pointer on a view, unless the user switched it off. The
|
|
2345
|
+
* cursor is the "the agent has taken over this tab" signal, so it is painted
|
|
2346
|
+
* for every operation that has a landing point — including DOM-level ones that
|
|
2347
|
+
* move no real pointer.
|
|
2348
|
+
* @param handle - the view to paint into.
|
|
2349
|
+
* @param x - viewport x in CSS pixels.
|
|
2350
|
+
* @param y - viewport y in CSS pixels.
|
|
2351
|
+
* @param action - click pulses a ripple; move only relocates.
|
|
2352
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
2353
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
2354
|
+
* always play; a bare move need not repeat).
|
|
2355
|
+
*/
|
|
2356
|
+
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction, label?: string, force = false): void {
|
|
2357
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
|
|
2358
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force)
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2163
2361
|
/**
|
|
2164
2362
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
2165
2363
|
* re-issued against the current page; execute re-runs its script. The
|
|
@@ -2237,6 +2435,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2237
2435
|
if (existing !== undefined) {
|
|
2238
2436
|
this.sessions.delete(session)
|
|
2239
2437
|
for (const tab of existing.tabs) this.host.destroyView(tab.handle)
|
|
2438
|
+
// Releasing the browser at session end is a setting, not fixed behaviour
|
|
2439
|
+
// (requirements §3). A self-hosted carrier already tore its window down with
|
|
2440
|
+
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2441
|
+
// — and its cookies plus the browsing history outlive the page either way.
|
|
2442
|
+
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2443
|
+
void this.host.releasePage?.().catch(() => undefined)
|
|
2444
|
+
}
|
|
2240
2445
|
}
|
|
2241
2446
|
return Promise.resolve()
|
|
2242
2447
|
}
|
|
@@ -2272,6 +2477,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2272
2477
|
return s.tabs.find(tab => tab.handle.id === viewId)
|
|
2273
2478
|
}
|
|
2274
2479
|
|
|
2480
|
+
/**
|
|
2481
|
+
* Whether a session is still live. The human closing a window ends its session
|
|
2482
|
+
* (see {@link handleViewClosed}), so callers that cache ids must ask first.
|
|
2483
|
+
* @param session - the session id to test.
|
|
2484
|
+
*/
|
|
2485
|
+
exists(session: BrowserSessionId): boolean {
|
|
2486
|
+
return this.sessions.has(session)
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* The human closed a browser window: the session that window showed is over.
|
|
2491
|
+
* Ending it here is what makes the next call a clean start rather than a
|
|
2492
|
+
* resurrection of an invisible window. Browsing history and login state live
|
|
2493
|
+
* on disk, so nothing the human cares about is lost with it.
|
|
2494
|
+
* @param windowId - the group key the host reported, which is the session id.
|
|
2495
|
+
*/
|
|
2496
|
+
private async handleViewClosed(windowId: string): Promise<void> {
|
|
2497
|
+
if (typeof windowId !== 'string' || windowId === '') return
|
|
2498
|
+
const s = this.sessions.get(windowId as BrowserSessionId)
|
|
2499
|
+
if (s === undefined) return
|
|
2500
|
+
await this.close(s.id).catch(() => { /* the window is already gone */ })
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2275
2503
|
/**
|
|
2276
2504
|
* Route a user-initiated action from the host's UI into the session model.
|
|
2277
2505
|
* Fire-and-forget by design: a user action failing (e.g. an unreachable
|
|
@@ -2325,19 +2553,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2325
2553
|
}
|
|
2326
2554
|
}
|
|
2327
2555
|
|
|
2328
|
-
/**
|
|
2556
|
+
/**
|
|
2557
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
2558
|
+
*
|
|
2559
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
2560
|
+
* handler (issue #16):
|
|
2561
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
2562
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
2563
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
2564
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
2565
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
2566
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
2567
|
+
*/
|
|
2329
2568
|
private notifyUserActionError(action: BrowserUserAction, error: unknown): void {
|
|
2330
|
-
const notify = (this.host as { notifyUserActionError?(windowId: string, message: string): void }).notifyUserActionError
|
|
2331
2569
|
const message = `action ${action.type} failed: ${String(error)}`
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2570
|
+
let reported = false
|
|
2571
|
+
try {
|
|
2572
|
+
const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
|
|
2573
|
+
const notify = host.notifyUserActionError
|
|
2574
|
+
if (typeof notify === 'function') {
|
|
2575
|
+
// Called on its owner: an unbound call here kills the host process.
|
|
2576
|
+
notify.call(host, action.windowId, message)
|
|
2577
|
+
reported = true
|
|
2578
|
+
}
|
|
2579
|
+
} catch (notifyError) {
|
|
2580
|
+
// Contained on purpose; the log line below still carries the original cause.
|
|
2581
|
+
process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`)
|
|
2336
2582
|
}
|
|
2583
|
+
if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
|
|
2337
2584
|
}
|
|
2338
2585
|
|
|
2339
2586
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
2340
2587
|
private showActive(s: Session): void {
|
|
2588
|
+
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2589
|
+
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2590
|
+
// driven — it is simply folded away, so the agent works without claiming the
|
|
2591
|
+
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2592
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2593
|
+
void this.host.collapse?.().catch(() => undefined)
|
|
2594
|
+
return
|
|
2595
|
+
}
|
|
2341
2596
|
this.host.showView?.(this.activeTab(s).handle, s.label)
|
|
2342
2597
|
}
|
|
2343
2598
|
|
|
@@ -2477,6 +2732,14 @@ function terminatePage(handle: ElectronViewHandle): void {
|
|
|
2477
2732
|
* @param expression - the JS expression.
|
|
2478
2733
|
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
2479
2734
|
*/
|
|
2735
|
+
/** Viewport point a target script reported, when it reported one. */
|
|
2736
|
+
function scriptPoint(out: Record<string, unknown> | undefined): { x: number; y: number } | undefined {
|
|
2737
|
+
const point = out?.__point as { x?: unknown; y?: unknown } | undefined
|
|
2738
|
+
const x = Number(point?.x)
|
|
2739
|
+
const y = Number(point?.y)
|
|
2740
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2480
2743
|
async function handleSendEvaluate(
|
|
2481
2744
|
handle: ElectronViewHandle,
|
|
2482
2745
|
expression: string,
|
|
@@ -440,12 +440,28 @@ class ElectronChildClient {
|
|
|
440
440
|
private readonly token: string,
|
|
441
441
|
private readonly onExit?: () => void,
|
|
442
442
|
private readonly onUserAction?: (action: BrowserUserAction) => void,
|
|
443
|
+
/**
|
|
444
|
+
* Called when the human closes a browser window. The session it showed is
|
|
445
|
+
* over: the provider releases it, while history and login state stay on disk
|
|
446
|
+
* so the next open is a clean session rather than a resurrected one.
|
|
447
|
+
*/
|
|
448
|
+
private readonly onViewClosed?: (windowId: string) => void,
|
|
443
449
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
444
450
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
445
451
|
private readonly executable?: string,
|
|
446
452
|
) {
|
|
447
453
|
const electron = this.executable ?? resolveElectronPath()
|
|
454
|
+
// Record what is about to be spawned — with existence checks — BEFORE the
|
|
455
|
+
// attempt. When Electron cannot load the app entry it exits 1 with EMPTY
|
|
456
|
+
// stderr, and that signature is otherwise indistinguishable from a missing
|
|
457
|
+
// binary: writing both paths (and whether they exist) in advance is what
|
|
458
|
+
// turns the next occurrence into a named cause instead of a guess.
|
|
448
459
|
process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
|
|
460
|
+
appendHostLog(
|
|
461
|
+
`${hostStamp()} spawning: electron=${electron} (exists=${String(existsSync(electron))})`
|
|
462
|
+
+ ` hostMain=${this.hostMainPath} (exists=${String(existsSync(this.hostMainPath))})`
|
|
463
|
+
+ ` port=${String(this.port)}\n`,
|
|
464
|
+
)
|
|
449
465
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
450
466
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
451
467
|
// break the child. Rebuild the env without either.
|
|
@@ -482,15 +498,26 @@ class ElectronChildClient {
|
|
|
482
498
|
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
483
499
|
// that would crash the whole DSH process.
|
|
484
500
|
this.child.on('error', error => {
|
|
485
|
-
const line =
|
|
501
|
+
const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`
|
|
486
502
|
process.stderr.write(`[dsh-browser host] ${line}\n`)
|
|
487
503
|
appendHostLog(`${line}\n`)
|
|
488
504
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
|
|
489
505
|
})
|
|
490
506
|
this.child.on('exit', (code, signal) => {
|
|
491
507
|
// 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
|
-
|
|
508
|
+
// restart loop is indistinguishable from a clean stop. The timestamp, pid
|
|
509
|
+
// and both paths are what turn a bare `code=1` into a diagnosis: Electron
|
|
510
|
+
// exits 1 with empty stderr precisely when it cannot load the app entry,
|
|
511
|
+
// so the dated `exists=` line written at spawn is the other half.
|
|
512
|
+
const entryExists = existsSync(this.hostMainPath)
|
|
513
|
+
const line = `${hostStamp()} browser host exited (code=${String(code)} signal=${String(signal)})`
|
|
514
|
+
+ ` pid=${String(this.child?.pid)} electron=${electron} hostMain=${this.hostMainPath}`
|
|
515
|
+
+ ` entryExists=${String(entryExists)}`
|
|
516
|
+
// Comparing this with the `exists=` recorded at spawn is the whole
|
|
517
|
+
// diagnosis: present at spawn and absent now means the installation was
|
|
518
|
+
// replaced underneath a running host, which is exactly how Electron ends
|
|
519
|
+
// up unable to load the app entry and exits 1 with empty stderr.
|
|
520
|
+
+ (entryExists ? '' : ' [entry script missing at exit]')
|
|
494
521
|
appendHostLog(`${line}\n`)
|
|
495
522
|
this.fail(new Error(`dsh-builtin-browser: ${line}`))
|
|
496
523
|
})
|
|
@@ -566,7 +593,7 @@ class ElectronChildClient {
|
|
|
566
593
|
const line = this.buffer.slice(0, nl).trim()
|
|
567
594
|
this.buffer = this.buffer.slice(nl + 1)
|
|
568
595
|
if (line === '') continue
|
|
569
|
-
let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown }
|
|
596
|
+
let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown; windowId?: unknown }
|
|
570
597
|
try {
|
|
571
598
|
msg = JSON.parse(line) as typeof msg
|
|
572
599
|
} catch {
|
|
@@ -580,6 +607,14 @@ class ElectronChildClient {
|
|
|
580
607
|
this.onUserAction?.(msg.action as BrowserUserAction)
|
|
581
608
|
continue
|
|
582
609
|
}
|
|
610
|
+
if (msg.op === 'viewClosed') {
|
|
611
|
+
// Fire-and-forget notification from the child's own UI: the human closed
|
|
612
|
+
// the window, so the interface that session showed is gone. The provider
|
|
613
|
+
// decides what that ends — browsing history and login state survive on
|
|
614
|
+
// disk, only the session itself does not.
|
|
615
|
+
this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
583
618
|
if (this.awaitingHello) {
|
|
584
619
|
// The FIRST line must be the child's hello carrying the spawn token.
|
|
585
620
|
// Anything else (or a wrong token) means a spoofed connection: drop
|
|
@@ -717,6 +752,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
717
752
|
private readonly groups = new Map<string, { windowId: string; label?: string }>()
|
|
718
753
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
719
754
|
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
755
|
+
/**
|
|
756
|
+
* The provider's handler for a window the human closed. Closing the interface
|
|
757
|
+
* ends the session it showed: history and login state stay on disk, so the
|
|
758
|
+
* next open starts clean instead of resuming a window nobody can see.
|
|
759
|
+
*/
|
|
760
|
+
private viewClosedHandler: ((windowId: string) => void) | undefined
|
|
720
761
|
|
|
721
762
|
constructor(
|
|
722
763
|
private readonly hostMainPath: string,
|
|
@@ -758,12 +799,27 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
758
799
|
return this.electronAvailable
|
|
759
800
|
}
|
|
760
801
|
|
|
761
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
804
|
+
*
|
|
805
|
+
* Never throws synchronously. Callers are fire-and-forget
|
|
806
|
+
* (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
|
|
807
|
+
* `.catch` entirely — the shape that let a failed toolbar report take the whole
|
|
808
|
+
* DSH host down (issue #16). A disposed host is reported as a rejected promise
|
|
809
|
+
* instead, which those callers already handle.
|
|
810
|
+
*/
|
|
762
811
|
private ready(): Promise<void> {
|
|
763
812
|
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
764
813
|
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
765
814
|
// nobody will ever kill.
|
|
766
|
-
if (this.disposed)
|
|
815
|
+
if (this.disposed) {
|
|
816
|
+
const gone = Promise.reject(new HostGoneError('browser host disposed'))
|
|
817
|
+
// Keep the rejection observable to callers while marking it handled here:
|
|
818
|
+
// a fire-and-forget caller that forgot to attach a handler must not turn
|
|
819
|
+
// this into an unhandled rejection either.
|
|
820
|
+
gone.catch(() => undefined)
|
|
821
|
+
return gone
|
|
822
|
+
}
|
|
767
823
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
768
824
|
const started = this.start()
|
|
769
825
|
const wrapped = started.catch(error => {
|
|
@@ -826,6 +882,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
826
882
|
token,
|
|
827
883
|
() => this.onChildExit(),
|
|
828
884
|
action => this.userActionHandler?.(action),
|
|
885
|
+
windowId => this.viewClosedHandler?.(windowId),
|
|
829
886
|
this.spawnExecutable,
|
|
830
887
|
)
|
|
831
888
|
if (this.pendingSocket !== undefined) {
|
|
@@ -959,6 +1016,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
959
1016
|
this.userActionHandler = handler
|
|
960
1017
|
}
|
|
961
1018
|
|
|
1019
|
+
/** Register the provider's handler for windows the human closed. */
|
|
1020
|
+
onViewClosed(handler: (windowId: string) => void): void {
|
|
1021
|
+
this.viewClosedHandler = handler
|
|
1022
|
+
}
|
|
1023
|
+
|
|
962
1024
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
963
1025
|
notifyUserActionError(windowId: string, message: string): void {
|
|
964
1026
|
void this.ready()
|
|
@@ -1080,6 +1142,16 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1080
1142
|
*/
|
|
1081
1143
|
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
|
|
1082
1144
|
|
|
1145
|
+
/**
|
|
1146
|
+
* ISO timestamp prefix for host-log lines. This log is the only place a spawn
|
|
1147
|
+
* failure is recorded, and an undated line cannot be lined up with anything else
|
|
1148
|
+
* that happened on the machine — a plugin update replacing the entry script, for
|
|
1149
|
+
* instance — which is precisely the question this log gets asked.
|
|
1150
|
+
*/
|
|
1151
|
+
function hostStamp(): string {
|
|
1152
|
+
return new Date().toISOString()
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1083
1155
|
function appendHostLog(text: string): void {
|
|
1084
1156
|
const home = process.env.DSH_HOME
|
|
1085
1157
|
if (home === undefined || home === '') return
|
|
@@ -1087,7 +1159,12 @@ function appendHostLog(text: string): void {
|
|
|
1087
1159
|
const dir = join(home, 'logs')
|
|
1088
1160
|
const file = join(dir, 'dsh-builtin-browser-host.log')
|
|
1089
1161
|
try {
|
|
1090
|
-
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1162
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES) {
|
|
1163
|
+
// Discard the old bytes but leave a dated marker: truncating to empty
|
|
1164
|
+
// makes a rotation look like "nothing ever happened", which is how the
|
|
1165
|
+
// history of a crash loop gets silently swallowed.
|
|
1166
|
+
writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`)
|
|
1167
|
+
}
|
|
1091
1168
|
} catch { /* first write, or unreadable: just append */ }
|
|
1092
1169
|
mkdirSync(dir, { recursive: true })
|
|
1093
1170
|
appendFileSync(file, text)
|