dsh-builtin-browser 0.1.23 → 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.
Files changed (48) hide show
  1. package/README.en.md +91 -3
  2. package/README.md +98 -4
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +24 -5
  5. package/desktop-bridge/install.mjs +98 -0
  6. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  7. package/docs/README.md +1 -1
  8. package/docs/architecture.md +1 -1
  9. package/docs/tool-reference.md +2 -1
  10. package/lib/browser/runtime.d.ts +18 -1
  11. package/lib/browser/runtime.js +19 -0
  12. package/lib/browser/types.d.ts +33 -0
  13. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  14. package/lib/browser-electron/bridge-connection.js +133 -0
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  16. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  17. package/lib/browser-electron/entry.js +195 -11
  18. package/lib/browser-electron/history-store.d.ts +75 -0
  19. package/lib/browser-electron/history-store.js +162 -0
  20. package/lib/browser-electron/host-main.js +28 -1
  21. package/lib/browser-electron/provider.d.ts +122 -1
  22. package/lib/browser-electron/provider.js +233 -21
  23. package/lib/browser-electron/remote-host.d.ts +17 -1
  24. package/lib/browser-electron/remote-host.js +81 -9
  25. package/lib/browser-electron/settings-store.d.ts +92 -0
  26. package/lib/browser-electron/settings-store.js +183 -0
  27. package/lib/browser-electron/system-browser.d.ts +100 -0
  28. package/lib/browser-electron/system-browser.js +322 -0
  29. package/lib/browser-electron/virtual-cursor.d.ts +67 -0
  30. package/lib/browser-electron/virtual-cursor.js +141 -0
  31. package/lib/index.d.ts +16 -2
  32. package/lib/index.js +18 -2
  33. package/lib/tool-browser/index.js +122 -16
  34. package/package.json +14 -1
  35. package/src/browser/runtime.ts +22 -0
  36. package/src/browser/types.ts +31 -0
  37. package/src/browser-electron/bridge-connection.ts +145 -0
  38. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  39. package/src/browser-electron/entry.ts +217 -11
  40. package/src/browser-electron/history-store.ts +171 -0
  41. package/src/browser-electron/host-main.ts +26 -1
  42. package/src/browser-electron/provider.ts +284 -21
  43. package/src/browser-electron/remote-host.ts +84 -7
  44. package/src/browser-electron/settings-store.ts +227 -0
  45. package/src/browser-electron/system-browser.ts +318 -0
  46. package/src/browser-electron/virtual-cursor.ts +152 -0
  47. package/src/index.ts +20 -2
  48. package/src/tool-browser/index.ts +121 -15
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Plugin-owned settings document — the switches the browser settings panel
3
+ * exposes, stored next to the browsing history so both follow the same rules:
4
+ * they survive the browser process and DSH restarts.
5
+ *
6
+ * Every field is optional on disk and falls back to a default, so a hand-edited
7
+ * or older file never breaks startup; an unreadable file is treated as "all
8
+ * defaults" rather than an error.
9
+ * @module dsh-browser/browser-electron/settings-store
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import type { BrowserChannel } from './system-browser.js'
16
+
17
+ /** Vision/operation strategy the provider prefers when the model can see. */
18
+ export type VisionStrategy = 'auto' | 'nonVisual'
19
+
20
+ /** The full settings document, with every field resolved. */
21
+ export interface BrowserSettings {
22
+ /** Persistent browsing history (visited pages). */
23
+ readonly history: {
24
+ readonly enabled: boolean
25
+ readonly maxEntries: number
26
+ readonly maxAgeDays: number
27
+ }
28
+ /** Login-state retention: cookies and the browser profile. */
29
+ readonly cookies: {
30
+ readonly persist: boolean
31
+ }
32
+ /** Presentation behaviour. */
33
+ readonly ui: {
34
+ /** Auto-expand the side panel once per task, then leave the user alone. */
35
+ readonly autoExpandOnce: boolean
36
+ /** Release the browser when its session ends, instead of waiting for a close. */
37
+ readonly closeWithSession: boolean
38
+ /** Show the synthetic cursor/click feedback while the agent operates. */
39
+ readonly virtualCursor: boolean
40
+ }
41
+ /** Visual vs non-visual operation strategy. */
42
+ readonly vision: {
43
+ readonly strategy: VisionStrategy
44
+ }
45
+ /** Which browser binary carries the agent's pages. */
46
+ readonly browser: {
47
+ readonly channel: BrowserChannel
48
+ }
49
+ /** Whether the agent may read cookies / export login state. */
50
+ readonly credentials: {
51
+ readonly allowRead: boolean
52
+ }
53
+ }
54
+
55
+ /** Defaults for every switch: the documented out-of-the-box behaviour. */
56
+ export const DEFAULT_SETTINGS: BrowserSettings = {
57
+ history: { enabled: true, maxEntries: 5_000, maxAgeDays: 90 },
58
+ cookies: { persist: true },
59
+ ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
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' },
65
+ credentials: { allowRead: true },
66
+ }
67
+
68
+ /**
69
+ * Absolute path of the settings file, beside the browser profile and the
70
+ * browsing history.
71
+ * @returns the settings file path.
72
+ */
73
+ export function settingsPath(): string {
74
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
75
+ return join(home, 'dsh-builtin-browser-host', 'settings.json')
76
+ }
77
+
78
+ /** Coerce one boolean field, keeping the default when absent or mistyped. */
79
+ function bool(value: unknown, fallback: boolean): boolean {
80
+ return typeof value === 'boolean' ? value : fallback
81
+ }
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
+
95
+ /** Coerce one positive-integer field, keeping the default when invalid. */
96
+ function count(value: unknown, fallback: number): number {
97
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
98
+ }
99
+
100
+ /** Read one nested section of an unknown document, or an empty object. */
101
+ function section(source: Record<string, unknown>, key: string): Record<string, unknown> {
102
+ const value = source[key]
103
+ return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
104
+ }
105
+
106
+ /**
107
+ * Resolve a raw settings document (already parsed, shape unknown) into the full
108
+ * document, field by field. Unknown keys are dropped: the file is not a place to
109
+ * smuggle configuration.
110
+ * @param raw - the parsed file contents.
111
+ * @returns the resolved settings.
112
+ */
113
+ export function resolveSettings(raw: unknown): BrowserSettings {
114
+ const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
115
+ const history = section(source, 'history')
116
+ const cookies = section(source, 'cookies')
117
+ const ui = section(source, 'ui')
118
+ const vision = section(source, 'vision')
119
+ const credentials = section(source, 'credentials')
120
+ const browser = section(source, 'browser')
121
+ return {
122
+ history: {
123
+ enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
124
+ maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
125
+ maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
126
+ },
127
+ cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
128
+ ui: {
129
+ autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
130
+ closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
131
+ virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
132
+ },
133
+ vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
134
+ browser: { channel: resolveChannel(browser.channel) },
135
+ credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
136
+ }
137
+ }
138
+
139
+ /**
140
+ * File-backed settings with an in-memory snapshot. Reads re-stat the file so an
141
+ * edit from another process (or the settings panel writing through a second
142
+ * code path) is picked up without a restart; writes are atomic enough for a
143
+ * single-writer plugin and never throw at the caller.
144
+ */
145
+ export class SettingsStore {
146
+ private cached: BrowserSettings | undefined
147
+ /** Last observed mtime, used to invalidate the cache on external edits. */
148
+ private cachedMtimeMs = -1
149
+
150
+ /**
151
+ * @param file - settings file path (defaults beside the browser profile).
152
+ */
153
+ constructor(private readonly file: string = settingsPath()) {}
154
+
155
+ /** Path this store reads and writes. */
156
+ path(): string {
157
+ return this.file
158
+ }
159
+
160
+ /**
161
+ * Current settings, re-read when the file changed on disk.
162
+ * @returns the resolved settings document.
163
+ */
164
+ get(): BrowserSettings {
165
+ let mtimeMs = -1
166
+ let text: string | undefined
167
+ try {
168
+ if (existsSync(this.file)) {
169
+ mtimeMs = statSync(this.file).mtimeMs
170
+ text = readFileSync(this.file, 'utf8')
171
+ }
172
+ } catch {
173
+ // Unreadable file: fall through to defaults below.
174
+ mtimeMs = -1
175
+ text = undefined
176
+ }
177
+ if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
178
+ try {
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))
186
+ } catch {
187
+ // Malformed JSON: behave as defaults rather than fail startup.
188
+ this.cached = DEFAULT_SETTINGS
189
+ }
190
+ this.cachedMtimeMs = mtimeMs
191
+ return this.cached
192
+ }
193
+
194
+ /**
195
+ * Merge a partial patch into the stored document and persist it.
196
+ * @param patch - the fields to change (unknown keys are ignored).
197
+ * @returns the settings after the merge.
198
+ */
199
+ update(patch: unknown): BrowserSettings {
200
+ const current = this.get()
201
+ const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
202
+ try {
203
+ mkdirSync(dirname(this.file), { recursive: true })
204
+ writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
205
+ } catch {
206
+ // Keep the merged value in memory even when the write fails.
207
+ }
208
+ this.cached = merged
209
+ this.cachedMtimeMs = Date.now()
210
+ return merged
211
+ }
212
+ }
213
+
214
+ /** One-level-deep merge over the settings sections (patch wins). */
215
+ function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
216
+ if (typeof patch !== 'object' || patch === null) return base
217
+ const result: Record<string, unknown> = { ...base }
218
+ for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
219
+ const existing = result[key]
220
+ if (typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null) {
221
+ result[key] = { ...existing as Record<string, unknown>, ...value as Record<string, unknown> }
222
+ } else {
223
+ result[key] = value
224
+ }
225
+ }
226
+ return result
227
+ }
@@ -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
+ }