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.
Files changed (38) hide show
  1. package/README.en.md +50 -3
  2. package/README.md +51 -4
  3. package/client.js +298 -0
  4. package/cordis.patch.yml +24 -5
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +1 -1
  7. package/docs/tool-reference.md +2 -1
  8. package/lib/browser/runtime.d.ts +18 -1
  9. package/lib/browser/runtime.js +28 -1
  10. package/lib/browser/types.d.ts +33 -0
  11. package/lib/browser-electron/entry.js +115 -0
  12. package/lib/browser-electron/history-store.d.ts +71 -0
  13. package/lib/browser-electron/history-store.js +151 -0
  14. package/lib/browser-electron/host-main.d.ts +1 -0
  15. package/lib/browser-electron/host-main.js +88 -1
  16. package/lib/browser-electron/provider.d.ts +105 -4
  17. package/lib/browser-electron/provider.js +226 -14
  18. package/lib/browser-electron/remote-host.d.ts +29 -6
  19. package/lib/browser-electron/remote-host.js +114 -24
  20. package/lib/browser-electron/settings-store.d.ts +87 -0
  21. package/lib/browser-electron/settings-store.js +160 -0
  22. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  23. package/lib/browser-electron/virtual-cursor.js +71 -0
  24. package/lib/index.d.ts +16 -2
  25. package/lib/index.js +18 -2
  26. package/lib/tool-browser/index.js +72 -2
  27. package/package.json +20 -6
  28. package/src/browser/runtime.ts +37 -1
  29. package/src/browser/types.ts +31 -0
  30. package/src/browser-electron/entry.ts +135 -0
  31. package/src/browser-electron/history-store.ts +162 -0
  32. package/src/browser-electron/host-main.ts +83 -1
  33. package/src/browser-electron/provider.ts +267 -19
  34. package/src/browser-electron/remote-host.ts +114 -21
  35. package/src/browser-electron/settings-store.ts +198 -0
  36. package/src/browser-electron/virtual-cursor.ts +82 -0
  37. package/src/index.ts +20 -2
  38. package/src/tool-browser/index.ts +71 -1
@@ -6,6 +6,8 @@
6
6
  * shell that owns the `BrowserWindow`.
7
7
  * @module dsh-browser/browser-electron
8
8
  */
9
+ import { type VisitedPage } from './history-store.js';
10
+ import type { BrowserSettings } from './settings-store.js';
9
11
  import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
10
12
  /** Stable provider id registered with `ctx.browser`. */
11
13
  export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
@@ -78,6 +80,13 @@ export interface ElectronBrowserViewHost {
78
80
  * @param handler - called for every user action; must not throw.
79
81
  */
80
82
  onUserAction?(handler: (action: BrowserUserAction) => void): void;
83
+ /**
84
+ * Optional: receive notice that the human closed a browser window. Closing the
85
+ * interface ends the session it showed — the next call opens a clean one —
86
+ * while browsing history and login state survive on disk.
87
+ * @param handler - called with the group (session) key; must not throw.
88
+ */
89
+ onViewClosed?(handler: (windowId: string) => void): void;
81
90
  }
82
91
  /**
83
92
  * A user-initiated browser action from the host's own UI (toolbar). The
@@ -126,11 +135,39 @@ export interface ElectronViewHandle {
126
135
  * @returns the CDP `result` object.
127
136
  */
128
137
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
138
+ /**
139
+ * Give the backing view web focus so keyboard input reaches its page.
140
+ * Optional: an adapter that cannot focus a view omits it, and the provider
141
+ * then behaves exactly as before.
142
+ */
143
+ focus?(): Promise<void>;
129
144
  }
130
145
  /** Provider config: navigation admission defaults and snapshot caps. */
131
146
  export interface ElectronBrowserProviderConfig {
132
147
  /** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
133
148
  readonly httpOnly?: boolean;
149
+ /**
150
+ * Persistent browsing history (visited pages), independent of session
151
+ * lifetime. `enabled: false` keeps the browser working but records nothing;
152
+ * the retained limits ride along so a user's choice from the settings panel
153
+ * reaches the store without a second configuration path.
154
+ */
155
+ readonly history?: {
156
+ readonly enabled?: boolean;
157
+ readonly maxEntries?: number;
158
+ readonly maxAgeDays?: number;
159
+ /**
160
+ * Explicit history file path. Default: beside the browser profile
161
+ * (`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`).
162
+ */
163
+ readonly file?: string;
164
+ };
165
+ /**
166
+ * Live settings source (the settings panel's document). When present it wins
167
+ * over the static `history` config, so a switch flipped in the UI takes effect
168
+ * without restarting DSH.
169
+ */
170
+ readonly settings?: () => BrowserSettings;
134
171
  /** Maximum snapshot elements before truncation. Default 60. */
135
172
  readonly snapshotMaxElements?: number;
136
173
  /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
@@ -151,14 +188,16 @@ export interface CdpNavigateParams {
151
188
  readonly url: string;
152
189
  }
153
190
  /**
154
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
191
+ * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
192
+ * of a click's press+release pair. `button`/`clickCount` belong to the press and
193
+ * release halves; a move carries only the position.
155
194
  */
156
195
  export interface CdpMouseParams {
157
- readonly type: 'mousePressed' | 'mouseReleased';
196
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
158
197
  readonly x: number;
159
198
  readonly y: number;
160
- readonly button: 'left';
161
- readonly clickCount: number;
199
+ readonly button?: 'left';
200
+ readonly clickCount?: number;
162
201
  }
163
202
  /** CDP method/params for `Input.insertText`. */
164
203
  export interface CdpInsertTextParams {
@@ -200,6 +239,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
200
239
  private readonly snapshotMaxElements;
201
240
  private readonly contentMaxChars;
202
241
  private readonly downloadDir;
242
+ /**
243
+ * Persistent browsing history, or `undefined` when the user turned it off.
244
+ * Sessions come and go; this record outlives all of them.
245
+ */
246
+ private readonly historyStore;
247
+ /** Live settings source; absent when nothing owns a settings document. */
248
+ private readonly settingsSource;
203
249
  constructor(host: ElectronBrowserViewHost, config?: ElectronBrowserProviderConfig);
204
250
  /**
205
251
  * Usable when the host says it can back views (the self-hosted host probes
@@ -278,6 +324,17 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
278
324
  * rather than reporting a success the page never saw.
279
325
  */
280
326
  private present;
327
+ /**
328
+ * Give the view web focus before synthesizing keyboard input.
329
+ *
330
+ * A renderer only delivers key events to the view that holds web focus. A
331
+ * view that was created but never clicked holds none — and `Input` events
332
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
333
+ * session vanished inside the renderer while the command still answered
334
+ * success. Hosts that cannot focus a view (a test double, a different shell
335
+ * adapter) simply have no `focus`, and nothing changes for them.
336
+ */
337
+ private focusView;
281
338
  /** Execute JS in the active tab's page context. */
282
339
  execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
283
340
  /**
@@ -342,6 +399,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
342
399
  * (polling until it appears or the budget runs out) and then runs `body`
343
400
  * with `el` in scope. Shared by the target-based tools: click/type (②),
344
401
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
402
+ * A selector that fails to PARSE is reported immediately instead of being
403
+ * polled until the budget expires — see the comment in `match`.
345
404
  */
346
405
  private buildTargetScript;
347
406
  /**
@@ -426,6 +485,34 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
426
485
  private record;
427
486
  /** Return the session's chronological operation log (newest last). */
428
487
  history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>;
488
+ /**
489
+ * Record one page visit in the persistent browsing history. Fire-and-forget by
490
+ * design: a visit must never delay or fail the navigation that produced it,
491
+ * and the title is only worth reading once the document has settled.
492
+ * @param s - the session that drove the visit.
493
+ * @param handle - the view whose document just loaded.
494
+ */
495
+ private recordVisit;
496
+ /**
497
+ * List persisted visits, newest first — what `browser_visited` reads back.
498
+ * @param options - result cap and an optional hostname filter.
499
+ * @returns the matching visits, or an empty list when history is disabled.
500
+ */
501
+ visited(options?: {
502
+ readonly limit?: number;
503
+ readonly domain?: string;
504
+ }): readonly VisitedPage[];
505
+ /**
506
+ * Show the synthetic pointer on a view, unless the user switched it off. The
507
+ * cursor is the "the agent has taken over this tab" signal, so it is painted
508
+ * for every operation that has a landing point — including DOM-level ones that
509
+ * move no real pointer.
510
+ * @param handle - the view to paint into.
511
+ * @param x - viewport x in CSS pixels.
512
+ * @param y - viewport y in CSS pixels.
513
+ * @param action - click pulses a ripple; move only relocates.
514
+ */
515
+ private showCursor;
429
516
  /**
430
517
  * Replay one recorded operation by sequence number. Navigate/click/type are
431
518
  * re-issued against the current page; execute re-runs its script. The
@@ -444,6 +531,20 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
444
531
  private newTab;
445
532
  /** Find a session's tab by its backing view id (toolbar actions carry view ids). */
446
533
  private tabByViewId;
534
+ /**
535
+ * Whether a session is still live. The human closing a window ends its session
536
+ * (see {@link handleViewClosed}), so callers that cache ids must ask first.
537
+ * @param session - the session id to test.
538
+ */
539
+ exists(session: BrowserSessionId): boolean;
540
+ /**
541
+ * The human closed a browser window: the session that window showed is over.
542
+ * Ending it here is what makes the next call a clean start rather than a
543
+ * resurrection of an invisible window. Browsing history and login state live
544
+ * on disk, so nothing the human cares about is lost with it.
545
+ * @param windowId - the group key the host reported, which is the session id.
546
+ */
547
+ private handleViewClosed;
447
548
  /**
448
549
  * Route a user-initiated action from the host's UI into the session model.
449
550
  * Fire-and-forget by design: a user action failing (e.g. an unreachable
@@ -10,6 +10,8 @@ import { randomUUID } from 'node:crypto';
10
10
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13
+ import { HistoryStore } from './history-store.js';
14
+ import { paintCursor } from './virtual-cursor.js';
13
15
  import { BrowserError } from '../browser/types.js';
14
16
  /**
15
17
  * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
@@ -135,6 +137,13 @@ export class ElectronBrowserProvider {
135
137
  snapshotMaxElements;
136
138
  contentMaxChars;
137
139
  downloadDir;
140
+ /**
141
+ * Persistent browsing history, or `undefined` when the user turned it off.
142
+ * Sessions come and go; this record outlives all of them.
143
+ */
144
+ historyStore;
145
+ /** Live settings source; absent when nothing owns a settings document. */
146
+ settingsSource;
138
147
  constructor(host, config = {}) {
139
148
  this.host = host;
140
149
  this.httpOnly = config.httpOnly ?? true;
@@ -146,9 +155,21 @@ export class ElectronBrowserProvider {
146
155
  // sandbox does not have to arbitrate. The localized name is probed so a
147
156
  // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
148
157
  this.downloadDir = config.downloadDir ?? defaultDownloadDir();
158
+ this.settingsSource = config.settings;
159
+ // Browsing history follows the browser profile's persistence rules (close
160
+ // the interface, lose nothing), and can be switched off from settings.
161
+ this.historyStore = config.history?.enabled === false
162
+ ? undefined
163
+ : new HistoryStore(config.history?.file, {
164
+ ...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
165
+ ...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
166
+ });
149
167
  // Route toolbar (host UI) actions into the session model: the human and
150
168
  // the agent then always drive the same tabs, history, and navigation.
151
169
  this.host.onUserAction?.(action => { void this.handleUserAction(action); });
170
+ // A window the human closed ends the session it showed: the next call opens a
171
+ // clean one instead of driving a page nobody can see.
172
+ this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId); });
152
173
  }
153
174
  /**
154
175
  * Usable when the host says it can back views (the self-hosted host probes
@@ -313,6 +334,7 @@ export class ElectronBrowserProvider {
313
334
  // NEXT observation sees, not whether this action happened).
314
335
  this.record(s, 'navigate', { url }, true);
315
336
  await this.settleDocument(handle, before, signal);
337
+ this.recordVisit(s, handle);
316
338
  this.showActive(s);
317
339
  }
318
340
  catch (error) {
@@ -419,6 +441,27 @@ export class ElectronBrowserProvider {
419
441
  throw new BrowserError(`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`, 'BROWSER_VIEW_NOT_PRESENTED', { cause: error });
420
442
  }
421
443
  }
444
+ /**
445
+ * Give the view web focus before synthesizing keyboard input.
446
+ *
447
+ * A renderer only delivers key events to the view that holds web focus. A
448
+ * view that was created but never clicked holds none — and `Input` events
449
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
450
+ * session vanished inside the renderer while the command still answered
451
+ * success. Hosts that cannot focus a view (a test double, a different shell
452
+ * adapter) simply have no `focus`, and nothing changes for them.
453
+ */
454
+ async focusView(handle) {
455
+ if (handle.focus === undefined)
456
+ return;
457
+ try {
458
+ await handle.focus();
459
+ }
460
+ catch {
461
+ // Best-effort: a view that refuses focus must not fail the caller's key
462
+ // press; the dispatch below reports a real channel failure itself.
463
+ }
464
+ }
422
465
  /** Execute JS in the active tab's page context. */
423
466
  async execute(session, request, signal) {
424
467
  const s = this.session(session);
@@ -915,24 +958,51 @@ export class ElectronBrowserProvider {
915
958
  // Input.* is only delivered to a view with a current display surface; the
916
959
  // barrier also re-presents after a navigation replaced the renderer.
917
960
  await this.present(s, signal);
918
- const press = (type) => handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
919
961
  const timeoutMs = 30_000;
962
+ const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
963
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
964
+ // hover target rather than hit-testing the coordinates, and a real pointer
965
+ // click is always preceded by movement. A view that has never received a
966
+ // mouse event has no hover target yet, so the press of the FIRST click on a
967
+ // fresh tab went nowhere while CDP still answered success; the second click
968
+ // landed because the first had quietly established the target. Move the
969
+ // pointer first so a click is self-contained.
920
970
  try {
921
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`);
971
+ await send({ type: 'mouseMoved', x, y }, 'move');
972
+ }
973
+ catch (error) {
974
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
975
+ }
976
+ // Paint before the press so the ripple is already on screen when the click
977
+ // lands — the cursor exists to show the human WHERE the agent acts.
978
+ this.showCursor(handle, x, y, 'click');
979
+ const release = () => {
980
+ void handle
981
+ .sendCommand('Input.dispatchMouseEvent', {
982
+ type: 'mouseReleased',
983
+ x,
984
+ y,
985
+ button: 'left',
986
+ clickCount: 1,
987
+ })
988
+ .catch(() => { });
989
+ };
990
+ try {
991
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
922
992
  }
923
993
  catch (error) {
924
994
  // The press may still land late; release best-effort so the button is
925
995
  // never left in a stuck pressed state.
926
- void press('mouseReleased').catch(() => { });
996
+ release();
927
997
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
928
998
  }
929
999
  try {
930
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`);
1000
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
931
1001
  }
932
1002
  catch (error) {
933
1003
  // The press already landed; retry the release so the button is not
934
1004
  // left pressed before surfacing the failure.
935
- void press('mouseReleased').catch(() => { });
1005
+ release();
936
1006
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
937
1007
  }
938
1008
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true);
@@ -952,7 +1022,11 @@ export class ElectronBrowserProvider {
952
1022
  el.focus()
953
1023
  return { ok: true }
954
1024
  `);
955
- await this.runTargetScript(this.activeTab(s), script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
1025
+ const locateTab = this.activeTab(s);
1026
+ const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
1027
+ const cursorPoint = scriptPoint(out);
1028
+ if (cursorPoint !== undefined)
1029
+ this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click');
956
1030
  }
957
1031
  const text = 'text' in request ? request.text : '';
958
1032
  const timeoutMs = 30_000;
@@ -1022,6 +1096,7 @@ export class ElectronBrowserProvider {
1022
1096
  await withTimeout(handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }), timeoutMs, signal, `browser: history navigation timed out after ${timeoutMs}ms`, () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
1023
1097
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
1024
1098
  await this.settleDocument(handle, before, signal);
1099
+ this.recordVisit(s, handle);
1025
1100
  this.showActive(s);
1026
1101
  }
1027
1102
  /** Go back in the active tab's history. */
@@ -1046,6 +1121,7 @@ export class ElectronBrowserProvider {
1046
1121
  () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
1047
1122
  this.record(s, 'reload', {}, true);
1048
1123
  await this.settleDocument(handle, before, signal);
1124
+ this.recordVisit(s, handle);
1049
1125
  this.showActive(s);
1050
1126
  }
1051
1127
  /** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
@@ -1061,6 +1137,7 @@ export class ElectronBrowserProvider {
1061
1137
  const timeoutMs = 15_000;
1062
1138
  // Input.dispatchKeyEvent is subject to the same display-surface rule.
1063
1139
  await this.present(s, signal);
1140
+ await this.focusView(handle);
1064
1141
  const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
1065
1142
  try {
1066
1143
  await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
@@ -1231,10 +1308,16 @@ export class ElectronBrowserProvider {
1231
1308
  * (polling until it appears or the budget runs out) and then runs `body`
1232
1309
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1233
1310
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1311
+ * A selector that fails to PARSE is reported immediately instead of being
1312
+ * polled until the budget expires — see the comment in `match`.
1234
1313
  */
1235
1314
  buildTargetScript(spec, timeoutMs, body) {
1315
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1316
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1317
+ // plain label was queried as a CSS selector.
1318
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' };
1236
1319
  return `(async () => {
1237
- const spec = ${JSON.stringify(spec)}
1320
+ const spec = ${JSON.stringify(resolvedSpec)}
1238
1321
  const timeoutMs = ${String(timeoutMs)}
1239
1322
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1240
1323
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1246,7 +1329,13 @@ export class ElectronBrowserProvider {
1246
1329
  const index = typeof spec.index === 'number' ? spec.index : 0
1247
1330
  let els = []
1248
1331
  if (by === 'css') {
1249
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1332
+ // A selector that does not PARSE can never match on a later poll, so
1333
+ // it must not look like a miss: the old "return null" kept the caller
1334
+ // polling until the whole budget was gone, and "element not found
1335
+ // after 10s" reads like a slow page while the real cause is a
1336
+ // malformed selector (a label or plain text passed where one belongs).
1337
+ try { els = Array.from(document.querySelectorAll(value)) }
1338
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1250
1339
  } else if (by === 'xpath') {
1251
1340
  try {
1252
1341
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1254,7 +1343,7 @@ export class ElectronBrowserProvider {
1254
1343
  const n = snap.snapshotItem(i)
1255
1344
  if (n instanceof Element) els.push(n)
1256
1345
  }
1257
- } catch { return null }
1346
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1258
1347
  } else {
1259
1348
  const all = Array.from(document.querySelectorAll('body *'))
1260
1349
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1268,12 +1357,24 @@ export class ElectronBrowserProvider {
1268
1357
  const deadline = Date.now() + timeoutMs
1269
1358
  let el = null
1270
1359
  for (;;) {
1271
- el = match()
1360
+ // Only a parse error escapes the poll: see the comment in match() above.
1361
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1272
1362
  if (el !== null) break
1273
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1363
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1274
1364
  await sleep(100)
1275
1365
  }
1366
+ const __result = await (async () => {
1276
1367
  ${body}
1368
+ })()
1369
+ // Report the operated element's viewport center next to the script's own
1370
+ // verdict: the synthetic cursor (and any future feedback) needs the point,
1371
+ // and no individual body should have to compute it a second time. Kept
1372
+ // under a distinct key so a body that already returns x/y keeps them.
1373
+ const __rect = el.getBoundingClientRect()
1374
+ return {
1375
+ ...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
1376
+ __point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
1377
+ }
1277
1378
  })()`;
1278
1379
  }
1279
1380
  /**
@@ -1282,7 +1383,12 @@ export class ElectronBrowserProvider {
1282
1383
  * evaluation failure or an in-page `{ ok: false, error }` verdict.
1283
1384
  */
1284
1385
  async runTargetScript(tab, script, timeoutMs, signal, errorCode, errorLabel) {
1285
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1386
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1387
+ // outer budget of the same size fires at the same instant and wins: the
1388
+ // caller saw `click timed out after 10000ms` while the page had already
1389
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1390
+ // get back before the generic timeout replaces it.
1391
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1286
1392
  if (!result.ok)
1287
1393
  throw new BrowserError(`${errorLabel} failed: ${result.exception}`, errorCode);
1288
1394
  const value = result.value;
@@ -1333,6 +1439,9 @@ export class ElectronBrowserProvider {
1333
1439
  `;
1334
1440
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1335
1441
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue');
1442
+ const cursorPoint = scriptPoint(out);
1443
+ if (cursorPoint !== undefined)
1444
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1336
1445
  this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method });
1337
1446
  return { method: out.method, value: out.value };
1338
1447
  }
@@ -1358,6 +1467,9 @@ export class ElectronBrowserProvider {
1358
1467
  `;
1359
1468
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1360
1469
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check');
1470
+ const cursorPoint = scriptPoint(out);
1471
+ if (cursorPoint !== undefined)
1472
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1361
1473
  this.record(s, 'check', { target: request.target, checked: want }, true);
1362
1474
  return { checked: out.checked === true };
1363
1475
  }
@@ -1389,6 +1501,9 @@ export class ElectronBrowserProvider {
1389
1501
  `;
1390
1502
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1391
1503
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select');
1504
+ const cursorPoint = scriptPoint(out);
1505
+ if (cursorPoint !== undefined)
1506
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1392
1507
  this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true);
1393
1508
  return { value: out.value, text: out.text };
1394
1509
  }
@@ -1421,7 +1536,10 @@ export class ElectronBrowserProvider {
1421
1536
  return { ok: true }
1422
1537
  `;
1423
1538
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1424
- await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
1539
+ const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
1540
+ const cursorPoint = scriptPoint(out);
1541
+ if (cursorPoint !== undefined)
1542
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1425
1543
  this.record(s, 'clear', { target: request.target }, true);
1426
1544
  return { cleared: true };
1427
1545
  }
@@ -1474,7 +1592,9 @@ export class ElectronBrowserProvider {
1474
1592
  const deadline = Date.now() + timeoutMs
1475
1593
  let items = []
1476
1594
  for (;;) {
1477
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1595
+ // Immediate, like match() above: an unparseable selector can never match.
1596
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1597
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1478
1598
  if (items.length > 0) break
1479
1599
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1480
1600
  await sleep(100)
@@ -1709,6 +1829,62 @@ export class ElectronBrowserProvider {
1709
1829
  async history(session) {
1710
1830
  return this.session(session).history;
1711
1831
  }
1832
+ /**
1833
+ * Record one page visit in the persistent browsing history. Fire-and-forget by
1834
+ * design: a visit must never delay or fail the navigation that produced it,
1835
+ * and the title is only worth reading once the document has settled.
1836
+ * @param s - the session that drove the visit.
1837
+ * @param handle - the view whose document just loaded.
1838
+ */
1839
+ recordVisit(s, handle) {
1840
+ const store = this.historyStore;
1841
+ if (store === undefined)
1842
+ return;
1843
+ // The settings panel can switch recording off at runtime; the static config
1844
+ // only supplies the startup default.
1845
+ if (this.settingsSource !== undefined && !this.settingsSource().history.enabled)
1846
+ return;
1847
+ void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
1848
+ .then(result => {
1849
+ if (!result.ok || typeof result.value !== 'string')
1850
+ return;
1851
+ const [url = '', title = ''] = result.value.split('\u0000');
1852
+ // Internal documents are not visits: an untouched browser is not history.
1853
+ if (url === '' || url.startsWith('about:') || url.startsWith('devtools:'))
1854
+ return;
1855
+ store.append({
1856
+ at: Date.now(),
1857
+ url,
1858
+ ...title.trim() !== '' ? { title: title.trim() } : {},
1859
+ ...s.label !== undefined ? { session: s.label } : {},
1860
+ });
1861
+ store.prune();
1862
+ })
1863
+ .catch(() => { });
1864
+ }
1865
+ /**
1866
+ * List persisted visits, newest first — what `browser_visited` reads back.
1867
+ * @param options - result cap and an optional hostname filter.
1868
+ * @returns the matching visits, or an empty list when history is disabled.
1869
+ */
1870
+ visited(options = {}) {
1871
+ return this.historyStore?.list(options) ?? [];
1872
+ }
1873
+ /**
1874
+ * Show the synthetic pointer on a view, unless the user switched it off. The
1875
+ * cursor is the "the agent has taken over this tab" signal, so it is painted
1876
+ * for every operation that has a landing point — including DOM-level ones that
1877
+ * move no real pointer.
1878
+ * @param handle - the view to paint into.
1879
+ * @param x - viewport x in CSS pixels.
1880
+ * @param y - viewport y in CSS pixels.
1881
+ * @param action - click pulses a ripple; move only relocates.
1882
+ */
1883
+ showCursor(handle, x, y, action) {
1884
+ if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor)
1885
+ return;
1886
+ paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression));
1887
+ }
1712
1888
  /**
1713
1889
  * Replay one recorded operation by sequence number. Navigate/click/type are
1714
1890
  * re-issued against the current page; execute re-runs its script. The
@@ -1822,6 +1998,29 @@ export class ElectronBrowserProvider {
1822
1998
  tabByViewId(s, viewId) {
1823
1999
  return s.tabs.find(tab => tab.handle.id === viewId);
1824
2000
  }
2001
+ /**
2002
+ * Whether a session is still live. The human closing a window ends its session
2003
+ * (see {@link handleViewClosed}), so callers that cache ids must ask first.
2004
+ * @param session - the session id to test.
2005
+ */
2006
+ exists(session) {
2007
+ return this.sessions.has(session);
2008
+ }
2009
+ /**
2010
+ * The human closed a browser window: the session that window showed is over.
2011
+ * Ending it here is what makes the next call a clean start rather than a
2012
+ * resurrection of an invisible window. Browsing history and login state live
2013
+ * on disk, so nothing the human cares about is lost with it.
2014
+ * @param windowId - the group key the host reported, which is the session id.
2015
+ */
2016
+ async handleViewClosed(windowId) {
2017
+ if (typeof windowId !== 'string' || windowId === '')
2018
+ return;
2019
+ const s = this.sessions.get(windowId);
2020
+ if (s === undefined)
2021
+ return;
2022
+ await this.close(s.id).catch(() => { });
2023
+ }
1825
2024
  /**
1826
2025
  * Route a user-initiated action from the host's UI into the session model.
1827
2026
  * Fire-and-forget by design: a user action failing (e.g. an unreachable
@@ -1913,6 +2112,12 @@ const SETTLE_TIMEOUT_MS = 5_000;
1913
2112
  const SETTLE_GRACE_MS = 150;
1914
2113
  /** Poll interval for the document-settle loop. */
1915
2114
  const SETTLE_POLL_MS = 25;
2115
+ /**
2116
+ * Extra time the transport gets over an in-page locate script's own budget: the
2117
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
2118
+ * must outlast it or its generic timeout hides the in-page reason.
2119
+ */
2120
+ const TARGET_SCRIPT_GRACE_MS = 2_000;
1916
2121
  /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
1917
2122
  function stripTabPrefix(id) {
1918
2123
  return id.startsWith('tab:') ? id.slice(4) : id;
@@ -2007,6 +2212,13 @@ function terminatePage(handle) {
2007
2212
  * @param expression - the JS expression.
2008
2213
  * @param signal - optional abort signal; a fired signal rejects the call.
2009
2214
  */
2215
+ /** Viewport point a target script reported, when it reported one. */
2216
+ function scriptPoint(out) {
2217
+ const point = out?.__point;
2218
+ const x = Number(point?.x);
2219
+ const y = Number(point?.y);
2220
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
2221
+ }
2010
2222
  async function handleSendEvaluate(handle, expression, signal) {
2011
2223
  signal?.throwIfAborted();
2012
2224
  const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {