dsh-builtin-browser 0.1.21 → 0.1.23
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 +22 -10
- package/README.md +22 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser/runtime.js +9 -1
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +157 -15
- package/lib/browser-electron/provider.d.ts +107 -12
- package/lib/browser-electron/provider.js +340 -50
- package/lib/browser-electron/remote-host.d.ts +46 -6
- package/lib/browser-electron/remote-host.js +147 -33
- package/lib/tool-browser/index.js +2 -2
- package/package.json +8 -6
- package/src/browser/runtime.ts +15 -1
- package/src/browser-electron/host-main.ts +163 -9
- package/src/browser-electron/provider.ts +385 -55
- package/src/browser-electron/remote-host.ts +149 -31
- package/src/tool-browser/index.ts +2 -2
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
14
14
|
* { id, op: 'groupView', viewId, windowId, label? } |
|
|
15
15
|
* { id, op: 'command', viewId, method, params } |
|
|
16
|
+
* { id, op: 'focus', viewId } |
|
|
16
17
|
* { id, op: 'userActionError', windowId, message }
|
|
17
18
|
* -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
|
|
18
19
|
* parent's stdin token; the parent refuses the connection otherwise)
|
|
@@ -32,19 +33,19 @@ import { dirname, join } from 'node:path';
|
|
|
32
33
|
* The spawn token the parent sent via stdin (first line). Must be echoed in
|
|
33
34
|
* our hello. Read from stdin so the token never appears in argv (WMI /
|
|
34
35
|
* Process Explorer / /proc/*).
|
|
35
|
-
|
|
36
|
+
*/
|
|
36
37
|
// Isolate this host's profile from the DSH app's default Electron userData:
|
|
37
38
|
// several Electron instances sharing Roaming\Electron fight over the GPU
|
|
38
39
|
// cache/session locks, which can leave the window without a display surface
|
|
39
40
|
// (capturePage then fails). A dedicated userData also persists cookies across
|
|
40
41
|
// host restarts (on top of browser_auth). Must run before app is ready.
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
43
|
+
const base = process.env.DSH_HOME ?? app.getPath('appData');
|
|
44
|
+
app.setPath('userData', join(base, 'dsh-builtin-browser-host'));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
process.stderr.write(`[dsh-browser host] userData setup failed: ${String(error)}\n`);
|
|
46
48
|
}
|
|
47
|
-
|
|
48
49
|
/**
|
|
49
50
|
* Read the spawn token. The parent sends it two ways and the child prefers
|
|
50
51
|
* the first that yields a value:
|
|
@@ -141,7 +142,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
|
|
|
141
142
|
<div class="strip" id="strip"></div>
|
|
142
143
|
<div id="err"></div>
|
|
143
144
|
<script>
|
|
144
|
-
|
|
145
|
+
// The body runs inside an IIFE on purpose. The preload above exposes
|
|
146
|
+
// window.bridge via contextBridge.exposeInMainWorld(), which installs a
|
|
147
|
+
// NON-CONFIGURABLE global property. A top-level const/let named bridge here
|
|
148
|
+
// would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
|
|
149
|
+
// — an early error, so the whole script is discarded: nothing gets bound, the
|
|
150
|
+
// address bar and nav buttons go silently dead, and the user sees no error.
|
|
151
|
+
// Function scope sidesteps that check (the tb name below stops the collision
|
|
152
|
+
// from returning by habit) and keeps these bindings off the page global.
|
|
153
|
+
// Keep this block free of backticks — it lives inside a TS template literal.
|
|
154
|
+
(() => {
|
|
155
|
+
const tb = window.bridge
|
|
145
156
|
const addr = document.getElementById('addr')
|
|
146
157
|
const strip = document.getElementById('strip')
|
|
147
158
|
const errBox = document.getElementById('err')
|
|
@@ -152,7 +163,7 @@ function showErr(text) {
|
|
|
152
163
|
clearTimeout(showErrT)
|
|
153
164
|
showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
|
|
154
165
|
}
|
|
155
|
-
function post(action, payload) {
|
|
166
|
+
function post(action, payload) { tb.post(action, payload || {}) }
|
|
156
167
|
document.getElementById('back').onclick = () => post('back')
|
|
157
168
|
document.getElementById('fwd').onclick = () => post('forward')
|
|
158
169
|
document.getElementById('reload').onclick = () => post('reload')
|
|
@@ -167,7 +178,7 @@ addr.addEventListener('keydown', e => {
|
|
|
167
178
|
if (v !== '') { post('navigate', { url: v }); addr.blur() }
|
|
168
179
|
}
|
|
169
180
|
})
|
|
170
|
-
|
|
181
|
+
tb.onTabs(payload => {
|
|
171
182
|
const tabs = (payload && payload.tabs) || []
|
|
172
183
|
strip.textContent = ''
|
|
173
184
|
for (const t of tabs) {
|
|
@@ -189,7 +200,8 @@ bridge.onTabs(payload => {
|
|
|
189
200
|
const act = tabs.find(t => t.active)
|
|
190
201
|
if (act && document.activeElement !== addr) addr.value = act.url || ''
|
|
191
202
|
})
|
|
192
|
-
|
|
203
|
+
tb.onError(text => showErr(text))
|
|
204
|
+
})()
|
|
193
205
|
</script>
|
|
194
206
|
</body>
|
|
195
207
|
</html>
|
|
@@ -206,6 +218,49 @@ function ensureToolbarFiles() {
|
|
|
206
218
|
writeFileSync(join(TOOLBAR_DIR, 'preload.js'), TOOLBAR_PRELOAD);
|
|
207
219
|
writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML);
|
|
208
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Bound an await with a timeout. Used so a wedged renderer surfaces as an
|
|
223
|
+
* explicit error instead of a command that never settles.
|
|
224
|
+
*/
|
|
225
|
+
function withTimeout(promise, ms, label) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
229
|
+
}, ms);
|
|
230
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* CDP commands are bounded here as well as in the parent: a view whose
|
|
235
|
+
* renderer stopped responding must produce a clear error, not a hang that
|
|
236
|
+
* only the tool layer's 10–30s timeout eventually cuts off.
|
|
237
|
+
*/
|
|
238
|
+
const COMMAND_TIMEOUT_MS = 20000;
|
|
239
|
+
/**
|
|
240
|
+
* How long to let a `focus` request land before answering it. Focus moves
|
|
241
|
+
* asynchronously and a key dispatched in the same turn as the request is still
|
|
242
|
+
* dropped, so the caller needs this settle — but only on the call where focus
|
|
243
|
+
* actually had to move.
|
|
244
|
+
*/
|
|
245
|
+
const FOCUS_SETTLE_MS = 80;
|
|
246
|
+
/**
|
|
247
|
+
* A brand-new WebContentsView has no renderer process until something loads,
|
|
248
|
+
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
249
|
+
* settle. That is what made a host restart unrecoverable: the provider
|
|
250
|
+
* rebuilds the session with a fresh blank view whose very first action is a
|
|
251
|
+
* location.href read, which hung until the tool timeout.
|
|
252
|
+
*
|
|
253
|
+
* Loading about:blank spawns the renderer, so the view answers the moment it
|
|
254
|
+
* exists; a real navigation replaces it right after. Bounded + best-effort —
|
|
255
|
+
* the caller still gets its reply if this fails.
|
|
256
|
+
*/
|
|
257
|
+
const RENDERER_PRIME_TIMEOUT_MS = 3000;
|
|
258
|
+
async function primeRenderer(view) {
|
|
259
|
+
try {
|
|
260
|
+
await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime');
|
|
261
|
+
}
|
|
262
|
+
catch { /* best effort: the first real navigation creates the renderer */ }
|
|
263
|
+
}
|
|
209
264
|
/** Reply to the parent over the RPC socket. */
|
|
210
265
|
function reply(id, payload) {
|
|
211
266
|
if (rpcSocket === undefined) {
|
|
@@ -434,7 +489,19 @@ function showViewInWindow(win, viewId, label) {
|
|
|
434
489
|
const entry = win.views.get(viewId);
|
|
435
490
|
if (entry === undefined)
|
|
436
491
|
return;
|
|
437
|
-
|
|
492
|
+
// A navigation replaced this view's renderer, and the new one has no
|
|
493
|
+
// display surface yet. Chromium drops CDP synthesized input (Input.*) for a
|
|
494
|
+
// view without a surface, which silently swallowed the FIRST click/keystroke
|
|
495
|
+
// after every navigation — and because the view was already the visible one,
|
|
496
|
+
// the dance below used to be skipped entirely. Force it once.
|
|
497
|
+
const mustRepresent = entry.needsRepresent;
|
|
498
|
+
if (mustRepresent) {
|
|
499
|
+
try {
|
|
500
|
+
entry.webContentsView.setVisible(false);
|
|
501
|
+
}
|
|
502
|
+
catch { /* destroyed */ }
|
|
503
|
+
}
|
|
504
|
+
if (win.visibleViewId !== viewId || mustRepresent) {
|
|
438
505
|
// Hide every other view in THIS window, then show and RAISE the target
|
|
439
506
|
// (topmost child wins). When the target is already visible, skip the
|
|
440
507
|
// remove/re-add dance — doing it on every operation flickered.
|
|
@@ -453,6 +520,7 @@ function showViewInWindow(win, viewId, label) {
|
|
|
453
520
|
}
|
|
454
521
|
catch { /* window closing */ }
|
|
455
522
|
win.visibleViewId = viewId;
|
|
523
|
+
entry.needsRepresent = false;
|
|
456
524
|
syncToolbar(win);
|
|
457
525
|
}
|
|
458
526
|
// Always refresh the title: it reflects the CURRENT page of the visible
|
|
@@ -551,11 +619,19 @@ async function handle(op, msg) {
|
|
|
551
619
|
// New views start hidden: only the shown one may be visible.
|
|
552
620
|
view.setVisible(false);
|
|
553
621
|
win.window.contentView.addChildView(view);
|
|
554
|
-
win.views.set(viewId, { webContentsView: view });
|
|
622
|
+
win.views.set(viewId, { webContentsView: view, needsRepresent: false });
|
|
555
623
|
// Keep the window title and the toolbar tab strip live as the page
|
|
556
624
|
// changes (navigations, title updates).
|
|
557
625
|
view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win); });
|
|
558
|
-
view.webContents.on('did-navigate', () => {
|
|
626
|
+
view.webContents.on('did-navigate', () => {
|
|
627
|
+
// The renderer process was just replaced: mark the view so the next
|
|
628
|
+
// show forces a fresh display surface (see showViewInWindow).
|
|
629
|
+
const current = win.views.get(viewId);
|
|
630
|
+
if (current !== undefined)
|
|
631
|
+
current.needsRepresent = true;
|
|
632
|
+
updateWindowTitle(win, viewId, undefined);
|
|
633
|
+
syncToolbar(win);
|
|
634
|
+
});
|
|
559
635
|
view.webContents.on('did-navigate-in-page', () => syncToolbar(win));
|
|
560
636
|
wireFocusRouting(win, view, viewId);
|
|
561
637
|
const first = win.views.size === 1;
|
|
@@ -567,6 +643,10 @@ async function handle(op, msg) {
|
|
|
567
643
|
if (first)
|
|
568
644
|
updateWindowTitle(win, viewId, undefined);
|
|
569
645
|
syncToolbar(win);
|
|
646
|
+
// Prime the renderer BEFORE replying: the parent's next command
|
|
647
|
+
// (a location.href read) would otherwise hang on a view that has no
|
|
648
|
+
// renderer yet — the root of "host restarted and never recovers".
|
|
649
|
+
await primeRenderer(view);
|
|
570
650
|
reply(msg.id, { ok: true });
|
|
571
651
|
return;
|
|
572
652
|
}
|
|
@@ -649,10 +729,33 @@ async function handle(op, msg) {
|
|
|
649
729
|
const method = msg.method;
|
|
650
730
|
if (typeof method !== 'string')
|
|
651
731
|
throw new Error('command missing method');
|
|
652
|
-
const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
|
|
732
|
+
const result = await withTimeout(entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}), COMMAND_TIMEOUT_MS, `CDP ${method}`);
|
|
653
733
|
reply(msg.id, { ok: true, result });
|
|
654
734
|
return;
|
|
655
735
|
}
|
|
736
|
+
case 'focus': {
|
|
737
|
+
const viewId = msg.viewId;
|
|
738
|
+
if (viewId === undefined)
|
|
739
|
+
throw new Error('focus missing viewId');
|
|
740
|
+
const win = windowOfView(viewId);
|
|
741
|
+
const entry = win?.views.get(viewId);
|
|
742
|
+
if (win === undefined || entry === undefined)
|
|
743
|
+
throw new Error(`focus: unknown view ${viewId}`);
|
|
744
|
+
// Keys go to the view that holds web focus. A view that was never
|
|
745
|
+
// clicked holds none, so the FIRST browser_key of a session was dropped
|
|
746
|
+
// by the renderer while CDP still answered the dispatch with success —
|
|
747
|
+
// and `document.hasFocus()` inside the page even read true.
|
|
748
|
+
const contents = entry.webContentsView.webContents;
|
|
749
|
+
const hadWebFocus = contents.isFocused();
|
|
750
|
+
contents.focus();
|
|
751
|
+
// Focus lands asynchronously, and a key dispatched in the same turn as
|
|
752
|
+
// the request is still dropped, so hold the reply until the renderer can
|
|
753
|
+
// take input. Only on the call where focus actually had to move.
|
|
754
|
+
if (!hadWebFocus)
|
|
755
|
+
await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS));
|
|
756
|
+
reply(msg.id, { ok: true, focused: contents.isFocused() });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
656
759
|
case 'capture': {
|
|
657
760
|
const viewId = msg.viewId;
|
|
658
761
|
if (viewId === undefined)
|
|
@@ -925,6 +1028,35 @@ async function handle(op, msg) {
|
|
|
925
1028
|
reply(msg.id, { ok: false, err: String(error) });
|
|
926
1029
|
}
|
|
927
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
|
|
1033
|
+
* window is covered by another window and, when it is, moves the WebContents to
|
|
1034
|
+
* `Visibility::HIDDEN`. That is the wrong model for this host: the shared
|
|
1035
|
+
* browser window normally sits *behind* whatever the human is working in, and
|
|
1036
|
+
* that is exactly the state the agent drives it from. A hidden WebContents
|
|
1037
|
+
* produces no frames at all (`requestAnimationFrame` never fires) and
|
|
1038
|
+
* `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
|
|
1039
|
+
* `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
|
|
1040
|
+
* floor while the CDP call itself still answers `{}` — clicks and keys that
|
|
1041
|
+
* silently do nothing, with no error anywhere. Keep the window's visibility
|
|
1042
|
+
* state application-controlled instead; the parent already shows/raises views
|
|
1043
|
+
* explicitly, and `webContentsView.setVisible()` remains the single source of
|
|
1044
|
+
* truth for whether a view renders.
|
|
1045
|
+
*/
|
|
1046
|
+
function keepWindowsNonOccluding() {
|
|
1047
|
+
if (process.platform !== 'win32')
|
|
1048
|
+
return;
|
|
1049
|
+
const DISABLED = 'CalculateNativeWinOcclusion';
|
|
1050
|
+
const current = app.commandLine.getSwitchValue('disable-features') ?? '';
|
|
1051
|
+
const features = current
|
|
1052
|
+
.split(',')
|
|
1053
|
+
.map(part => part.trim())
|
|
1054
|
+
.filter(part => part !== '');
|
|
1055
|
+
if (!features.includes(DISABLED))
|
|
1056
|
+
features.push(DISABLED);
|
|
1057
|
+
app.commandLine.appendSwitch('disable-features', features.join(','));
|
|
1058
|
+
}
|
|
1059
|
+
keepWindowsNonOccluding();
|
|
928
1060
|
/**
|
|
929
1061
|
* Electron entry: connect back to the parent's RPC server (port from
|
|
930
1062
|
* `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
|
|
@@ -965,6 +1097,13 @@ void app.whenReady().then(() => {
|
|
|
965
1097
|
}
|
|
966
1098
|
}
|
|
967
1099
|
const rl = createInterface({ input: socket });
|
|
1100
|
+
// Handle messages STRICTLY in order. Ops depend on each other completing:
|
|
1101
|
+
// a `command` for a view must not overtake the `createView` that makes it,
|
|
1102
|
+
// and the parent's present-barrier (`showView` then `ping`) is only
|
|
1103
|
+
// meaningful if the child cannot answer the ping mid-show. The previous
|
|
1104
|
+
// fire-and-forget loop let all of them race, which is how a fresh tab's
|
|
1105
|
+
// first click could be dispatched before its view was ever on screen.
|
|
1106
|
+
let queue = Promise.resolve();
|
|
968
1107
|
rl.on('line', line => {
|
|
969
1108
|
const text = line.trim();
|
|
970
1109
|
if (text === '')
|
|
@@ -978,7 +1117,10 @@ void app.whenReady().then(() => {
|
|
|
978
1117
|
}
|
|
979
1118
|
if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
|
|
980
1119
|
return;
|
|
981
|
-
|
|
1120
|
+
// Bind the narrowed op now: the check above does not survive into the
|
|
1121
|
+
// deferred callback (msg is a mutable binding).
|
|
1122
|
+
const op = msg.op;
|
|
1123
|
+
queue = queue.then(() => handle(op, msg)).catch(() => { });
|
|
982
1124
|
});
|
|
983
1125
|
socket.on('error', error => {
|
|
984
1126
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
|
|
@@ -37,6 +37,20 @@ export interface ElectronBrowserViewHost {
|
|
|
37
37
|
* tell which task's page is currently visible.
|
|
38
38
|
*/
|
|
39
39
|
showView?(handle: ElectronViewHandle, label?: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Optional: (re)present a view and WAIT until the host confirms it. Chromium
|
|
42
|
+
* silently drops CDP-synthesized input (`Input.*`) for a view that has no
|
|
43
|
+
* display surface, which made clicks/typing report success while the page
|
|
44
|
+
* received nothing (and made the first input after a navigation vanish,
|
|
45
|
+
* because the replaced renderer has no surface yet). Unlike
|
|
46
|
+
* {@link showView}, this round-trips an RPC barrier that is ordered AFTER
|
|
47
|
+
* the show on the same socket, so its reply means the view is on screen.
|
|
48
|
+
* Rejects when the view cannot be presented; a host without this method is
|
|
49
|
+
* assumed to present every view (headless/probe hosts).
|
|
50
|
+
* @param handle - the handle to present.
|
|
51
|
+
* @param label - human-readable session/task label, as in {@link showView}.
|
|
52
|
+
*/
|
|
53
|
+
presentView?(handle: ElectronViewHandle, label?: string): Promise<void>;
|
|
40
54
|
/**
|
|
41
55
|
* Optional cheap usability probe (no network): whether the host can back
|
|
42
56
|
* views at all right now. The self-hosted host checks for a usable Electron
|
|
@@ -112,6 +126,12 @@ export interface ElectronViewHandle {
|
|
|
112
126
|
* @returns the CDP `result` object.
|
|
113
127
|
*/
|
|
114
128
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
129
|
+
/**
|
|
130
|
+
* Give the backing view web focus so keyboard input reaches its page.
|
|
131
|
+
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
132
|
+
* then behaves exactly as before.
|
|
133
|
+
*/
|
|
134
|
+
focus?(): Promise<void>;
|
|
115
135
|
}
|
|
116
136
|
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
117
137
|
export interface ElectronBrowserProviderConfig {
|
|
@@ -137,14 +157,16 @@ export interface CdpNavigateParams {
|
|
|
137
157
|
readonly url: string;
|
|
138
158
|
}
|
|
139
159
|
/**
|
|
140
|
-
* CDP method/params for `Input.dispatchMouseEvent
|
|
160
|
+
* CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
|
|
161
|
+
* of a click's press+release pair. `button`/`clickCount` belong to the press and
|
|
162
|
+
* release halves; a move carries only the position.
|
|
141
163
|
*/
|
|
142
164
|
export interface CdpMouseParams {
|
|
143
|
-
readonly type: 'mousePressed' | 'mouseReleased';
|
|
165
|
+
readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
|
|
144
166
|
readonly x: number;
|
|
145
167
|
readonly y: number;
|
|
146
|
-
readonly button
|
|
147
|
-
readonly clickCount
|
|
168
|
+
readonly button?: 'left';
|
|
169
|
+
readonly clickCount?: number;
|
|
148
170
|
}
|
|
149
171
|
/** CDP method/params for `Input.insertText`. */
|
|
150
172
|
export interface CdpInsertTextParams {
|
|
@@ -226,6 +248,55 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
226
248
|
navigate(session: BrowserSessionId, request: {
|
|
227
249
|
readonly url: string;
|
|
228
250
|
}, signal?: AbortSignal): Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* A per-document stamp: `performance.timeOrigin` is unique per document load,
|
|
253
|
+
* so it tells a same-URL reload and an A→B→A redirect apart from the document
|
|
254
|
+
* that was current before the navigation. Empty string when unreadable.
|
|
255
|
+
*/
|
|
256
|
+
private documentStamp;
|
|
257
|
+
/**
|
|
258
|
+
* One cheap in-page reading of the document's identity and parse state.
|
|
259
|
+
*
|
|
260
|
+
* Returns null when the page did NOT answer (mid-commit, execution context
|
|
261
|
+
* destroyed) — the caller keeps waiting. A page that answered with an
|
|
262
|
+
* unexpected shape (an override or a non-conforming host; the production
|
|
263
|
+
* expression always yields a string) is reported as `unknown` rather than as
|
|
264
|
+
* "no answer": blocking the navigation for the whole budget on a page that
|
|
265
|
+
* demonstrably responded has no upside.
|
|
266
|
+
*/
|
|
267
|
+
private documentProbe;
|
|
268
|
+
/**
|
|
269
|
+
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
270
|
+
* is already current (so location.href and document.title are the new page's)
|
|
271
|
+
* while its DOM is still being parsed. browser_open snapshots immediately
|
|
272
|
+
* after navigating, which is why it could report the right title with zero
|
|
273
|
+
* interactive elements — and why a separate browser_snapshot right after
|
|
274
|
+
* always found them.
|
|
275
|
+
*
|
|
276
|
+
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
277
|
+
* must reach interactive/complete AND the document identity must have
|
|
278
|
+
* changed. A page that never settles must not fail a navigation that already
|
|
279
|
+
* succeeded, so a timeout here is swallowed.
|
|
280
|
+
*/
|
|
281
|
+
private settleDocument;
|
|
282
|
+
/**
|
|
283
|
+
* Make sure the active tab's view can actually receive synthesized input.
|
|
284
|
+
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
285
|
+
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
286
|
+
* rather than reporting a success the page never saw.
|
|
287
|
+
*/
|
|
288
|
+
private present;
|
|
289
|
+
/**
|
|
290
|
+
* Give the view web focus before synthesizing keyboard input.
|
|
291
|
+
*
|
|
292
|
+
* A renderer only delivers key events to the view that holds web focus. A
|
|
293
|
+
* view that was created but never clicked holds none — and `Input` events
|
|
294
|
+
* injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
|
|
295
|
+
* session vanished inside the renderer while the command still answered
|
|
296
|
+
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
297
|
+
* adapter) simply have no `focus`, and nothing changes for them.
|
|
298
|
+
*/
|
|
299
|
+
private focusView;
|
|
229
300
|
/** Execute JS in the active tab's page context. */
|
|
230
301
|
execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
|
|
231
302
|
/**
|
|
@@ -290,6 +361,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
290
361
|
* (polling until it appears or the budget runs out) and then runs `body`
|
|
291
362
|
* with `el` in scope. Shared by the target-based tools: click/type (②),
|
|
292
363
|
* setValue/check/select/clear/getValue (③), and the scrape item wait.
|
|
364
|
+
* A selector that fails to PARSE is reported immediately instead of being
|
|
365
|
+
* polled until the budget expires — see the comment in `match`.
|
|
293
366
|
*/
|
|
294
367
|
private buildTargetScript;
|
|
295
368
|
/**
|
|
@@ -320,13 +393,29 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
320
393
|
*/
|
|
321
394
|
scrape(session: BrowserSessionId, request: BrowserScrapeRequest, signal?: AbortSignal): Promise<BrowserScrapeResult>;
|
|
322
395
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
396
|
+
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
397
|
+
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
398
|
+
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
399
|
+
* exist. Without it a prompt-injected agent could write — or silently
|
|
400
|
+
* replace — any file the DSH process can reach, which also escapes the file
|
|
401
|
+
* sandbox every other tool in the set runs under.
|
|
402
|
+
* @param savePath - the caller's target path.
|
|
403
|
+
* @param kind - the operation, used in the error code and message.
|
|
404
|
+
* @returns the resolved absolute target path.
|
|
405
|
+
* @throws BrowserError when the path is relative, outside the directory, or occupied.
|
|
406
|
+
*/
|
|
407
|
+
private admitSavePath;
|
|
408
|
+
/**
|
|
409
|
+
* Download an HTTP(S) URL with the session's cookies to a local file.
|
|
410
|
+
* Admission is the shared {@link admitSavePath} gate; only the self-hosted
|
|
411
|
+
* host implements it (the desktop shell's embedded views delegate downloads
|
|
412
|
+
* to the real browser UI). Both admitted paths and screenshots are confined
|
|
413
|
+
* to `downloadDir`, so a prompt-injected agent cannot write arbitrary
|
|
414
|
+
* machine paths.
|
|
415
|
+
* @param session - the session whose cookies are used.
|
|
416
|
+
* @param request - the URL plus the absolute target path.
|
|
417
|
+
* @param signal - optional cancellation.
|
|
418
|
+
* @returns the path the file was written to.
|
|
330
419
|
*/
|
|
331
420
|
download(session: BrowserSessionId, request: {
|
|
332
421
|
readonly url: string;
|
|
@@ -346,7 +435,13 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
346
435
|
readonly dataUrl: string;
|
|
347
436
|
readonly path?: string;
|
|
348
437
|
}>;
|
|
349
|
-
/**
|
|
438
|
+
/**
|
|
439
|
+
* Build the data URL and optionally write the image to disk. The caller's
|
|
440
|
+
* path goes through the SAME {@link admitSavePath} gate as a download — it
|
|
441
|
+
* must be absolute, resolve inside `downloadDir`, and not be an existing
|
|
442
|
+
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
443
|
+
* files anywhere the DSH process happens to have permission (issue #13).
|
|
444
|
+
*/
|
|
350
445
|
private saveScreenshot;
|
|
351
446
|
/** Append one operation to the session's history. */
|
|
352
447
|
private record;
|