dsh-builtin-browser 0.1.22 → 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 +50 -3
- package/README.md +51 -4
- 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 +28 -1
- 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.d.ts +1 -0
- package/lib/browser-electron/host-main.js +88 -1
- package/lib/browser-electron/provider.d.ts +105 -4
- package/lib/browser-electron/provider.js +226 -14
- package/lib/browser-electron/remote-host.d.ts +29 -6
- package/lib/browser-electron/remote-host.js +114 -24
- 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 +20 -6
- package/src/browser/runtime.ts +37 -1
- 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 +83 -1
- package/src/browser-electron/provider.ts +267 -19
- package/src/browser-electron/remote-host.ts +114 -21
- 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
|
@@ -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
|
/**
|
|
@@ -207,6 +217,12 @@ export interface ElectronViewHandle {
|
|
|
207
217
|
* @returns the CDP `result` object.
|
|
208
218
|
*/
|
|
209
219
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
220
|
+
/**
|
|
221
|
+
* Give the backing view web focus so keyboard input reaches its page.
|
|
222
|
+
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
223
|
+
* then behaves exactly as before.
|
|
224
|
+
*/
|
|
225
|
+
focus?(): Promise<void>
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
/** One tab inside a session: its view plus a stable id. */
|
|
@@ -232,6 +248,28 @@ interface Session {
|
|
|
232
248
|
export interface ElectronBrowserProviderConfig {
|
|
233
249
|
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
234
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
|
|
235
273
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
236
274
|
readonly snapshotMaxElements?: number
|
|
237
275
|
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
@@ -254,14 +292,16 @@ export interface CdpNavigateParams {
|
|
|
254
292
|
}
|
|
255
293
|
|
|
256
294
|
/**
|
|
257
|
-
* CDP method/params for `Input.dispatchMouseEvent
|
|
295
|
+
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
296
|
+
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
297
|
+
* release halves; a move carries only the position.
|
|
258
298
|
*/
|
|
259
299
|
export interface CdpMouseParams {
|
|
260
|
-
readonly type: 'mousePressed' | 'mouseReleased'
|
|
300
|
+
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
|
|
261
301
|
readonly x: number
|
|
262
302
|
readonly y: number
|
|
263
|
-
readonly button
|
|
264
|
-
readonly clickCount
|
|
303
|
+
readonly button?: 'left'
|
|
304
|
+
readonly clickCount?: number
|
|
265
305
|
}
|
|
266
306
|
|
|
267
307
|
/** CDP method/params for `Input.insertText`. */
|
|
@@ -357,6 +397,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
357
397
|
private readonly snapshotMaxElements: number
|
|
358
398
|
private readonly contentMaxChars: number
|
|
359
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
|
|
360
407
|
|
|
361
408
|
constructor(
|
|
362
409
|
private readonly host: ElectronBrowserViewHost,
|
|
@@ -371,9 +418,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
371
418
|
// sandbox does not have to arbitrate. The localized name is probed so a
|
|
372
419
|
// Chinese desktop (`~/下载`) works without configuring `downloadDir`.
|
|
373
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
|
+
})
|
|
374
430
|
// Route toolbar (host UI) actions into the session model: the human and
|
|
375
431
|
// the agent then always drive the same tabs, history, and navigation.
|
|
376
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) })
|
|
377
436
|
}
|
|
378
437
|
|
|
379
438
|
/**
|
|
@@ -548,6 +607,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
548
607
|
// NEXT observation sees, not whether this action happened).
|
|
549
608
|
this.record(s, 'navigate', { url }, true)
|
|
550
609
|
await this.settleDocument(handle, before, signal)
|
|
610
|
+
this.recordVisit(s, handle)
|
|
551
611
|
this.showActive(s)
|
|
552
612
|
} catch (error) {
|
|
553
613
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -664,6 +724,26 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
664
724
|
}
|
|
665
725
|
}
|
|
666
726
|
|
|
727
|
+
/**
|
|
728
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
729
|
+
*
|
|
730
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
731
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
732
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
733
|
+
* session vanished inside the renderer while the command still answered
|
|
734
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
735
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
736
|
+
*/
|
|
737
|
+
private async focusView(handle: ElectronViewHandle): Promise<void> {
|
|
738
|
+
if (handle.focus === undefined) return
|
|
739
|
+
try {
|
|
740
|
+
await handle.focus()
|
|
741
|
+
} catch {
|
|
742
|
+
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
743
|
+
// press; the dispatch below reports a real channel failure itself.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
667
747
|
/** Execute JS in the active tab's page context. */
|
|
668
748
|
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
669
749
|
const s = this.session(session)
|
|
@@ -1196,23 +1276,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1196
1276
|
// Input.* is only delivered to a view with a current display surface; the
|
|
1197
1277
|
// barrier also re-presents after a navigation replaced the renderer.
|
|
1198
1278
|
await this.present(s, signal)
|
|
1199
|
-
const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
|
|
1200
|
-
handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
|
|
1201
1279
|
const timeoutMs = 30_000
|
|
1280
|
+
const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
|
|
1281
|
+
withTimeout(
|
|
1282
|
+
handle.sendCommand('Input.dispatchMouseEvent', params),
|
|
1283
|
+
timeoutMs,
|
|
1284
|
+
signal,
|
|
1285
|
+
`browser: click ${label} timed out after ${timeoutMs}ms`,
|
|
1286
|
+
)
|
|
1287
|
+
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
1288
|
+
// hover target rather than hit-testing the coordinates, and a real pointer
|
|
1289
|
+
// click is always preceded by movement. A view that has never received a
|
|
1290
|
+
// mouse event has no hover target yet, so the press of the FIRST click on a
|
|
1291
|
+
// fresh tab went nowhere while CDP still answered success; the second click
|
|
1292
|
+
// landed because the first had quietly established the target. Move the
|
|
1293
|
+
// pointer first so a click is self-contained.
|
|
1202
1294
|
try {
|
|
1203
|
-
await
|
|
1295
|
+
await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
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')
|
|
1302
|
+
const release = (): void => {
|
|
1303
|
+
void handle
|
|
1304
|
+
.sendCommand('Input.dispatchMouseEvent', {
|
|
1305
|
+
type: 'mouseReleased',
|
|
1306
|
+
x,
|
|
1307
|
+
y,
|
|
1308
|
+
button: 'left',
|
|
1309
|
+
clickCount: 1,
|
|
1310
|
+
} satisfies CdpMouseParams)
|
|
1311
|
+
.catch(() => {})
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
|
|
1204
1315
|
} catch (error) {
|
|
1205
1316
|
// The press may still land late; release best-effort so the button is
|
|
1206
1317
|
// never left in a stuck pressed state.
|
|
1207
|
-
|
|
1318
|
+
release()
|
|
1208
1319
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1209
1320
|
}
|
|
1210
1321
|
try {
|
|
1211
|
-
await
|
|
1322
|
+
await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
|
|
1212
1323
|
} catch (error) {
|
|
1213
1324
|
// The press already landed; retry the release so the button is not
|
|
1214
1325
|
// left pressed before surfacing the failure.
|
|
1215
|
-
|
|
1326
|
+
release()
|
|
1216
1327
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
|
|
1217
1328
|
}
|
|
1218
1329
|
this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
|
|
@@ -1237,7 +1348,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1237
1348
|
el.focus()
|
|
1238
1349
|
return { ok: true }
|
|
1239
1350
|
`)
|
|
1240
|
-
|
|
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')
|
|
1241
1355
|
}
|
|
1242
1356
|
const text = 'text' in request ? request.text : ''
|
|
1243
1357
|
const timeoutMs = 30_000
|
|
@@ -1327,6 +1441,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1327
1441
|
)
|
|
1328
1442
|
this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
|
|
1329
1443
|
await this.settleDocument(handle, before, signal)
|
|
1444
|
+
this.recordVisit(s, handle)
|
|
1330
1445
|
this.showActive(s)
|
|
1331
1446
|
}
|
|
1332
1447
|
|
|
@@ -1359,6 +1474,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1359
1474
|
)
|
|
1360
1475
|
this.record(s, 'reload', {}, true)
|
|
1361
1476
|
await this.settleDocument(handle, before, signal)
|
|
1477
|
+
this.recordVisit(s, handle)
|
|
1362
1478
|
this.showActive(s)
|
|
1363
1479
|
}
|
|
1364
1480
|
|
|
@@ -1375,6 +1491,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1375
1491
|
const timeoutMs = 15_000
|
|
1376
1492
|
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1377
1493
|
await this.present(s, signal)
|
|
1494
|
+
await this.focusView(handle)
|
|
1378
1495
|
const release = (): Promise<Record<string, unknown>> =>
|
|
1379
1496
|
handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
|
|
1380
1497
|
try {
|
|
@@ -1552,10 +1669,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1552
1669
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
1553
1670
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
1554
1671
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
1672
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
1673
|
+
* polled until the budget expires — see the comment in `match`.
|
|
1555
1674
|
*/
|
|
1556
1675
|
private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
|
|
1676
|
+
// `by` defaults to css in-page; report the strategy that WAS used, or a
|
|
1677
|
+
// miss says only `{"value":"Learn more"}` and the caller cannot tell that a
|
|
1678
|
+
// plain label was queried as a CSS selector.
|
|
1679
|
+
const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
|
|
1557
1680
|
return `(async () => {
|
|
1558
|
-
const spec = ${JSON.stringify(
|
|
1681
|
+
const spec = ${JSON.stringify(resolvedSpec)}
|
|
1559
1682
|
const timeoutMs = ${String(timeoutMs)}
|
|
1560
1683
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
1561
1684
|
const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
|
|
@@ -1567,7 +1690,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1567
1690
|
const index = typeof spec.index === 'number' ? spec.index : 0
|
|
1568
1691
|
let els = []
|
|
1569
1692
|
if (by === 'css') {
|
|
1570
|
-
|
|
1693
|
+
// A selector that does not PARSE can never match on a later poll, so
|
|
1694
|
+
// it must not look like a miss: the old "return null" kept the caller
|
|
1695
|
+
// polling until the whole budget was gone, and "element not found
|
|
1696
|
+
// after 10s" reads like a slow page while the real cause is a
|
|
1697
|
+
// malformed selector (a label or plain text passed where one belongs).
|
|
1698
|
+
try { els = Array.from(document.querySelectorAll(value)) }
|
|
1699
|
+
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1571
1700
|
} else if (by === 'xpath') {
|
|
1572
1701
|
try {
|
|
1573
1702
|
const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
@@ -1575,7 +1704,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1575
1704
|
const n = snap.snapshotItem(i)
|
|
1576
1705
|
if (n instanceof Element) els.push(n)
|
|
1577
1706
|
}
|
|
1578
|
-
} catch {
|
|
1707
|
+
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1579
1708
|
} else {
|
|
1580
1709
|
const all = Array.from(document.querySelectorAll('body *'))
|
|
1581
1710
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
@@ -1589,12 +1718,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1589
1718
|
const deadline = Date.now() + timeoutMs
|
|
1590
1719
|
let el = null
|
|
1591
1720
|
for (;;) {
|
|
1592
|
-
|
|
1721
|
+
// Only a parse error escapes the poll: see the comment in match() above.
|
|
1722
|
+
try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
|
|
1593
1723
|
if (el !== null) break
|
|
1594
|
-
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
|
|
1724
|
+
if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
|
|
1595
1725
|
await sleep(100)
|
|
1596
1726
|
}
|
|
1727
|
+
const __result = await (async () => {
|
|
1597
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
|
+
}
|
|
1598
1739
|
})()`
|
|
1599
1740
|
}
|
|
1600
1741
|
|
|
@@ -1611,9 +1752,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1611
1752
|
errorCode: string,
|
|
1612
1753
|
errorLabel: string,
|
|
1613
1754
|
): Promise<Record<string, unknown>> {
|
|
1755
|
+
// The script polls for its whole `timeoutMs` and only then answers, so an
|
|
1756
|
+
// outer budget of the same size fires at the same instant and wins: the
|
|
1757
|
+
// caller saw `click timed out after 10000ms` while the page had already
|
|
1758
|
+
// decided WHY (selector not found / malformed). Give the answer a chance to
|
|
1759
|
+
// get back before the generic timeout replaces it.
|
|
1614
1760
|
const result = await withTimeout(
|
|
1615
1761
|
handleSendEvaluate(tab.handle, script),
|
|
1616
|
-
timeoutMs,
|
|
1762
|
+
timeoutMs + TARGET_SCRIPT_GRACE_MS,
|
|
1617
1763
|
signal,
|
|
1618
1764
|
`${errorLabel} timed out after ${timeoutMs}ms`,
|
|
1619
1765
|
() => terminatePage(tab.handle),
|
|
@@ -1667,6 +1813,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1667
1813
|
`
|
|
1668
1814
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1669
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')
|
|
1670
1818
|
this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method as string })
|
|
1671
1819
|
return { method: out.method as string, value: out.value as string }
|
|
1672
1820
|
}
|
|
@@ -1693,6 +1841,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1693
1841
|
`
|
|
1694
1842
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1695
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')
|
|
1696
1846
|
this.record(s, 'check', { target: request.target, checked: want }, true)
|
|
1697
1847
|
return { checked: out.checked === true }
|
|
1698
1848
|
}
|
|
@@ -1725,6 +1875,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1725
1875
|
`
|
|
1726
1876
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1727
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')
|
|
1728
1880
|
this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true)
|
|
1729
1881
|
return { value: out.value as string, text: out.text as string }
|
|
1730
1882
|
}
|
|
@@ -1758,7 +1910,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1758
1910
|
return { ok: true }
|
|
1759
1911
|
`
|
|
1760
1912
|
const script = this.buildTargetScript(request.target, timeoutMs, body)
|
|
1761
|
-
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')
|
|
1762
1916
|
this.record(s, 'clear', { target: request.target }, true)
|
|
1763
1917
|
return { cleared: true }
|
|
1764
1918
|
}
|
|
@@ -1813,7 +1967,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1813
1967
|
const deadline = Date.now() + timeoutMs
|
|
1814
1968
|
let items = []
|
|
1815
1969
|
for (;;) {
|
|
1816
|
-
|
|
1970
|
+
// Immediate, like match() above: an unparseable selector can never match.
|
|
1971
|
+
try { items = Array.from(document.querySelectorAll(itemSel)) }
|
|
1972
|
+
catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
|
|
1817
1973
|
if (items.length > 0) break
|
|
1818
1974
|
if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
|
|
1819
1975
|
await sleep(100)
|
|
@@ -2083,6 +2239,60 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2083
2239
|
return this.session(session).history
|
|
2084
2240
|
}
|
|
2085
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
|
+
|
|
2086
2296
|
/**
|
|
2087
2297
|
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
2088
2298
|
* re-issued against the current page; execute re-runs its script. The
|
|
@@ -2195,6 +2405,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2195
2405
|
return s.tabs.find(tab => tab.handle.id === viewId)
|
|
2196
2406
|
}
|
|
2197
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
|
+
|
|
2198
2431
|
/**
|
|
2199
2432
|
* Route a user-initiated action from the host's UI into the session model.
|
|
2200
2433
|
* Fire-and-forget by design: a user action failing (e.g. an unreachable
|
|
@@ -2295,6 +2528,13 @@ const SETTLE_GRACE_MS = 150
|
|
|
2295
2528
|
/** Poll interval for the document-settle loop. */
|
|
2296
2529
|
const SETTLE_POLL_MS = 25
|
|
2297
2530
|
|
|
2531
|
+
/**
|
|
2532
|
+
* Extra time the transport gets over an in-page locate script's own budget: the
|
|
2533
|
+
* script polls for `timeoutMs` and answers only afterwards, so the outer wait
|
|
2534
|
+
* must outlast it or its generic timeout hides the in-page reason.
|
|
2535
|
+
*/
|
|
2536
|
+
const TARGET_SCRIPT_GRACE_MS = 2_000
|
|
2537
|
+
|
|
2298
2538
|
/** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
|
|
2299
2539
|
function stripTabPrefix(id: string): string {
|
|
2300
2540
|
return id.startsWith('tab:') ? id.slice(4) : id
|
|
@@ -2393,6 +2633,14 @@ function terminatePage(handle: ElectronViewHandle): void {
|
|
|
2393
2633
|
* @param expression - the JS expression.
|
|
2394
2634
|
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
2395
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
|
+
|
|
2396
2644
|
async function handleSendEvaluate(
|
|
2397
2645
|
handle: ElectronViewHandle,
|
|
2398
2646
|
expression: string,
|