dsh-builtin-browser 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +78 -33
- package/README.md +74 -33
- package/cordis.patch.yml +13 -7
- package/desktop-bridge/install.mjs +274 -12
- package/desktop-bridge/plugin-browser-bridge.js +55 -15
- package/lib/browser-electron/desktop-bridge-host.d.ts +10 -1
- package/lib/browser-electron/desktop-bridge-host.js +10 -2
- package/lib/browser-electron/entry.js +41 -22
- package/lib/browser-electron/history-store.js +5 -1
- package/lib/browser-electron/missing-system-browser.d.ts +54 -0
- package/lib/browser-electron/missing-system-browser.js +89 -0
- package/lib/browser-electron/provider.d.ts +54 -1
- package/lib/browser-electron/provider.js +241 -37
- package/lib/browser-electron/remote-host.js +6 -3
- package/lib/browser-electron/settings-store.js +10 -4
- package/lib/browser-electron/system-browser.d.ts +75 -22
- package/lib/browser-electron/system-browser.js +314 -65
- package/lib/tool-browser/index.d.ts +14 -0
- package/lib/tool-browser/index.js +142 -57
- package/package.json +1 -1
- package/src/browser-electron/desktop-bridge-host.ts +11 -2
- package/src/browser-electron/entry.ts +42 -22
- package/src/browser-electron/history-store.ts +5 -1
- package/src/browser-electron/missing-system-browser.ts +113 -0
- package/src/browser-electron/provider.ts +267 -36
- package/src/browser-electron/remote-host.ts +6 -3
- package/src/browser-electron/settings-store.ts +11 -4
- package/src/browser-electron/system-browser.ts +292 -59
- package/src/tool-browser/index.ts +148 -62
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host that exists only to explain why the browser you asked for is not here.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* When the browser setting names a browser explicitly (Chrome or Edge rather than
|
|
6
|
+
* "bundled" or "automatic"), the plugin used to log a warning and quietly fall back
|
|
7
|
+
* to its own Electron window. That is worse than failing: you asked for Chrome, you
|
|
8
|
+
* got an Electron window, and the error that eventually surfaced talked about
|
|
9
|
+
* Electron — pointing at something you never chose and never wanted to install. The
|
|
10
|
+
* actual problem ("no Chrome on this machine") never reached you.
|
|
11
|
+
*
|
|
12
|
+
* So the two choices are treated as the different requests they are:
|
|
13
|
+
* - `auto` means "any browser will do", and falling back to the bundled one is the
|
|
14
|
+
* behaviour it asks for;
|
|
15
|
+
* - `chrome` / `edge` are explicit, and an explicit request that cannot be met is
|
|
16
|
+
* reported as such, with the ways out of it.
|
|
17
|
+
*
|
|
18
|
+
* `available()` reports true on purpose. Reporting false would make the provider pick
|
|
19
|
+
* a different carrier and hide the problem again, which is exactly the bug.
|
|
20
|
+
*/
|
|
21
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
22
|
+
import type { BrowserChannel } from './system-browser.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How each product is named in the message, and what to install.
|
|
26
|
+
*
|
|
27
|
+
* Only chrome and edge can appear: the constructor's type excludes `bundled` and `auto`,
|
|
28
|
+
* and `BrowserChannel` has no `brave`. A brave entry here would be unreachable code.
|
|
29
|
+
*/
|
|
30
|
+
const PRODUCT: Record<string, { label: string; install: string; variable: string }> = {
|
|
31
|
+
chrome: {
|
|
32
|
+
label: 'Google Chrome',
|
|
33
|
+
install: 'install Google Chrome',
|
|
34
|
+
variable: 'DSH_BROWSER_CHROME_PATH',
|
|
35
|
+
},
|
|
36
|
+
edge: {
|
|
37
|
+
label: 'Microsoft Edge',
|
|
38
|
+
install: 'install Microsoft Edge',
|
|
39
|
+
variable: 'DSH_BROWSER_EDGE_PATH',
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How the setting is named to the user. */
|
|
44
|
+
const SETTING_LABEL: Record<string, string> = {
|
|
45
|
+
chrome: '本机 Chrome / installed Chrome',
|
|
46
|
+
edge: '本机 Edge / installed Edge',
|
|
47
|
+
auto: '自动 / automatic',
|
|
48
|
+
bundled: '内置 / bundled',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A carrier that cannot carry anything: every command explains the real problem.
|
|
53
|
+
*
|
|
54
|
+
* The message is written to be acted on, not just read: it says which browser was
|
|
55
|
+
* requested, where the plugin looked, and the three concrete ways to resolve it.
|
|
56
|
+
*/
|
|
57
|
+
export class MissingSystemBrowserHost implements ElectronBrowserViewHost {
|
|
58
|
+
/**
|
|
59
|
+
* @param kind - the browser the user asked for.
|
|
60
|
+
* @param searched - the names and locations that were checked, for the report.
|
|
61
|
+
*/
|
|
62
|
+
constructor(private readonly kind: Exclude<BrowserChannel, 'bundled' | 'auto'>, private readonly searched: readonly string[]) {}
|
|
63
|
+
|
|
64
|
+
/** @returns always true: see the module comment — reporting false would hide this. */
|
|
65
|
+
available(): boolean {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The message every command fails with.
|
|
71
|
+
* @returns a multi-line explanation with the fixes.
|
|
72
|
+
*/
|
|
73
|
+
private explain(): string {
|
|
74
|
+
const product = PRODUCT[this.kind] ?? { label: this.kind, install: `install ${this.kind}`, variable: 'DSH_BROWSER_<KIND>_PATH' }
|
|
75
|
+
const searched = this.searched.length > 0 ? this.searched.join(', ') : 'PATH and the usual install locations'
|
|
76
|
+
return [
|
|
77
|
+
`dsh-builtin-browser: the browser you selected is not installed — no ${product.label} was found.`,
|
|
78
|
+
``,
|
|
79
|
+
` Setting: browser carrier = "${SETTING_LABEL[this.kind] ?? this.kind}"`,
|
|
80
|
+
` Looked for: ${searched}`,
|
|
81
|
+
``,
|
|
82
|
+
`Nothing else is wrong: the plugin falls back to its own bundled browser only when the`,
|
|
83
|
+
`setting is "bundled" or "automatic". Because this one names a browser explicitly, it`,
|
|
84
|
+
`reports the missing browser instead of silently using a different one.`,
|
|
85
|
+
``,
|
|
86
|
+
`To fix it, any ONE of these is enough:`,
|
|
87
|
+
` 1. ${product.install}, then restart DSH;`,
|
|
88
|
+
` 2. point ${product.variable} at its executable;`,
|
|
89
|
+
` 3. set the browser carrier back to "内置 / bundled" (or "自动 / automatic" to let it`,
|
|
90
|
+
` fall back on its own next time).`,
|
|
91
|
+
].join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a handle whose every command fails with the explanation.
|
|
96
|
+
* @returns a handle that never reaches a page.
|
|
97
|
+
*/
|
|
98
|
+
createView(): ElectronViewHandle {
|
|
99
|
+
const message = this.explain()
|
|
100
|
+
return {
|
|
101
|
+
id: `missing-${this.kind}`,
|
|
102
|
+
sendCommand: async () => { throw new Error(message) },
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Accept a handle for destruction. Nothing was created, so nothing is released.
|
|
108
|
+
* @param handle - the handle returned by {@link createView}.
|
|
109
|
+
*/
|
|
110
|
+
destroyView(handle: ElectronViewHandle): void {
|
|
111
|
+
void handle
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -242,6 +242,15 @@ export interface ElectronViewHandle {
|
|
|
242
242
|
* @returns the CDP `result` object.
|
|
243
243
|
*/
|
|
244
244
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
245
|
+
/**
|
|
246
|
+
* Whether this view's CDP can encode JPEG and honour a quality setting.
|
|
247
|
+
*
|
|
248
|
+
* Electron 43's CDP hangs on `Page.captureScreenshot` with `format: 'jpeg'`, so a view
|
|
249
|
+
* backed by Electron must leave this unset and the provider keeps to PNG. A view backed
|
|
250
|
+
* by an installed Chrome or Edge is the real browser, where JPEG works, and sets it so
|
|
251
|
+
* the requested format is not silently downgraded.
|
|
252
|
+
*/
|
|
253
|
+
readonly supportsCdpJpeg?: boolean
|
|
245
254
|
/**
|
|
246
255
|
* Give the backing view web focus so keyboard input reaches its page.
|
|
247
256
|
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
@@ -344,6 +353,114 @@ export interface CdpEvaluateParams {
|
|
|
344
353
|
/** CDP method for a full-page screenshot capture. */
|
|
345
354
|
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
|
|
346
355
|
|
|
356
|
+
/** One cookie as CDP reports it (`Storage.getCookies`). */
|
|
357
|
+
interface CdpCookie {
|
|
358
|
+
readonly name?: unknown
|
|
359
|
+
readonly value?: unknown
|
|
360
|
+
readonly domain?: unknown
|
|
361
|
+
readonly path?: unknown
|
|
362
|
+
readonly expires?: unknown
|
|
363
|
+
readonly httpOnly?: unknown
|
|
364
|
+
readonly secure?: unknown
|
|
365
|
+
readonly sameSite?: unknown
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The document size out of a `Page.getLayoutMetrics` response.
|
|
370
|
+
*
|
|
371
|
+
* CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
|
|
372
|
+
* visible); the newer spellings `contentSize` / `layoutViewport` appear on some
|
|
373
|
+
* versions, so both are accepted. Returns undefined when neither is usable, leaving the
|
|
374
|
+
* caller to capture unscaled rather than guess.
|
|
375
|
+
* @param metrics - the raw CDP response.
|
|
376
|
+
* @returns the document and viewport size in CSS pixels.
|
|
377
|
+
*/
|
|
378
|
+
export function layoutSize(metrics: Record<string, unknown>): { width: number; height: number; viewportHeight: number } | undefined {
|
|
379
|
+
const pick = (candidates: readonly unknown[]): { width: number; height: number } | undefined => {
|
|
380
|
+
for (const candidate of candidates) {
|
|
381
|
+
if (typeof candidate !== 'object' || candidate === null) continue
|
|
382
|
+
const box = candidate as { width?: unknown; height?: unknown }
|
|
383
|
+
if (typeof box.width === 'number' && typeof box.height === 'number' && box.width > 0 && box.height > 0) {
|
|
384
|
+
return { width: box.width, height: box.height }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return undefined
|
|
388
|
+
}
|
|
389
|
+
const content = pick([metrics.cssContentSize, metrics.contentSize])
|
|
390
|
+
const viewport = pick([metrics.cssLayoutViewport, metrics.layoutViewport])
|
|
391
|
+
if (content === undefined) return undefined
|
|
392
|
+
return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Map CDP cookies onto the shape `browser_auth` exports.
|
|
397
|
+
*
|
|
398
|
+
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
399
|
+
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
400
|
+
* dropped rather than exported as broken cookies the caller cannot restore.
|
|
401
|
+
* @param raw - the `cookies` array from a CDP response, of unknown shape.
|
|
402
|
+
* @returns the mappable cookies.
|
|
403
|
+
*/
|
|
404
|
+
export function toExportedCookies(raw: unknown): ExportedCookie[] {
|
|
405
|
+
if (!Array.isArray(raw)) return []
|
|
406
|
+
const out: ExportedCookie[] = []
|
|
407
|
+
for (const entry of raw) {
|
|
408
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
409
|
+
const cookie = entry as CdpCookie
|
|
410
|
+
// An empty domain is as unusable as a missing one: it cannot form a cookie URL, so
|
|
411
|
+
// exporting it would hand the caller something that fails to restore.
|
|
412
|
+
if (typeof cookie.name !== 'string' || cookie.name === '') continue
|
|
413
|
+
if (typeof cookie.value !== 'string') continue
|
|
414
|
+
if (typeof cookie.domain !== 'string' || cookie.domain === '') continue
|
|
415
|
+
const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
|
|
416
|
+
// A leading dot means "this domain and its subdomains"; a cookie URL must not keep it.
|
|
417
|
+
const host = cookie.domain.replace(/^\./, '')
|
|
418
|
+
out.push({
|
|
419
|
+
url: `${cookie.secure === true ? 'https' : 'http'}://${host}${path}`,
|
|
420
|
+
name: cookie.name,
|
|
421
|
+
value: cookie.value,
|
|
422
|
+
domain: cookie.domain,
|
|
423
|
+
path,
|
|
424
|
+
...cookie.secure === true ? { secure: true } : {},
|
|
425
|
+
...cookie.httpOnly === true ? { httpOnly: true } : {},
|
|
426
|
+
...typeof cookie.expires === 'number' && cookie.expires > 0 ? { expirationDate: cookie.expires } : {},
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
return out
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Map an exported cookie onto the fields `Storage.setCookies` expects.
|
|
434
|
+
*
|
|
435
|
+
* An exported cookie may carry only a URL, so the domain and path are recovered from it
|
|
436
|
+
* when they are absent; without either, CDP cannot place the cookie and it is skipped.
|
|
437
|
+
* @param cookie - the cookie to convert.
|
|
438
|
+
* @returns the CDP cookie, or undefined when it lacks a usable domain.
|
|
439
|
+
*/
|
|
440
|
+
export function toCdpCookie(cookie: ExportedCookie): Record<string, unknown> | undefined {
|
|
441
|
+
let domain = cookie.domain
|
|
442
|
+
let path = cookie.path
|
|
443
|
+
if (domain === undefined || domain === '') {
|
|
444
|
+
try {
|
|
445
|
+
const parsed = new URL(cookie.url)
|
|
446
|
+
domain = parsed.hostname
|
|
447
|
+
if (path === undefined || path === '') path = parsed.pathname === '' ? '/' : parsed.pathname
|
|
448
|
+
} catch {
|
|
449
|
+
return undefined
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (typeof domain !== 'string' || domain === '') return undefined
|
|
453
|
+
return {
|
|
454
|
+
name: cookie.name,
|
|
455
|
+
value: cookie.value,
|
|
456
|
+
domain,
|
|
457
|
+
path: path === undefined || path === '' ? '/' : path,
|
|
458
|
+
...cookie.secure === true ? { secure: true } : {},
|
|
459
|
+
...cookie.httpOnly === true ? { httpOnly: true } : {},
|
|
460
|
+
...typeof cookie.expirationDate === 'number' ? { expires: cookie.expirationDate } : {},
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
347
464
|
/** Native capture options the self-hosted view handle understands. */
|
|
348
465
|
export interface ScreenshotOptions {
|
|
349
466
|
readonly format?: 'png' | 'jpeg'
|
|
@@ -566,10 +683,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
566
683
|
const wanted = stripTabPrefix(tabId)
|
|
567
684
|
const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
|
|
568
685
|
if (own >= 0) return { s, index: own }
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
686
|
+
// Deliberately NOT searched across other sessions. Accepting a bare uuid and
|
|
687
|
+
// searching elsewhere are different things: ids are globally unique, so the first is
|
|
688
|
+
// harmless convenience, but the second lets a stale id close or switch a tab that
|
|
689
|
+
// belongs to another task — or to the human — while reporting success, which
|
|
690
|
+
// contradicts the isolation the tools promise.
|
|
573
691
|
const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
|
|
574
692
|
throw new BrowserError(
|
|
575
693
|
`browser: tab "${tabId}" is not open in this session (session tabs: ${ownIds})`,
|
|
@@ -630,6 +748,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
630
748
|
// Record as soon as the navigation has committed: history bookkeeping must
|
|
631
749
|
// not wait on the best-effort settle below (which only delays what the
|
|
632
750
|
// NEXT observation sees, not whether this action happened).
|
|
751
|
+
// The page has already moved by now, so a caller that aborted must be told that rather
|
|
752
|
+
// than handed a success: settleDocument returns quietly on abort by design, so without
|
|
753
|
+
// this the whole path completes exception-free — success reported, success recorded, and
|
|
754
|
+
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
755
|
+
signal?.throwIfAborted()
|
|
633
756
|
this.record(s, 'navigate', { url }, true)
|
|
634
757
|
await this.settleDocument(handle, before, signal)
|
|
635
758
|
this.recordVisit(s, handle)
|
|
@@ -767,7 +890,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
767
890
|
private async focusView(handle: ElectronViewHandle): Promise<void> {
|
|
768
891
|
if (handle.focus === undefined) return
|
|
769
892
|
try {
|
|
770
|
-
|
|
893
|
+
// Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
|
|
894
|
+
// and this runs before typing, so an unresponsive child would hang browser_key for
|
|
895
|
+
// good. A timeout is caught below exactly like any other focus failure, so the
|
|
896
|
+
// best-effort contract is unchanged.
|
|
897
|
+
await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out')
|
|
771
898
|
} catch {
|
|
772
899
|
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
773
900
|
// press; the dispatch below reports a real channel failure itself.
|
|
@@ -826,6 +953,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
826
953
|
}, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
|
|
827
954
|
return { ok: true, value }
|
|
828
955
|
} catch (error) {
|
|
956
|
+
// A caller that aborted is not a failure of the page: report its own reason, or the
|
|
957
|
+
// cancellation surfaces as "execute failed: AbortError" and — if the caller's reason
|
|
958
|
+
// happens to be named TimeoutError — as "timed out after 30000ms", quoting a budget
|
|
959
|
+
// that belongs to the caller rather than to this call.
|
|
960
|
+
if (signal?.aborted) throw signal.reason
|
|
829
961
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
830
962
|
throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
|
|
831
963
|
}
|
|
@@ -900,10 +1032,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
900
1032
|
return { ready: false, reason: misses.join('; ') }
|
|
901
1033
|
}
|
|
902
1034
|
// Sleep between polls, abortable by the caller.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
})
|
|
1035
|
+
// Sleep between polls; delay() removes its abort listener on both paths, which the
|
|
1036
|
+
// hand-rolled version here did not — a 30s wait leaked about 150 of them.
|
|
1037
|
+
await delay(200, signal)
|
|
907
1038
|
}
|
|
908
1039
|
}
|
|
909
1040
|
|
|
@@ -1641,7 +1772,31 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1641
1772
|
continue
|
|
1642
1773
|
}
|
|
1643
1774
|
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
1644
|
-
|
|
1775
|
+
let el = els[0]
|
|
1776
|
+
// The caller may name a kind. Honor it: the branches below read the element's own
|
|
1777
|
+
// 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.
|
|
1779
|
+
if (spec.kind) {
|
|
1780
|
+
const matchesKind = (node) => {
|
|
1781
|
+
const nodeTag = node.tagName
|
|
1782
|
+
const nodeType = (node.type || '').toLowerCase()
|
|
1783
|
+
if (spec.kind === 'select') return nodeTag === 'SELECT'
|
|
1784
|
+
if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
|
|
1785
|
+
if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
|
|
1786
|
+
return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
|
|
1787
|
+
}
|
|
1788
|
+
const wanted = els.find(matchesKind)
|
|
1789
|
+
if (wanted === undefined) {
|
|
1790
|
+
out.push({ ok: false, error: 'no "' + spec.kind + '" control matched', target: describe(spec) })
|
|
1791
|
+
continue
|
|
1792
|
+
}
|
|
1793
|
+
els.length = 0
|
|
1794
|
+
els.push(wanted)
|
|
1795
|
+
// el was bound before the filter narrowed els, so the branches below would still
|
|
1796
|
+
// read the first match — the very silent wrong-element behaviour this filter exists
|
|
1797
|
+
// to remove.
|
|
1798
|
+
el = els[0]
|
|
1799
|
+
}
|
|
1645
1800
|
const tag = el.tagName
|
|
1646
1801
|
const type = (el.type || '').toLowerCase()
|
|
1647
1802
|
try {
|
|
@@ -2057,7 +2212,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2057
2212
|
})()`
|
|
2058
2213
|
const result = await withTimeout(
|
|
2059
2214
|
handleSendEvaluate(tab.handle, script),
|
|
2060
|
-
timeoutMs,
|
|
2215
|
+
timeoutMs + TARGET_SCRIPT_GRACE_MS,
|
|
2061
2216
|
signal,
|
|
2062
2217
|
`browser: scrape timed out after ${timeoutMs}ms`,
|
|
2063
2218
|
() => terminatePage(tab.handle),
|
|
@@ -2165,29 +2320,65 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2165
2320
|
* Self-hosted only; the desktop shell's embedded views use the real profile.
|
|
2166
2321
|
*/
|
|
2167
2322
|
async flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]> {
|
|
2323
|
+
// The settings panel offers "allow the agent to read cookies / export login state".
|
|
2324
|
+
// Until now nothing read that switch, so turning it off changed nothing at all —
|
|
2325
|
+
// the tool exported every cookie regardless.
|
|
2326
|
+
if (this.settingsSource?.().credentials.allowRead === false) {
|
|
2327
|
+
throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED')
|
|
2328
|
+
}
|
|
2168
2329
|
const s = this.session(session)
|
|
2169
2330
|
const { handle } = this.activeTab(s)
|
|
2170
|
-
const host = handle as { flushAuth?(): Promise<ExportedCookie[]> }
|
|
2171
|
-
if (typeof host.flushAuth !== 'function') {
|
|
2172
|
-
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
2173
|
-
}
|
|
2174
2331
|
const timeoutMs = 30_000
|
|
2175
|
-
|
|
2176
|
-
|
|
2332
|
+
// The self-hosted carrier has a native path (the child reads its own session), which
|
|
2333
|
+
// is worth preferring: it reports exactly what the browser persisted.
|
|
2334
|
+
const native = handle as { flushAuth?(): Promise<readonly ExportedCookie[]> }
|
|
2335
|
+
if (typeof native.flushAuth === 'function') {
|
|
2336
|
+
const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
|
|
2337
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
|
|
2338
|
+
return cookies
|
|
2339
|
+
}
|
|
2340
|
+
// Every other carrier drives a real browser over CDP, and cookies are part of CDP —
|
|
2341
|
+
// the sidebar goes through the shell's webContents.debugger, an installed Chrome or
|
|
2342
|
+
// Edge is the browser itself. So this works everywhere rather than only self-hosted.
|
|
2343
|
+
const result = await withTimeout(
|
|
2344
|
+
handle.sendCommand('Storage.getCookies', {}),
|
|
2345
|
+
timeoutMs,
|
|
2346
|
+
undefined,
|
|
2347
|
+
`browser: auth export timed out after ${timeoutMs}ms`,
|
|
2348
|
+
)
|
|
2349
|
+
const cookies = toExportedCookies(result.cookies)
|
|
2350
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` })
|
|
2177
2351
|
return cookies
|
|
2178
2352
|
}
|
|
2179
2353
|
|
|
2180
2354
|
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
2181
2355
|
async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
|
|
2356
|
+
// The settings panel offers "allow the agent to read cookies / export login state".
|
|
2357
|
+
// Until now nothing read that switch, so turning it off changed nothing at all —
|
|
2358
|
+
// the tool exported every cookie regardless.
|
|
2359
|
+
if (this.settingsSource?.().credentials.allowRead === false) {
|
|
2360
|
+
throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED')
|
|
2361
|
+
}
|
|
2182
2362
|
const s = this.session(session)
|
|
2183
2363
|
const { handle } = this.activeTab(s)
|
|
2184
|
-
const host = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
|
|
2185
|
-
if (typeof host.restoreAuth !== 'function') {
|
|
2186
|
-
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
2187
|
-
}
|
|
2188
2364
|
const timeoutMs = 30_000
|
|
2189
|
-
const
|
|
2190
|
-
|
|
2365
|
+
const native = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
|
|
2366
|
+
if (typeof native.restoreAuth === 'function') {
|
|
2367
|
+
const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
|
|
2368
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
|
|
2369
|
+
return restored
|
|
2370
|
+
}
|
|
2371
|
+
// CDP again, for the same reason as the export: any carrier that drives a real
|
|
2372
|
+
// browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
|
|
2373
|
+
// the CDP way, including the sameSite spelling and seconds rather than milliseconds.
|
|
2374
|
+
const result = await withTimeout(
|
|
2375
|
+
handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }),
|
|
2376
|
+
timeoutMs,
|
|
2377
|
+
undefined,
|
|
2378
|
+
`browser: auth restore timed out after ${timeoutMs}ms`,
|
|
2379
|
+
)
|
|
2380
|
+
const restored = toExportedCookies(result.cookies).length
|
|
2381
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` })
|
|
2191
2382
|
return restored
|
|
2192
2383
|
}
|
|
2193
2384
|
|
|
@@ -2227,16 +2418,50 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2227
2418
|
}
|
|
2228
2419
|
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png')
|
|
2229
2420
|
}
|
|
2230
|
-
// Fallback: a
|
|
2231
|
-
// uses CDP; full-page needs `captureBeyondViewport
|
|
2232
|
-
//
|
|
2421
|
+
// Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
|
|
2422
|
+
// or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
|
|
2423
|
+
//
|
|
2424
|
+
// JPEG: Electron 43's CDP hangs on it, which is why the sidebar stays PNG. An
|
|
2425
|
+
// installed Chrome or Edge is the real browser and has no such defect, so it says so
|
|
2426
|
+
// through `supportsCdpJpeg` and gets the format it asked for, and the same for
|
|
2427
|
+
// downscaling, which CDP performs through a full-document clip.
|
|
2428
|
+
const timeoutMs = 30_000
|
|
2233
2429
|
const params: Record<string, unknown> = {}
|
|
2234
2430
|
if (request?.fullPage === true) {
|
|
2235
2431
|
// `captureBeyondViewport` captures the full scrollable content; without
|
|
2236
2432
|
// a clip this yields the full-page image (CDP default is the viewport).
|
|
2237
2433
|
params.captureBeyondViewport = true
|
|
2238
2434
|
}
|
|
2239
|
-
const
|
|
2435
|
+
const supportsJpeg = (handle as { supportsCdpJpeg?: boolean }).supportsCdpJpeg === true
|
|
2436
|
+
if (format === 'jpeg' && supportsJpeg) {
|
|
2437
|
+
params.format = 'jpeg'
|
|
2438
|
+
// CDP range is 0-100 and defaults to 80 when omitted.
|
|
2439
|
+
if (request?.quality !== undefined) params.quality = request.quality
|
|
2440
|
+
}
|
|
2441
|
+
// Downscaling on this path: CDP scales through `clip.scale`, and a clip only applies
|
|
2442
|
+
// to the region it names, so it has to cover the whole document. The page's own size
|
|
2443
|
+
// is read first; when that is unavailable the capture proceeds unscaled rather than
|
|
2444
|
+
// failing — an image the caller can shrink itself beats no image at all.
|
|
2445
|
+
if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
|
|
2446
|
+
const metrics = await withTimeout(
|
|
2447
|
+
handle.sendCommand('Page.getLayoutMetrics', {}),
|
|
2448
|
+
timeoutMs,
|
|
2449
|
+
signal,
|
|
2450
|
+
`browser: screenshot timed out after ${timeoutMs}ms`,
|
|
2451
|
+
)
|
|
2452
|
+
const size = layoutSize(metrics)
|
|
2453
|
+
if (size !== undefined) {
|
|
2454
|
+
const height = request.fullPage === true ? size.height : size.viewportHeight
|
|
2455
|
+
const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1
|
|
2456
|
+
const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1
|
|
2457
|
+
const scale = Math.min(1, byWidth, byHeight)
|
|
2458
|
+
if (scale < 1) {
|
|
2459
|
+
params.clip = { x: 0, y: 0, width: size.width, height, scale }
|
|
2460
|
+
// The clip defines the captured area; captureBeyondViewport must not also apply.
|
|
2461
|
+
delete params.captureBeyondViewport
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2240
2465
|
const result = await withTimeout(
|
|
2241
2466
|
handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
|
|
2242
2467
|
timeoutMs,
|
|
@@ -2583,17 +2808,23 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2583
2808
|
if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
|
|
2584
2809
|
}
|
|
2585
2810
|
|
|
2586
|
-
/**
|
|
2811
|
+
/**
|
|
2812
|
+
* Ask the host to show the active tab's view, carrying the session label.
|
|
2813
|
+
*
|
|
2814
|
+
* The two halves are sequential, not alternatives. `showView` makes the view the
|
|
2815
|
+
* visible one — which is what keeps what the human sees in step with what the agent
|
|
2816
|
+
* drives, and what lets `capturePage` return an image at all (a hidden view captures
|
|
2817
|
+
* empty) — while `collapse` folds the carrier's own chrome away when the user asked
|
|
2818
|
+
* for no auto-expand. Making them exclusive meant that with auto-expand off this
|
|
2819
|
+
* method did nothing at all on carriers without `collapse` (the self-hosted one),
|
|
2820
|
+
* silently desynchronising the two and failing every screenshot.
|
|
2821
|
+
*/
|
|
2587
2822
|
private showActive(s: Session): void {
|
|
2588
|
-
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2589
|
-
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2590
|
-
// driven — it is simply folded away, so the agent works without claiming the
|
|
2591
|
-
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2592
|
-
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2593
|
-
void this.host.collapse?.().catch(() => undefined)
|
|
2594
|
-
return
|
|
2595
|
-
}
|
|
2596
2823
|
this.host.showView?.(this.activeTab(s).handle, s.label)
|
|
2824
|
+
const autoExpand = this.settingsSource === undefined || this.settingsSource().ui.autoExpandOnce
|
|
2825
|
+
if (autoExpand) return
|
|
2826
|
+
// Best effort: a carrier that owns no presentation (self-hosted) simply has none.
|
|
2827
|
+
void this.host.collapse?.().catch(() => undefined)
|
|
2597
2828
|
}
|
|
2598
2829
|
|
|
2599
2830
|
/** Read the current URL of a view through CDP. */
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* @module dsh-browser/browser-electron/remote-host
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { execFileSync, spawn
|
|
28
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
29
29
|
import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
|
30
30
|
import { randomBytes } from 'node:crypto'
|
|
31
31
|
import { createRequire } from 'node:module'
|
|
@@ -907,8 +907,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
907
907
|
|
|
908
908
|
/** The child died: tear down so the next use starts a fresh child. */
|
|
909
909
|
private onChildExit(): void {
|
|
910
|
-
|
|
911
|
-
|
|
910
|
+
// A disposed host still has to drop these: returning here left a child that had
|
|
911
|
+
// already been replaced during a dispose race in no map at all, so nothing would
|
|
912
|
+
// ever kill it. Only the restart is suppressed, not the cleanup.
|
|
913
|
+
const wasDisposed = this.disposed
|
|
914
|
+
if (!wasDisposed) process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
|
|
912
915
|
this.client = undefined
|
|
913
916
|
this.server?.close()
|
|
914
917
|
this.server = undefined
|
|
@@ -202,11 +202,18 @@ export class SettingsStore {
|
|
|
202
202
|
try {
|
|
203
203
|
mkdirSync(dirname(this.file), { recursive: true })
|
|
204
204
|
writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
|
205
|
-
} catch {
|
|
206
|
-
//
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Do NOT publish the new value as cached, and do not pretend the file changed: a
|
|
207
|
+
// reader compares the file's mtime against this stamp, so stamping "now" on a failed
|
|
208
|
+
// write made the next get() re-read the file and silently revert the user's change.
|
|
209
|
+
// Clear the cache before failing: the value on disk is still the old one, so leaving
|
|
210
|
+
// the merged document cached would hand the next reader something that does not exist —
|
|
211
|
+
// and the stamp would make it look fresh until the mtime comparison rolled it back.
|
|
212
|
+
this.cached = undefined
|
|
213
|
+
this.cachedMtimeMs = -1
|
|
214
|
+
throw new Error(`dsh-builtin-browser: settings could not be saved: ${error instanceof Error ? error.message : String(error)}`)
|
|
215
|
+
|
|
207
216
|
}
|
|
208
|
-
this.cached = merged
|
|
209
|
-
this.cachedMtimeMs = Date.now()
|
|
210
217
|
return merged
|
|
211
218
|
}
|
|
212
219
|
}
|