dsh-builtin-browser 0.3.1 → 0.4.1
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 +84 -39
- package/README.md +78 -38
- package/client.js +2 -3
- package/cordis.patch.yml +13 -7
- package/desktop-bridge/install.mjs +274 -12
- package/desktop-bridge/plugin-browser-bridge.js +55 -15
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +8 -8
- package/docs/user-guide.md +4 -2
- package/docs/why-browser.md +1 -1
- 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 +274 -37
- package/lib/tool-browser/index.d.ts +14 -0
- package/lib/tool-browser/index.js +143 -58
- 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 +256 -31
- package/src/tool-browser/index.ts +149 -63
|
@@ -16,7 +16,8 @@ import { homedir } from 'node:os';
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js';
|
|
18
18
|
import { DesktopBridgeViewHost } from './desktop-bridge-host.js';
|
|
19
|
-
import { detectBrowser, SystemBrowserViewHost } from './system-browser.js';
|
|
19
|
+
import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js';
|
|
20
|
+
import { MissingSystemBrowserHost } from './missing-system-browser.js';
|
|
20
21
|
import { SettingsStore } from './settings-store.js';
|
|
21
22
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.js';
|
|
22
23
|
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.js';
|
|
@@ -96,10 +97,25 @@ export function apply(ctx, config) {
|
|
|
96
97
|
// spawns a browser window. (An earlier version launched it here, which meant a
|
|
97
98
|
// browser appeared the moment the plugin loaded — reported and fixed.)
|
|
98
99
|
const channel = settings.get().browser.channel;
|
|
100
|
+
// Naming a browser is a decision about which one to use, so it also decides whether the
|
|
101
|
+
// desktop sidebar may take over. Skipping discovery for an explicit choice is what makes
|
|
102
|
+
// that choice real — otherwise a user who picked Chrome would silently get the sidebar,
|
|
103
|
+
// and a user who picked a browser that is not installed would never see the explanation
|
|
104
|
+
// written for exactly that case, because the sidebar would quietly replace it.
|
|
105
|
+
const explicitChoice = channel !== 'bundled' && channel !== 'auto';
|
|
99
106
|
if (channel !== 'bundled') {
|
|
100
107
|
const detected = detectBrowser(channel);
|
|
101
108
|
if (detected === undefined) {
|
|
102
|
-
|
|
109
|
+
// `auto` promises to take whatever is available, so falling back is what it
|
|
110
|
+
// asked for. Naming a browser is a specific request, and quietly satisfying it
|
|
111
|
+
// with a different one turns the real problem ("no Chrome here") into a
|
|
112
|
+
// confusing error about Electron later on. Report it instead.
|
|
113
|
+
if (channel === 'auto') {
|
|
114
|
+
ctx.logger?.warn?.('dsh-builtin-browser: no installed browser was found; using the bundled browser');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
adopt(new MissingSystemBrowserHost(channel, searchSummary(channel)), `the selected ${channel} is not installed — commands will explain how to fix it`);
|
|
118
|
+
}
|
|
103
119
|
}
|
|
104
120
|
else {
|
|
105
121
|
const home = process.env.DSH_HOME ?? homedir();
|
|
@@ -114,6 +130,10 @@ export function apply(ctx, config) {
|
|
|
114
130
|
adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir), `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`);
|
|
115
131
|
}
|
|
116
132
|
}
|
|
133
|
+
// An explicit choice was already adopted above; discovering the sidebar here would
|
|
134
|
+
// replace it, which is exactly what this guard prevents.
|
|
135
|
+
if (explicitChoice)
|
|
136
|
+
return;
|
|
117
137
|
void DesktopBridgeViewHost.discover().then(sidebar => {
|
|
118
138
|
// No bridge (plain `dsh web`, an older desktop build, or a shell that already
|
|
119
139
|
// exited): keep self-hosting, which is what every surface has always done.
|
|
@@ -137,7 +137,11 @@ export class HistoryStore {
|
|
|
137
137
|
return [];
|
|
138
138
|
}
|
|
139
139
|
const pages = [];
|
|
140
|
-
|
|
140
|
+
// Strip a BOM first: trim() does not remove U+FEFF (it is not whitespace in
|
|
141
|
+
// ECMAScript), so a file written by Notepad or PowerShell made the first line fail to
|
|
142
|
+
// parse and be discarded as corrupt — silently losing one visit, or the whole file when
|
|
143
|
+
// it holds one. settings-store strips one for the same reason.
|
|
144
|
+
for (const line of raw.replace(/^\uFEFF/, '').split('\n')) {
|
|
141
145
|
const trimmed = line.trim();
|
|
142
146
|
if (trimmed === '')
|
|
143
147
|
continue;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
* A carrier that cannot carry anything: every command explains the real problem.
|
|
25
|
+
*
|
|
26
|
+
* The message is written to be acted on, not just read: it says which browser was
|
|
27
|
+
* requested, where the plugin looked, and the three concrete ways to resolve it.
|
|
28
|
+
*/
|
|
29
|
+
export declare class MissingSystemBrowserHost implements ElectronBrowserViewHost {
|
|
30
|
+
private readonly kind;
|
|
31
|
+
private readonly searched;
|
|
32
|
+
/**
|
|
33
|
+
* @param kind - the browser the user asked for.
|
|
34
|
+
* @param searched - the names and locations that were checked, for the report.
|
|
35
|
+
*/
|
|
36
|
+
constructor(kind: Exclude<BrowserChannel, 'bundled' | 'auto'>, searched: readonly string[]);
|
|
37
|
+
/** @returns always true: see the module comment — reporting false would hide this. */
|
|
38
|
+
available(): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The message every command fails with.
|
|
41
|
+
* @returns a multi-line explanation with the fixes.
|
|
42
|
+
*/
|
|
43
|
+
private explain;
|
|
44
|
+
/**
|
|
45
|
+
* Create a handle whose every command fails with the explanation.
|
|
46
|
+
* @returns a handle that never reaches a page.
|
|
47
|
+
*/
|
|
48
|
+
createView(): ElectronViewHandle;
|
|
49
|
+
/**
|
|
50
|
+
* Accept a handle for destruction. Nothing was created, so nothing is released.
|
|
51
|
+
* @param handle - the handle returned by {@link createView}.
|
|
52
|
+
*/
|
|
53
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
54
|
+
}
|
|
@@ -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) {
|