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
|
@@ -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
|
|
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
|
|
161
|
-
readonly clickCount
|
|
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
|
|
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
|
-
|
|
996
|
+
release();
|
|
927
997
|
throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
|
|
928
998
|
}
|
|
929
999
|
try {
|
|
930
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|