dsh-builtin-browser 0.2.0 → 0.3.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 +46 -1
- package/README.md +58 -7
- package/client.js +44 -8
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +82 -13
- package/lib/browser-electron/history-store.d.ts +5 -1
- package/lib/browser-electron/history-store.js +12 -1
- package/lib/browser-electron/provider.d.ts +42 -1
- package/lib/browser-electron/provider.js +96 -24
- package/lib/browser-electron/remote-host.d.ts +9 -1
- package/lib/browser-electron/remote-host.js +17 -3
- package/lib/browser-electron/settings-store.d.ts +5 -0
- package/lib/browser-electron/settings-store.js +24 -1
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +27 -2
- package/lib/browser-electron/virtual-cursor.js +77 -7
- package/lib/tool-browser/index.js +51 -15
- package/package.json +2 -1
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +84 -13
- package/src/browser-electron/history-store.ts +11 -2
- package/src/browser-electron/provider.ts +124 -25
- package/src/browser-electron/remote-host.ts +17 -2
- package/src/browser-electron/settings-store.ts +30 -1
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -13,7 +13,7 @@ import { homedir } from 'node:os'
|
|
|
13
13
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
14
|
import { HistoryStore, type VisitedPage } from './history-store.js'
|
|
15
15
|
import type { BrowserSettings } from './settings-store.js'
|
|
16
|
-
import { paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
16
|
+
import { forgetCursor, paintCursor, type CursorAction } from './virtual-cursor.js'
|
|
17
17
|
import type {
|
|
18
18
|
BrowserA11yRequest,
|
|
19
19
|
BrowserA11yResult,
|
|
@@ -185,6 +185,31 @@ export interface ElectronBrowserViewHost {
|
|
|
185
185
|
* @param handler - called with the group (session) key; must not throw.
|
|
186
186
|
*/
|
|
187
187
|
onViewClosed?(handler: (windowId: string) => void): void
|
|
188
|
+
/**
|
|
189
|
+
* Release the service's page(s) without ending the plugin's own lifetime.
|
|
190
|
+
*
|
|
191
|
+
* Only carriers that outlive a browser session need this. A self-hosted window
|
|
192
|
+
* dies with its view, but the desktop shell's sidebar keeps running no matter
|
|
193
|
+
* what the plugin does — so "release the browser when the session ends"
|
|
194
|
+
* (settings: ui.closeWithSession) has to be asked for explicitly there.
|
|
195
|
+
* @returns a promise that settles once the release was attempted.
|
|
196
|
+
*/
|
|
197
|
+
releasePage?(): Promise<void>
|
|
198
|
+
/**
|
|
199
|
+
* Fold the carrier's presentation away while keeping the page alive.
|
|
200
|
+
*
|
|
201
|
+
* The desktop sidebar keeps running regardless of the plugin, so "do not take
|
|
202
|
+
* over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
|
|
203
|
+
* asking the shell to fold it. A self-hosted window has no such state and simply
|
|
204
|
+
* does not implement this.
|
|
205
|
+
* @returns a promise that settles once the fold was attempted.
|
|
206
|
+
*/
|
|
207
|
+
collapse?(): Promise<void>
|
|
208
|
+
/**
|
|
209
|
+
* Release everything the carrier owns (a spawned browser, a socket, a child).
|
|
210
|
+
* Called when the plugin stops using it, never while it is still in service.
|
|
211
|
+
*/
|
|
212
|
+
dispose?(): void
|
|
188
213
|
}
|
|
189
214
|
|
|
190
215
|
/**
|
|
@@ -670,6 +695,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
670
695
|
private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
|
|
671
696
|
const started = Date.now()
|
|
672
697
|
const deadline = started + SETTLE_TIMEOUT_MS
|
|
698
|
+
// The document is being replaced, so the synthetic pointer drawn into the old one
|
|
699
|
+
// is gone. Dropping the remembered position is what lets it be painted again:
|
|
700
|
+
// without this, a paint at coincidentally identical coordinates would be skipped
|
|
701
|
+
// and the pointer would never come back after a navigation.
|
|
702
|
+
forgetCursor(handle)
|
|
673
703
|
for (;;) {
|
|
674
704
|
if (signal?.aborted === true) return
|
|
675
705
|
// A probe failure is expected while the navigation commits (the context is
|
|
@@ -1180,21 +1210,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1180
1210
|
// iframes stay opaque — the browser forbids reading them).
|
|
1181
1211
|
let content = ''
|
|
1182
1212
|
if (fmt === 'txt') {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1213
|
+
// Prefer the browser's own rendered text.
|
|
1214
|
+
//
|
|
1215
|
+
// Walking text nodes and joining them is not equivalent: pages that wrap
|
|
1216
|
+
// every character in its own element — one-character spans for a
|
|
1217
|
+
// per-character animation, which is common — produce one text node per
|
|
1218
|
+
// letter, and joining those with spaces turns every word into a column of
|
|
1219
|
+
// letters. The rendered text reassembles them the way a reader sees them,
|
|
1220
|
+
// and it also honours visibility, so hidden text does not leak out.
|
|
1221
|
+
// (No backticks in this comment: it lives inside a template literal.)
|
|
1222
|
+
const rendered = typeof root.innerText === 'string' ? root.innerText : ''
|
|
1223
|
+
if (rendered.trim() !== '') {
|
|
1224
|
+
content = rendered
|
|
1225
|
+
} else {
|
|
1226
|
+
// Fallback for nodes without layout (a detached root, or a fragment):
|
|
1227
|
+
// collect text directly, still piercing shadow roots and same-origin
|
|
1228
|
+
// iframes, and join without inventing separators beyond what the markup
|
|
1229
|
+
// already implies.
|
|
1230
|
+
const parts = []
|
|
1231
|
+
const textWalk = (node) => {
|
|
1232
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
1233
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
1234
|
+
const tag = node.tagName.toLowerCase()
|
|
1235
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
1236
|
+
if (tag === 'iframe') {
|
|
1237
|
+
try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
|
|
1241
|
+
for (const child of node.childNodes) textWalk(child)
|
|
1192
1242
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1243
|
+
textWalk(root)
|
|
1244
|
+
content = parts.join(' ')
|
|
1195
1245
|
}
|
|
1196
|
-
textWalk(root)
|
|
1197
|
-
content = parts.join(' ')
|
|
1198
1246
|
}
|
|
1199
1247
|
else if (fmt === 'html') content = root.outerHTML || ''
|
|
1200
1248
|
else if (fmt === 'json') content = JSON.stringify(root)
|
|
@@ -1270,6 +1318,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1270
1318
|
x = Number(out.x)
|
|
1271
1319
|
y = Number(out.y)
|
|
1272
1320
|
} else {
|
|
1321
|
+
// A coordinate only exists because somebody read a picture. Under the
|
|
1322
|
+
// non-visual strategy the plugin must not accept one: a model that cannot
|
|
1323
|
+
// see cannot have produced it, so the coordinates are either copied from an
|
|
1324
|
+
// earlier vision pass or invented, and honouring them turns a silent
|
|
1325
|
+
// mis-click into a mystery. Refusing here, with a message that says what to
|
|
1326
|
+
// do instead, converts that into a usable instruction.
|
|
1327
|
+
if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
|
|
1328
|
+
throw new BrowserError(
|
|
1329
|
+
'browser: the configured non-visual strategy refuses coordinate clicks — pass a semantic target instead, e.g. target { by: "text", value: "Sign in" }, which locates the element from the DOM and clicks its centre',
|
|
1330
|
+
'BROWSER_NON_VISUAL_COORDINATES',
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1273
1333
|
x = request.x
|
|
1274
1334
|
y = request.y
|
|
1275
1335
|
}
|
|
@@ -1298,7 +1358,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1298
1358
|
}
|
|
1299
1359
|
// Paint before the press so the ripple is already on screen when the click
|
|
1300
1360
|
// lands — the cursor exists to show the human WHERE the agent acts.
|
|
1301
|
-
|
|
1361
|
+
// Name the operation in the overlay: the pointer is the "agent is driving this
|
|
1362
|
+
// tab" signal, so the bubble is what lets a human follow along without the log.
|
|
1363
|
+
this.showCursor(handle, x, y, 'click', 'click', true)
|
|
1302
1364
|
const release = (): void => {
|
|
1303
1365
|
void handle
|
|
1304
1366
|
.sendCommand('Input.dispatchMouseEvent', {
|
|
@@ -1351,7 +1413,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1351
1413
|
const locateTab = this.activeTab(s)
|
|
1352
1414
|
const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
|
|
1353
1415
|
const cursorPoint = scriptPoint(out)
|
|
1354
|
-
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click')
|
|
1416
|
+
if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true)
|
|
1355
1417
|
}
|
|
1356
1418
|
const text = 'text' in request ? request.text : ''
|
|
1357
1419
|
const timeoutMs = 30_000
|
|
@@ -2274,7 +2336,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2274
2336
|
* @param options - result cap and an optional hostname filter.
|
|
2275
2337
|
* @returns the matching visits, or an empty list when history is disabled.
|
|
2276
2338
|
*/
|
|
2277
|
-
visited(options: { readonly limit?: number; readonly domain?: string } = {}): readonly VisitedPage[] {
|
|
2339
|
+
visited(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): readonly VisitedPage[] {
|
|
2278
2340
|
return this.historyStore?.list(options) ?? []
|
|
2279
2341
|
}
|
|
2280
2342
|
|
|
@@ -2287,10 +2349,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2287
2349
|
* @param x - viewport x in CSS pixels.
|
|
2288
2350
|
* @param y - viewport y in CSS pixels.
|
|
2289
2351
|
* @param action - click pulses a ripple; move only relocates.
|
|
2352
|
+
* @param label - short description of the operation, shown beside the pointer.
|
|
2353
|
+
* @param force - paint even when the pointer is already there (a click ripple must
|
|
2354
|
+
* always play; a bare move need not repeat).
|
|
2290
2355
|
*/
|
|
2291
|
-
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction): void {
|
|
2356
|
+
private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction, label?: string, force = false): void {
|
|
2292
2357
|
if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
|
|
2293
|
-
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression))
|
|
2358
|
+
paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force)
|
|
2294
2359
|
}
|
|
2295
2360
|
|
|
2296
2361
|
/**
|
|
@@ -2370,6 +2435,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2370
2435
|
if (existing !== undefined) {
|
|
2371
2436
|
this.sessions.delete(session)
|
|
2372
2437
|
for (const tab of existing.tabs) this.host.destroyView(tab.handle)
|
|
2438
|
+
// Releasing the browser at session end is a setting, not fixed behaviour
|
|
2439
|
+
// (requirements §3). A self-hosted carrier already tore its window down with
|
|
2440
|
+
// the views above; the desktop's sidebar does not, so it is asked explicitly
|
|
2441
|
+
// — and its cookies plus the browsing history outlive the page either way.
|
|
2442
|
+
if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
|
|
2443
|
+
void this.host.releasePage?.().catch(() => undefined)
|
|
2444
|
+
}
|
|
2373
2445
|
}
|
|
2374
2446
|
return Promise.resolve()
|
|
2375
2447
|
}
|
|
@@ -2481,19 +2553,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2481
2553
|
}
|
|
2482
2554
|
}
|
|
2483
2555
|
|
|
2484
|
-
/**
|
|
2556
|
+
/**
|
|
2557
|
+
* Report a failed user action to the host UI (toolbar), when supported.
|
|
2558
|
+
*
|
|
2559
|
+
* Two properties matter here, because this runs inside the catch of an async
|
|
2560
|
+
* handler (issue #16):
|
|
2561
|
+
* - the receiver must be preserved. Reading the method off the host and calling
|
|
2562
|
+
* it unbound runs the host's implementation with `this === undefined`, so its
|
|
2563
|
+
* very first statement (`void this.ready()`) throws, the throw escapes the
|
|
2564
|
+
* async catch as an unhandled rejection, and the whole DSH host exits;
|
|
2565
|
+
* - this method must be incapable of throwing. Reporting a failed action is
|
|
2566
|
+
* diagnostics: it can never be allowed to become the failure.
|
|
2567
|
+
*/
|
|
2485
2568
|
private notifyUserActionError(action: BrowserUserAction, error: unknown): void {
|
|
2486
|
-
const notify = (this.host as { notifyUserActionError?(windowId: string, message: string): void }).notifyUserActionError
|
|
2487
2569
|
const message = `action ${action.type} failed: ${String(error)}`
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2570
|
+
let reported = false
|
|
2571
|
+
try {
|
|
2572
|
+
const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
|
|
2573
|
+
const notify = host.notifyUserActionError
|
|
2574
|
+
if (typeof notify === 'function') {
|
|
2575
|
+
// Called on its owner: an unbound call here kills the host process.
|
|
2576
|
+
notify.call(host, action.windowId, message)
|
|
2577
|
+
reported = true
|
|
2578
|
+
}
|
|
2579
|
+
} catch (notifyError) {
|
|
2580
|
+
// Contained on purpose; the log line below still carries the original cause.
|
|
2581
|
+
process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`)
|
|
2492
2582
|
}
|
|
2583
|
+
if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
|
|
2493
2584
|
}
|
|
2494
2585
|
|
|
2495
2586
|
/** Ask the host to show the active tab's view, carrying the session label. */
|
|
2496
2587
|
private showActive(s: Session): void {
|
|
2588
|
+
// §3: whether the agent's page takes over the screen is the user's choice, not
|
|
2589
|
+
// a fixed behaviour. With auto-expand off the page still exists and is still
|
|
2590
|
+
// driven — it is simply folded away, so the agent works without claiming the
|
|
2591
|
+
// sidebar. Carriers that own no presentation (self-hosted) ignore the call.
|
|
2592
|
+
if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
|
|
2593
|
+
void this.host.collapse?.().catch(() => undefined)
|
|
2594
|
+
return
|
|
2595
|
+
}
|
|
2497
2596
|
this.host.showView?.(this.activeTab(s).handle, s.label)
|
|
2498
2597
|
}
|
|
2499
2598
|
|
|
@@ -799,12 +799,27 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
799
799
|
return this.electronAvailable
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* Ensure the child is up and ready (lazy on first use; restarts after a crash).
|
|
804
|
+
*
|
|
805
|
+
* Never throws synchronously. Callers are fire-and-forget
|
|
806
|
+
* (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
|
|
807
|
+
* `.catch` entirely — the shape that let a failed toolbar report take the whole
|
|
808
|
+
* DSH host down (issue #16). A disposed host is reported as a rejected promise
|
|
809
|
+
* instead, which those callers already handle.
|
|
810
|
+
*/
|
|
803
811
|
private ready(): Promise<void> {
|
|
804
812
|
// Fail fast on a disposed host: a recovery path can re-enter ready() after
|
|
805
813
|
// dispose() cleared its state, and starting fresh here would spawn a child
|
|
806
814
|
// nobody will ever kill.
|
|
807
|
-
if (this.disposed)
|
|
815
|
+
if (this.disposed) {
|
|
816
|
+
const gone = Promise.reject(new HostGoneError('browser host disposed'))
|
|
817
|
+
// Keep the rejection observable to callers while marking it handled here:
|
|
818
|
+
// a fire-and-forget caller that forgot to attach a handler must not turn
|
|
819
|
+
// this into an unhandled rejection either.
|
|
820
|
+
gone.catch(() => undefined)
|
|
821
|
+
return gone
|
|
822
|
+
}
|
|
808
823
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
809
824
|
const started = this.start()
|
|
810
825
|
const wrapped = started.catch(error => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
|
+
import type { BrowserChannel } from './system-browser.js'
|
|
15
16
|
|
|
16
17
|
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
17
18
|
export type VisionStrategy = 'auto' | 'nonVisual'
|
|
@@ -41,6 +42,10 @@ export interface BrowserSettings {
|
|
|
41
42
|
readonly vision: {
|
|
42
43
|
readonly strategy: VisionStrategy
|
|
43
44
|
}
|
|
45
|
+
/** Which browser binary carries the agent's pages. */
|
|
46
|
+
readonly browser: {
|
|
47
|
+
readonly channel: BrowserChannel
|
|
48
|
+
}
|
|
44
49
|
/** Whether the agent may read cookies / export login state. */
|
|
45
50
|
readonly credentials: {
|
|
46
51
|
readonly allowRead: boolean
|
|
@@ -53,6 +58,10 @@ export const DEFAULT_SETTINGS: BrowserSettings = {
|
|
|
53
58
|
cookies: { persist: true },
|
|
54
59
|
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
55
60
|
vision: { strategy: 'auto' },
|
|
61
|
+
// Bundled by default: it needs nothing installed, and on the desktop the carrier
|
|
62
|
+
// is the sidebar anyway. Choosing a system browser is an explicit opt-in because
|
|
63
|
+
// it starts a real Chrome/Edge with its own profile.
|
|
64
|
+
browser: { channel: 'bundled' },
|
|
56
65
|
credentials: { allowRead: true },
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -71,6 +80,18 @@ function bool(value: unknown, fallback: boolean): boolean {
|
|
|
71
80
|
return typeof value === 'boolean' ? value : fallback
|
|
72
81
|
}
|
|
73
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Coerce the browser channel, keeping the default when it is not one of the
|
|
85
|
+
* known choices. A hand-edited typo must not silently select a different browser.
|
|
86
|
+
* @param value - the raw value from the settings document.
|
|
87
|
+
* @returns a valid channel.
|
|
88
|
+
*/
|
|
89
|
+
function resolveChannel(value: unknown): BrowserChannel {
|
|
90
|
+
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
|
|
91
|
+
? value
|
|
92
|
+
: DEFAULT_SETTINGS.browser.channel
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
75
96
|
function count(value: unknown, fallback: number): number {
|
|
76
97
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
@@ -96,6 +117,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
96
117
|
const ui = section(source, 'ui')
|
|
97
118
|
const vision = section(source, 'vision')
|
|
98
119
|
const credentials = section(source, 'credentials')
|
|
120
|
+
const browser = section(source, 'browser')
|
|
99
121
|
return {
|
|
100
122
|
history: {
|
|
101
123
|
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
@@ -109,6 +131,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
109
131
|
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
110
132
|
},
|
|
111
133
|
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
134
|
+
browser: { channel: resolveChannel(browser.channel) },
|
|
112
135
|
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
113
136
|
}
|
|
114
137
|
}
|
|
@@ -153,7 +176,13 @@ export class SettingsStore {
|
|
|
153
176
|
}
|
|
154
177
|
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
|
|
155
178
|
try {
|
|
156
|
-
|
|
179
|
+
// A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
|
|
180
|
+
// `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
|
|
181
|
+
// Without this strip, hand-editing the settings file silently discarded every
|
|
182
|
+
// value in it: the document looked correct, and the plugin quietly ran on
|
|
183
|
+
// defaults — including switches the user had just turned off.
|
|
184
|
+
const body = text?.replace(/^\uFEFF/, '')
|
|
185
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
|
|
157
186
|
} catch {
|
|
158
187
|
// Malformed JSON: behave as defaults rather than fail startup.
|
|
159
188
|
this.cached = DEFAULT_SETTINGS
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive the browser the user already has (Chrome / Edge / any Chromium) instead of
|
|
3
|
+
* the Electron we ship — the same approach Codex Browser Use takes.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The bundled Electron is a private copy with its own profile, so it can never see
|
|
7
|
+
* the logins the user already has in Chrome, and on a managed machine the user may
|
|
8
|
+
* simply prefer their own browser. Launching their browser with a *separate*
|
|
9
|
+
* `--user-data-dir` gives us a real Chromium we can drive over CDP without touching
|
|
10
|
+
* their everyday profile, bookmarks or windows: nothing of theirs is opened, locked
|
|
11
|
+
* or modified, and closing us never closes them.
|
|
12
|
+
*
|
|
13
|
+
* HOW
|
|
14
|
+
* Chromium exposes CDP on `--remote-debugging-port=0`, and writes the port it chose
|
|
15
|
+
* into `<profile>/DevToolsActivePort`. That file is the discovery mechanism — no
|
|
16
|
+
* port guessing, no collisions. From there everything is CDP over one WebSocket:
|
|
17
|
+
* `Target.createTarget` makes a page, `Target.attachToTarget` (flattened) gives a
|
|
18
|
+
* session for it, and the plugin's commands ride that session. Node 22 ships a global
|
|
19
|
+
* `WebSocket`, so this needs no dependency at all.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT IMPLEMENTS
|
|
22
|
+
* The same `ElectronBrowserViewHost` seam as the self-hosted and sidebar hosts, so
|
|
23
|
+
* the provider, the tools, history, the cursor and teardown rules are unchanged —
|
|
24
|
+
* only the carrier differs.
|
|
25
|
+
* @module dsh-browser/browser-electron/system-browser
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync, rmSync } from 'node:fs'
|
|
29
|
+
import { existsSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
32
|
+
import { randomUUID } from 'node:crypto'
|
|
33
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
34
|
+
|
|
35
|
+
/** Which browser the user asked for. */
|
|
36
|
+
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto'
|
|
37
|
+
|
|
38
|
+
/** A resolved browser installation. */
|
|
39
|
+
export interface DetectedBrowser {
|
|
40
|
+
/** Which product this is. */
|
|
41
|
+
readonly kind: 'chrome' | 'edge' | 'brave'
|
|
42
|
+
/** Absolute path to the executable. */
|
|
43
|
+
readonly path: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where each Chromium product installs on Windows, plus the registry paths that
|
|
48
|
+
* survive a non-default install location. Only paths that exist are returned, so a
|
|
49
|
+
* caller can tell "not installed" from "installed elsewhere".
|
|
50
|
+
*/
|
|
51
|
+
const WINDOWS_LOCATIONS: Record<DetectedBrowser['kind'], string[]> = {
|
|
52
|
+
chrome: [
|
|
53
|
+
String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
54
|
+
String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
55
|
+
],
|
|
56
|
+
edge: [
|
|
57
|
+
String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
58
|
+
String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
59
|
+
],
|
|
60
|
+
brave: [
|
|
61
|
+
String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
62
|
+
String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find an installed Chromium browser.
|
|
68
|
+
*
|
|
69
|
+
* `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 Windows shipped.
|
|
71
|
+
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
72
|
+
* @param env - environment lookup, injected so tests need no real machine.
|
|
73
|
+
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
74
|
+
*/
|
|
75
|
+
export function detectBrowser(channel: BrowserChannel, env: NodeJS.ProcessEnv = process.env): DetectedBrowser | undefined {
|
|
76
|
+
if (channel === 'bundled') return undefined
|
|
77
|
+
const order: DetectedBrowser['kind'][] = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel as DetectedBrowser['kind']]
|
|
78
|
+
for (const kind of order) {
|
|
79
|
+
const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined)
|
|
80
|
+
if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
|
|
81
|
+
for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
|
|
82
|
+
if (existsSync(candidate)) return { kind, path: candidate }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One CDP connection to a browser-level WebSocket, with flattened sessions. */
|
|
89
|
+
class CdpClient {
|
|
90
|
+
private nextId = 1
|
|
91
|
+
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
|
92
|
+
private readonly ready: Promise<void>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param url - the browser's `webSocketDebuggerUrl`.
|
|
96
|
+
*/
|
|
97
|
+
constructor(private readonly url: string) {
|
|
98
|
+
// Node 22 provides WebSocket globally; no dependency is added for this.
|
|
99
|
+
const socket = new WebSocket(url)
|
|
100
|
+
this.socket = socket
|
|
101
|
+
this.ready = new Promise<void>((resolve, reject) => {
|
|
102
|
+
socket.addEventListener('open', () => resolve())
|
|
103
|
+
socket.addEventListener('error', () => reject(new Error('dsh-builtin-browser: could not reach the browser over CDP')))
|
|
104
|
+
})
|
|
105
|
+
socket.addEventListener('message', event => this.onMessage(String((event as MessageEvent).data)))
|
|
106
|
+
socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private readonly socket: WebSocket
|
|
110
|
+
|
|
111
|
+
/** Resolve once the socket is open. */
|
|
112
|
+
async whenReady(): Promise<void> {
|
|
113
|
+
await this.ready
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Send one CDP command, optionally on an attached session.
|
|
118
|
+
* @param method - the CDP method.
|
|
119
|
+
* @param params - its parameters.
|
|
120
|
+
* @param sessionId - the flattened session to target, when this is a page command.
|
|
121
|
+
* @returns the CDP result.
|
|
122
|
+
*/
|
|
123
|
+
async send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown> {
|
|
124
|
+
await this.ready
|
|
125
|
+
const id = this.nextId++
|
|
126
|
+
const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} }
|
|
127
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
128
|
+
this.pending.set(id, { resolve, reject })
|
|
129
|
+
this.socket.send(JSON.stringify(message))
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
if (this.pending.delete(id)) reject(new Error(`dsh-builtin-browser: ${method} timed out`))
|
|
132
|
+
}, 30_000).unref?.()
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Close the socket. */
|
|
137
|
+
close(): void {
|
|
138
|
+
try { this.socket.close() } catch { /* already closed */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Route one CDP message to its waiter. */
|
|
142
|
+
private onMessage(raw: string): void {
|
|
143
|
+
let message: { id?: number; result?: unknown; error?: { message?: string } }
|
|
144
|
+
try { message = JSON.parse(raw) } catch { return }
|
|
145
|
+
if (message.id === undefined) return
|
|
146
|
+
const entry = this.pending.get(message.id)
|
|
147
|
+
if (entry === undefined) return
|
|
148
|
+
this.pending.delete(message.id)
|
|
149
|
+
if (message.error !== undefined) entry.reject(new Error(`dsh-builtin-browser: CDP error: ${message.error.message ?? 'unknown'}`))
|
|
150
|
+
else entry.resolve(message.result)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fail everything in flight. */
|
|
154
|
+
private fail(error: Error): void {
|
|
155
|
+
const pending = [...this.pending.values()]
|
|
156
|
+
this.pending.clear()
|
|
157
|
+
for (const entry of pending) entry.reject(error)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A system browser driven over CDP, presented as a browser view host. */
|
|
162
|
+
export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
163
|
+
private readonly views = new Map<string, string>()
|
|
164
|
+
private disposed = false
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param child - the launched browser process.
|
|
168
|
+
* @param client - the CDP connection to it.
|
|
169
|
+
* @param kind - which product this is (for diagnostics).
|
|
170
|
+
* @param ephemeralDir - a throwaway profile to remove on release, when the user
|
|
171
|
+
* has turned persistence off and no login state should outlive the session.
|
|
172
|
+
*/
|
|
173
|
+
private constructor(
|
|
174
|
+
private readonly child: ChildProcess,
|
|
175
|
+
private readonly client: CdpClient,
|
|
176
|
+
readonly kind: DetectedBrowser['kind'],
|
|
177
|
+
private readonly ephemeralDir?: string,
|
|
178
|
+
) {}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param browser - the detected installation to launch.
|
|
182
|
+
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
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.
|
|
187
|
+
*/
|
|
188
|
+
static async launch(browser: DetectedBrowser, profileDir: string, extraArgs: readonly string[] = [], ephemeralDir?: string): Promise<SystemBrowserViewHost | undefined> {
|
|
189
|
+
const args = [
|
|
190
|
+
'--remote-debugging-port=0',
|
|
191
|
+
`--user-data-dir=${profileDir}`,
|
|
192
|
+
// Behave like a fresh, unattended browser: no first-run UI, no default-browser
|
|
193
|
+
// prompt, no restore bubble, and no "Chrome is being controlled" infobar.
|
|
194
|
+
'--no-first-run',
|
|
195
|
+
'--no-default-browser-check',
|
|
196
|
+
'--disable-features=Translate,MediaRouter',
|
|
197
|
+
'--disable-session-crashed-bubble',
|
|
198
|
+
'--hide-crash-restore-bubble',
|
|
199
|
+
...extraArgs,
|
|
200
|
+
'about:blank',
|
|
201
|
+
]
|
|
202
|
+
const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
|
|
203
|
+
const portFile = join(profileDir, 'DevToolsActivePort')
|
|
204
|
+
const deadline = Date.now() + 30_000
|
|
205
|
+
for (;;) {
|
|
206
|
+
if (Date.now() > deadline) { child.kill(); return undefined }
|
|
207
|
+
if (child.exitCode !== null) return undefined
|
|
208
|
+
if (existsSync(portFile)) {
|
|
209
|
+
try {
|
|
210
|
+
const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
|
|
211
|
+
if (port !== '') {
|
|
212
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() as { webSocketDebuggerUrl?: string }
|
|
213
|
+
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
214
|
+
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
215
|
+
await client.whenReady()
|
|
216
|
+
return new SystemBrowserViewHost(child, client, browser.kind, ephemeralDir)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// The file can exist a moment before the port answers; poll again.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether this host can back views: the CDP connection is live. */
|
|
228
|
+
available(): boolean {
|
|
229
|
+
return !this.disposed
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
createView(): ElectronViewHandle {
|
|
233
|
+
const viewId = randomUUID()
|
|
234
|
+
return {
|
|
235
|
+
id: viewId,
|
|
236
|
+
sendCommand: async (method: string, params?: Record<string, unknown>) => {
|
|
237
|
+
const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
|
|
238
|
+
const result = await this.client.send(method, params, session)
|
|
239
|
+
return (result ?? {}) as Record<string, unknown>
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
destroyView(handle: ElectronViewHandle): void {
|
|
245
|
+
const session = this.views.get(handle.id)
|
|
246
|
+
this.views.delete(handle.id)
|
|
247
|
+
if (session === undefined) return
|
|
248
|
+
// A session id is not a target id: closing the page needs the target the session
|
|
249
|
+
// was attached to, which is why they are tracked separately.
|
|
250
|
+
const targetId = this.sessions.get(session)
|
|
251
|
+
this.sessions.delete(session)
|
|
252
|
+
if (targetId !== undefined) {
|
|
253
|
+
// Best-effort: a page that is already gone is not an error.
|
|
254
|
+
void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Nothing to show: the browser owns its own windows. */
|
|
259
|
+
showView(): void {}
|
|
260
|
+
|
|
261
|
+
/** Same as {@link showView}. */
|
|
262
|
+
async presentView(): Promise<void> {}
|
|
263
|
+
|
|
264
|
+
/** The browser owns window grouping. */
|
|
265
|
+
groupView(): void {}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Bring the browser's window forward.
|
|
269
|
+
*
|
|
270
|
+
* The browser is a separate application, so there is no view to raise; focusing
|
|
271
|
+
* the process is the closest honest equivalent, and failing to do so (a minimized
|
|
272
|
+
* window, a locked session) is never worth an error.
|
|
273
|
+
*/
|
|
274
|
+
async focus(): Promise<void> {
|
|
275
|
+
try { this.child.kill('SIGCONT') } catch { /* not supported on Windows; harmless */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The browser reports its own window lifecycle. */
|
|
279
|
+
onUserAction(): void {}
|
|
280
|
+
|
|
281
|
+
/** The browser reports its own window lifecycle. */
|
|
282
|
+
onViewClosed(): void {}
|
|
283
|
+
|
|
284
|
+
/** Close the browser we launched. The user's own windows are a different process. */
|
|
285
|
+
dispose(): void {
|
|
286
|
+
if (this.disposed) return
|
|
287
|
+
this.disposed = true
|
|
288
|
+
this.views.clear()
|
|
289
|
+
this.sessions.clear()
|
|
290
|
+
this.client.close()
|
|
291
|
+
try { this.child.kill() } catch { /* already gone */ }
|
|
292
|
+
const ephemeral = this.ephemeralDir
|
|
293
|
+
if (ephemeral !== undefined) {
|
|
294
|
+
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
295
|
+
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
296
|
+
setTimeout(() => { try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ } }, 3_000).unref?.()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The flattened session for a view, creating its page on first use. */
|
|
301
|
+
private async ensureSession(viewId: string): Promise<string> {
|
|
302
|
+
const existing = this.views.get(viewId)
|
|
303
|
+
if (existing !== undefined) return existing
|
|
304
|
+
const created = await this.client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
305
|
+
const targetId = created?.targetId
|
|
306
|
+
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
307
|
+
const attached = await this.client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
308
|
+
const sessionId = attached?.sessionId
|
|
309
|
+
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
310
|
+
// The session id is what page commands ride; the target id is what closes it.
|
|
311
|
+
this.sessions.set(sessionId, targetId)
|
|
312
|
+
this.views.set(viewId, sessionId)
|
|
313
|
+
return sessionId
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
317
|
+
private readonly sessions = new Map<string, string>()
|
|
318
|
+
}
|