dsh-builtin-browser 0.1.23 → 0.2.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 +45 -2
- package/README.md +46 -3
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- 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/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +80 -0
- package/lib/browser-electron/provider.js +142 -2
- package/lib/browser-electron/remote-host.d.ts +8 -0
- package/lib/browser-electron/remote-host.js +64 -6
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +13 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +166 -2
- package/src/browser-electron/remote-host.ts +67 -5
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent browsing history: which pages the shared browser actually visited,
|
|
3
|
+
* kept independently from any session's operation log (`browser_history`) and
|
|
4
|
+
* from the browser process's lifetime.
|
|
5
|
+
*
|
|
6
|
+
* It lives beside the browser profile (`$DSH_HOME/dsh-builtin-browser-host/
|
|
7
|
+
* history.jsonl`), so it follows the same persistence rules as the login state:
|
|
8
|
+
* closing the interface releases the process, never the record.
|
|
9
|
+
*
|
|
10
|
+
* Format: one JSON object per line, append-only. Appending stays cheap for a
|
|
11
|
+
* long session, and a half-written tail (crash, power loss) is dropped on read
|
|
12
|
+
* instead of poisoning the whole file.
|
|
13
|
+
* @module dsh-browser/browser-electron/history-store
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import type { VisitedPage } from '../browser/types.js'
|
|
20
|
+
|
|
21
|
+
export type { VisitedPage }
|
|
22
|
+
|
|
23
|
+
/** Retention limits; whichever is hit first starts trimming. */
|
|
24
|
+
export interface HistoryLimits {
|
|
25
|
+
/** Maximum retained entries. Default 5000. */
|
|
26
|
+
readonly maxEntries?: number
|
|
27
|
+
/** Maximum age in days. Default 90. */
|
|
28
|
+
readonly maxAgeDays?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default entry cap — mirrors the documented retention rule. */
|
|
32
|
+
const DEFAULT_MAX_ENTRIES = 5_000
|
|
33
|
+
/** Default age cap in days. */
|
|
34
|
+
const DEFAULT_MAX_AGE_DAYS = 90
|
|
35
|
+
/** Rewrite the file only once it has grown this far past the cap. */
|
|
36
|
+
const PRUNE_SLACK = 500
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Absolute path of the browsing-history file, beside the browser profile so the
|
|
40
|
+
* record shares the login state's persistence rules.
|
|
41
|
+
* @returns the history file path.
|
|
42
|
+
*/
|
|
43
|
+
export function visitedHistoryPath(): string {
|
|
44
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
45
|
+
return join(home, 'dsh-builtin-browser-host', 'history.jsonl')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Append-only browsing history with bounded retention. Every method tolerates a
|
|
50
|
+
* missing or damaged file: recording a visit must never fail a navigation, and
|
|
51
|
+
* a corrupted line must never hide the rest of the history.
|
|
52
|
+
*/
|
|
53
|
+
export class HistoryStore {
|
|
54
|
+
private readonly maxEntries: number
|
|
55
|
+
private readonly maxAgeMs: number
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param file - absolute path of the JSONL history file.
|
|
59
|
+
* @param limits - retention overrides (entry count and age).
|
|
60
|
+
*/
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly file: string = visitedHistoryPath(),
|
|
63
|
+
limits: HistoryLimits = {},
|
|
64
|
+
) {
|
|
65
|
+
this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
66
|
+
this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the history file exists yet (diagnostics and tests). */
|
|
70
|
+
exists(): boolean {
|
|
71
|
+
return existsSync(this.file)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record one visit. Failures are swallowed by design: history is a
|
|
76
|
+
* convenience, and losing an entry must never break the tool call that
|
|
77
|
+
* produced it.
|
|
78
|
+
* @param page - the visited page.
|
|
79
|
+
*/
|
|
80
|
+
append(page: VisitedPage): void {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
83
|
+
appendFileSync(this.file, `${JSON.stringify(page)}\n`)
|
|
84
|
+
} catch {
|
|
85
|
+
// Best-effort: history is not worth failing a navigation over.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read visits, newest first. Unparseable lines are skipped; entries older
|
|
91
|
+
* than the age limit are filtered out but left on disk until the next prune.
|
|
92
|
+
* @param options - optional result cap and domain filter.
|
|
93
|
+
* @returns the matching visits, newest first.
|
|
94
|
+
*/
|
|
95
|
+
list(options: { readonly limit?: number; readonly domain?: string } = {}): VisitedPage[] {
|
|
96
|
+
const all = this.readAll()
|
|
97
|
+
const cutoff = Date.now() - this.maxAgeMs
|
|
98
|
+
const domain = options.domain?.toLowerCase()
|
|
99
|
+
const matches = all.filter(page => {
|
|
100
|
+
if (page.at < cutoff) return false
|
|
101
|
+
if (domain === undefined) return true
|
|
102
|
+
try {
|
|
103
|
+
return new URL(page.url).hostname.toLowerCase().includes(domain)
|
|
104
|
+
} catch {
|
|
105
|
+
return page.url.toLowerCase().includes(domain)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
matches.reverse()
|
|
109
|
+
return options.limit === undefined ? matches : matches.slice(0, Math.max(0, options.limit))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Enforce retention: drop entries past the age limit, then the oldest beyond
|
|
114
|
+
* the count cap. Cheap no-op until the file grows past the cap, so callers can
|
|
115
|
+
* run it after every append.
|
|
116
|
+
*/
|
|
117
|
+
prune(): void {
|
|
118
|
+
const all = this.readAll()
|
|
119
|
+
const cutoff = Date.now() - this.maxAgeMs
|
|
120
|
+
const fresh = all.filter(page => page.at >= cutoff)
|
|
121
|
+
const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
|
|
122
|
+
// Only rewrite when something actually fell off, and only once the excess
|
|
123
|
+
// is worth a full rewrite.
|
|
124
|
+
if (kept.length === all.length) return
|
|
125
|
+
if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries) return
|
|
126
|
+
try {
|
|
127
|
+
const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
|
|
128
|
+
writeFileSync(this.file, body)
|
|
129
|
+
} catch {
|
|
130
|
+
// Best-effort: an unpruned file still reads correctly.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse the whole file, skipping blank and damaged lines. */
|
|
135
|
+
private readAll(): VisitedPage[] {
|
|
136
|
+
let raw: string
|
|
137
|
+
try {
|
|
138
|
+
raw = readFileSync(this.file, 'utf8')
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
const pages: VisitedPage[] = []
|
|
143
|
+
for (const line of raw.split('\n')) {
|
|
144
|
+
const trimmed = line.trim()
|
|
145
|
+
if (trimmed === '') continue
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(trimmed) as Partial<VisitedPage>
|
|
148
|
+
if (typeof parsed.at !== 'number' || typeof parsed.url !== 'string') continue
|
|
149
|
+
pages.push({
|
|
150
|
+
at: parsed.at,
|
|
151
|
+
url: parsed.url,
|
|
152
|
+
...typeof parsed.title === 'string' && parsed.title !== '' ? { title: parsed.title } : {},
|
|
153
|
+
...typeof parsed.session === 'string' && parsed.session !== '' ? { session: parsed.session } : {},
|
|
154
|
+
})
|
|
155
|
+
} catch {
|
|
156
|
+
// A damaged line (typically a truncated tail) must not hide the rest.
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return pages
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -389,7 +389,32 @@ function windowFor(windowId: string | undefined): HostWindow {
|
|
|
389
389
|
const existing = windows.get(key)
|
|
390
390
|
if (existing !== undefined) return existing
|
|
391
391
|
const w = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' })
|
|
392
|
-
w.on('closed', () => {
|
|
392
|
+
w.on('closed', () => {
|
|
393
|
+
const closing = windows.get(key)
|
|
394
|
+
windows.delete(key)
|
|
395
|
+
// A BrowserWindow does not destroy its child views: their webContents (the
|
|
396
|
+
// renderer process, its timers and audio) outlive the window unless closed
|
|
397
|
+
// explicitly, so release them here instead of leaking a renderer per window.
|
|
398
|
+
if (closing !== undefined) {
|
|
399
|
+
for (const view of closing.views.values()) {
|
|
400
|
+
try {
|
|
401
|
+
view.webContentsView.webContents.close()
|
|
402
|
+
} catch {
|
|
403
|
+
// Already destroyed along with the window.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
closing.views.clear()
|
|
407
|
+
}
|
|
408
|
+
// The interface is gone for good. Report it, so the plugin ends that session
|
|
409
|
+
// instead of holding one whose window the human already closed. Browsing
|
|
410
|
+
// history and login state live on disk, so the next open starts a clean
|
|
411
|
+
// session rather than resurrecting this one.
|
|
412
|
+
try {
|
|
413
|
+
rpcSocket?.write(JSON.stringify({ id: 0, op: 'viewClosed', windowId: key }) + '\n')
|
|
414
|
+
} catch {
|
|
415
|
+
// Socket already gone (parent shutting down): nothing left to report to.
|
|
416
|
+
}
|
|
417
|
+
})
|
|
393
418
|
const win: HostWindow = { windowId: key, window: w, toolbarView: undefined, views: new Map(), visibleViewId: undefined, lastFocusedViewId: undefined, closeTimer: undefined }
|
|
394
419
|
// Keep every view (and the toolbar) filling the window as the human
|
|
395
420
|
// resizes it; otherwise pages stay at their original size and break.
|
|
@@ -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 { paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
14
17
|
import type {
|
|
15
18
|
BrowserA11yRequest,
|
|
16
19
|
BrowserA11yResult,
|
|
@@ -175,6 +178,13 @@ 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
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/**
|
|
@@ -238,6 +248,28 @@ interface Session {
|
|
|
238
248
|
export interface ElectronBrowserProviderConfig {
|
|
239
249
|
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
240
250
|
readonly httpOnly?: boolean
|
|
251
|
+
/**
|
|
252
|
+
* Persistent browsing history (visited pages), independent of session
|
|
253
|
+
* lifetime. `enabled: false` keeps the browser working but records nothing;
|
|
254
|
+
* the retained limits ride along so a user's choice from the settings panel
|
|
255
|
+
* reaches the store without a second configuration path.
|
|
256
|
+
*/
|
|
257
|
+
readonly history?: {
|
|
258
|
+
readonly enabled?: boolean
|
|
259
|
+
readonly maxEntries?: number
|
|
260
|
+
readonly maxAgeDays?: number
|
|
261
|
+
/**
|
|
262
|
+
* Explicit history file path. Default: beside the browser profile
|
|
263
|
+
* (`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`).
|
|
264
|
+
*/
|
|
265
|
+
readonly file?: string
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Live settings source (the settings panel's document). When present it wins
|
|
269
|
+
* over the static `history` config, so a switch flipped in the UI takes effect
|
|
270
|
+
* without restarting DSH.
|
|
271
|
+
*/
|
|
272
|
+
readonly settings?: () => BrowserSettings
|
|
241
273
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
242
274
|
readonly snapshotMaxElements?: number
|
|
243
275
|
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
@@ -365,6 +397,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
365
397
|
private readonly snapshotMaxElements: number
|
|
366
398
|
private readonly contentMaxChars: number
|
|
367
399
|
private readonly downloadDir: string | undefined
|
|
400
|
+
/**
|
|
401
|
+
* Persistent browsing history, or `undefined` when the user turned it off.
|
|
402
|
+
* Sessions come and go; this record outlives all of them.
|
|
403
|
+
*/
|
|
404
|
+
private readonly historyStore: HistoryStore | undefined
|
|
405
|
+
/** Live settings source; absent when nothing owns a settings document. */
|
|
406
|
+
private readonly settingsSource: (() => BrowserSettings) | undefined
|
|
368
407
|
|
|
369
408
|
constructor(
|
|
370
409
|
private readonly host: ElectronBrowserViewHost,
|
|
@@ -379,9 +418,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
379
418
|
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
380
419
|
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
381
420
|
this.downloadDir = config.downloadDir ?? defaultDownloadDir()
|
|
421
|
+
this.settingsSource = config.settings
|
|
422
|
+
// Browsing history follows the browser profile's persistence rules (close
|
|
423
|
+
// the interface, lose nothing), and can be switched off from settings.
|
|
424
|
+
this.historyStore = config.history?.enabled === false
|
|
425
|
+
? undefined
|
|
426
|
+
: new HistoryStore(config.history?.file, {
|
|
427
|
+
...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
|
|
428
|
+
...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
|
|
429
|
+
})
|
|
382
430
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
383
431
|
// the agent then always drive the same tabs, history, and navigation.
|
|
384
432
|
this.host.onUserAction?.(action => { void this.handleUserAction(action) })
|
|
433
|
+
// A window the human closed ends the session it showed: the next call opens a
|
|
434
|
+
// clean one instead of driving a page nobody can see.
|
|
435
|
+
this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId) })
|
|
385
436
|
}
|
|
386
437
|
|
|
387
438
|
/**
|
|
@@ -556,6 +607,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
556
607
|
// NEXT observation sees, not whether this action happened).
|
|
557
608
|
this.record(s, 'navigate', { url }, true)
|
|
558
609
|
await this.settleDocument(handle, before, signal)
|
|
610
|
+
this.recordVisit(s, handle)
|
|
559
611
|
this.showActive(s)
|
|
560
612
|
} catch (error) {
|
|
561
613
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -1244,6 +1296,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1244
1296
|
} catch (error) {
|
|
1245
1297
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1246
1298
|
}
|
|
1299
|
+
// Paint before the press so the ripple is already on screen when the click
|
|
1300
|
+
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
1301
|
+
this.showCursor(handle, x, y, 'click')
|
|
1247
1302
|
const release = (): void => {
|
|
1248
1303
|
void handle
|
|
1249
1304
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -1293,7 +1348,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1293
1348
|
el.focus()
|
|
1294
1349
|
return { ok: true }
|
|
1295
1350
|
`)
|
|
1296
|
-
|
|
1351
|
+
const locateTab = this.activeTab(s)
|
|
1352
|
+
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
|
|
1353
|
+
const cursorPoint = scriptPoint(out)
|
|
1354
|
+
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1297
1355
|
}
|
|
1298
1356
|
const text = 'text' in request ? request.text : ''
|
|
1299
1357
|
const timeoutMs = 30_000
|
|
@@ -1383,6 +1441,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1383
1441
|
)
|
|
1384
1442
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
|
|
1385
1443
|
await this.settleDocument(handle, before, signal)
|
|
1444
|
+
this.recordVisit(s, handle)
|
|
1386
1445
|
this.showActive(s)
|
|
1387
1446
|
}
|
|
1388
1447
|
|
|
@@ -1415,6 +1474,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1415
1474
|
)
|
|
1416
1475
|
this.record(s, 'reload', {}, true)
|
|
1417
1476
|
await this.settleDocument(handle, before, signal)
|
|
1477
|
+
this.recordVisit(s, handle)
|
|
1418
1478
|
this.showActive(s)
|
|
1419
1479
|
}
|
|
1420
1480
|
|
|
@@ -1664,7 +1724,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1664
1724
|
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1665
1725
|
await sleep(100)
|
|
1666
1726
|
}
|
|
1727
|
+
const __result = await (async () => {
|
|
1667
1728
|
${body}
|
|
1729
|
+
})()
|
|
1730
|
+
// Report the operated element's viewport center next to the script's own
|
|
1731
|
+
// verdict: the synthetic cursor (and any future feedback) needs the point,
|
|
1732
|
+
// and no individual body should have to compute it a second time. Kept
|
|
1733
|
+
// under a distinct key so a body that already returns x/y keeps them.
|
|
1734
|
+
const __rect = el.getBoundingClientRect()
|
|
1735
|
+
return {
|
|
1736
|
+
...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
|
|
1737
|
+
__point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
|
|
1738
|
+
}
|
|
1668
1739
|
})()`
|
|
1669
1740
|
}
|
|
1670
1741
|
|
|
@@ -1742,6 +1813,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1742
1813
|
`
|
|
1743
1814
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1744
1815
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue')
|
|
1816
|
+
const cursorPoint = scriptPoint(out)
|
|
1817
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1745
1818
|
this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method as string })
|
|
1746
1819
|
return { method: out.method as string, value: out.value as string }
|
|
1747
1820
|
}
|
|
@@ -1768,6 +1841,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1768
1841
|
`
|
|
1769
1842
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1770
1843
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check')
|
|
1844
|
+
const cursorPoint = scriptPoint(out)
|
|
1845
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1771
1846
|
this.record(s, 'check', { target: request.target, checked: want }, true)
|
|
1772
1847
|
return { checked: out.checked === true }
|
|
1773
1848
|
}
|
|
@@ -1800,6 +1875,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1800
1875
|
`
|
|
1801
1876
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1802
1877
|
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select')
|
|
1878
|
+
const cursorPoint = scriptPoint(out)
|
|
1879
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1803
1880
|
this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true)
|
|
1804
1881
|
return { value: out.value as string, text: out.text as string }
|
|
1805
1882
|
}
|
|
@@ -1833,7 +1910,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1833
1910
|
return { ok: true }
|
|
1834
1911
|
`
|
|
1835
1912
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1836
|
-
await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
|
|
1913
|
+
const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
|
|
1914
|
+
const cursorPoint = scriptPoint(out)
|
|
1915
|
+
if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1837
1916
|
this.record(s, 'clear', { target: request.target }, true)
|
|
1838
1917
|
return { cleared: true }
|
|
1839
1918
|
}
|
|
@@ -2160,6 +2239,60 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2160
2239
|
return this.session(session).history
|
|
2161
2240
|
}
|
|
2162
2241
|
|
|
2242
|
+
/**
|
|
2243
|
+
* Record one page visit in the persistent browsing history. Fire-and-forget by
|
|
2244
|
+
* design: a visit must never delay or fail the navigation that produced it,
|
|
2245
|
+
* and the title is only worth reading once the document has settled.
|
|
2246
|
+
* @param s - the session that drove the visit.
|
|
2247
|
+
* @param handle - the view whose document just loaded.
|
|
2248
|
+
*/
|
|
2249
|
+
private recordVisit(s: Session, handle: ElectronViewHandle): void {
|
|
2250
|
+
const store = this.historyStore
|
|
2251
|
+
if (store === undefined) return
|
|
2252
|
+
// The settings panel can switch recording off at runtime; the static config
|
|
2253
|
+
// only supplies the startup default.
|
|
2254
|
+
if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
|
|
2255
|
+
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
2256
|
+
.then(result => {
|
|
2257
|
+
if (!result.ok || typeof result.value !== 'string') return
|
|
2258
|
+
const [url = '', title = ''] = result.value.split('\u0000')
|
|
2259
|
+
// Internal documents are not visits: an untouched browser is not history.
|
|
2260
|
+
if (url === '' || url.startsWith('about:') || url.startsWith('devtools:')) return
|
|
2261
|
+
store.append({
|
|
2262
|
+
at: Date.now(),
|
|
2263
|
+
url,
|
|
2264
|
+
...title.trim() !== '' ? { title: title.trim() } : {},
|
|
2265
|
+
...s.label !== undefined ? { session: s.label } : {},
|
|
2266
|
+
})
|
|
2267
|
+
store.prune()
|
|
2268
|
+
})
|
|
2269
|
+
.catch(() => { /* best-effort: history is never worth surfacing an error */ })
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* List persisted visits, newest first — what `browser_visited` reads back.
|
|
2274
|
+
* @param options - result cap and an optional hostname filter.
|
|
2275
|
+
* @returns the matching visits, or an empty list when history is disabled.
|
|
2276
|
+
*/
|
|
2277
|
+
visited(options: { readonly limit?: number; readonly domain?: string } = {}): readonly VisitedPage[] {
|
|
2278
|
+
return this.historyStore?.list(options) ?? []
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* Show the synthetic pointer on a view, unless the user switched it off. The
|
|
2283
|
+
* cursor is the "the agent has taken over this tab" signal, so it is painted
|
|
2284
|
+
* for every operation that has a landing point — including DOM-level ones that
|
|
2285
|
+
* move no real pointer.
|
|
2286
|
+
* @param handle - the view to paint into.
|
|
2287
|
+
* @param x - viewport x in CSS pixels.
|
|
2288
|
+
* @param y - viewport y in CSS pixels.
|
|
2289
|
+
* @param action - click pulses a ripple; move only relocates.
|
|
2290
|
+
*/
|
|
2291
|
+
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction): void {
|
|
2292
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
|
|
2293
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression))
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2163
2296
|
/**
|
|
2164
2297
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
2165
2298
|
* re-issued against the current page; execute re-runs its script. The
|
|
@@ -2272,6 +2405,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2272
2405
|
return s.tabs.find(tab => tab.handle.id === viewId)
|
|
2273
2406
|
}
|
|
2274
2407
|
|
|
2408
|
+
/**
|
|
2409
|
+
* Whether a session is still live. The human closing a window ends its session
|
|
2410
|
+
* (see {@link handleViewClosed}), so callers that cache ids must ask first.
|
|
2411
|
+
* @param session - the session id to test.
|
|
2412
|
+
*/
|
|
2413
|
+
exists(session: BrowserSessionId): boolean {
|
|
2414
|
+
return this.sessions.has(session)
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* The human closed a browser window: the session that window showed is over.
|
|
2419
|
+
* Ending it here is what makes the next call a clean start rather than a
|
|
2420
|
+
* resurrection of an invisible window. Browsing history and login state live
|
|
2421
|
+
* on disk, so nothing the human cares about is lost with it.
|
|
2422
|
+
* @param windowId - the group key the host reported, which is the session id.
|
|
2423
|
+
*/
|
|
2424
|
+
private async handleViewClosed(windowId: string): Promise<void> {
|
|
2425
|
+
if (typeof windowId !== 'string' || windowId === '') return
|
|
2426
|
+
const s = this.sessions.get(windowId as BrowserSessionId)
|
|
2427
|
+
if (s === undefined) return
|
|
2428
|
+
await this.close(s.id).catch(() => { /* the window is already gone */ })
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2275
2431
|
/**
|
|
2276
2432
|
* Route a user-initiated action from the host's UI into the session model.
|
|
2277
2433
|
* Fire-and-forget by design: a user action failing (e.g. an unreachable
|
|
@@ -2477,6 +2633,14 @@ function terminatePage(handle: ElectronViewHandle): void {
|
|
|
2477
2633
|
* @param expression - the JS expression.
|
|
2478
2634
|
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
2479
2635
|
*/
|
|
2636
|
+
/** Viewport point a target script reported, when it reported one. */
|
|
2637
|
+
function scriptPoint(out: Record<string, unknown> | undefined): { x: number; y: number } | undefined {
|
|
2638
|
+
const point = out?.__point as { x?: unknown; y?: unknown } | undefined
|
|
2639
|
+
const x = Number(point?.x)
|
|
2640
|
+
const y = Number(point?.y)
|
|
2641
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2480
2644
|
async function handleSendEvaluate(
|
|
2481
2645
|
handle: ElectronViewHandle,
|
|
2482
2646
|
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,
|
|
@@ -826,6 +867,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
826
867
|
token,
|
|
827
868
|
() => this.onChildExit(),
|
|
828
869
|
action => this.userActionHandler?.(action),
|
|
870
|
+
windowId => this.viewClosedHandler?.(windowId),
|
|
829
871
|
this.spawnExecutable,
|
|
830
872
|
)
|
|
831
873
|
if (this.pendingSocket !== undefined) {
|
|
@@ -959,6 +1001,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
959
1001
|
this.userActionHandler = handler
|
|
960
1002
|
}
|
|
961
1003
|
|
|
1004
|
+
/** Register the provider's handler for windows the human closed. */
|
|
1005
|
+
onViewClosed(handler: (windowId: string) => void): void {
|
|
1006
|
+
this.viewClosedHandler = handler
|
|
1007
|
+
}
|
|
1008
|
+
|
|
962
1009
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
963
1010
|
notifyUserActionError(windowId: string, message: string): void {
|
|
964
1011
|
void this.ready()
|
|
@@ -1080,6 +1127,16 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1080
1127
|
*/
|
|
1081
1128
|
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
|
|
1082
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* ISO timestamp prefix for host-log lines. This log is the only place a spawn
|
|
1132
|
+
* failure is recorded, and an undated line cannot be lined up with anything else
|
|
1133
|
+
* that happened on the machine — a plugin update replacing the entry script, for
|
|
1134
|
+
* instance — which is precisely the question this log gets asked.
|
|
1135
|
+
*/
|
|
1136
|
+
function hostStamp(): string {
|
|
1137
|
+
return new Date().toISOString()
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1083
1140
|
function appendHostLog(text: string): void {
|
|
1084
1141
|
const home = process.env.DSH_HOME
|
|
1085
1142
|
if (home === undefined || home === '') return
|
|
@@ -1087,7 +1144,12 @@ function appendHostLog(text: string): void {
|
|
|
1087
1144
|
const dir = join(home, 'logs')
|
|
1088
1145
|
const file = join(dir, 'dsh-builtin-browser-host.log')
|
|
1089
1146
|
try {
|
|
1090
|
-
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1147
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES) {
|
|
1148
|
+
// Discard the old bytes but leave a dated marker: truncating to empty
|
|
1149
|
+
// makes a rotation look like "nothing ever happened", which is how the
|
|
1150
|
+
// history of a crash loop gets silently swallowed.
|
|
1151
|
+
writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`)
|
|
1152
|
+
}
|
|
1091
1153
|
} catch { /* first write, or unreadable: just append */ }
|
|
1092
1154
|
mkdirSync(dir, { recursive: true })
|
|
1093
1155
|
appendFileSync(file, text)
|