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