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
|
@@ -38,38 +38,80 @@ 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
|
-
private readonly
|
|
50
|
-
private readonly
|
|
51
|
-
readonly
|
|
65
|
+
private readonly browser;
|
|
66
|
+
private readonly profileDir;
|
|
67
|
+
private readonly extraArgs;
|
|
52
68
|
private readonly ephemeralDir?;
|
|
53
69
|
private readonly views;
|
|
54
70
|
private disposed;
|
|
55
71
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
72
|
+
* The browser is started on FIRST USE, never on construction.
|
|
73
|
+
*
|
|
74
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
75
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
76
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
77
|
+
* spawned when a view is actually needed.
|
|
61
78
|
*/
|
|
62
|
-
private
|
|
79
|
+
private client;
|
|
80
|
+
private child;
|
|
81
|
+
private starting;
|
|
63
82
|
/**
|
|
64
|
-
* @param browser - the detected installation to launch.
|
|
83
|
+
* @param browser - the detected installation to launch on first use.
|
|
65
84
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
66
85
|
* @param extraArgs - additional Chromium switches.
|
|
67
|
-
* @param ephemeralDir - a directory to delete when the
|
|
68
|
-
*
|
|
69
|
-
|
|
86
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
87
|
+
* persistence off so no login state outlives the session.
|
|
88
|
+
*/
|
|
89
|
+
constructor(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string | undefined);
|
|
90
|
+
/** Which product this host would drive (for diagnostics). */
|
|
91
|
+
get kind(): DetectedBrowser['kind'];
|
|
92
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
93
|
+
get started(): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The CDP client, starting the browser on first use.
|
|
96
|
+
* @returns the connected client.
|
|
97
|
+
*/
|
|
98
|
+
private ensureClient;
|
|
99
|
+
/**
|
|
100
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
101
|
+
*
|
|
102
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
103
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
104
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
105
|
+
* @returns the connected client.
|
|
106
|
+
*/
|
|
107
|
+
private start;
|
|
108
|
+
/**
|
|
109
|
+
* Whether this host can back views.
|
|
110
|
+
*
|
|
111
|
+
* True while the host is usable — including before the browser has been started,
|
|
112
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
113
|
+
* make the plugin fall back for no reason.
|
|
70
114
|
*/
|
|
71
|
-
static launch(browser: DetectedBrowser, profileDir: string, extraArgs?: readonly string[], ephemeralDir?: string): Promise<SystemBrowserViewHost | undefined>;
|
|
72
|
-
/** Whether this host can back views: the CDP connection is live. */
|
|
73
115
|
available(): boolean;
|
|
74
116
|
createView(): ElectronViewHandle;
|
|
75
117
|
destroyView(handle: ElectronViewHandle): void;
|
|
@@ -80,18 +122,29 @@ export declare class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
|
80
122
|
/** The browser owns window grouping. */
|
|
81
123
|
groupView(): void;
|
|
82
124
|
/**
|
|
83
|
-
*
|
|
125
|
+
* Best effort at bringing the browser's window forward.
|
|
84
126
|
*
|
|
85
|
-
* The browser is a separate application, so there is no view to raise
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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.
|
|
88
135
|
*/
|
|
89
136
|
focus(): Promise<void>;
|
|
90
137
|
/** The browser reports its own window lifecycle. */
|
|
91
138
|
onUserAction(): void;
|
|
92
139
|
/** The browser reports its own window lifecycle. */
|
|
93
140
|
onViewClosed(): void;
|
|
94
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Close the browser we launched, if we ever launched one.
|
|
143
|
+
*
|
|
144
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
145
|
+
* host whose browser was never started (the common case when nobody used the
|
|
146
|
+
* browser) simply tears down its bookkeeping.
|
|
147
|
+
*/
|
|
95
148
|
dispose(): void;
|
|
96
149
|
/** The flattened session for a view, creating its page on first use. */
|
|
97
150
|
private ensureSession;
|
|
@@ -30,34 +30,121 @@ import { join } from 'node:path';
|
|
|
30
30
|
import { spawn } from 'node:child_process';
|
|
31
31
|
import { randomUUID } from 'node:crypto';
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Launcher names to look for on PATH, per product and per platform.
|
|
34
|
+
*
|
|
35
|
+
* The names genuinely differ: Linux distributions package the launcher under a
|
|
36
|
+
* versioned name (`microsoft-edge-stable`, `google-chrome-stable`), Windows puts
|
|
37
|
+
* `chrome.exe` in an Application directory that may or may not be on PATH, and macOS
|
|
38
|
+
* ships the executable inside the .app bundle. PATH is still consulted first on every
|
|
39
|
+
* platform — it is what reflects how the user actually installed the browser.
|
|
40
|
+
*/
|
|
41
|
+
const PATH_NAMES = {
|
|
42
|
+
chrome: {
|
|
43
|
+
win32: ['chrome'],
|
|
44
|
+
darwin: ['Google Chrome', 'Chromium'],
|
|
45
|
+
linux: ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'],
|
|
46
|
+
},
|
|
47
|
+
edge: {
|
|
48
|
+
win32: ['msedge'],
|
|
49
|
+
darwin: ['Microsoft Edge'],
|
|
50
|
+
linux: ['microsoft-edge-stable', 'microsoft-edge', 'microsoft-edge-dev'],
|
|
51
|
+
},
|
|
52
|
+
brave: {
|
|
53
|
+
win32: ['brave'],
|
|
54
|
+
darwin: ['Brave Browser'],
|
|
55
|
+
linux: ['brave-browser', 'brave'],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Where each product installs when its launcher is not on PATH.
|
|
60
|
+
*
|
|
61
|
+
* Windows keeps the executable inside a version-less Application directory; macOS
|
|
62
|
+
* keeps it inside the .app bundle; Linux distributions that do not use a launcher
|
|
63
|
+
* put it under /usr/bin or /opt. All are checked, and anything missed can be named
|
|
64
|
+
* explicitly with DSH_BROWSER_<KIND>_PATH.
|
|
36
65
|
*/
|
|
37
|
-
const
|
|
38
|
-
chrome:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
const INSTALL_LOCATIONS = {
|
|
67
|
+
chrome: {
|
|
68
|
+
win32: [
|
|
69
|
+
String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
70
|
+
String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
71
|
+
],
|
|
72
|
+
darwin: [
|
|
73
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
74
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
75
|
+
],
|
|
76
|
+
linux: [
|
|
77
|
+
'/usr/bin/google-chrome-stable',
|
|
78
|
+
'/usr/bin/google-chrome',
|
|
79
|
+
'/usr/bin/chromium',
|
|
80
|
+
'/opt/google/chrome/chrome',
|
|
81
|
+
'/snap/bin/chromium',
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
edge: {
|
|
85
|
+
win32: [
|
|
86
|
+
String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
87
|
+
String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
88
|
+
],
|
|
89
|
+
darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
90
|
+
linux: [
|
|
91
|
+
'/usr/bin/microsoft-edge-stable',
|
|
92
|
+
'/usr/bin/microsoft-edge',
|
|
93
|
+
'/usr/bin/microsoft-edge-dev',
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
brave: {
|
|
97
|
+
win32: [
|
|
98
|
+
String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
99
|
+
String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
100
|
+
],
|
|
101
|
+
darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
|
|
102
|
+
linux: ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'],
|
|
103
|
+
},
|
|
50
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* Find an executable by name on PATH.
|
|
107
|
+
*
|
|
108
|
+
* PATH is read from the supplied environment rather than the process's own, so the
|
|
109
|
+
* detection stays testable. On Windows a bare name needs its extension; elsewhere
|
|
110
|
+
* the launcher is the file itself.
|
|
111
|
+
* @param names - candidate launcher names, in preference order.
|
|
112
|
+
* @param env - the environment to read PATH from.
|
|
113
|
+
* @param platform - the platform whose PATH and extension conventions apply.
|
|
114
|
+
* @returns the first existing absolute path, or undefined.
|
|
115
|
+
*/
|
|
116
|
+
function findOnPath(names, env, platform) {
|
|
117
|
+
// Windows spells it Path as often as PATH; the separator differs too.
|
|
118
|
+
const raw = env.PATH ?? env.Path ?? env.path ?? '';
|
|
119
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
120
|
+
const directories = raw.split(separator).map(entry => entry.trim()).filter(entry => entry !== '');
|
|
121
|
+
const suffixes = platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
for (const directory of directories) {
|
|
124
|
+
for (const suffix of suffixes) {
|
|
125
|
+
const candidate = join(directory, `${name}${suffix}`);
|
|
126
|
+
if (existsSync(candidate))
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
51
133
|
/**
|
|
52
134
|
* Find an installed Chromium browser.
|
|
53
135
|
*
|
|
54
136
|
* `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
|
|
55
|
-
* is to be the browser a user actually chose rather than one
|
|
137
|
+
* is to be the browser a user actually chose rather than one the system shipped.
|
|
138
|
+
* Each candidate is looked up in three places, most specific first: an explicit
|
|
139
|
+
* environment override, then PATH, then the platform's conventional install
|
|
140
|
+
* locations. PATH comes before the fixed paths because it is what actually reflects
|
|
141
|
+
* how the browser was installed.
|
|
56
142
|
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
57
143
|
* @param env - environment lookup, injected so tests need no real machine.
|
|
144
|
+
* @param platform - the platform to use conventions for; injected for the same reason.
|
|
58
145
|
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
59
146
|
*/
|
|
60
|
-
export function detectBrowser(channel, env = process.env) {
|
|
147
|
+
export function detectBrowser(channel, env = process.env, platform = process.platform) {
|
|
61
148
|
if (channel === 'bundled')
|
|
62
149
|
return undefined;
|
|
63
150
|
const order = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel];
|
|
@@ -65,16 +152,33 @@ export function detectBrowser(channel, env = process.env) {
|
|
|
65
152
|
const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined);
|
|
66
153
|
if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv))
|
|
67
154
|
return { kind, path: fromEnv };
|
|
68
|
-
|
|
155
|
+
const onPath = findOnPath(PATH_NAMES[kind]?.[platform] ?? [], env, platform);
|
|
156
|
+
if (onPath !== undefined)
|
|
157
|
+
return { kind, path: onPath };
|
|
158
|
+
for (const candidate of INSTALL_LOCATIONS[kind]?.[platform] ?? []) {
|
|
69
159
|
if (existsSync(candidate))
|
|
70
160
|
return { kind, path: candidate };
|
|
71
161
|
}
|
|
72
162
|
}
|
|
73
163
|
return undefined;
|
|
74
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Everything that was checked for one product, for reporting a miss.
|
|
167
|
+
*
|
|
168
|
+
* Detection returns undefined with no explanation, which left the caller able to say
|
|
169
|
+
* only "not found". This gives the user the actual list so they can see whether the
|
|
170
|
+
* plugin looked somewhere their browser is not.
|
|
171
|
+
* @param kind - the product to describe.
|
|
172
|
+
* @param platform - the platform whose names and locations apply.
|
|
173
|
+
* @returns the launcher names (as PATH lookups) and the fixed locations checked.
|
|
174
|
+
*/
|
|
175
|
+
export function searchSummary(kind, platform = process.platform) {
|
|
176
|
+
const onPath = (PATH_NAMES[kind]?.[platform] ?? []).map(name => `${name} (on PATH)`);
|
|
177
|
+
const located = INSTALL_LOCATIONS[kind]?.[platform] ?? [];
|
|
178
|
+
return [...onPath, ...located];
|
|
179
|
+
}
|
|
75
180
|
/** One CDP connection to a browser-level WebSocket, with flattened sessions. */
|
|
76
181
|
class CdpClient {
|
|
77
|
-
url;
|
|
78
182
|
nextId = 1;
|
|
79
183
|
pending = new Map();
|
|
80
184
|
ready;
|
|
@@ -82,7 +186,6 @@ class CdpClient {
|
|
|
82
186
|
* @param url - the browser's `webSocketDebuggerUrl`.
|
|
83
187
|
*/
|
|
84
188
|
constructor(url) {
|
|
85
|
-
this.url = url;
|
|
86
189
|
// Node 22 provides WebSocket globally; no dependency is added for this.
|
|
87
190
|
const socket = new WebSocket(url);
|
|
88
191
|
this.socket = socket;
|
|
@@ -94,9 +197,30 @@ class CdpClient {
|
|
|
94
197
|
socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')));
|
|
95
198
|
}
|
|
96
199
|
socket;
|
|
97
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Resolve once the socket is open.
|
|
202
|
+
*
|
|
203
|
+
* Bounded on purpose. `ready` settles on the socket's own `open` or `error` events, and
|
|
204
|
+
* a connection that is dropped by something in between may deliver neither — in which
|
|
205
|
+
* case an unbounded await here would hang the tool call forever, because the caller's
|
|
206
|
+
* own deadline is only checked between polls and this await happens inside one of them.
|
|
207
|
+
* @returns when the handshake completed.
|
|
208
|
+
*/
|
|
98
209
|
async whenReady() {
|
|
99
|
-
|
|
210
|
+
const timeoutMs = 30_000;
|
|
211
|
+
let timer;
|
|
212
|
+
try {
|
|
213
|
+
await Promise.race([
|
|
214
|
+
this.ready,
|
|
215
|
+
new Promise((_resolve, reject) => {
|
|
216
|
+
timer = setTimeout(() => reject(new Error(`dsh-builtin-browser: the browser did not complete the CDP handshake within ${timeoutMs / 1000}s`)), timeoutMs);
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (timer !== undefined)
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
}
|
|
100
224
|
}
|
|
101
225
|
/**
|
|
102
226
|
* Send one CDP command, optionally on an attached session.
|
|
@@ -155,37 +279,71 @@ class CdpClient {
|
|
|
155
279
|
}
|
|
156
280
|
/** A system browser driven over CDP, presented as a browser view host. */
|
|
157
281
|
export class SystemBrowserViewHost {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
282
|
+
browser;
|
|
283
|
+
profileDir;
|
|
284
|
+
extraArgs;
|
|
161
285
|
ephemeralDir;
|
|
162
286
|
views = new Map();
|
|
163
287
|
disposed = false;
|
|
164
288
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
289
|
+
* The browser is started on FIRST USE, never on construction.
|
|
290
|
+
*
|
|
291
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
292
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
293
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
294
|
+
* spawned when a view is actually needed.
|
|
170
295
|
*/
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.kind = kind;
|
|
175
|
-
this.ephemeralDir = ephemeralDir;
|
|
176
|
-
}
|
|
296
|
+
client;
|
|
297
|
+
child;
|
|
298
|
+
starting;
|
|
177
299
|
/**
|
|
178
|
-
* @param browser - the detected installation to launch.
|
|
300
|
+
* @param browser - the detected installation to launch on first use.
|
|
179
301
|
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
180
302
|
* @param extraArgs - additional Chromium switches.
|
|
181
|
-
* @param ephemeralDir - a directory to delete when the
|
|
182
|
-
*
|
|
183
|
-
|
|
303
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
304
|
+
* persistence off so no login state outlives the session.
|
|
305
|
+
*/
|
|
306
|
+
constructor(browser, profileDir, extraArgs = [], ephemeralDir) {
|
|
307
|
+
this.browser = browser;
|
|
308
|
+
this.profileDir = profileDir;
|
|
309
|
+
this.extraArgs = extraArgs;
|
|
310
|
+
this.ephemeralDir = ephemeralDir;
|
|
311
|
+
}
|
|
312
|
+
/** Which product this host would drive (for diagnostics). */
|
|
313
|
+
get kind() {
|
|
314
|
+
return this.browser.kind;
|
|
315
|
+
}
|
|
316
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
317
|
+
get started() {
|
|
318
|
+
return this.child !== undefined;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The CDP client, starting the browser on first use.
|
|
322
|
+
* @returns the connected client.
|
|
184
323
|
*/
|
|
185
|
-
|
|
324
|
+
async ensureClient() {
|
|
325
|
+
// A disposed host must never start a browser: doing so would spawn a process
|
|
326
|
+
// nobody owns and nobody will kill.
|
|
327
|
+
if (this.disposed)
|
|
328
|
+
throw new Error('dsh-builtin-browser: this browser host was released');
|
|
329
|
+
if (this.client !== undefined)
|
|
330
|
+
return this.client;
|
|
331
|
+
// Concurrent first calls share one startup rather than racing two browsers.
|
|
332
|
+
this.starting ??= this.start().finally(() => { this.starting = undefined; });
|
|
333
|
+
return await this.starting;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
337
|
+
*
|
|
338
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
339
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
340
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
341
|
+
* @returns the connected client.
|
|
342
|
+
*/
|
|
343
|
+
async start() {
|
|
186
344
|
const args = [
|
|
187
345
|
'--remote-debugging-port=0',
|
|
188
|
-
`--user-data-dir=${profileDir}`,
|
|
346
|
+
`--user-data-dir=${this.profileDir}`,
|
|
189
347
|
// Behave like a fresh, unattended browser: no first-run UI, no default-browser
|
|
190
348
|
// prompt, no restore bubble, and no "Chrome is being controlled" infobar.
|
|
191
349
|
'--no-first-run',
|
|
@@ -193,19 +351,59 @@ export class SystemBrowserViewHost {
|
|
|
193
351
|
'--disable-features=Translate,MediaRouter',
|
|
194
352
|
'--disable-session-crashed-bubble',
|
|
195
353
|
'--hide-crash-restore-bubble',
|
|
196
|
-
...extraArgs,
|
|
354
|
+
...this.extraArgs,
|
|
197
355
|
'about:blank',
|
|
198
356
|
];
|
|
199
|
-
const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
200
|
-
|
|
357
|
+
const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
358
|
+
this.child = child;
|
|
359
|
+
/**
|
|
360
|
+
* A launch that fails (ENOENT, no permission, a broken binary) is reported by an
|
|
361
|
+
* asynchronous 'error' event on the child, NOT by a throw here and NOT by exitCode.
|
|
362
|
+
* Without this listener Node treats it as unhandled and takes the whole host process
|
|
363
|
+
* down — one bad browser path would kill DSH, not just fail a tool call.
|
|
364
|
+
*/
|
|
365
|
+
let launchError;
|
|
366
|
+
child.on('error', error => { launchError = error; });
|
|
367
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort');
|
|
201
368
|
const deadline = Date.now() + 30_000;
|
|
369
|
+
/**
|
|
370
|
+
* Why the loop stopped, so the failure can say what actually happened. Reporting
|
|
371
|
+
* every ending as "did not expose CDP within 30s" was wrong three ways: the cause,
|
|
372
|
+
* the words, and the elapsed time — a browser that cannot be launched says so in
|
|
373
|
+
* half a second, and the caller then looks for a CDP problem that does not exist.
|
|
374
|
+
*/
|
|
375
|
+
let stopped = 'timeout';
|
|
202
376
|
for (;;) {
|
|
377
|
+
// Released while we were starting: stop here rather than keep polling for a
|
|
378
|
+
// browser that dispose() has already killed, which would leave a connection
|
|
379
|
+
// to a dead process behind. The child is killed here too — dispose() may have
|
|
380
|
+
// run before this child was recorded, leaving nobody else to do it.
|
|
381
|
+
if (this.disposed) {
|
|
382
|
+
child.kill();
|
|
383
|
+
if (this.child === child)
|
|
384
|
+
this.child = undefined;
|
|
385
|
+
stopped = 'released';
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
if (launchError !== undefined) {
|
|
389
|
+
if (this.child === child)
|
|
390
|
+
this.child = undefined;
|
|
391
|
+
stopped = 'launch-failed';
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
203
394
|
if (Date.now() > deadline) {
|
|
204
395
|
child.kill();
|
|
205
|
-
|
|
396
|
+
if (this.child === child)
|
|
397
|
+
this.child = undefined;
|
|
398
|
+
stopped = 'timeout';
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (child.exitCode !== null) {
|
|
402
|
+
if (this.child === child)
|
|
403
|
+
this.child = undefined;
|
|
404
|
+
stopped = 'exited';
|
|
405
|
+
break;
|
|
206
406
|
}
|
|
207
|
-
if (child.exitCode !== null)
|
|
208
|
-
return undefined;
|
|
209
407
|
if (existsSync(portFile)) {
|
|
210
408
|
try {
|
|
211
409
|
const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '';
|
|
@@ -214,7 +412,14 @@ export class SystemBrowserViewHost {
|
|
|
214
412
|
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
215
413
|
const client = new CdpClient(version.webSocketDebuggerUrl);
|
|
216
414
|
await client.whenReady();
|
|
217
|
-
|
|
415
|
+
// Still wanted? A dispose() during the handshake has already killed the
|
|
416
|
+
// process, so publishing the client would leave a dangling connection.
|
|
417
|
+
if (this.disposed) {
|
|
418
|
+
client.close();
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
this.client = client;
|
|
422
|
+
return client;
|
|
218
423
|
}
|
|
219
424
|
}
|
|
220
425
|
}
|
|
@@ -224,8 +429,28 @@ export class SystemBrowserViewHost {
|
|
|
224
429
|
}
|
|
225
430
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
226
431
|
}
|
|
432
|
+
// Each ending gets its own message. The old single message blamed a CDP timeout for
|
|
433
|
+
// all of them, which sent the reader looking for a port problem when the browser had
|
|
434
|
+
// simply failed to launch.
|
|
435
|
+
const target = `${this.browser.kind} (${this.browser.path})`;
|
|
436
|
+
if (stopped === 'launch-failed') {
|
|
437
|
+
throw new Error(`dsh-builtin-browser: could not launch ${target}: ${launchError?.message ?? 'unknown error'}`);
|
|
438
|
+
}
|
|
439
|
+
if (stopped === 'exited') {
|
|
440
|
+
throw new Error(`dsh-builtin-browser: ${target} exited immediately after launch (exit code ${child.exitCode}); check that the path is a runnable browser`);
|
|
441
|
+
}
|
|
442
|
+
if (stopped === 'released') {
|
|
443
|
+
throw new Error(`dsh-builtin-browser: ${target} was released while it was starting`);
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`dsh-builtin-browser: ${target} did not expose CDP within 30s`);
|
|
227
446
|
}
|
|
228
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Whether this host can back views.
|
|
449
|
+
*
|
|
450
|
+
* True while the host is usable — including before the browser has been started,
|
|
451
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
452
|
+
* make the plugin fall back for no reason.
|
|
453
|
+
*/
|
|
229
454
|
available() {
|
|
230
455
|
return !this.disposed;
|
|
231
456
|
}
|
|
@@ -233,9 +458,13 @@ export class SystemBrowserViewHost {
|
|
|
233
458
|
const viewId = randomUUID();
|
|
234
459
|
return {
|
|
235
460
|
id: viewId,
|
|
461
|
+
// This is the real browser, not Electron: CDP's JPEG encoder works here, so the
|
|
462
|
+
// provider may pass format and quality through instead of falling back to PNG.
|
|
463
|
+
supportsCdpJpeg: true,
|
|
236
464
|
sendCommand: async (method, params) => {
|
|
465
|
+
const client = await this.ensureClient();
|
|
237
466
|
const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
|
|
238
|
-
const result = await
|
|
467
|
+
const result = await client.send(method, params, session);
|
|
239
468
|
return (result ?? {});
|
|
240
469
|
},
|
|
241
470
|
};
|
|
@@ -249,7 +478,8 @@ export class SystemBrowserViewHost {
|
|
|
249
478
|
// was attached to, which is why they are tracked separately.
|
|
250
479
|
const targetId = this.sessions.get(session);
|
|
251
480
|
this.sessions.delete(session);
|
|
252
|
-
if
|
|
481
|
+
// Nothing to close if the browser was never started.
|
|
482
|
+
if (targetId !== undefined && this.client !== undefined) {
|
|
253
483
|
// Best-effort: a page that is already gone is not an error.
|
|
254
484
|
void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined);
|
|
255
485
|
}
|
|
@@ -261,36 +491,47 @@ export class SystemBrowserViewHost {
|
|
|
261
491
|
/** The browser owns window grouping. */
|
|
262
492
|
groupView() { }
|
|
263
493
|
/**
|
|
264
|
-
*
|
|
494
|
+
* Best effort at bringing the browser's window forward.
|
|
265
495
|
*
|
|
266
|
-
* The browser is a separate application, so there is no view to raise
|
|
267
|
-
*
|
|
268
|
-
*
|
|
496
|
+
* The browser is a separate application, so there is no view to raise. What this can
|
|
497
|
+
* actually do is ask its process to continue from a stopped state — which is a no-op on
|
|
498
|
+
* Windows, where `SIGCONT` is not a supported signal and the call throws. It is kept
|
|
499
|
+
* because it costs nothing and does help on POSIX (a process stopped with SIGSTOP
|
|
500
|
+
* resumes), but it must not be read as a promise that the window comes to the front;
|
|
501
|
+
* importantly, it does NOT raise a window that is merely behind another. Raising the
|
|
502
|
+
* page properly would need `Page.bringToFront` on a specific target session, which is a
|
|
503
|
+
* view-level concern and not available to the host.
|
|
269
504
|
*/
|
|
270
505
|
async focus() {
|
|
271
506
|
try {
|
|
272
|
-
this.child
|
|
507
|
+
this.child?.kill('SIGCONT');
|
|
273
508
|
}
|
|
274
|
-
catch { /*
|
|
509
|
+
catch { /* Windows has no SIGCONT; nothing to do */ }
|
|
275
510
|
}
|
|
276
511
|
/** The browser reports its own window lifecycle. */
|
|
277
512
|
onUserAction() { }
|
|
278
513
|
/** The browser reports its own window lifecycle. */
|
|
279
514
|
onViewClosed() { }
|
|
280
|
-
/**
|
|
515
|
+
/**
|
|
516
|
+
* Close the browser we launched, if we ever launched one.
|
|
517
|
+
*
|
|
518
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
519
|
+
* host whose browser was never started (the common case when nobody used the
|
|
520
|
+
* browser) simply tears down its bookkeeping.
|
|
521
|
+
*/
|
|
281
522
|
dispose() {
|
|
282
523
|
if (this.disposed)
|
|
283
524
|
return;
|
|
284
525
|
this.disposed = true;
|
|
285
526
|
this.views.clear();
|
|
286
527
|
this.sessions.clear();
|
|
287
|
-
this.client
|
|
528
|
+
this.client?.close();
|
|
288
529
|
try {
|
|
289
|
-
this.child
|
|
530
|
+
this.child?.kill();
|
|
290
531
|
}
|
|
291
532
|
catch { /* already gone */ }
|
|
292
533
|
const ephemeral = this.ephemeralDir;
|
|
293
|
-
if (ephemeral !== undefined) {
|
|
534
|
+
if (ephemeral !== undefined && this.child !== undefined) {
|
|
294
535
|
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
295
536
|
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
296
537
|
setTimeout(() => { try {
|
|
@@ -298,17 +539,25 @@ export class SystemBrowserViewHost {
|
|
|
298
539
|
}
|
|
299
540
|
catch { /* best effort */ } }, 3_000).unref?.();
|
|
300
541
|
}
|
|
542
|
+
else if (ephemeral !== undefined) {
|
|
543
|
+
// Never started, so nothing can be holding it open.
|
|
544
|
+
try {
|
|
545
|
+
rmSync(ephemeral, { recursive: true, force: true });
|
|
546
|
+
}
|
|
547
|
+
catch { /* best effort */ }
|
|
548
|
+
}
|
|
301
549
|
}
|
|
302
550
|
/** The flattened session for a view, creating its page on first use. */
|
|
303
551
|
async ensureSession(viewId) {
|
|
304
552
|
const existing = this.views.get(viewId);
|
|
305
553
|
if (existing !== undefined)
|
|
306
554
|
return existing;
|
|
307
|
-
const
|
|
555
|
+
const client = await this.ensureClient();
|
|
556
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
308
557
|
const targetId = created?.targetId;
|
|
309
558
|
if (typeof targetId !== 'string')
|
|
310
559
|
throw new Error('dsh-builtin-browser: the browser did not create a page');
|
|
311
|
-
const attached = await
|
|
560
|
+
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
312
561
|
const sessionId = attached?.sessionId;
|
|
313
562
|
if (typeof sessionId !== 'string')
|
|
314
563
|
throw new Error('dsh-builtin-browser: the browser did not attach to the new page');
|