dsh-builtin-browser 0.4.1 → 0.4.5
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/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module dsh-browser/browser-electron
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
13
|
import { HistoryStore } from './history-store.js';
|
|
@@ -225,6 +225,58 @@ function defaultDownloadDir() {
|
|
|
225
225
|
}
|
|
226
226
|
return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads');
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether a directory entry exists at `path`, judging the ENTRY rather than what it
|
|
230
|
+
* points at.
|
|
231
|
+
*
|
|
232
|
+
* `existsSync` follows links, so a DANGLING symlink — a link whose target does not
|
|
233
|
+
* exist — reads as "nothing here", and a write to that path creates the target
|
|
234
|
+
* instead: outside `downloadDir` whenever the link points out of it. `lstatSync`
|
|
235
|
+
* sees the link itself, which is the only correct answer to "is this name free".
|
|
236
|
+
* @param path - the path to judge.
|
|
237
|
+
* @returns true when any entry (file, directory, link) occupies the name.
|
|
238
|
+
*/
|
|
239
|
+
function entryExists(path) {
|
|
240
|
+
try {
|
|
241
|
+
lstatSync(path);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The real path of the deepest existing ancestor of `path` — `path` itself when it
|
|
250
|
+
* exists, otherwise the nearest directory above it that does.
|
|
251
|
+
*
|
|
252
|
+
* `resolve()` normalizes `..` but never follows a link, so nothing textual can tell
|
|
253
|
+
* that `<downloadDir>/link/x.png` leaves the directory through `link`. The first
|
|
254
|
+
* ancestor that exists (judged by lstat, so a dangling link counts — the write would
|
|
255
|
+
* go through it) is the last component whose real location still determines where
|
|
256
|
+
* the bytes land, so that is the one to resolve and check.
|
|
257
|
+
* @param path - an absolute path that may not exist yet.
|
|
258
|
+
* @returns the resolved real path of its deepest existing ancestor.
|
|
259
|
+
*/
|
|
260
|
+
function realAncestorOf(path) {
|
|
261
|
+
let current = path;
|
|
262
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
263
|
+
if (entryExists(current)) {
|
|
264
|
+
try {
|
|
265
|
+
return realpathSync(current);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// The entry vanished between the two calls, or cannot be resolved: the
|
|
269
|
+
// textual path is the best available answer, and the write reports the rest.
|
|
270
|
+
return current;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const parent = dirname(current);
|
|
274
|
+
if (parent === current)
|
|
275
|
+
return current;
|
|
276
|
+
current = parent;
|
|
277
|
+
}
|
|
278
|
+
return current;
|
|
279
|
+
}
|
|
228
280
|
/**
|
|
229
281
|
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
230
282
|
* tabs; each tab is one view created by the host. The active tab receives
|
|
@@ -251,7 +303,7 @@ export class ElectronBrowserProvider {
|
|
|
251
303
|
this.host = host;
|
|
252
304
|
this.httpOnly = config.httpOnly ?? true;
|
|
253
305
|
this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
|
|
254
|
-
this.contentMaxChars = config.contentMaxChars
|
|
306
|
+
this.contentMaxChars = config.contentMaxChars;
|
|
255
307
|
// Confine downloads AND screenshot saves to a dedicated directory by
|
|
256
308
|
// default: the OS Downloads folder is the human-visible, browser-natural
|
|
257
309
|
// place for written files, and it is the one directory the DSH file
|
|
@@ -291,7 +343,10 @@ export class ElectronBrowserProvider {
|
|
|
291
343
|
* in the window title so a human can tell which task's page is visible.
|
|
292
344
|
*/
|
|
293
345
|
open(label) {
|
|
294
|
-
|
|
346
|
+
// The label is the calling DSH task, and it is handed to the view as its owner: a host shared
|
|
347
|
+
// by several conversations (the desktop's) keeps one conversation's page out of another's on
|
|
348
|
+
// the strength of it, so it is not decoration.
|
|
349
|
+
const handle = this.host.createView(label);
|
|
295
350
|
const id = `browser:${randomUUID()}`;
|
|
296
351
|
// Group the session's views under its own window (one window per
|
|
297
352
|
// session), carrying the label for the window title. Hosts without
|
|
@@ -536,13 +591,16 @@ export class ElectronBrowserProvider {
|
|
|
536
591
|
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
537
592
|
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
538
593
|
* rather than reporting a success the page never saw.
|
|
594
|
+
* @param s - the session whose active view must be presented.
|
|
595
|
+
* @param signal - optional cancellation.
|
|
596
|
+
* @returns the view the session's active tab has AFTER the presentation barrier.
|
|
539
597
|
*/
|
|
540
598
|
async present(s, signal) {
|
|
541
599
|
const { handle } = this.activeTab(s);
|
|
542
600
|
const present = this.host.presentView?.bind(this.host);
|
|
543
601
|
if (present === undefined) {
|
|
544
602
|
this.showActive(s);
|
|
545
|
-
return;
|
|
603
|
+
return this.activeTab(s).handle;
|
|
546
604
|
}
|
|
547
605
|
signal?.throwIfAborted();
|
|
548
606
|
const timeoutMs = 10_000;
|
|
@@ -553,6 +611,42 @@ export class ElectronBrowserProvider {
|
|
|
553
611
|
catch (error) {
|
|
554
612
|
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 });
|
|
555
613
|
}
|
|
614
|
+
// Read the active tab AGAIN. `present` is a round-trip that can take up to the budget
|
|
615
|
+
// above, and the human is invited to drive the same window (a toolbar tab switch, a
|
|
616
|
+
// closed window) while the agent waits. Chromium answers `Input.*` with success even
|
|
617
|
+
// for a view that has no display surface, so dispatching to a handle that is no longer
|
|
618
|
+
// the visible one does not fail — it silently lands nowhere, or on the wrong page.
|
|
619
|
+
//
|
|
620
|
+
// Returning the re-read handle was wrong, though: `handle` above is the one that was
|
|
621
|
+
// actually presented, and if the active tab changed during the round trip then the
|
|
622
|
+
// re-read one was never presented at all. Handing it back meant the caller dispatched
|
|
623
|
+
// to a view with no display surface — the exact failure this method exists to prevent,
|
|
624
|
+
// arrived at from the other side. Either the presented view is still the active one, or
|
|
625
|
+
// the caller must be told rather than given something else to aim at.
|
|
626
|
+
const live = this.activeTab(s).handle;
|
|
627
|
+
if (live !== handle) {
|
|
628
|
+
throw new BrowserError('browser: the active tab changed while the page view was being presented, so synthesized input would go to an unshown view — retry, and the new page will be presented instead', 'BROWSER_VIEW_CHANGED');
|
|
629
|
+
}
|
|
630
|
+
return handle;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Refuse to send synthesized input to a view that is no longer the one it was located
|
|
634
|
+
* against.
|
|
635
|
+
*
|
|
636
|
+
* `locateHandle` was captured before a page-side locate that can consume its whole
|
|
637
|
+
* 10s budget; the session's active tab is re-read afterwards. If they differ, the human
|
|
638
|
+
* switched tabs mid-flight (the product's whole point is that they can take over), and
|
|
639
|
+
* the coordinates belong to one page while the dispatch would go to another. Comparing
|
|
640
|
+
* the handles turns that into a loud, retryable error instead of input the caller
|
|
641
|
+
* believes landed.
|
|
642
|
+
* @param session - the session id, for the message.
|
|
643
|
+
* @param locateHandle - the view the locate ran in.
|
|
644
|
+
* @param liveHandle - the view that is active now.
|
|
645
|
+
*/
|
|
646
|
+
assertSameView(session, locateHandle, liveHandle) {
|
|
647
|
+
if (locateHandle === liveHandle)
|
|
648
|
+
return;
|
|
649
|
+
throw new BrowserError(`browser: the session's active view changed while the element was being located (session ${session}); the operation was not dispatched — retry it`, 'BROWSER_VIEW_CHANGED');
|
|
556
650
|
}
|
|
557
651
|
/**
|
|
558
652
|
* Give the view web focus before synthesizing keyboard input.
|
|
@@ -581,6 +675,7 @@ export class ElectronBrowserProvider {
|
|
|
581
675
|
}
|
|
582
676
|
/** Execute JS in the active tab's page context. */
|
|
583
677
|
async execute(session, request, signal) {
|
|
678
|
+
this.assertActionAllowed('execute');
|
|
584
679
|
const s = this.session(session);
|
|
585
680
|
const { handle } = this.activeTab(s);
|
|
586
681
|
signal?.throwIfAborted();
|
|
@@ -804,7 +899,9 @@ export class ElectronBrowserProvider {
|
|
|
804
899
|
const tab = this.activeTab(s);
|
|
805
900
|
signal?.throwIfAborted();
|
|
806
901
|
const includeHidden = request.includeHidden === true;
|
|
807
|
-
|
|
902
|
+
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
903
|
+
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
904
|
+
const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 150)));
|
|
808
905
|
const script = `(() => {
|
|
809
906
|
const includeHidden = ${String(includeHidden)}
|
|
810
907
|
const maxNodes = ${String(maxNodes)}
|
|
@@ -946,6 +1043,11 @@ export class ElectronBrowserProvider {
|
|
|
946
1043
|
value: valueOf(el),
|
|
947
1044
|
states: statesOf(el),
|
|
948
1045
|
depth: baseDepth + depthOf(el),
|
|
1046
|
+
// Same derivation as the snapshot collector: an id or a name is what makes a node
|
|
1047
|
+
// something the caller can point at without guessing at its text. The tool-layer
|
|
1048
|
+
// schema declared this field before this line existed, so the schema was promising
|
|
1049
|
+
// a value the tool never sent.
|
|
1050
|
+
selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
949
1051
|
tag: el.tagName.toLowerCase(),
|
|
950
1052
|
x: Math.round(a.x + a.w / 2),
|
|
951
1053
|
y: Math.round(a.y + a.h / 2),
|
|
@@ -981,9 +1083,18 @@ export class ElectronBrowserProvider {
|
|
|
981
1083
|
async content(session, request, signal) {
|
|
982
1084
|
const tab = this.activeTab(this.session(session));
|
|
983
1085
|
signal?.throwIfAborted();
|
|
984
|
-
const maxChars = request.maxChars ?? this.contentMaxChars;
|
|
985
1086
|
const selector = request.selector ?? '';
|
|
986
1087
|
const format = request.format;
|
|
1088
|
+
// Per-format caps. A full HTML document is enormous, but the same limit applied to
|
|
1089
|
+
// plain text is no limit at all (a whole page of text measured 25,882 characters).
|
|
1090
|
+
// This bounds the common case instead of letting one call spend 25k-33k tokens.
|
|
1091
|
+
//
|
|
1092
|
+
// The plugin config overrides the lot when it is set — see `contentMaxChars`. The caller's
|
|
1093
|
+
// own `maxChars` still outranks both: an explicit per-call bound is the most specific
|
|
1094
|
+
// statement of intent there is.
|
|
1095
|
+
const maxChars = request.maxChars
|
|
1096
|
+
?? this.contentMaxChars
|
|
1097
|
+
?? (format === 'html' || format === 'json' ? 50_000 : 20_000);
|
|
987
1098
|
const script = `(() => {
|
|
988
1099
|
const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
|
|
989
1100
|
if (!root) return { ok: false, reason: 'selector not found' }
|
|
@@ -1027,7 +1138,12 @@ export class ElectronBrowserProvider {
|
|
|
1027
1138
|
}
|
|
1028
1139
|
}
|
|
1029
1140
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
1030
|
-
|
|
1141
|
+
// A DOM element has no own enumerable properties — everything lives on the prototype
|
|
1142
|
+
// chain — so JSON.stringify(root) is always "{}". That is not a structured view of
|
|
1143
|
+
// the page, it is an empty object the caller will read as "no data here" and then
|
|
1144
|
+
// retry with other selectors. Serialise the markup instead, which is what a caller
|
|
1145
|
+
// asking for JSON of an element can actually use.
|
|
1146
|
+
else if (fmt === 'json') content = JSON.stringify({ html: root.outerHTML, tag: root.tagName.toLowerCase() })
|
|
1031
1147
|
else {
|
|
1032
1148
|
// markdown: headings, paragraphs, links, lists (best-effort)
|
|
1033
1149
|
const parts = []
|
|
@@ -1073,7 +1189,9 @@ export class ElectronBrowserProvider {
|
|
|
1073
1189
|
*/
|
|
1074
1190
|
async click(session, request, signal) {
|
|
1075
1191
|
const s = this.session(session);
|
|
1076
|
-
|
|
1192
|
+
// The view the locate runs against. It can stop being the session's active view while
|
|
1193
|
+
// the locate waits (see prepareTarget), so every dispatch below re-reads the live one.
|
|
1194
|
+
const locateHandle = this.activeTab(s).handle;
|
|
1077
1195
|
signal?.throwIfAborted();
|
|
1078
1196
|
let x = 0;
|
|
1079
1197
|
let y = 0;
|
|
@@ -1105,8 +1223,14 @@ export class ElectronBrowserProvider {
|
|
|
1105
1223
|
y = request.y;
|
|
1106
1224
|
}
|
|
1107
1225
|
// Input.* is only delivered to a view with a current display surface; the
|
|
1108
|
-
// barrier also re-presents after a navigation replaced the renderer.
|
|
1109
|
-
|
|
1226
|
+
// barrier also re-presents after a navigation replaced the renderer. It returns the
|
|
1227
|
+
// handle that may still be receiving input when it settles.
|
|
1228
|
+
const handle = await this.present(s, signal);
|
|
1229
|
+
// The locate (up to 10s) and the present barrier both ran against the view captured
|
|
1230
|
+
// earlier; if the human switched tabs meanwhile, these coordinates belong to one page
|
|
1231
|
+
// and the dispatch would go to another — and Chromium answers success either way.
|
|
1232
|
+
if ('target' in request)
|
|
1233
|
+
this.assertSameView(session, locateHandle, handle);
|
|
1110
1234
|
const timeoutMs = 30_000;
|
|
1111
1235
|
const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
|
|
1112
1236
|
// Chromium routes a synthesized `mousePressed` to the widget's *current*
|
|
@@ -1128,6 +1252,10 @@ export class ElectronBrowserProvider {
|
|
|
1128
1252
|
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1129
1253
|
this.showCursor(handle, x, y, 'click', 'click', true);
|
|
1130
1254
|
const release = () => {
|
|
1255
|
+
// Back to the handle the PRESS may have landed on, deliberately not to the currently
|
|
1256
|
+
// active tab: this only runs after a failed press/release, and re-targeting a
|
|
1257
|
+
// compensating half of a click to a view the human just switched to would press a
|
|
1258
|
+
// button there. Best-effort either way.
|
|
1131
1259
|
void handle
|
|
1132
1260
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
1133
1261
|
type: 'mouseReleased',
|
|
@@ -1164,7 +1292,10 @@ export class ElectronBrowserProvider {
|
|
|
1164
1292
|
*/
|
|
1165
1293
|
async type(session, request, signal) {
|
|
1166
1294
|
const s = this.session(session);
|
|
1167
|
-
|
|
1295
|
+
// The view the locate runs against. It can stop being the session's active view while
|
|
1296
|
+
// the locate waits (the human is invited to drive the same window), so the input below
|
|
1297
|
+
// goes to the handle `present` reports instead of this one.
|
|
1298
|
+
const locateHandle = this.activeTab(s).handle;
|
|
1168
1299
|
signal?.throwIfAborted();
|
|
1169
1300
|
const hasTarget = 'target' in request;
|
|
1170
1301
|
if (hasTarget) {
|
|
@@ -1182,8 +1313,14 @@ export class ElectronBrowserProvider {
|
|
|
1182
1313
|
const text = 'text' in request ? request.text : '';
|
|
1183
1314
|
const timeoutMs = 30_000;
|
|
1184
1315
|
// Input.insertText goes through the Input domain: same display-surface
|
|
1185
|
-
// requirement as click/key.
|
|
1186
|
-
|
|
1316
|
+
// requirement as click/key. It also reports which view is active once the barrier has
|
|
1317
|
+
// settled, which is what makes the tab-switch check below meaningful.
|
|
1318
|
+
const handle = await this.present(s, signal);
|
|
1319
|
+
// Focusing one page and inserting into another is exactly the silent mis-target this
|
|
1320
|
+
// refuses: the element was focused in the located view, and the text would have gone to
|
|
1321
|
+
// whichever page is now visible — possibly a password field there.
|
|
1322
|
+
if (hasTarget)
|
|
1323
|
+
this.assertSameView(session, locateHandle, handle);
|
|
1187
1324
|
try {
|
|
1188
1325
|
await withTimeout(handle.sendCommand('Input.insertText', { text }), timeoutMs, signal, `browser: type timed out after ${timeoutMs}ms`);
|
|
1189
1326
|
}
|
|
@@ -1278,7 +1415,6 @@ export class ElectronBrowserProvider {
|
|
|
1278
1415
|
/** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
|
|
1279
1416
|
async key(session, request, signal) {
|
|
1280
1417
|
const s = this.session(session);
|
|
1281
|
-
const { handle } = this.activeTab(s);
|
|
1282
1418
|
signal?.throwIfAborted();
|
|
1283
1419
|
const spec = KEY_SPECS[request.key];
|
|
1284
1420
|
if (spec === undefined) {
|
|
@@ -1286,8 +1422,13 @@ export class ElectronBrowserProvider {
|
|
|
1286
1422
|
}
|
|
1287
1423
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} };
|
|
1288
1424
|
const timeoutMs = 15_000;
|
|
1289
|
-
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1290
|
-
|
|
1425
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule. The handle comes
|
|
1426
|
+
// back from the presentation barrier rather than from a capture taken before it: this
|
|
1427
|
+
// session is shared with the human, who may switch tabs while the barrier round-trips,
|
|
1428
|
+
// and a key sent to the view they left is silently dropped (or lands on the new page).
|
|
1429
|
+
const handle = await this.present(s, signal);
|
|
1430
|
+
// Focus the SAME view the key is dispatched to; focusing the pre-switch handle would
|
|
1431
|
+
// grant web focus to a page that then receives nothing.
|
|
1291
1432
|
await this.focusView(handle);
|
|
1292
1433
|
const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
|
|
1293
1434
|
try {
|
|
@@ -1325,7 +1466,13 @@ export class ElectronBrowserProvider {
|
|
|
1325
1466
|
name: f.name ?? null,
|
|
1326
1467
|
label: f.label ?? null,
|
|
1327
1468
|
placeholder: f.placeholder ?? null,
|
|
1328
|
-
kind
|
|
1469
|
+
// A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
|
|
1470
|
+
// it to 'text' here made `if (spec.kind)` above always true, so every unspecified
|
|
1471
|
+
// field was narrowed to an <input> — browser_fill on a <textarea> reported
|
|
1472
|
+
// `no "text" control matched`, and a contenteditable could not match any kind at all.
|
|
1473
|
+
// The value stays a string or null (never undefined): the in-page check tests truthiness,
|
|
1474
|
+
// and JSON drops an undefined member entirely, which is the same thing here.
|
|
1475
|
+
kind: f.kind ?? null,
|
|
1329
1476
|
value: f.value,
|
|
1330
1477
|
})));
|
|
1331
1478
|
const submitFlag = request.submit === true;
|
|
@@ -1358,10 +1505,34 @@ export class ElectronBrowserProvider {
|
|
|
1358
1505
|
}
|
|
1359
1506
|
return false
|
|
1360
1507
|
}
|
|
1508
|
+
// Every root this page exposes: the top document plus its shadow trees (recursively)
|
|
1509
|
+
// and same-origin iframes. The snapshot and waitFor already walk all of them, so an
|
|
1510
|
+
// element inside one is shown to the model and can be waited for — resolving a click or
|
|
1511
|
+
// a fill used to look at the top document alone and answer "not found" for something
|
|
1512
|
+
// the model had just been handed a reference to. The two must agree about what exists.
|
|
1513
|
+
// (Kept in step with the collector in the snapshot script above.)
|
|
1514
|
+
const allRoots = () => {
|
|
1515
|
+
const roots = []
|
|
1516
|
+
const seen = new Set()
|
|
1517
|
+
const visit = doc => {
|
|
1518
|
+
if (seen.has(doc)) return
|
|
1519
|
+
seen.add(doc)
|
|
1520
|
+
roots.push(doc)
|
|
1521
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
1522
|
+
if (el.shadowRoot) visit(el.shadowRoot)
|
|
1523
|
+
if (el.tagName === 'IFRAME') {
|
|
1524
|
+
try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
visit(document)
|
|
1529
|
+
return roots
|
|
1530
|
+
}
|
|
1531
|
+
const roots = allRoots()
|
|
1361
1532
|
const candidates = (spec) => {
|
|
1362
1533
|
const all = spec.selector
|
|
1363
|
-
? [...
|
|
1364
|
-
: [...
|
|
1534
|
+
? roots.flatMap(root => [...root.querySelectorAll(spec.selector)])
|
|
1535
|
+
: roots.flatMap(root => [...root.querySelectorAll('input, textarea, select, [contenteditable="true"]')]).filter(el => matches(el, spec))
|
|
1365
1536
|
const vis = all.filter(visible)
|
|
1366
1537
|
return vis.length > 0 ? vis : all
|
|
1367
1538
|
}
|
|
@@ -1379,7 +1550,9 @@ export class ElectronBrowserProvider {
|
|
|
1379
1550
|
let el = els[0]
|
|
1380
1551
|
// The caller may name a kind. Honor it: the branches below read the element's own
|
|
1381
1552
|
// tag/type, so without this a selector matching two different controls acted on
|
|
1382
|
-
// whichever came first — usually right, which is what hid the mistake.
|
|
1553
|
+
// whichever came first — usually right, which is what hid the mistake. A field that
|
|
1554
|
+
// names NO kind gets no filter at all: the element's own tag then decides the
|
|
1555
|
+
// branch, which is what "kind defaults to text" was always trying to say.
|
|
1383
1556
|
if (spec.kind) {
|
|
1384
1557
|
const matchesKind = (node) => {
|
|
1385
1558
|
const nodeTag = node.tagName
|
|
@@ -1387,6 +1560,12 @@ export class ElectronBrowserProvider {
|
|
|
1387
1560
|
if (spec.kind === 'select') return nodeTag === 'SELECT'
|
|
1388
1561
|
if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
|
|
1389
1562
|
if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
|
|
1563
|
+
// 'text': the plain-value branch covers <input> AND <textarea>, and the batch
|
|
1564
|
+
// already collects [contenteditable="true"] as a settable control — so all three
|
|
1565
|
+
// must match. Requiring nodeTag === 'INPUT' here reported "no text control
|
|
1566
|
+
// matched" for a textarea and left the contenteditable branch below unreachable.
|
|
1567
|
+
if (nodeTag === 'TEXTAREA') return true
|
|
1568
|
+
if (node.isContentEditable === true) return true
|
|
1390
1569
|
return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
|
|
1391
1570
|
}
|
|
1392
1571
|
const wanted = els.find(matchesKind)
|
|
@@ -1501,33 +1680,78 @@ export class ElectronBrowserProvider {
|
|
|
1501
1680
|
const match = () => {
|
|
1502
1681
|
const by = typeof spec.by === 'string' ? spec.by : 'css'
|
|
1503
1682
|
const value = String(spec.value ?? '')
|
|
1504
|
-
|
|
1683
|
+
// Named matchIndex rather than index: this whole function is one host-side template
|
|
1684
|
+
// literal, so a bare name declared here can silently shadow a host binding of the
|
|
1685
|
+
// same name and turn an inner reference into a type error.
|
|
1686
|
+
const matchIndex = typeof spec.index === 'number' ? spec.index : 0
|
|
1687
|
+
// An index that cannot address an element can never match on a later poll, exactly
|
|
1688
|
+
// like a selector that fails to parse — so it must not look like a miss either. A
|
|
1689
|
+
// negative index, the common "last one" shorthand, otherwise burned the caller's
|
|
1690
|
+
// whole locate budget (10s) and then reported "element not found", which reads like a
|
|
1691
|
+
// slow page rather than a bad argument.
|
|
1692
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
1693
|
+
if (!Number.isInteger(matchIndex) || matchIndex < 0) {
|
|
1694
|
+
throw new Error('target.index must be a non-negative integer, got ' + JSON.stringify(spec.index))
|
|
1695
|
+
}
|
|
1505
1696
|
let els = []
|
|
1697
|
+
// Every root this page exposes: the top document plus its shadow trees (recursively)
|
|
1698
|
+
// and same-origin iframes. The snapshot and waitFor already walk all of them, so an
|
|
1699
|
+
// element inside one is shown to the model and can be waited for — resolving a click or
|
|
1700
|
+
// a fill used to look at the top document alone and answer "not found" for something
|
|
1701
|
+
// the model had just been handed a reference to. The two must agree about what exists.
|
|
1702
|
+
// (Kept in step with the collector in the snapshot script above.)
|
|
1703
|
+
const allRoots = () => {
|
|
1704
|
+
const roots = []
|
|
1705
|
+
const seen = new Set()
|
|
1706
|
+
const visit = doc => {
|
|
1707
|
+
if (seen.has(doc)) return
|
|
1708
|
+
seen.add(doc)
|
|
1709
|
+
roots.push(doc)
|
|
1710
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
1711
|
+
if (el.shadowRoot) visit(el.shadowRoot)
|
|
1712
|
+
if (el.tagName === 'IFRAME') {
|
|
1713
|
+
try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
visit(document)
|
|
1718
|
+
return roots
|
|
1719
|
+
}
|
|
1720
|
+
const roots = allRoots()
|
|
1506
1721
|
if (by === 'css') {
|
|
1507
1722
|
// A selector that does not PARSE can never match on a later poll, so
|
|
1508
1723
|
// it must not look like a miss: the old "return null" kept the caller
|
|
1509
1724
|
// polling until the whole budget was gone, and "element not found
|
|
1510
1725
|
// after 10s" reads like a slow page while the real cause is a
|
|
1511
1726
|
// malformed selector (a label or plain text passed where one belongs).
|
|
1512
|
-
|
|
1727
|
+
// Parse on the top document first: a selector that does not PARSE must still be
|
|
1728
|
+
// reported as a bad argument rather than as a miss, and that check is about syntax,
|
|
1729
|
+
// not about which root holds the element.
|
|
1730
|
+
try { document.querySelectorAll(value) }
|
|
1513
1731
|
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1732
|
+
for (const root of roots) els.push(...root.querySelectorAll(value))
|
|
1514
1733
|
} else if (by === 'xpath') {
|
|
1515
1734
|
try {
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1735
|
+
for (const root of roots) {
|
|
1736
|
+
const snap = document.evaluate(value, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
1737
|
+
for (let i = 0; i < snap.snapshotLength; i++) {
|
|
1738
|
+
const n = snap.snapshotItem(i)
|
|
1739
|
+
if (n instanceof Element) els.push(n)
|
|
1740
|
+
}
|
|
1520
1741
|
}
|
|
1521
1742
|
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1522
1743
|
} else {
|
|
1523
|
-
|
|
1744
|
+
// '*' rather than 'body *': a shadow root and an iframe document have no body of
|
|
1745
|
+
// their own to anchor to, and the visibility and own-text predicates below are what
|
|
1746
|
+
// actually decide whether an element counts.
|
|
1747
|
+
const all = roots.flatMap(root => [...root.querySelectorAll('*')])
|
|
1524
1748
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
1525
1749
|
// Exact first, then contains; deepest element preferred (so a
|
|
1526
1750
|
// button inside a card wins over the card itself).
|
|
1527
1751
|
const pool = exact.length > 0 ? exact : all.filter(el => isVisible(el) && ownText(el).includes(value))
|
|
1528
1752
|
els = pool.sort((a, b) => depth(b) - depth(a))
|
|
1529
1753
|
}
|
|
1530
|
-
return els[
|
|
1754
|
+
return els[matchIndex] ?? null
|
|
1531
1755
|
}
|
|
1532
1756
|
const deadline = Date.now() + timeoutMs
|
|
1533
1757
|
let el = null
|
|
@@ -1803,6 +2027,35 @@ export class ElectronBrowserProvider {
|
|
|
1803
2027
|
this.record(s, 'scrape', { item: request.item, fields: request.fields.map(f => f.name) }, true, { result: `${items.length} items` });
|
|
1804
2028
|
return { count: items.length, items };
|
|
1805
2029
|
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Refuse an action the settings have switched off.
|
|
2032
|
+
*
|
|
2033
|
+
* These switches belong to the OPERATOR. The settings document is written through the
|
|
2034
|
+
* plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
|
|
2035
|
+
* model owns and can lift at will — a refusal here stands for as long as the setting does.
|
|
2036
|
+
* That is the point of them: a deployment can take page-script execution, downloads, or
|
|
2037
|
+
* login-state writes off the table without relying on the model's cooperation.
|
|
2038
|
+
*
|
|
2039
|
+
* Read through `settingsSource` on every call rather than captured at construction, so
|
|
2040
|
+
* flipping a switch applies to the next command instead of the next restart — the same
|
|
2041
|
+
* rule the credentials gate follows.
|
|
2042
|
+
* @param action - which switch to consult.
|
|
2043
|
+
* @throws BrowserError when that switch is off.
|
|
2044
|
+
*/
|
|
2045
|
+
assertActionAllowed(action) {
|
|
2046
|
+
const settings = this.settingsSource?.();
|
|
2047
|
+
if (settings === undefined)
|
|
2048
|
+
return;
|
|
2049
|
+
if (action === 'execute' && !settings.actions.allowExecute) {
|
|
2050
|
+
throw new BrowserError('browser: running page scripts is switched off in settings (Browser → actions)', 'BROWSER_EXECUTE_DISABLED');
|
|
2051
|
+
}
|
|
2052
|
+
if (action === 'download' && !settings.actions.allowDownload) {
|
|
2053
|
+
throw new BrowserError('browser: downloads are switched off in settings (Browser → actions)', 'BROWSER_DOWNLOAD_DISABLED');
|
|
2054
|
+
}
|
|
2055
|
+
if (action === 'credentialWrite' && !settings.actions.allowCredentialWrite) {
|
|
2056
|
+
throw new BrowserError('browser: writing cookies is switched off in settings (Browser → actions)', 'BROWSER_AUTH_WRITE_DISABLED');
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
1806
2059
|
/**
|
|
1807
2060
|
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
1808
2061
|
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
@@ -1823,19 +2076,56 @@ export class ElectronBrowserProvider {
|
|
|
1823
2076
|
const file = resolve(savePath);
|
|
1824
2077
|
if (this.downloadDir !== undefined) {
|
|
1825
2078
|
const dir = resolve(this.downloadDir);
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2079
|
+
// Windows paths are case-insensitive and resolve() does not normalize case, so there
|
|
2080
|
+
// the containment check folds case — otherwise C:\Users\X\Downloads and
|
|
2081
|
+
// c:\users\x\downloads would read as different roots. Everywhere else the fold is
|
|
2082
|
+
// REMOVED: a case-sensitive filesystem (Linux) treats /home/u/DOWNLOADS and
|
|
2083
|
+
// /home/u/Downloads as different directories, so folding case there let a savePath
|
|
2084
|
+
// that merely differed in case pass admission — a directory-confinement escape, and
|
|
2085
|
+
// one that mkdirSync below would then happily create.
|
|
2086
|
+
const fold = process.platform === 'win32' ? (value) => value.toLowerCase() : (value) => value;
|
|
2087
|
+
const dirKey = fold(dir);
|
|
2088
|
+
const fileKey = fold(file);
|
|
2089
|
+
// The directory ITSELF is refused, not accepted: a save path that IS downloadDir names
|
|
2090
|
+
// a directory, so there is no file to write — and mkdirSync would have created the
|
|
2091
|
+
// directory when it did not exist. Two paths are "inside" only when the file path is
|
|
2092
|
+
// strictly below the directory, which the separator check below already expresses.
|
|
2093
|
+
if (!fileKey.startsWith(dirKey + fold(sep))) {
|
|
1832
2094
|
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code);
|
|
1833
2095
|
}
|
|
2096
|
+
// The check above is textual, and a symlink defeats it: resolve() normalizes `..`
|
|
2097
|
+
// but never follows a link, so `<downloadDir>/out/x.png` passed every test above
|
|
2098
|
+
// while `out` pointed at a directory outside the gate — and the write then landed
|
|
2099
|
+
// there. Resolve the REAL location of the deepest existing ancestor (which is what
|
|
2100
|
+
// decides where the bytes go) and require it to be inside the real directory too.
|
|
2101
|
+
// A legitimate path is unaffected: its ancestor resolves to the directory itself.
|
|
2102
|
+
//
|
|
2103
|
+
// The directory is created first so that it can be resolved at all — mkdirSync is a
|
|
2104
|
+
// no-op for one that already exists — and a directory that cannot be created or
|
|
2105
|
+
// resolved falls back to its textual form, leaving the real filesystem error to the
|
|
2106
|
+
// write instead of inventing one here.
|
|
2107
|
+
let realDir;
|
|
2108
|
+
try {
|
|
2109
|
+
mkdirSync(dir, { recursive: true });
|
|
2110
|
+
realDir = fold(realpathSync(dir));
|
|
2111
|
+
}
|
|
2112
|
+
catch {
|
|
2113
|
+
realDir = fold(dir);
|
|
2114
|
+
}
|
|
2115
|
+
const realAncestor = fold(realAncestorOf(file));
|
|
2116
|
+
if (realAncestor !== realDir && !realAncestor.startsWith(realDir + fold(sep))) {
|
|
2117
|
+
throw new BrowserError(`browser: ${kind} savePath resolves outside downloadDir "${dir}" (a symlink leaves it)`, code);
|
|
2118
|
+
}
|
|
1834
2119
|
}
|
|
1835
2120
|
// Never replace an existing file: its previous content is unrecoverable,
|
|
1836
2121
|
// and the admitted directory may hold files the human put there. A new
|
|
1837
2122
|
// name is one tool call away.
|
|
1838
|
-
|
|
2123
|
+
//
|
|
2124
|
+
// Judged with lstat (see {@link entryExists}), not existsSync: existsSync FOLLOWS
|
|
2125
|
+
// links, so a DANGLING symlink read as "nothing here" and the write below created
|
|
2126
|
+
// its target — outside the directory whenever the link pointed out of it. It is the
|
|
2127
|
+
// directory entry that has to be free, whatever that entry points at.
|
|
2128
|
+
if (entryExists(file)) {
|
|
1839
2129
|
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code);
|
|
1840
2130
|
}
|
|
1841
2131
|
return file;
|
|
@@ -1853,6 +2143,9 @@ export class ElectronBrowserProvider {
|
|
|
1853
2143
|
* @returns the path the file was written to.
|
|
1854
2144
|
*/
|
|
1855
2145
|
async download(session, request, signal) {
|
|
2146
|
+
// Before admission, and before the session is even resolved: a switched-off action is
|
|
2147
|
+
// not a path problem, and the caller should hear the real reason.
|
|
2148
|
+
this.assertActionAllowed('download');
|
|
1856
2149
|
const s = this.session(session);
|
|
1857
2150
|
const { handle } = this.activeTab(s);
|
|
1858
2151
|
signal?.throwIfAborted();
|
|
@@ -1912,19 +2205,37 @@ export class ElectronBrowserProvider {
|
|
|
1912
2205
|
// Every other carrier drives a real browser over CDP, and cookies are part of CDP —
|
|
1913
2206
|
// the sidebar goes through the shell's webContents.debugger, an installed Chrome or
|
|
1914
2207
|
// Edge is the browser itself. So this works everywhere rather than only self-hosted.
|
|
1915
|
-
|
|
2208
|
+
// Scoped to the page this session is on. The jar itself is SHARED on purpose — every
|
|
2209
|
+
// window and every task drives the same browser, so signing in once works everywhere,
|
|
2210
|
+
// which is the whole point of "use your own browser". What must not be shared is the
|
|
2211
|
+
// HANDING OVER: an unfiltered read puts every domain every task has visited, plus
|
|
2212
|
+
// whatever the human is signed into, in front of one task's caller. Least exposure,
|
|
2213
|
+
// not isolation.
|
|
2214
|
+
//
|
|
2215
|
+
// Two corrections to the previous attempt. It called Storage.getCookies with a `urls`
|
|
2216
|
+
// parameter, but that method takes only browserContextId and ignores everything else, so
|
|
2217
|
+
// the filter did nothing and the whole jar was still exported; `urls` belongs to
|
|
2218
|
+
// Network.getCookies. And it silently fell back to an unfiltered read when the page URL
|
|
2219
|
+
// was unavailable — an error path that asked for MORE than the normal one. Both are
|
|
2220
|
+
// fixed: the method that honours the filter, and a failure that refuses instead of
|
|
2221
|
+
// widening.
|
|
2222
|
+
const cookiesUrl = await this.currentUrl(handle).catch(() => '');
|
|
2223
|
+
if (cookiesUrl === '') {
|
|
2224
|
+
throw new BrowserError('browser: cannot export cookies without knowing which page this session is on — the URL was unavailable, and reading the whole cookie jar would expose every other session\'s logins', 'BROWSER_AUTH_SCOPE_UNKNOWN');
|
|
2225
|
+
}
|
|
2226
|
+
const result = await withTimeout(handle.sendCommand('Network.getCookies', { urls: [cookiesUrl] }), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
|
|
1916
2227
|
const cookies = toExportedCookies(result.cookies);
|
|
1917
2228
|
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` });
|
|
1918
2229
|
return cookies;
|
|
1919
2230
|
}
|
|
1920
2231
|
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
1921
2232
|
async restoreAuth(session, cookies) {
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
// the
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2233
|
+
// Restore WRITES cookies, so it answers to the write switch — not to
|
|
2234
|
+
// `credentials.allowRead`. It used to consult the read switch and report "reading
|
|
2235
|
+
// cookies is switched off", which is both the wrong gate and the wrong sentence: an
|
|
2236
|
+
// operator who allowed reading but wanted logins left alone had no switch at all,
|
|
2237
|
+
// and one who turned reading off lost the restore path for the wrong reason.
|
|
2238
|
+
this.assertActionAllowed('credentialWrite');
|
|
1928
2239
|
const s = this.session(session);
|
|
1929
2240
|
const { handle } = this.activeTab(s);
|
|
1930
2241
|
const timeoutMs = 30_000;
|
|
@@ -1937,9 +2248,23 @@ export class ElectronBrowserProvider {
|
|
|
1937
2248
|
// CDP again, for the same reason as the export: any carrier that drives a real
|
|
1938
2249
|
// browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
|
|
1939
2250
|
// the CDP way, including the sameSite spelling and seconds rather than milliseconds.
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2251
|
+
//
|
|
2252
|
+
// toCdpCookie returns undefined for a cookie it cannot place (no usable domain), and
|
|
2253
|
+
// `cookies.map(...)` passed that undefined straight through: the array then held a
|
|
2254
|
+
// JSON `null`, CDP rejected the WHOLE call, and every valid cookie failed with an error
|
|
2255
|
+
// naming none of them. Filter the unusable ones out and say how many went.
|
|
2256
|
+
const mapped = cookies.map(toCdpCookie);
|
|
2257
|
+
const convertible = mapped.filter((cookie) => cookie !== undefined);
|
|
2258
|
+
const dropped = mapped.length - convertible.length;
|
|
2259
|
+
if (dropped > 0) {
|
|
2260
|
+
process.stderr.write(`[dsh-browser] restoreAuth: dropped ${dropped} of ${mapped.length} cookie(s) with no usable domain; the rest were still restored\n`);
|
|
2261
|
+
}
|
|
2262
|
+
const result = await withTimeout(handle.sendCommand('Storage.setCookies', { cookies: convertible }), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
|
|
2263
|
+
// Storage.setCookies returns nothing, so the count comes from what was actually
|
|
2264
|
+
// sent. Reading result.cookies here was always undefined, which rendered as
|
|
2265
|
+
// "Restored 0 cookies." while the write had succeeded.
|
|
2266
|
+
const restored = convertible.length;
|
|
2267
|
+
this.record(s, 'restoreAuth', { count: cookies.length, dropped }, true, { result: `${restored} cookies (CDP)${dropped > 0 ? `; ${dropped} dropped` : ''}` });
|
|
1943
2268
|
return restored;
|
|
1944
2269
|
}
|
|
1945
2270
|
/** Capture the current page, optionally full-page, PNG or JPEG, scalable. */
|
|
@@ -1967,7 +2292,13 @@ export class ElectronBrowserProvider {
|
|
|
1967
2292
|
if (shot.base64 === '') {
|
|
1968
2293
|
throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED');
|
|
1969
2294
|
}
|
|
1970
|
-
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png'
|
|
2295
|
+
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png',
|
|
2296
|
+
// The child measured these after applying any downscale, and they describe the image
|
|
2297
|
+
// the caller receives. Without them this path — the default self-hosted one — could
|
|
2298
|
+
// never answer the width/height its own tool schema declares.
|
|
2299
|
+
shot.width !== undefined && shot.height !== undefined
|
|
2300
|
+
? { width: shot.width, height: shot.height }
|
|
2301
|
+
: undefined);
|
|
1971
2302
|
}
|
|
1972
2303
|
// Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
|
|
1973
2304
|
// or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
|
|
@@ -1994,6 +2325,7 @@ export class ElectronBrowserProvider {
|
|
|
1994
2325
|
// to the region it names, so it has to cover the whole document. The page's own size
|
|
1995
2326
|
// is read first; when that is unavailable the capture proceeds unscaled rather than
|
|
1996
2327
|
// failing — an image the caller can shrink itself beats no image at all.
|
|
2328
|
+
let rendered;
|
|
1997
2329
|
if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
|
|
1998
2330
|
const metrics = await withTimeout(handle.sendCommand('Page.getLayoutMetrics', {}), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
|
|
1999
2331
|
const size = layoutSize(metrics);
|
|
@@ -2002,6 +2334,9 @@ export class ElectronBrowserProvider {
|
|
|
2002
2334
|
const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1;
|
|
2003
2335
|
const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1;
|
|
2004
2336
|
const scale = Math.min(1, byWidth, byHeight);
|
|
2337
|
+
// What the caller will actually receive. Recorded before the branch below, because the
|
|
2338
|
+
// size is worth reporting even when no scaling happened (scale 1).
|
|
2339
|
+
rendered = { width: Math.round(size.width * scale), height: Math.round(height * scale) };
|
|
2005
2340
|
if (scale < 1) {
|
|
2006
2341
|
params.clip = { x: 0, y: 0, width: size.width, height, scale };
|
|
2007
2342
|
// The clip defines the captured area; captureBeyondViewport must not also apply.
|
|
@@ -2014,7 +2349,12 @@ export class ElectronBrowserProvider {
|
|
|
2014
2349
|
if (typeof data !== 'string') {
|
|
2015
2350
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
|
|
2016
2351
|
}
|
|
2017
|
-
|
|
2352
|
+
// The mime must name the format that was ACTUALLY requested (params.format), not PNG
|
|
2353
|
+
// unconditionally: a carrier that sets supportsCdpJpeg asks for `format: 'jpeg'`, so
|
|
2354
|
+
// hardcoding the PNG mime announced a JPEG body as a PNG — the data URL then carried a
|
|
2355
|
+
// mislabelled image, and a caller that decodes by mime wrote the wrong file.
|
|
2356
|
+
const mime = params.format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
2357
|
+
return this.saveScreenshot(data, request?.savePath, mime, rendered);
|
|
2018
2358
|
}
|
|
2019
2359
|
/**
|
|
2020
2360
|
* Build the data URL and optionally write the image to disk. The caller's
|
|
@@ -2023,7 +2363,7 @@ export class ElectronBrowserProvider {
|
|
|
2023
2363
|
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
2024
2364
|
* files anywhere the DSH process happens to have permission (issue #13).
|
|
2025
2365
|
*/
|
|
2026
|
-
saveScreenshot(base64, savePath, mime) {
|
|
2366
|
+
saveScreenshot(base64, savePath, mime, rendered) {
|
|
2027
2367
|
if (savePath !== undefined) {
|
|
2028
2368
|
// Admission failures propagate unchanged: they describe the caller's
|
|
2029
2369
|
// path, not a disk problem.
|
|
@@ -2031,14 +2371,14 @@ export class ElectronBrowserProvider {
|
|
|
2031
2371
|
try {
|
|
2032
2372
|
mkdirSync(dirname(target), { recursive: true });
|
|
2033
2373
|
writeFileSync(target, Buffer.from(base64, 'base64'));
|
|
2034
|
-
return { dataUrl: `data:${mime};base64,${base64}`, path: target };
|
|
2374
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target, ...rendered };
|
|
2035
2375
|
}
|
|
2036
2376
|
catch (error) {
|
|
2037
2377
|
// Report the write problem but keep the capture usable.
|
|
2038
2378
|
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
|
|
2039
2379
|
}
|
|
2040
2380
|
}
|
|
2041
|
-
return { dataUrl: `data:${mime};base64,${base64}
|
|
2381
|
+
return { dataUrl: `data:${mime};base64,${base64}`, ...rendered };
|
|
2042
2382
|
}
|
|
2043
2383
|
/** Append one operation to the session's history. */
|
|
2044
2384
|
record(s, action, params, ok, detail) {
|
|
@@ -2072,9 +2412,15 @@ export class ElectronBrowserProvider {
|
|
|
2072
2412
|
if (store === undefined)
|
|
2073
2413
|
return;
|
|
2074
2414
|
// The settings panel can switch recording off at runtime; the static config
|
|
2075
|
-
// only supplies the startup default.
|
|
2076
|
-
|
|
2077
|
-
|
|
2415
|
+
// only supplies the startup default. The RETENTION limits are read here too: they were
|
|
2416
|
+
// parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
|
|
2417
|
+
// the file changed nothing — the switch worked and the numbers beside it did not.
|
|
2418
|
+
if (this.settingsSource !== undefined) {
|
|
2419
|
+
const live = this.settingsSource().history;
|
|
2420
|
+
if (!live.enabled)
|
|
2421
|
+
return;
|
|
2422
|
+
store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays });
|
|
2423
|
+
}
|
|
2078
2424
|
void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
|
|
2079
2425
|
.then(result => {
|
|
2080
2426
|
if (!result.ok || typeof result.value !== 'string')
|
|
@@ -2206,7 +2552,9 @@ export class ElectronBrowserProvider {
|
|
|
2206
2552
|
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2207
2553
|
// — and its cookies plus the browsing history outlive the page either way.
|
|
2208
2554
|
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2209
|
-
|
|
2555
|
+
// Named, because one host serves every conversation: an unnamed release would end the
|
|
2556
|
+
// pages of conversations that are still running.
|
|
2557
|
+
void this.host.releasePage?.(existing.label).catch(() => undefined);
|
|
2210
2558
|
}
|
|
2211
2559
|
}
|
|
2212
2560
|
return Promise.resolve();
|
|
@@ -2228,7 +2576,7 @@ export class ElectronBrowserProvider {
|
|
|
2228
2576
|
}
|
|
2229
2577
|
/** Append a fresh tab and make it active. */
|
|
2230
2578
|
newTab(s) {
|
|
2231
|
-
const handle = this.host.createView();
|
|
2579
|
+
const handle = this.host.createView(s.label);
|
|
2232
2580
|
// Same window as the session's other tabs.
|
|
2233
2581
|
this.host.groupView?.(handle, s.id, s.label);
|
|
2234
2582
|
s.tabs.push({ id: `tab:${randomUUID()}`, handle });
|
|
@@ -2333,11 +2681,10 @@ export class ElectronBrowserProvider {
|
|
|
2333
2681
|
const message = `action ${action.type} failed: ${String(error)}`;
|
|
2334
2682
|
let reported = false;
|
|
2335
2683
|
try {
|
|
2336
|
-
const
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
notify.call(host, action.windowId, message);
|
|
2684
|
+
const notify = this.host.notifyUserActionError?.bind(this.host);
|
|
2685
|
+
if (notify !== undefined) {
|
|
2686
|
+
// Bound at the call: an unbound call here kills the host process (issue #16).
|
|
2687
|
+
notify(action.windowId, message);
|
|
2341
2688
|
reported = true;
|
|
2342
2689
|
}
|
|
2343
2690
|
}
|