dsh-builtin-browser 0.4.0 → 0.4.2
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 +68 -30
- package/README.md +66 -30
- package/client.js +60 -6
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/plugin-browser-bridge.js +12 -4
- package/docs/architecture.md +12 -2
- package/docs/tool-reference.md +17 -17
- package/docs/user-guide.md +13 -4
- package/docs/why-browser.md +2 -2
- package/lib/browser/types.d.ts +25 -1
- package/lib/browser-electron/bridge-connection.d.ts +31 -5
- package/lib/browser-electron/bridge-connection.js +143 -21
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +61 -35
- 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 +25 -0
- package/lib/browser-electron/history-store.js +51 -4
- package/lib/browser-electron/host-main.js +75 -27
- package/lib/browser-electron/provider.d.ts +44 -1
- package/lib/browser-electron/provider.js +379 -48
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +244 -23
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +28 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +107 -11
- package/lib/browser-electron/system-browser.d.ts +71 -3
- package/lib/browser-electron/system-browser.js +355 -38
- package/lib/tool-browser/index.js +143 -34
- package/package.json +5 -3
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +25 -1
- package/src/browser-electron/bridge-connection.ts +142 -21
- package/src/browser-electron/entry.ts +58 -33
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +51 -4
- package/src/browser-electron/host-main.ts +105 -35
- package/src/browser-electron/provider.ts +401 -51
- package/src/browser-electron/remote-host.ts +261 -36
- package/src/browser-electron/settings-route.ts +46 -0
- package/src/browser-electron/settings-store.ts +121 -11
- package/src/browser-electron/system-browser.ts +342 -21
- package/src/tool-browser/index.ts +144 -36
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto'
|
|
11
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { homedir } from 'node:os'
|
|
13
13
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
14
|
import { HistoryStore, type VisitedPage } from './history-store.js'
|
|
@@ -306,7 +306,16 @@ export interface ElectronBrowserProviderConfig {
|
|
|
306
306
|
readonly settings?: () => BrowserSettings
|
|
307
307
|
/** Maximum snapshot elements before truncation. Default 60. */
|
|
308
308
|
readonly snapshotMaxElements?: number
|
|
309
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* Explicit content cap, overriding the per-format default when set.
|
|
311
|
+
*
|
|
312
|
+
* Honoured again, and it is worth saying why that is a fix rather than a feature: this was
|
|
313
|
+
* `config.contentMaxChars ?? 100_000`, one number for every format, and when the caps became
|
|
314
|
+
* per-format (html and json 50 000, otherwise 20 000) the field was left assigned and never
|
|
315
|
+
* read. A configuration knob that silently does nothing is worse than no knob — it is an
|
|
316
|
+
* operator's decision that quietly went nowhere. Leaving it unset means the per-format
|
|
317
|
+
* default; setting it means the operator meant it.
|
|
318
|
+
*/
|
|
310
319
|
readonly contentMaxChars?: number
|
|
311
320
|
/**
|
|
312
321
|
* Directory `browser_download` save paths must resolve inside (prevents a
|
|
@@ -524,6 +533,57 @@ function defaultDownloadDir(): string {
|
|
|
524
533
|
return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
|
|
525
534
|
}
|
|
526
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Whether a directory entry exists at `path`, judging the ENTRY rather than what it
|
|
538
|
+
* points at.
|
|
539
|
+
*
|
|
540
|
+
* `existsSync` follows links, so a DANGLING symlink — a link whose target does not
|
|
541
|
+
* exist — reads as "nothing here", and a write to that path creates the target
|
|
542
|
+
* instead: outside `downloadDir` whenever the link points out of it. `lstatSync`
|
|
543
|
+
* sees the link itself, which is the only correct answer to "is this name free".
|
|
544
|
+
* @param path - the path to judge.
|
|
545
|
+
* @returns true when any entry (file, directory, link) occupies the name.
|
|
546
|
+
*/
|
|
547
|
+
function entryExists(path: string): boolean {
|
|
548
|
+
try {
|
|
549
|
+
lstatSync(path)
|
|
550
|
+
return true
|
|
551
|
+
} catch {
|
|
552
|
+
return false
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The real path of the deepest existing ancestor of `path` — `path` itself when it
|
|
558
|
+
* exists, otherwise the nearest directory above it that does.
|
|
559
|
+
*
|
|
560
|
+
* `resolve()` normalizes `..` but never follows a link, so nothing textual can tell
|
|
561
|
+
* that `<downloadDir>/link/x.png` leaves the directory through `link`. The first
|
|
562
|
+
* ancestor that exists (judged by lstat, so a dangling link counts — the write would
|
|
563
|
+
* go through it) is the last component whose real location still determines where
|
|
564
|
+
* the bytes land, so that is the one to resolve and check.
|
|
565
|
+
* @param path - an absolute path that may not exist yet.
|
|
566
|
+
* @returns the resolved real path of its deepest existing ancestor.
|
|
567
|
+
*/
|
|
568
|
+
function realAncestorOf(path: string): string {
|
|
569
|
+
let current = path
|
|
570
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
571
|
+
if (entryExists(current)) {
|
|
572
|
+
try {
|
|
573
|
+
return realpathSync(current)
|
|
574
|
+
} catch {
|
|
575
|
+
// The entry vanished between the two calls, or cannot be resolved: the
|
|
576
|
+
// textual path is the best available answer, and the write reports the rest.
|
|
577
|
+
return current
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const parent = dirname(current)
|
|
581
|
+
if (parent === current) return current
|
|
582
|
+
current = parent
|
|
583
|
+
}
|
|
584
|
+
return current
|
|
585
|
+
}
|
|
586
|
+
|
|
527
587
|
/**
|
|
528
588
|
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
529
589
|
* tabs; each tab is one view created by the host. The active tab receives
|
|
@@ -537,7 +597,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
537
597
|
private readonly sessions = new Map<BrowserSessionId, Session>()
|
|
538
598
|
private readonly httpOnly: boolean
|
|
539
599
|
private readonly snapshotMaxElements: number
|
|
540
|
-
private readonly contentMaxChars: number
|
|
600
|
+
private readonly contentMaxChars: number | undefined
|
|
541
601
|
private readonly downloadDir: string | undefined
|
|
542
602
|
/**
|
|
543
603
|
* Persistent browsing history, or `undefined` when the user turned it off.
|
|
@@ -553,7 +613,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
553
613
|
) {
|
|
554
614
|
this.httpOnly = config.httpOnly ?? true
|
|
555
615
|
this.snapshotMaxElements = config.snapshotMaxElements ?? 60
|
|
556
|
-
this.contentMaxChars = config.contentMaxChars
|
|
616
|
+
this.contentMaxChars = config.contentMaxChars
|
|
557
617
|
// Confine downloads AND screenshot saves to a dedicated directory by
|
|
558
618
|
// default: the OS Downloads folder is the human-visible, browser-natural
|
|
559
619
|
// place for written files, and it is the one directory the DSH file
|
|
@@ -850,13 +910,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
850
910
|
* Chromium drops `Input.*` events for a view with no display surface, so this
|
|
851
911
|
* runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
|
|
852
912
|
* rather than reporting a success the page never saw.
|
|
913
|
+
* @param s - the session whose active view must be presented.
|
|
914
|
+
* @param signal - optional cancellation.
|
|
915
|
+
* @returns the view the session's active tab has AFTER the presentation barrier.
|
|
853
916
|
*/
|
|
854
|
-
private async present(s: Session, signal?: AbortSignal): Promise<
|
|
917
|
+
private async present(s: Session, signal?: AbortSignal): Promise<ElectronViewHandle> {
|
|
855
918
|
const { handle } = this.activeTab(s)
|
|
856
919
|
const present = this.host.presentView?.bind(this.host)
|
|
857
920
|
if (present === undefined) {
|
|
858
921
|
this.showActive(s)
|
|
859
|
-
return
|
|
922
|
+
return this.activeTab(s).handle
|
|
860
923
|
}
|
|
861
924
|
signal?.throwIfAborted()
|
|
862
925
|
const timeoutMs = 10_000
|
|
@@ -875,6 +938,48 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
875
938
|
{ cause: error },
|
|
876
939
|
)
|
|
877
940
|
}
|
|
941
|
+
// Read the active tab AGAIN. `present` is a round-trip that can take up to the budget
|
|
942
|
+
// above, and the human is invited to drive the same window (a toolbar tab switch, a
|
|
943
|
+
// closed window) while the agent waits. Chromium answers `Input.*` with success even
|
|
944
|
+
// for a view that has no display surface, so dispatching to a handle that is no longer
|
|
945
|
+
// the visible one does not fail — it silently lands nowhere, or on the wrong page.
|
|
946
|
+
//
|
|
947
|
+
// Returning the re-read handle was wrong, though: `handle` above is the one that was
|
|
948
|
+
// actually presented, and if the active tab changed during the round trip then the
|
|
949
|
+
// re-read one was never presented at all. Handing it back meant the caller dispatched
|
|
950
|
+
// to a view with no display surface — the exact failure this method exists to prevent,
|
|
951
|
+
// arrived at from the other side. Either the presented view is still the active one, or
|
|
952
|
+
// the caller must be told rather than given something else to aim at.
|
|
953
|
+
const live = this.activeTab(s).handle
|
|
954
|
+
if (live !== handle) {
|
|
955
|
+
throw new BrowserError(
|
|
956
|
+
'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',
|
|
957
|
+
'BROWSER_VIEW_CHANGED',
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
return handle
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Refuse to send synthesized input to a view that is no longer the one it was located
|
|
965
|
+
* against.
|
|
966
|
+
*
|
|
967
|
+
* `locateHandle` was captured before a page-side locate that can consume its whole
|
|
968
|
+
* 10s budget; the session's active tab is re-read afterwards. If they differ, the human
|
|
969
|
+
* switched tabs mid-flight (the product's whole point is that they can take over), and
|
|
970
|
+
* the coordinates belong to one page while the dispatch would go to another. Comparing
|
|
971
|
+
* the handles turns that into a loud, retryable error instead of input the caller
|
|
972
|
+
* believes landed.
|
|
973
|
+
* @param session - the session id, for the message.
|
|
974
|
+
* @param locateHandle - the view the locate ran in.
|
|
975
|
+
* @param liveHandle - the view that is active now.
|
|
976
|
+
*/
|
|
977
|
+
private assertSameView(session: BrowserSessionId, locateHandle: ElectronViewHandle, liveHandle: ElectronViewHandle): void {
|
|
978
|
+
if (locateHandle === liveHandle) return
|
|
979
|
+
throw new BrowserError(
|
|
980
|
+
`browser: the session's active view changed while the element was being located (session ${session}); the operation was not dispatched — retry it`,
|
|
981
|
+
'BROWSER_VIEW_CHANGED',
|
|
982
|
+
)
|
|
878
983
|
}
|
|
879
984
|
|
|
880
985
|
/**
|
|
@@ -903,6 +1008,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
903
1008
|
|
|
904
1009
|
/** Execute JS in the active tab's page context. */
|
|
905
1010
|
async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
|
|
1011
|
+
this.assertActionAllowed('execute')
|
|
906
1012
|
const s = this.session(session)
|
|
907
1013
|
const { handle } = this.activeTab(s)
|
|
908
1014
|
signal?.throwIfAborted()
|
|
@@ -1140,7 +1246,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1140
1246
|
const tab = this.activeTab(s)
|
|
1141
1247
|
signal?.throwIfAborted()
|
|
1142
1248
|
const includeHidden = request.includeHidden === true
|
|
1143
|
-
|
|
1249
|
+
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
1250
|
+
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
1251
|
+
const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 150)))
|
|
1144
1252
|
const script = `(() => {
|
|
1145
1253
|
const includeHidden = ${String(includeHidden)}
|
|
1146
1254
|
const maxNodes = ${String(maxNodes)}
|
|
@@ -1282,6 +1390,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1282
1390
|
value: valueOf(el),
|
|
1283
1391
|
states: statesOf(el),
|
|
1284
1392
|
depth: baseDepth + depthOf(el),
|
|
1393
|
+
// Same derivation as the snapshot collector: an id or a name is what makes a node
|
|
1394
|
+
// something the caller can point at without guessing at its text. The tool-layer
|
|
1395
|
+
// schema declared this field before this line existed, so the schema was promising
|
|
1396
|
+
// a value the tool never sent.
|
|
1397
|
+
selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
1285
1398
|
tag: el.tagName.toLowerCase(),
|
|
1286
1399
|
x: Math.round(a.x + a.w / 2),
|
|
1287
1400
|
y: Math.round(a.y + a.h / 2),
|
|
@@ -1330,9 +1443,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1330
1443
|
async content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult> {
|
|
1331
1444
|
const tab = this.activeTab(this.session(session))
|
|
1332
1445
|
signal?.throwIfAborted()
|
|
1333
|
-
const maxChars = request.maxChars ?? this.contentMaxChars
|
|
1334
1446
|
const selector = request.selector ?? ''
|
|
1335
1447
|
const format = request.format
|
|
1448
|
+
// Per-format caps. A full HTML document is enormous, but the same limit applied to
|
|
1449
|
+
// plain text is no limit at all (a whole page of text measured 25,882 characters).
|
|
1450
|
+
// This bounds the common case instead of letting one call spend 25k-33k tokens.
|
|
1451
|
+
//
|
|
1452
|
+
// The plugin config overrides the lot when it is set — see `contentMaxChars`. The caller's
|
|
1453
|
+
// own `maxChars` still outranks both: an explicit per-call bound is the most specific
|
|
1454
|
+
// statement of intent there is.
|
|
1455
|
+
const maxChars = request.maxChars
|
|
1456
|
+
?? this.contentMaxChars
|
|
1457
|
+
?? (format === 'html' || format === 'json' ? 50_000 : 20_000)
|
|
1336
1458
|
const script = `(() => {
|
|
1337
1459
|
const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
|
|
1338
1460
|
if (!root) return { ok: false, reason: 'selector not found' }
|
|
@@ -1376,7 +1498,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1376
1498
|
}
|
|
1377
1499
|
}
|
|
1378
1500
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
1379
|
-
|
|
1501
|
+
// A DOM element has no own enumerable properties — everything lives on the prototype
|
|
1502
|
+
// chain — so JSON.stringify(root) is always "{}". That is not a structured view of
|
|
1503
|
+
// the page, it is an empty object the caller will read as "no data here" and then
|
|
1504
|
+
// retry with other selectors. Serialise the markup instead, which is what a caller
|
|
1505
|
+
// asking for JSON of an element can actually use.
|
|
1506
|
+
else if (fmt === 'json') content = JSON.stringify({ html: root.outerHTML, tag: root.tagName.toLowerCase() })
|
|
1380
1507
|
else {
|
|
1381
1508
|
// markdown: headings, paragraphs, links, lists (best-effort)
|
|
1382
1509
|
const parts = []
|
|
@@ -1431,7 +1558,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1431
1558
|
signal?: AbortSignal,
|
|
1432
1559
|
): Promise<void> {
|
|
1433
1560
|
const s = this.session(session)
|
|
1434
|
-
|
|
1561
|
+
// The view the locate runs against. It can stop being the session's active view while
|
|
1562
|
+
// the locate waits (see prepareTarget), so every dispatch below re-reads the live one.
|
|
1563
|
+
const locateHandle = this.activeTab(s).handle
|
|
1435
1564
|
signal?.throwIfAborted()
|
|
1436
1565
|
let x = 0
|
|
1437
1566
|
let y = 0
|
|
@@ -1465,8 +1594,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1465
1594
|
y = request.y
|
|
1466
1595
|
}
|
|
1467
1596
|
// Input.* is only delivered to a view with a current display surface; the
|
|
1468
|
-
// barrier also re-presents after a navigation replaced the renderer.
|
|
1469
|
-
|
|
1597
|
+
// barrier also re-presents after a navigation replaced the renderer. It returns the
|
|
1598
|
+
// handle that may still be receiving input when it settles.
|
|
1599
|
+
const handle = await this.present(s, signal)
|
|
1600
|
+
// The locate (up to 10s) and the present barrier both ran against the view captured
|
|
1601
|
+
// earlier; if the human switched tabs meanwhile, these coordinates belong to one page
|
|
1602
|
+
// and the dispatch would go to another — and Chromium answers success either way.
|
|
1603
|
+
if ('target' in request) this.assertSameView(session, locateHandle, handle)
|
|
1470
1604
|
const timeoutMs = 30_000
|
|
1471
1605
|
const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
|
|
1472
1606
|
withTimeout(
|
|
@@ -1493,6 +1627,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1493
1627
|
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1494
1628
|
this.showCursor(handle, x, y, 'click', 'click', true)
|
|
1495
1629
|
const release = (): void => {
|
|
1630
|
+
// Back to the handle the PRESS may have landed on, deliberately not to the currently
|
|
1631
|
+
// active tab: this only runs after a failed press/release, and re-targeting a
|
|
1632
|
+
// compensating half of a click to a view the human just switched to would press a
|
|
1633
|
+
// button there. Best-effort either way.
|
|
1496
1634
|
void handle
|
|
1497
1635
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
1498
1636
|
type: 'mouseReleased',
|
|
@@ -1532,7 +1670,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1532
1670
|
signal?: AbortSignal,
|
|
1533
1671
|
): Promise<void> {
|
|
1534
1672
|
const s = this.session(session)
|
|
1535
|
-
|
|
1673
|
+
// The view the locate runs against. It can stop being the session's active view while
|
|
1674
|
+
// the locate waits (the human is invited to drive the same window), so the input below
|
|
1675
|
+
// goes to the handle `present` reports instead of this one.
|
|
1676
|
+
const locateHandle = this.activeTab(s).handle
|
|
1536
1677
|
signal?.throwIfAborted()
|
|
1537
1678
|
const hasTarget = 'target' in request
|
|
1538
1679
|
if (hasTarget) {
|
|
@@ -1549,8 +1690,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1549
1690
|
const text = 'text' in request ? request.text : ''
|
|
1550
1691
|
const timeoutMs = 30_000
|
|
1551
1692
|
// Input.insertText goes through the Input domain: same display-surface
|
|
1552
|
-
// requirement as click/key.
|
|
1553
|
-
|
|
1693
|
+
// requirement as click/key. It also reports which view is active once the barrier has
|
|
1694
|
+
// settled, which is what makes the tab-switch check below meaningful.
|
|
1695
|
+
const handle = await this.present(s, signal)
|
|
1696
|
+
// Focusing one page and inserting into another is exactly the silent mis-target this
|
|
1697
|
+
// refuses: the element was focused in the located view, and the text would have gone to
|
|
1698
|
+
// whichever page is now visible — possibly a password field there.
|
|
1699
|
+
if (hasTarget) this.assertSameView(session, locateHandle, handle)
|
|
1554
1700
|
try {
|
|
1555
1701
|
await withTimeout(
|
|
1556
1702
|
handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
|
|
@@ -1674,7 +1820,6 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1674
1820
|
/** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
|
|
1675
1821
|
async key(session: BrowserSessionId, request: BrowserKeyRequest, signal?: AbortSignal): Promise<void> {
|
|
1676
1822
|
const s = this.session(session)
|
|
1677
|
-
const { handle } = this.activeTab(s)
|
|
1678
1823
|
signal?.throwIfAborted()
|
|
1679
1824
|
const spec = KEY_SPECS[request.key]
|
|
1680
1825
|
if (spec === undefined) {
|
|
@@ -1682,8 +1827,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1682
1827
|
}
|
|
1683
1828
|
const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
|
|
1684
1829
|
const timeoutMs = 15_000
|
|
1685
|
-
// Input.dispatchKeyEvent is subject to the same display-surface rule.
|
|
1686
|
-
|
|
1830
|
+
// Input.dispatchKeyEvent is subject to the same display-surface rule. The handle comes
|
|
1831
|
+
// back from the presentation barrier rather than from a capture taken before it: this
|
|
1832
|
+
// session is shared with the human, who may switch tabs while the barrier round-trips,
|
|
1833
|
+
// and a key sent to the view they left is silently dropped (or lands on the new page).
|
|
1834
|
+
const handle = await this.present(s, signal)
|
|
1835
|
+
// Focus the SAME view the key is dispatched to; focusing the pre-switch handle would
|
|
1836
|
+
// grant web focus to a page that then receives nothing.
|
|
1687
1837
|
await this.focusView(handle)
|
|
1688
1838
|
const release = (): Promise<Record<string, unknown>> =>
|
|
1689
1839
|
handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
|
|
@@ -1721,7 +1871,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1721
1871
|
name: f.name ?? null,
|
|
1722
1872
|
label: f.label ?? null,
|
|
1723
1873
|
placeholder: f.placeholder ?? null,
|
|
1724
|
-
kind
|
|
1874
|
+
// A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
|
|
1875
|
+
// it to 'text' here made `if (spec.kind)` above always true, so every unspecified
|
|
1876
|
+
// field was narrowed to an <input> — browser_fill on a <textarea> reported
|
|
1877
|
+
// `no "text" control matched`, and a contenteditable could not match any kind at all.
|
|
1878
|
+
// The value stays a string or null (never undefined): the in-page check tests truthiness,
|
|
1879
|
+
// and JSON drops an undefined member entirely, which is the same thing here.
|
|
1880
|
+
kind: f.kind ?? null,
|
|
1725
1881
|
value: f.value,
|
|
1726
1882
|
})))
|
|
1727
1883
|
const submitFlag = request.submit === true
|
|
@@ -1754,10 +1910,34 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1754
1910
|
}
|
|
1755
1911
|
return false
|
|
1756
1912
|
}
|
|
1913
|
+
// Every root this page exposes: the top document plus its shadow trees (recursively)
|
|
1914
|
+
// and same-origin iframes. The snapshot and waitFor already walk all of them, so an
|
|
1915
|
+
// element inside one is shown to the model and can be waited for — resolving a click or
|
|
1916
|
+
// a fill used to look at the top document alone and answer "not found" for something
|
|
1917
|
+
// the model had just been handed a reference to. The two must agree about what exists.
|
|
1918
|
+
// (Kept in step with the collector in the snapshot script above.)
|
|
1919
|
+
const allRoots = () => {
|
|
1920
|
+
const roots = []
|
|
1921
|
+
const seen = new Set()
|
|
1922
|
+
const visit = doc => {
|
|
1923
|
+
if (seen.has(doc)) return
|
|
1924
|
+
seen.add(doc)
|
|
1925
|
+
roots.push(doc)
|
|
1926
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
1927
|
+
if (el.shadowRoot) visit(el.shadowRoot)
|
|
1928
|
+
if (el.tagName === 'IFRAME') {
|
|
1929
|
+
try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
visit(document)
|
|
1934
|
+
return roots
|
|
1935
|
+
}
|
|
1936
|
+
const roots = allRoots()
|
|
1757
1937
|
const candidates = (spec) => {
|
|
1758
1938
|
const all = spec.selector
|
|
1759
|
-
? [...
|
|
1760
|
-
: [...
|
|
1939
|
+
? roots.flatMap(root => [...root.querySelectorAll(spec.selector)])
|
|
1940
|
+
: roots.flatMap(root => [...root.querySelectorAll('input, textarea, select, [contenteditable="true"]')]).filter(el => matches(el, spec))
|
|
1761
1941
|
const vis = all.filter(visible)
|
|
1762
1942
|
return vis.length > 0 ? vis : all
|
|
1763
1943
|
}
|
|
@@ -1775,7 +1955,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1775
1955
|
let el = els[0]
|
|
1776
1956
|
// The caller may name a kind. Honor it: the branches below read the element's own
|
|
1777
1957
|
// tag/type, so without this a selector matching two different controls acted on
|
|
1778
|
-
// whichever came first — usually right, which is what hid the mistake.
|
|
1958
|
+
// whichever came first — usually right, which is what hid the mistake. A field that
|
|
1959
|
+
// names NO kind gets no filter at all: the element's own tag then decides the
|
|
1960
|
+
// branch, which is what "kind defaults to text" was always trying to say.
|
|
1779
1961
|
if (spec.kind) {
|
|
1780
1962
|
const matchesKind = (node) => {
|
|
1781
1963
|
const nodeTag = node.tagName
|
|
@@ -1783,6 +1965,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1783
1965
|
if (spec.kind === 'select') return nodeTag === 'SELECT'
|
|
1784
1966
|
if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
|
|
1785
1967
|
if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
|
|
1968
|
+
// 'text': the plain-value branch covers <input> AND <textarea>, and the batch
|
|
1969
|
+
// already collects [contenteditable="true"] as a settable control — so all three
|
|
1970
|
+
// must match. Requiring nodeTag === 'INPUT' here reported "no text control
|
|
1971
|
+
// matched" for a textarea and left the contenteditable branch below unreachable.
|
|
1972
|
+
if (nodeTag === 'TEXTAREA') return true
|
|
1973
|
+
if (node.isContentEditable === true) return true
|
|
1786
1974
|
return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
|
|
1787
1975
|
}
|
|
1788
1976
|
const wanted = els.find(matchesKind)
|
|
@@ -1904,33 +2092,78 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1904
2092
|
const match = () => {
|
|
1905
2093
|
const by = typeof spec.by === 'string' ? spec.by : 'css'
|
|
1906
2094
|
const value = String(spec.value ?? '')
|
|
1907
|
-
|
|
2095
|
+
// Named matchIndex rather than index: this whole function is one host-side template
|
|
2096
|
+
// literal, so a bare name declared here can silently shadow a host binding of the
|
|
2097
|
+
// same name and turn an inner reference into a type error.
|
|
2098
|
+
const matchIndex = typeof spec.index === 'number' ? spec.index : 0
|
|
2099
|
+
// An index that cannot address an element can never match on a later poll, exactly
|
|
2100
|
+
// like a selector that fails to parse — so it must not look like a miss either. A
|
|
2101
|
+
// negative index, the common "last one" shorthand, otherwise burned the caller's
|
|
2102
|
+
// whole locate budget (10s) and then reported "element not found", which reads like a
|
|
2103
|
+
// slow page rather than a bad argument.
|
|
2104
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
2105
|
+
if (!Number.isInteger(matchIndex) || matchIndex < 0) {
|
|
2106
|
+
throw new Error('target.index must be a non-negative integer, got ' + JSON.stringify(spec.index))
|
|
2107
|
+
}
|
|
1908
2108
|
let els = []
|
|
2109
|
+
// Every root this page exposes: the top document plus its shadow trees (recursively)
|
|
2110
|
+
// and same-origin iframes. The snapshot and waitFor already walk all of them, so an
|
|
2111
|
+
// element inside one is shown to the model and can be waited for — resolving a click or
|
|
2112
|
+
// a fill used to look at the top document alone and answer "not found" for something
|
|
2113
|
+
// the model had just been handed a reference to. The two must agree about what exists.
|
|
2114
|
+
// (Kept in step with the collector in the snapshot script above.)
|
|
2115
|
+
const allRoots = () => {
|
|
2116
|
+
const roots = []
|
|
2117
|
+
const seen = new Set()
|
|
2118
|
+
const visit = doc => {
|
|
2119
|
+
if (seen.has(doc)) return
|
|
2120
|
+
seen.add(doc)
|
|
2121
|
+
roots.push(doc)
|
|
2122
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
2123
|
+
if (el.shadowRoot) visit(el.shadowRoot)
|
|
2124
|
+
if (el.tagName === 'IFRAME') {
|
|
2125
|
+
try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
visit(document)
|
|
2130
|
+
return roots
|
|
2131
|
+
}
|
|
2132
|
+
const roots = allRoots()
|
|
1909
2133
|
if (by === 'css') {
|
|
1910
2134
|
// A selector that does not PARSE can never match on a later poll, so
|
|
1911
2135
|
// it must not look like a miss: the old "return null" kept the caller
|
|
1912
2136
|
// polling until the whole budget was gone, and "element not found
|
|
1913
2137
|
// after 10s" reads like a slow page while the real cause is a
|
|
1914
2138
|
// malformed selector (a label or plain text passed where one belongs).
|
|
1915
|
-
|
|
2139
|
+
// Parse on the top document first: a selector that does not PARSE must still be
|
|
2140
|
+
// reported as a bad argument rather than as a miss, and that check is about syntax,
|
|
2141
|
+
// not about which root holds the element.
|
|
2142
|
+
try { document.querySelectorAll(value) }
|
|
1916
2143
|
catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
2144
|
+
for (const root of roots) els.push(...root.querySelectorAll(value))
|
|
1917
2145
|
} else if (by === 'xpath') {
|
|
1918
2146
|
try {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2147
|
+
for (const root of roots) {
|
|
2148
|
+
const snap = document.evaluate(value, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
|
|
2149
|
+
for (let i = 0; i < snap.snapshotLength; i++) {
|
|
2150
|
+
const n = snap.snapshotItem(i)
|
|
2151
|
+
if (n instanceof Element) els.push(n)
|
|
2152
|
+
}
|
|
1923
2153
|
}
|
|
1924
2154
|
} catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
|
|
1925
2155
|
} else {
|
|
1926
|
-
|
|
2156
|
+
// '*' rather than 'body *': a shadow root and an iframe document have no body of
|
|
2157
|
+
// their own to anchor to, and the visibility and own-text predicates below are what
|
|
2158
|
+
// actually decide whether an element counts.
|
|
2159
|
+
const all = roots.flatMap(root => [...root.querySelectorAll('*')])
|
|
1927
2160
|
const exact = all.filter(el => isVisible(el) && ownText(el) === value)
|
|
1928
2161
|
// Exact first, then contains; deepest element preferred (so a
|
|
1929
2162
|
// button inside a card wins over the card itself).
|
|
1930
2163
|
const pool = exact.length > 0 ? exact : all.filter(el => isVisible(el) && ownText(el).includes(value))
|
|
1931
2164
|
els = pool.sort((a, b) => depth(b) - depth(a))
|
|
1932
2165
|
}
|
|
1933
|
-
return els[
|
|
2166
|
+
return els[matchIndex] ?? null
|
|
1934
2167
|
}
|
|
1935
2168
|
const deadline = Date.now() + timeoutMs
|
|
1936
2169
|
let el = null
|
|
@@ -2225,6 +2458,35 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2225
2458
|
return { count: items.length, items }
|
|
2226
2459
|
}
|
|
2227
2460
|
|
|
2461
|
+
/**
|
|
2462
|
+
* Refuse an action the settings have switched off.
|
|
2463
|
+
*
|
|
2464
|
+
* These switches belong to the OPERATOR. The settings document is written through the
|
|
2465
|
+
* plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
|
|
2466
|
+
* model owns and can lift at will — a refusal here stands for as long as the setting does.
|
|
2467
|
+
* That is the point of them: a deployment can take page-script execution, downloads, or
|
|
2468
|
+
* login-state writes off the table without relying on the model's cooperation.
|
|
2469
|
+
*
|
|
2470
|
+
* Read through `settingsSource` on every call rather than captured at construction, so
|
|
2471
|
+
* flipping a switch applies to the next command instead of the next restart — the same
|
|
2472
|
+
* rule the credentials gate follows.
|
|
2473
|
+
* @param action - which switch to consult.
|
|
2474
|
+
* @throws BrowserError when that switch is off.
|
|
2475
|
+
*/
|
|
2476
|
+
private assertActionAllowed(action: 'execute' | 'download' | 'credentialWrite'): void {
|
|
2477
|
+
const settings = this.settingsSource?.()
|
|
2478
|
+
if (settings === undefined) return
|
|
2479
|
+
if (action === 'execute' && !settings.actions.allowExecute) {
|
|
2480
|
+
throw new BrowserError('browser: running page scripts is switched off in settings (Browser → actions)', 'BROWSER_EXECUTE_DISABLED')
|
|
2481
|
+
}
|
|
2482
|
+
if (action === 'download' && !settings.actions.allowDownload) {
|
|
2483
|
+
throw new BrowserError('browser: downloads are switched off in settings (Browser → actions)', 'BROWSER_DOWNLOAD_DISABLED')
|
|
2484
|
+
}
|
|
2485
|
+
if (action === 'credentialWrite' && !settings.actions.allowCredentialWrite) {
|
|
2486
|
+
throw new BrowserError('browser: writing cookies is switched off in settings (Browser → actions)', 'BROWSER_AUTH_WRITE_DISABLED')
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2228
2490
|
/**
|
|
2229
2491
|
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
2230
2492
|
* ONE gate for both `browser_download` and `browser_screenshot`: the path
|
|
@@ -2245,19 +2507,55 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2245
2507
|
const file = resolve(savePath)
|
|
2246
2508
|
if (this.downloadDir !== undefined) {
|
|
2247
2509
|
const dir = resolve(this.downloadDir)
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2510
|
+
// Windows paths are case-insensitive and resolve() does not normalize case, so there
|
|
2511
|
+
// the containment check folds case — otherwise C:\Users\X\Downloads and
|
|
2512
|
+
// c:\users\x\downloads would read as different roots. Everywhere else the fold is
|
|
2513
|
+
// REMOVED: a case-sensitive filesystem (Linux) treats /home/u/DOWNLOADS and
|
|
2514
|
+
// /home/u/Downloads as different directories, so folding case there let a savePath
|
|
2515
|
+
// that merely differed in case pass admission — a directory-confinement escape, and
|
|
2516
|
+
// one that mkdirSync below would then happily create.
|
|
2517
|
+
const fold = process.platform === 'win32' ? (value: string): string => value.toLowerCase() : (value: string): string => value
|
|
2518
|
+
const dirKey = fold(dir)
|
|
2519
|
+
const fileKey = fold(file)
|
|
2520
|
+
// The directory ITSELF is refused, not accepted: a save path that IS downloadDir names
|
|
2521
|
+
// a directory, so there is no file to write — and mkdirSync would have created the
|
|
2522
|
+
// directory when it did not exist. Two paths are "inside" only when the file path is
|
|
2523
|
+
// strictly below the directory, which the separator check below already expresses.
|
|
2524
|
+
if (!fileKey.startsWith(dirKey + fold(sep))) {
|
|
2254
2525
|
throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
|
|
2255
2526
|
}
|
|
2527
|
+
// The check above is textual, and a symlink defeats it: resolve() normalizes `..`
|
|
2528
|
+
// but never follows a link, so `<downloadDir>/out/x.png` passed every test above
|
|
2529
|
+
// while `out` pointed at a directory outside the gate — and the write then landed
|
|
2530
|
+
// there. Resolve the REAL location of the deepest existing ancestor (which is what
|
|
2531
|
+
// decides where the bytes go) and require it to be inside the real directory too.
|
|
2532
|
+
// A legitimate path is unaffected: its ancestor resolves to the directory itself.
|
|
2533
|
+
//
|
|
2534
|
+
// The directory is created first so that it can be resolved at all — mkdirSync is a
|
|
2535
|
+
// no-op for one that already exists — and a directory that cannot be created or
|
|
2536
|
+
// resolved falls back to its textual form, leaving the real filesystem error to the
|
|
2537
|
+
// write instead of inventing one here.
|
|
2538
|
+
let realDir: string
|
|
2539
|
+
try {
|
|
2540
|
+
mkdirSync(dir, { recursive: true })
|
|
2541
|
+
realDir = fold(realpathSync(dir))
|
|
2542
|
+
} catch {
|
|
2543
|
+
realDir = fold(dir)
|
|
2544
|
+
}
|
|
2545
|
+
const realAncestor = fold(realAncestorOf(file))
|
|
2546
|
+
if (realAncestor !== realDir && !realAncestor.startsWith(realDir + fold(sep))) {
|
|
2547
|
+
throw new BrowserError(`browser: ${kind} savePath resolves outside downloadDir "${dir}" (a symlink leaves it)`, code)
|
|
2548
|
+
}
|
|
2256
2549
|
}
|
|
2257
2550
|
// Never replace an existing file: its previous content is unrecoverable,
|
|
2258
2551
|
// and the admitted directory may hold files the human put there. A new
|
|
2259
2552
|
// name is one tool call away.
|
|
2260
|
-
|
|
2553
|
+
//
|
|
2554
|
+
// Judged with lstat (see {@link entryExists}), not existsSync: existsSync FOLLOWS
|
|
2555
|
+
// links, so a DANGLING symlink read as "nothing here" and the write below created
|
|
2556
|
+
// its target — outside the directory whenever the link pointed out of it. It is the
|
|
2557
|
+
// directory entry that has to be free, whatever that entry points at.
|
|
2558
|
+
if (entryExists(file)) {
|
|
2261
2559
|
throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
|
|
2262
2560
|
}
|
|
2263
2561
|
return file
|
|
@@ -2276,6 +2574,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2276
2574
|
* @returns the path the file was written to.
|
|
2277
2575
|
*/
|
|
2278
2576
|
async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
|
|
2577
|
+
// Before admission, and before the session is even resolved: a switched-off action is
|
|
2578
|
+
// not a path problem, and the caller should hear the real reason.
|
|
2579
|
+
this.assertActionAllowed('download')
|
|
2279
2580
|
const s = this.session(session)
|
|
2280
2581
|
const { handle } = this.activeTab(s)
|
|
2281
2582
|
signal?.throwIfAborted()
|
|
@@ -2340,8 +2641,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2340
2641
|
// Every other carrier drives a real browser over CDP, and cookies are part of CDP —
|
|
2341
2642
|
// the sidebar goes through the shell's webContents.debugger, an installed Chrome or
|
|
2342
2643
|
// Edge is the browser itself. So this works everywhere rather than only self-hosted.
|
|
2644
|
+
// Scoped to the page this session is on. The jar itself is SHARED on purpose — every
|
|
2645
|
+
// window and every task drives the same browser, so signing in once works everywhere,
|
|
2646
|
+
// which is the whole point of "use your own browser". What must not be shared is the
|
|
2647
|
+
// HANDING OVER: an unfiltered read puts every domain every task has visited, plus
|
|
2648
|
+
// whatever the human is signed into, in front of one task's caller. Least exposure,
|
|
2649
|
+
// not isolation.
|
|
2650
|
+
//
|
|
2651
|
+
// Two corrections to the previous attempt. It called Storage.getCookies with a `urls`
|
|
2652
|
+
// parameter, but that method takes only browserContextId and ignores everything else, so
|
|
2653
|
+
// the filter did nothing and the whole jar was still exported; `urls` belongs to
|
|
2654
|
+
// Network.getCookies. And it silently fell back to an unfiltered read when the page URL
|
|
2655
|
+
// was unavailable — an error path that asked for MORE than the normal one. Both are
|
|
2656
|
+
// fixed: the method that honours the filter, and a failure that refuses instead of
|
|
2657
|
+
// widening.
|
|
2658
|
+
const cookiesUrl = await this.currentUrl(handle).catch(() => '')
|
|
2659
|
+
if (cookiesUrl === '') {
|
|
2660
|
+
throw new BrowserError(
|
|
2661
|
+
'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',
|
|
2662
|
+
'BROWSER_AUTH_SCOPE_UNKNOWN',
|
|
2663
|
+
)
|
|
2664
|
+
}
|
|
2343
2665
|
const result = await withTimeout(
|
|
2344
|
-
handle.sendCommand('
|
|
2666
|
+
handle.sendCommand('Network.getCookies', { urls: [cookiesUrl] }),
|
|
2345
2667
|
timeoutMs,
|
|
2346
2668
|
undefined,
|
|
2347
2669
|
`browser: auth export timed out after ${timeoutMs}ms`,
|
|
@@ -2353,12 +2675,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2353
2675
|
|
|
2354
2676
|
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
2355
2677
|
async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
// the
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2678
|
+
// Restore WRITES cookies, so it answers to the write switch — not to
|
|
2679
|
+
// `credentials.allowRead`. It used to consult the read switch and report "reading
|
|
2680
|
+
// cookies is switched off", which is both the wrong gate and the wrong sentence: an
|
|
2681
|
+
// operator who allowed reading but wanted logins left alone had no switch at all,
|
|
2682
|
+
// and one who turned reading off lost the restore path for the wrong reason.
|
|
2683
|
+
this.assertActionAllowed('credentialWrite')
|
|
2362
2684
|
const s = this.session(session)
|
|
2363
2685
|
const { handle } = this.activeTab(s)
|
|
2364
2686
|
const timeoutMs = 30_000
|
|
@@ -2371,14 +2693,28 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2371
2693
|
// CDP again, for the same reason as the export: any carrier that drives a real
|
|
2372
2694
|
// browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
|
|
2373
2695
|
// the CDP way, including the sameSite spelling and seconds rather than milliseconds.
|
|
2696
|
+
//
|
|
2697
|
+
// toCdpCookie returns undefined for a cookie it cannot place (no usable domain), and
|
|
2698
|
+
// `cookies.map(...)` passed that undefined straight through: the array then held a
|
|
2699
|
+
// JSON `null`, CDP rejected the WHOLE call, and every valid cookie failed with an error
|
|
2700
|
+
// naming none of them. Filter the unusable ones out and say how many went.
|
|
2701
|
+
const mapped = cookies.map(toCdpCookie)
|
|
2702
|
+
const convertible = mapped.filter((cookie): cookie is Record<string, unknown> => cookie !== undefined)
|
|
2703
|
+
const dropped = mapped.length - convertible.length
|
|
2704
|
+
if (dropped > 0) {
|
|
2705
|
+
process.stderr.write(`[dsh-browser] restoreAuth: dropped ${dropped} of ${mapped.length} cookie(s) with no usable domain; the rest were still restored\n`)
|
|
2706
|
+
}
|
|
2374
2707
|
const result = await withTimeout(
|
|
2375
|
-
handle.sendCommand('Storage.setCookies', { cookies:
|
|
2708
|
+
handle.sendCommand('Storage.setCookies', { cookies: convertible }),
|
|
2376
2709
|
timeoutMs,
|
|
2377
2710
|
undefined,
|
|
2378
2711
|
`browser: auth restore timed out after ${timeoutMs}ms`,
|
|
2379
2712
|
)
|
|
2380
|
-
|
|
2381
|
-
|
|
2713
|
+
// Storage.setCookies returns nothing, so the count comes from what was actually
|
|
2714
|
+
// sent. Reading result.cookies here was always undefined, which rendered as
|
|
2715
|
+
// "Restored 0 cookies." while the write had succeeded.
|
|
2716
|
+
const restored = convertible.length
|
|
2717
|
+
this.record(s, 'restoreAuth', { count: cookies.length, dropped }, true, { result: `${restored} cookies (CDP)${dropped > 0 ? `; ${dropped} dropped` : ''}` })
|
|
2382
2718
|
return restored
|
|
2383
2719
|
}
|
|
2384
2720
|
|
|
@@ -2442,6 +2778,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2442
2778
|
// to the region it names, so it has to cover the whole document. The page's own size
|
|
2443
2779
|
// is read first; when that is unavailable the capture proceeds unscaled rather than
|
|
2444
2780
|
// failing — an image the caller can shrink itself beats no image at all.
|
|
2781
|
+
let rendered: { width: number; height: number } | undefined
|
|
2445
2782
|
if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
|
|
2446
2783
|
const metrics = await withTimeout(
|
|
2447
2784
|
handle.sendCommand('Page.getLayoutMetrics', {}),
|
|
@@ -2455,6 +2792,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2455
2792
|
const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1
|
|
2456
2793
|
const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1
|
|
2457
2794
|
const scale = Math.min(1, byWidth, byHeight)
|
|
2795
|
+
// What the caller will actually receive. Recorded before the branch below, because the
|
|
2796
|
+
// size is worth reporting even when no scaling happened (scale 1).
|
|
2797
|
+
rendered = { width: Math.round(size.width * scale), height: Math.round(height * scale) }
|
|
2458
2798
|
if (scale < 1) {
|
|
2459
2799
|
params.clip = { x: 0, y: 0, width: size.width, height, scale }
|
|
2460
2800
|
// The clip defines the captured area; captureBeyondViewport must not also apply.
|
|
@@ -2472,7 +2812,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2472
2812
|
if (typeof data !== 'string') {
|
|
2473
2813
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
|
|
2474
2814
|
}
|
|
2475
|
-
|
|
2815
|
+
// The mime must name the format that was ACTUALLY requested (params.format), not PNG
|
|
2816
|
+
// unconditionally: a carrier that sets supportsCdpJpeg asks for `format: 'jpeg'`, so
|
|
2817
|
+
// hardcoding the PNG mime announced a JPEG body as a PNG — the data URL then carried a
|
|
2818
|
+
// mislabelled image, and a caller that decodes by mime wrote the wrong file.
|
|
2819
|
+
const mime = params.format === 'jpeg' ? 'image/jpeg' : 'image/png'
|
|
2820
|
+
return this.saveScreenshot(data, request?.savePath, mime, rendered)
|
|
2476
2821
|
}
|
|
2477
2822
|
|
|
2478
2823
|
/**
|
|
@@ -2482,7 +2827,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2482
2827
|
* file — so a screenshot cannot be used to write to, or silently replace,
|
|
2483
2828
|
* files anywhere the DSH process happens to have permission (issue #13).
|
|
2484
2829
|
*/
|
|
2485
|
-
private saveScreenshot(
|
|
2830
|
+
private saveScreenshot(
|
|
2831
|
+
base64: string,
|
|
2832
|
+
savePath: string | undefined,
|
|
2833
|
+
mime: string,
|
|
2834
|
+
rendered?: { width: number; height: number },
|
|
2835
|
+
): { dataUrl: string; path?: string; width?: number; height?: number } {
|
|
2486
2836
|
if (savePath !== undefined) {
|
|
2487
2837
|
// Admission failures propagate unchanged: they describe the caller's
|
|
2488
2838
|
// path, not a disk problem.
|
|
@@ -2490,13 +2840,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2490
2840
|
try {
|
|
2491
2841
|
mkdirSync(dirname(target), { recursive: true })
|
|
2492
2842
|
writeFileSync(target, Buffer.from(base64, 'base64'))
|
|
2493
|
-
return { dataUrl: `data:${mime};base64,${base64}`, path: target }
|
|
2843
|
+
return { dataUrl: `data:${mime};base64,${base64}`, path: target, ...rendered }
|
|
2494
2844
|
} catch (error) {
|
|
2495
2845
|
// Report the write problem but keep the capture usable.
|
|
2496
2846
|
throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
|
|
2497
2847
|
}
|
|
2498
2848
|
}
|
|
2499
|
-
return { dataUrl: `data:${mime};base64,${base64}
|
|
2849
|
+
return { dataUrl: `data:${mime};base64,${base64}`, ...rendered }
|
|
2500
2850
|
}
|
|
2501
2851
|
|
|
2502
2852
|
/** Append one operation to the session's history. */
|