dsh-builtin-browser 0.3.1 → 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 +75 -30
- package/README.md +70 -30
- 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 +22 -2
- 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 +27 -6
- package/lib/browser-electron/system-browser.js +222 -31
- 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 +22 -2
- 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 +210 -30
- package/src/tool-browser/index.ts +148 -62
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How each product is named in the message, and what to install.
|
|
3
|
+
*
|
|
4
|
+
* Only chrome and edge can appear: the constructor's type excludes `bundled` and `auto`,
|
|
5
|
+
* and `BrowserChannel` has no `brave`. A brave entry here would be unreachable code.
|
|
6
|
+
*/
|
|
7
|
+
const PRODUCT = {
|
|
8
|
+
chrome: {
|
|
9
|
+
label: 'Google Chrome',
|
|
10
|
+
install: 'install Google Chrome',
|
|
11
|
+
variable: 'DSH_BROWSER_CHROME_PATH',
|
|
12
|
+
},
|
|
13
|
+
edge: {
|
|
14
|
+
label: 'Microsoft Edge',
|
|
15
|
+
install: 'install Microsoft Edge',
|
|
16
|
+
variable: 'DSH_BROWSER_EDGE_PATH',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
/** How the setting is named to the user. */
|
|
20
|
+
const SETTING_LABEL = {
|
|
21
|
+
chrome: '本机 Chrome / installed Chrome',
|
|
22
|
+
edge: '本机 Edge / installed Edge',
|
|
23
|
+
auto: '自动 / automatic',
|
|
24
|
+
bundled: '内置 / bundled',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A carrier that cannot carry anything: every command explains the real problem.
|
|
28
|
+
*
|
|
29
|
+
* The message is written to be acted on, not just read: it says which browser was
|
|
30
|
+
* requested, where the plugin looked, and the three concrete ways to resolve it.
|
|
31
|
+
*/
|
|
32
|
+
export class MissingSystemBrowserHost {
|
|
33
|
+
kind;
|
|
34
|
+
searched;
|
|
35
|
+
/**
|
|
36
|
+
* @param kind - the browser the user asked for.
|
|
37
|
+
* @param searched - the names and locations that were checked, for the report.
|
|
38
|
+
*/
|
|
39
|
+
constructor(kind, searched) {
|
|
40
|
+
this.kind = kind;
|
|
41
|
+
this.searched = searched;
|
|
42
|
+
}
|
|
43
|
+
/** @returns always true: see the module comment — reporting false would hide this. */
|
|
44
|
+
available() {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The message every command fails with.
|
|
49
|
+
* @returns a multi-line explanation with the fixes.
|
|
50
|
+
*/
|
|
51
|
+
explain() {
|
|
52
|
+
const product = PRODUCT[this.kind] ?? { label: this.kind, install: `install ${this.kind}`, variable: 'DSH_BROWSER_<KIND>_PATH' };
|
|
53
|
+
const searched = this.searched.length > 0 ? this.searched.join(', ') : 'PATH and the usual install locations';
|
|
54
|
+
return [
|
|
55
|
+
`dsh-builtin-browser: the browser you selected is not installed — no ${product.label} was found.`,
|
|
56
|
+
``,
|
|
57
|
+
` Setting: browser carrier = "${SETTING_LABEL[this.kind] ?? this.kind}"`,
|
|
58
|
+
` Looked for: ${searched}`,
|
|
59
|
+
``,
|
|
60
|
+
`Nothing else is wrong: the plugin falls back to its own bundled browser only when the`,
|
|
61
|
+
`setting is "bundled" or "automatic". Because this one names a browser explicitly, it`,
|
|
62
|
+
`reports the missing browser instead of silently using a different one.`,
|
|
63
|
+
``,
|
|
64
|
+
`To fix it, any ONE of these is enough:`,
|
|
65
|
+
` 1. ${product.install}, then restart DSH;`,
|
|
66
|
+
` 2. point ${product.variable} at its executable;`,
|
|
67
|
+
` 3. set the browser carrier back to "内置 / bundled" (or "自动 / automatic" to let it`,
|
|
68
|
+
` fall back on its own next time).`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create a handle whose every command fails with the explanation.
|
|
73
|
+
* @returns a handle that never reaches a page.
|
|
74
|
+
*/
|
|
75
|
+
createView() {
|
|
76
|
+
const message = this.explain();
|
|
77
|
+
return {
|
|
78
|
+
id: `missing-${this.kind}`,
|
|
79
|
+
sendCommand: async () => { throw new Error(message); },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Accept a handle for destruction. Nothing was created, so nothing is released.
|
|
84
|
+
* @param handle - the handle returned by {@link createView}.
|
|
85
|
+
*/
|
|
86
|
+
destroyView(handle) {
|
|
87
|
+
void handle;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -160,6 +160,15 @@ export interface ElectronViewHandle {
|
|
|
160
160
|
* @returns the CDP `result` object.
|
|
161
161
|
*/
|
|
162
162
|
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
163
|
+
/**
|
|
164
|
+
* Whether this view's CDP can encode JPEG and honour a quality setting.
|
|
165
|
+
*
|
|
166
|
+
* Electron 43's CDP hangs on `Page.captureScreenshot` with `format: 'jpeg'`, so a view
|
|
167
|
+
* backed by Electron must leave this unset and the provider keeps to PNG. A view backed
|
|
168
|
+
* by an installed Chrome or Edge is the real browser, where JPEG works, and sets it so
|
|
169
|
+
* the requested format is not silently downgraded.
|
|
170
|
+
*/
|
|
171
|
+
readonly supportsCdpJpeg?: boolean;
|
|
163
172
|
/**
|
|
164
173
|
* Give the backing view web focus so keyboard input reaches its page.
|
|
165
174
|
* Optional: an adapter that cannot focus a view omits it, and the provider
|
|
@@ -236,6 +245,40 @@ export interface CdpEvaluateParams {
|
|
|
236
245
|
}
|
|
237
246
|
/** CDP method for a full-page screenshot capture. */
|
|
238
247
|
export declare const CDP_PAGE_CAPTURE_SCREENSHOT = "Page.captureScreenshot";
|
|
248
|
+
/**
|
|
249
|
+
* The document size out of a `Page.getLayoutMetrics` response.
|
|
250
|
+
*
|
|
251
|
+
* CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
|
|
252
|
+
* visible); the newer spellings `contentSize` / `layoutViewport` appear on some
|
|
253
|
+
* versions, so both are accepted. Returns undefined when neither is usable, leaving the
|
|
254
|
+
* caller to capture unscaled rather than guess.
|
|
255
|
+
* @param metrics - the raw CDP response.
|
|
256
|
+
* @returns the document and viewport size in CSS pixels.
|
|
257
|
+
*/
|
|
258
|
+
export declare function layoutSize(metrics: Record<string, unknown>): {
|
|
259
|
+
width: number;
|
|
260
|
+
height: number;
|
|
261
|
+
viewportHeight: number;
|
|
262
|
+
} | undefined;
|
|
263
|
+
/**
|
|
264
|
+
* Map CDP cookies onto the shape `browser_auth` exports.
|
|
265
|
+
*
|
|
266
|
+
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
267
|
+
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
268
|
+
* dropped rather than exported as broken cookies the caller cannot restore.
|
|
269
|
+
* @param raw - the `cookies` array from a CDP response, of unknown shape.
|
|
270
|
+
* @returns the mappable cookies.
|
|
271
|
+
*/
|
|
272
|
+
export declare function toExportedCookies(raw: unknown): ExportedCookie[];
|
|
273
|
+
/**
|
|
274
|
+
* Map an exported cookie onto the fields `Storage.setCookies` expects.
|
|
275
|
+
*
|
|
276
|
+
* An exported cookie may carry only a URL, so the domain and path are recovered from it
|
|
277
|
+
* when they are absent; without either, CDP cannot place the cookie and it is skipped.
|
|
278
|
+
* @param cookie - the cookie to convert.
|
|
279
|
+
* @returns the CDP cookie, or undefined when it lacks a usable domain.
|
|
280
|
+
*/
|
|
281
|
+
export declare function toCdpCookie(cookie: ExportedCookie): Record<string, unknown> | undefined;
|
|
239
282
|
/** Native capture options the self-hosted view handle understands. */
|
|
240
283
|
export interface ScreenshotOptions {
|
|
241
284
|
readonly format?: 'png' | 'jpeg';
|
|
@@ -595,7 +638,17 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
|
595
638
|
* diagnostics: it can never be allowed to become the failure.
|
|
596
639
|
*/
|
|
597
640
|
private notifyUserActionError;
|
|
598
|
-
/**
|
|
641
|
+
/**
|
|
642
|
+
* Ask the host to show the active tab's view, carrying the session label.
|
|
643
|
+
*
|
|
644
|
+
* The two halves are sequential, not alternatives. `showView` makes the view the
|
|
645
|
+
* visible one — which is what keeps what the human sees in step with what the agent
|
|
646
|
+
* drives, and what lets `capturePage` return an image at all (a hidden view captures
|
|
647
|
+
* empty) — while `collapse` folds the carrier's own chrome away when the user asked
|
|
648
|
+
* for no auto-expand. Making them exclusive meant that with auto-expand off this
|
|
649
|
+
* method did nothing at all on carriers without `collapse` (the self-hosted one),
|
|
650
|
+
* silently desynchronising the two and failing every screenshot.
|
|
651
|
+
*/
|
|
599
652
|
private showActive;
|
|
600
653
|
/** Read the current URL of a view through CDP. */
|
|
601
654
|
private currentUrl;
|
|
@@ -70,6 +70,109 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
|
70
70
|
export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
|
|
71
71
|
/** CDP method for a full-page screenshot capture. */
|
|
72
72
|
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
|
|
73
|
+
/**
|
|
74
|
+
* The document size out of a `Page.getLayoutMetrics` response.
|
|
75
|
+
*
|
|
76
|
+
* CDP returns `cssContentSize` (the whole document) and `cssLayoutViewport` (what is
|
|
77
|
+
* visible); the newer spellings `contentSize` / `layoutViewport` appear on some
|
|
78
|
+
* versions, so both are accepted. Returns undefined when neither is usable, leaving the
|
|
79
|
+
* caller to capture unscaled rather than guess.
|
|
80
|
+
* @param metrics - the raw CDP response.
|
|
81
|
+
* @returns the document and viewport size in CSS pixels.
|
|
82
|
+
*/
|
|
83
|
+
export function layoutSize(metrics) {
|
|
84
|
+
const pick = (candidates) => {
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
87
|
+
continue;
|
|
88
|
+
const box = candidate;
|
|
89
|
+
if (typeof box.width === 'number' && typeof box.height === 'number' && box.width > 0 && box.height > 0) {
|
|
90
|
+
return { width: box.width, height: box.height };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
const content = pick([metrics.cssContentSize, metrics.contentSize]);
|
|
96
|
+
const viewport = pick([metrics.cssLayoutViewport, metrics.layoutViewport]);
|
|
97
|
+
if (content === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Map CDP cookies onto the shape `browser_auth` exports.
|
|
103
|
+
*
|
|
104
|
+
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
105
|
+
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
106
|
+
* dropped rather than exported as broken cookies the caller cannot restore.
|
|
107
|
+
* @param raw - the `cookies` array from a CDP response, of unknown shape.
|
|
108
|
+
* @returns the mappable cookies.
|
|
109
|
+
*/
|
|
110
|
+
export function toExportedCookies(raw) {
|
|
111
|
+
if (!Array.isArray(raw))
|
|
112
|
+
return [];
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const entry of raw) {
|
|
115
|
+
if (typeof entry !== 'object' || entry === null)
|
|
116
|
+
continue;
|
|
117
|
+
const cookie = entry;
|
|
118
|
+
// An empty domain is as unusable as a missing one: it cannot form a cookie URL, so
|
|
119
|
+
// exporting it would hand the caller something that fails to restore.
|
|
120
|
+
if (typeof cookie.name !== 'string' || cookie.name === '')
|
|
121
|
+
continue;
|
|
122
|
+
if (typeof cookie.value !== 'string')
|
|
123
|
+
continue;
|
|
124
|
+
if (typeof cookie.domain !== 'string' || cookie.domain === '')
|
|
125
|
+
continue;
|
|
126
|
+
const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/';
|
|
127
|
+
// A leading dot means "this domain and its subdomains"; a cookie URL must not keep it.
|
|
128
|
+
const host = cookie.domain.replace(/^\./, '');
|
|
129
|
+
out.push({
|
|
130
|
+
url: `${cookie.secure === true ? 'https' : 'http'}://${host}${path}`,
|
|
131
|
+
name: cookie.name,
|
|
132
|
+
value: cookie.value,
|
|
133
|
+
domain: cookie.domain,
|
|
134
|
+
path,
|
|
135
|
+
...cookie.secure === true ? { secure: true } : {},
|
|
136
|
+
...cookie.httpOnly === true ? { httpOnly: true } : {},
|
|
137
|
+
...typeof cookie.expires === 'number' && cookie.expires > 0 ? { expirationDate: cookie.expires } : {},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Map an exported cookie onto the fields `Storage.setCookies` expects.
|
|
144
|
+
*
|
|
145
|
+
* An exported cookie may carry only a URL, so the domain and path are recovered from it
|
|
146
|
+
* when they are absent; without either, CDP cannot place the cookie and it is skipped.
|
|
147
|
+
* @param cookie - the cookie to convert.
|
|
148
|
+
* @returns the CDP cookie, or undefined when it lacks a usable domain.
|
|
149
|
+
*/
|
|
150
|
+
export function toCdpCookie(cookie) {
|
|
151
|
+
let domain = cookie.domain;
|
|
152
|
+
let path = cookie.path;
|
|
153
|
+
if (domain === undefined || domain === '') {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = new URL(cookie.url);
|
|
156
|
+
domain = parsed.hostname;
|
|
157
|
+
if (path === undefined || path === '')
|
|
158
|
+
path = parsed.pathname === '' ? '/' : parsed.pathname;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (typeof domain !== 'string' || domain === '')
|
|
165
|
+
return undefined;
|
|
166
|
+
return {
|
|
167
|
+
name: cookie.name,
|
|
168
|
+
value: cookie.value,
|
|
169
|
+
domain,
|
|
170
|
+
path: path === undefined || path === '' ? '/' : path,
|
|
171
|
+
...cookie.secure === true ? { secure: true } : {},
|
|
172
|
+
...cookie.httpOnly === true ? { httpOnly: true } : {},
|
|
173
|
+
...typeof cookie.expirationDate === 'number' ? { expires: cookie.expirationDate } : {},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
73
176
|
/** CDP method for runtime evaluation (the execute path). */
|
|
74
177
|
export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
|
|
75
178
|
/** CDP method for navigation. */
|
|
@@ -275,11 +378,11 @@ export class ElectronBrowserProvider {
|
|
|
275
378
|
const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
|
|
276
379
|
if (own >= 0)
|
|
277
380
|
return { s, index: own };
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
381
|
+
// Deliberately NOT searched across other sessions. Accepting a bare uuid and
|
|
382
|
+
// searching elsewhere are different things: ids are globally unique, so the first is
|
|
383
|
+
// harmless convenience, but the second lets a stale id close or switch a tab that
|
|
384
|
+
// belongs to another task — or to the human — while reporting success, which
|
|
385
|
+
// contradicts the isolation the tools promise.
|
|
283
386
|
const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)';
|
|
284
387
|
throw new BrowserError(`browser: tab "${tabId}" is not open in this session (session tabs: ${ownIds})`, 'BROWSER_TAB_UNKNOWN');
|
|
285
388
|
}
|
|
@@ -332,6 +435,11 @@ export class ElectronBrowserProvider {
|
|
|
332
435
|
// Record as soon as the navigation has committed: history bookkeeping must
|
|
333
436
|
// not wait on the best-effort settle below (which only delays what the
|
|
334
437
|
// NEXT observation sees, not whether this action happened).
|
|
438
|
+
// The page has already moved by now, so a caller that aborted must be told that rather
|
|
439
|
+
// than handed a success: settleDocument returns quietly on abort by design, so without
|
|
440
|
+
// this the whole path completes exception-free — success reported, success recorded, and
|
|
441
|
+
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
442
|
+
signal?.throwIfAborted();
|
|
335
443
|
this.record(s, 'navigate', { url }, true);
|
|
336
444
|
await this.settleDocument(handle, before, signal);
|
|
337
445
|
this.recordVisit(s, handle);
|
|
@@ -460,7 +568,11 @@ export class ElectronBrowserProvider {
|
|
|
460
568
|
if (handle.focus === undefined)
|
|
461
569
|
return;
|
|
462
570
|
try {
|
|
463
|
-
|
|
571
|
+
// Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
|
|
572
|
+
// and this runs before typing, so an unresponsive child would hang browser_key for
|
|
573
|
+
// good. A timeout is caught below exactly like any other focus failure, so the
|
|
574
|
+
// best-effort contract is unchanged.
|
|
575
|
+
await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out');
|
|
464
576
|
}
|
|
465
577
|
catch {
|
|
466
578
|
// Best-effort: a view that refuses focus must not fail the caller's key
|
|
@@ -515,6 +627,12 @@ export class ElectronBrowserProvider {
|
|
|
515
627
|
return { ok: true, value };
|
|
516
628
|
}
|
|
517
629
|
catch (error) {
|
|
630
|
+
// A caller that aborted is not a failure of the page: report its own reason, or the
|
|
631
|
+
// cancellation surfaces as "execute failed: AbortError" and — if the caller's reason
|
|
632
|
+
// happens to be named TimeoutError — as "timed out after 30000ms", quoting a budget
|
|
633
|
+
// that belongs to the caller rather than to this call.
|
|
634
|
+
if (signal?.aborted)
|
|
635
|
+
throw signal.reason;
|
|
518
636
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
519
637
|
throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error });
|
|
520
638
|
}
|
|
@@ -585,10 +703,9 @@ export class ElectronBrowserProvider {
|
|
|
585
703
|
return { ready: false, reason: misses.join('; ') };
|
|
586
704
|
}
|
|
587
705
|
// Sleep between polls, abortable by the caller.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
});
|
|
706
|
+
// Sleep between polls; delay() removes its abort listener on both paths, which the
|
|
707
|
+
// hand-rolled version here did not — a 30s wait leaked about 150 of them.
|
|
708
|
+
await delay(200, signal);
|
|
592
709
|
}
|
|
593
710
|
}
|
|
594
711
|
/** Produce an AI-friendly snapshot of the active tab. */
|
|
@@ -1259,7 +1376,31 @@ export class ElectronBrowserProvider {
|
|
|
1259
1376
|
continue
|
|
1260
1377
|
}
|
|
1261
1378
|
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
1262
|
-
|
|
1379
|
+
let el = els[0]
|
|
1380
|
+
// The caller may name a kind. Honor it: the branches below read the element's own
|
|
1381
|
+
// 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.
|
|
1383
|
+
if (spec.kind) {
|
|
1384
|
+
const matchesKind = (node) => {
|
|
1385
|
+
const nodeTag = node.tagName
|
|
1386
|
+
const nodeType = (node.type || '').toLowerCase()
|
|
1387
|
+
if (spec.kind === 'select') return nodeTag === 'SELECT'
|
|
1388
|
+
if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
|
|
1389
|
+
if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
|
|
1390
|
+
return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
|
|
1391
|
+
}
|
|
1392
|
+
const wanted = els.find(matchesKind)
|
|
1393
|
+
if (wanted === undefined) {
|
|
1394
|
+
out.push({ ok: false, error: 'no "' + spec.kind + '" control matched', target: describe(spec) })
|
|
1395
|
+
continue
|
|
1396
|
+
}
|
|
1397
|
+
els.length = 0
|
|
1398
|
+
els.push(wanted)
|
|
1399
|
+
// el was bound before the filter narrowed els, so the branches below would still
|
|
1400
|
+
// read the first match — the very silent wrong-element behaviour this filter exists
|
|
1401
|
+
// to remove.
|
|
1402
|
+
el = els[0]
|
|
1403
|
+
}
|
|
1263
1404
|
const tag = el.tagName
|
|
1264
1405
|
const type = (el.type || '').toLowerCase()
|
|
1265
1406
|
try {
|
|
@@ -1652,7 +1793,7 @@ export class ElectronBrowserProvider {
|
|
|
1652
1793
|
}
|
|
1653
1794
|
return { ok: true, count: out.length, items: out }
|
|
1654
1795
|
})()`;
|
|
1655
|
-
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: scrape timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1796
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `browser: scrape timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1656
1797
|
if (!result.ok)
|
|
1657
1798
|
throw new BrowserError(`browser: scrape evaluation failed: ${result.exception}`, 'BROWSER_SCRAPE_FAILED');
|
|
1658
1799
|
const value = result.value;
|
|
@@ -1751,28 +1892,54 @@ export class ElectronBrowserProvider {
|
|
|
1751
1892
|
* Self-hosted only; the desktop shell's embedded views use the real profile.
|
|
1752
1893
|
*/
|
|
1753
1894
|
async flushAuth(session) {
|
|
1895
|
+
// The settings panel offers "allow the agent to read cookies / export login state".
|
|
1896
|
+
// Until now nothing read that switch, so turning it off changed nothing at all —
|
|
1897
|
+
// the tool exported every cookie regardless.
|
|
1898
|
+
if (this.settingsSource?.().credentials.allowRead === false) {
|
|
1899
|
+
throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED');
|
|
1900
|
+
}
|
|
1754
1901
|
const s = this.session(session);
|
|
1755
1902
|
const { handle } = this.activeTab(s);
|
|
1756
|
-
const host = handle;
|
|
1757
|
-
if (typeof host.flushAuth !== 'function') {
|
|
1758
|
-
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
1759
|
-
}
|
|
1760
1903
|
const timeoutMs = 30_000;
|
|
1761
|
-
|
|
1762
|
-
|
|
1904
|
+
// The self-hosted carrier has a native path (the child reads its own session), which
|
|
1905
|
+
// is worth preferring: it reports exactly what the browser persisted.
|
|
1906
|
+
const native = handle;
|
|
1907
|
+
if (typeof native.flushAuth === 'function') {
|
|
1908
|
+
const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
|
|
1909
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
|
|
1910
|
+
return cookies;
|
|
1911
|
+
}
|
|
1912
|
+
// Every other carrier drives a real browser over CDP, and cookies are part of CDP —
|
|
1913
|
+
// the sidebar goes through the shell's webContents.debugger, an installed Chrome or
|
|
1914
|
+
// Edge is the browser itself. So this works everywhere rather than only self-hosted.
|
|
1915
|
+
const result = await withTimeout(handle.sendCommand('Storage.getCookies', {}), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
|
|
1916
|
+
const cookies = toExportedCookies(result.cookies);
|
|
1917
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` });
|
|
1763
1918
|
return cookies;
|
|
1764
1919
|
}
|
|
1765
1920
|
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
1766
1921
|
async restoreAuth(session, cookies) {
|
|
1922
|
+
// The settings panel offers "allow the agent to read cookies / export login state".
|
|
1923
|
+
// Until now nothing read that switch, so turning it off changed nothing at all —
|
|
1924
|
+
// the tool exported every cookie regardless.
|
|
1925
|
+
if (this.settingsSource?.().credentials.allowRead === false) {
|
|
1926
|
+
throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED');
|
|
1927
|
+
}
|
|
1767
1928
|
const s = this.session(session);
|
|
1768
1929
|
const { handle } = this.activeTab(s);
|
|
1769
|
-
const host = handle;
|
|
1770
|
-
if (typeof host.restoreAuth !== 'function') {
|
|
1771
|
-
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
1772
|
-
}
|
|
1773
1930
|
const timeoutMs = 30_000;
|
|
1774
|
-
const
|
|
1775
|
-
|
|
1931
|
+
const native = handle;
|
|
1932
|
+
if (typeof native.restoreAuth === 'function') {
|
|
1933
|
+
const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
|
|
1934
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
|
|
1935
|
+
return restored;
|
|
1936
|
+
}
|
|
1937
|
+
// CDP again, for the same reason as the export: any carrier that drives a real
|
|
1938
|
+
// browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
|
|
1939
|
+
// the CDP way, including the sameSite spelling and seconds rather than milliseconds.
|
|
1940
|
+
const result = await withTimeout(handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
|
|
1941
|
+
const restored = toExportedCookies(result.cookies).length;
|
|
1942
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` });
|
|
1776
1943
|
return restored;
|
|
1777
1944
|
}
|
|
1778
1945
|
/** Capture the current page, optionally full-page, PNG or JPEG, scalable. */
|
|
@@ -1802,16 +1969,46 @@ export class ElectronBrowserProvider {
|
|
|
1802
1969
|
}
|
|
1803
1970
|
return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png');
|
|
1804
1971
|
}
|
|
1805
|
-
// Fallback: a
|
|
1806
|
-
// uses CDP; full-page needs `captureBeyondViewport
|
|
1807
|
-
//
|
|
1972
|
+
// Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
|
|
1973
|
+
// or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
|
|
1974
|
+
//
|
|
1975
|
+
// JPEG: Electron 43's CDP hangs on it, which is why the sidebar stays PNG. An
|
|
1976
|
+
// installed Chrome or Edge is the real browser and has no such defect, so it says so
|
|
1977
|
+
// through `supportsCdpJpeg` and gets the format it asked for, and the same for
|
|
1978
|
+
// downscaling, which CDP performs through a full-document clip.
|
|
1979
|
+
const timeoutMs = 30_000;
|
|
1808
1980
|
const params = {};
|
|
1809
1981
|
if (request?.fullPage === true) {
|
|
1810
1982
|
// `captureBeyondViewport` captures the full scrollable content; without
|
|
1811
1983
|
// a clip this yields the full-page image (CDP default is the viewport).
|
|
1812
1984
|
params.captureBeyondViewport = true;
|
|
1813
1985
|
}
|
|
1814
|
-
const
|
|
1986
|
+
const supportsJpeg = handle.supportsCdpJpeg === true;
|
|
1987
|
+
if (format === 'jpeg' && supportsJpeg) {
|
|
1988
|
+
params.format = 'jpeg';
|
|
1989
|
+
// CDP range is 0-100 and defaults to 80 when omitted.
|
|
1990
|
+
if (request?.quality !== undefined)
|
|
1991
|
+
params.quality = request.quality;
|
|
1992
|
+
}
|
|
1993
|
+
// Downscaling on this path: CDP scales through `clip.scale`, and a clip only applies
|
|
1994
|
+
// to the region it names, so it has to cover the whole document. The page's own size
|
|
1995
|
+
// is read first; when that is unavailable the capture proceeds unscaled rather than
|
|
1996
|
+
// failing — an image the caller can shrink itself beats no image at all.
|
|
1997
|
+
if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
|
|
1998
|
+
const metrics = await withTimeout(handle.sendCommand('Page.getLayoutMetrics', {}), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
|
|
1999
|
+
const size = layoutSize(metrics);
|
|
2000
|
+
if (size !== undefined) {
|
|
2001
|
+
const height = request.fullPage === true ? size.height : size.viewportHeight;
|
|
2002
|
+
const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1;
|
|
2003
|
+
const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1;
|
|
2004
|
+
const scale = Math.min(1, byWidth, byHeight);
|
|
2005
|
+
if (scale < 1) {
|
|
2006
|
+
params.clip = { x: 0, y: 0, width: size.width, height, scale };
|
|
2007
|
+
// The clip defines the captured area; captureBeyondViewport must not also apply.
|
|
2008
|
+
delete params.captureBeyondViewport;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
1815
2012
|
const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
|
|
1816
2013
|
const data = result.data;
|
|
1817
2014
|
if (typeof data !== 'string') {
|
|
@@ -2151,17 +2348,24 @@ export class ElectronBrowserProvider {
|
|
|
2151
2348
|
if (!reported)
|
|
2152
2349
|
process.stderr.write(`[dsh-browser] user action failed: ${message}\n`);
|
|
2153
2350
|
}
|
|
2154
|
-
/**
|
|
2351
|
+
/**
|
|
2352
|
+
* Ask the host to show the active tab's view, carrying the session label.
|
|
2353
|
+
*
|
|
2354
|
+
* The two halves are sequential, not alternatives. `showView` makes the view the
|
|
2355
|
+
* visible one — which is what keeps what the human sees in step with what the agent
|
|
2356
|
+
* drives, and what lets `capturePage` return an image at all (a hidden view captures
|
|
2357
|
+
* empty) — while `collapse` folds the carrier's own chrome away when the user asked
|
|
2358
|
+
* for no auto-expand. Making them exclusive meant that with auto-expand off this
|
|
2359
|
+
* method did nothing at all on carriers without `collapse` (the self-hosted one),
|
|
2360
|
+
* silently desynchronising the two and failing every screenshot.
|
|
2361
|
+
*/
|
|
2155
2362
|
showActive(s) {
|
|
2156
|
-
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2157
|
-
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2158
|
-
// driven — it is simply folded away, so the agent works without claiming the
|
|
2159
|
-
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2160
|
-
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2161
|
-
void this.host.collapse?.().catch(() => undefined);
|
|
2162
|
-
return;
|
|
2163
|
-
}
|
|
2164
2363
|
this.host.showView?.(this.activeTab(s).handle, s.label);
|
|
2364
|
+
const autoExpand = this.settingsSource === undefined || this.settingsSource().ui.autoExpandOnce;
|
|
2365
|
+
if (autoExpand)
|
|
2366
|
+
return;
|
|
2367
|
+
// Best effort: a carrier that owns no presentation (self-hosted) simply has none.
|
|
2368
|
+
void this.host.collapse?.().catch(() => undefined);
|
|
2165
2369
|
}
|
|
2166
2370
|
/** Read the current URL of a view through CDP. */
|
|
2167
2371
|
async currentUrl(handle) {
|
|
@@ -868,9 +868,12 @@ export class RemoteElectronViewHost {
|
|
|
868
868
|
}
|
|
869
869
|
/** The child died: tear down so the next use starts a fresh child. */
|
|
870
870
|
onChildExit() {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
871
|
+
// A disposed host still has to drop these: returning here left a child that had
|
|
872
|
+
// already been replaced during a dispose race in no map at all, so nothing would
|
|
873
|
+
// ever kill it. Only the restart is suppressed, not the cleanup.
|
|
874
|
+
const wasDisposed = this.disposed;
|
|
875
|
+
if (!wasDisposed)
|
|
876
|
+
process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n');
|
|
874
877
|
this.client = undefined;
|
|
875
878
|
this.server?.close();
|
|
876
879
|
this.server = undefined;
|
|
@@ -157,11 +157,17 @@ export class SettingsStore {
|
|
|
157
157
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
158
158
|
writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
159
159
|
}
|
|
160
|
-
catch {
|
|
161
|
-
//
|
|
160
|
+
catch (error) {
|
|
161
|
+
// Do NOT publish the new value as cached, and do not pretend the file changed: a
|
|
162
|
+
// reader compares the file's mtime against this stamp, so stamping "now" on a failed
|
|
163
|
+
// write made the next get() re-read the file and silently revert the user's change.
|
|
164
|
+
// Clear the cache before failing: the value on disk is still the old one, so leaving
|
|
165
|
+
// the merged document cached would hand the next reader something that does not exist —
|
|
166
|
+
// and the stamp would make it look fresh until the mtime comparison rolled it back.
|
|
167
|
+
this.cached = undefined;
|
|
168
|
+
this.cachedMtimeMs = -1;
|
|
169
|
+
throw new Error(`dsh-builtin-browser: settings could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
170
|
}
|
|
163
|
-
this.cached = merged;
|
|
164
|
-
this.cachedMtimeMs = Date.now();
|
|
165
171
|
return merged;
|
|
166
172
|
}
|
|
167
173
|
}
|
|
@@ -38,12 +38,28 @@ export interface DetectedBrowser {
|
|
|
38
38
|
* Find an installed Chromium browser.
|
|
39
39
|
*
|
|
40
40
|
* `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
|
|
41
|
-
* is to be the browser a user actually chose rather than one
|
|
41
|
+
* is to be the browser a user actually chose rather than one the system shipped.
|
|
42
|
+
* Each candidate is looked up in three places, most specific first: an explicit
|
|
43
|
+
* environment override, then PATH, then the platform's conventional install
|
|
44
|
+
* locations. PATH comes before the fixed paths because it is what actually reflects
|
|
45
|
+
* how the browser was installed.
|
|
42
46
|
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
43
47
|
* @param env - environment lookup, injected so tests need no real machine.
|
|
48
|
+
* @param platform - the platform to use conventions for; injected for the same reason.
|
|
44
49
|
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
45
50
|
*/
|
|
46
|
-
export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv): DetectedBrowser | undefined;
|
|
51
|
+
export declare function detectBrowser(channel: BrowserChannel, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): DetectedBrowser | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Everything that was checked for one product, for reporting a miss.
|
|
54
|
+
*
|
|
55
|
+
* Detection returns undefined with no explanation, which left the caller able to say
|
|
56
|
+
* only "not found". This gives the user the actual list so they can see whether the
|
|
57
|
+
* plugin looked somewhere their browser is not.
|
|
58
|
+
* @param kind - the product to describe.
|
|
59
|
+
* @param platform - the platform whose names and locations apply.
|
|
60
|
+
* @returns the launcher names (as PATH lookups) and the fixed locations checked.
|
|
61
|
+
*/
|
|
62
|
+
export declare function searchSummary(kind: DetectedBrowser['kind'], platform?: NodeJS.Platform): string[];
|
|
47
63
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
48
64
|
export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
49
65
|
private readonly browser;
|
|
@@ -106,11 +122,16 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
106
122
|
/** The browser owns window grouping. */
|
|
107
123
|
groupView(): void;
|
|
108
124
|
/**
|
|
109
|
-
*
|
|
125
|
+
* Best effort at bringing the browser's window forward.
|
|
110
126
|
*
|
|
111
|
-
* The browser is a separate application, so there is no view to raise
|
|
112
|
-
*
|
|
113
|
-
*
|
|
127
|
+
* The browser is a separate application, so there is no view to raise. What this can
|
|
128
|
+
* actually do is ask its process to continue from a stopped state — which is a no-op on
|
|
129
|
+
* Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
|
|
130
|
+
* because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
|
|
131
|
+
* resumes), but it must not be read as a promise that the window comes to the front;
|
|
132
|
+
* importantly, it does NOT raise a window that is merely behind another. Raising the
|
|
133
|
+
* page properly would need `Page.bringToFront` on a specific target session, which is a
|
|
134
|
+
* view-level concern and not available to the host.
|
|
114
135
|
*/
|
|
115
136
|
focus(): Promise<void>;
|
|
116
137
|
/** The browser reports its own window lifecycle. */
|