dsh-builtin-browser 0.1.6 → 0.1.8

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Self-hosted Electron browser host (parent side): an
3
+ * {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
4
+ * Electron child process (host-main.js) and drives it over line-delimited
5
+ * JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
6
+ * desktop shell's electronViewHost (plain dsh web): installing the plugin is
7
+ * enough — the browser window appears on first use.
8
+ *
9
+ * Protocol (one JSON object per line, both directions):
10
+ * -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
11
+ * { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
12
+ * <- { id, ok: true, result? } | { id, ok: false, err }
13
+ *
14
+ * The child is Electron's main process; host-main.js owns the BrowserWindow,
15
+ * WebContentsViews, and webContents.debugger (CDP).
16
+ * @module dsh-browser/browser-electron/remote-host
17
+ */
18
+
19
+ import { spawn, type ChildProcessByStdio } from 'node:child_process'
20
+ import { createRequire } from 'node:module'
21
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { createServer, type Server, type Socket } from 'node:net'
24
+ import { fileURLToPath } from 'node:url'
25
+ import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts'
26
+ import type { ExportedCookie } from '../browser/types.ts'
27
+
28
+ /** How long to wait for the child to signal readiness before failing. */
29
+ const READY_TIMEOUT_MS = 20_000
30
+
31
+ /**
32
+ * Locate the Electron binary, trying, in order:
33
+ * 1. require('electron') from this module (optional peer dependency),
34
+ * 2. require.resolve('electron') against DSH install anchors (global dsh
35
+ * package, npm global root) — desktop-shell environments satisfy this,
36
+ * 3. a pnpm virtual store under common workspace layouts,
37
+ * 4. ELECTRON_PATH (explicit override).
38
+ * @returns the path to the Electron executable.
39
+ */
40
+ function resolveElectronPath(): string {
41
+ const require = createRequire(import.meta.url)
42
+ // 1. Optional peer dependency.
43
+ try {
44
+ const resolved: unknown = require('electron')
45
+ if (typeof resolved === 'string' && resolved.length > 0) return resolved
46
+ } catch {
47
+ // continue probing
48
+ }
49
+ // 2. DSH install anchors: the global dsh package and the npm global root
50
+ // both carry electron junctions under pnpm layouts.
51
+ const anchors: string[] = []
52
+ const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX
53
+ if (globalPrefix !== undefined) {
54
+ anchors.push(join(globalPrefix, 'node_modules'))
55
+ anchors.push(join(globalPrefix, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'))
56
+ }
57
+ if (process.env.DSH_HOME !== undefined) {
58
+ anchors.push(join(process.env.DSH_HOME, 'profiles', 'node_modules'))
59
+ }
60
+ for (const anchor of anchors) {
61
+ try {
62
+ const resolved: unknown = require.resolve('electron', { paths: [anchor] })
63
+ if (typeof resolved === 'string' && resolved.length > 0) {
64
+ // electron's index.js is not the binary; find the dist executable
65
+ // next to the package root.
66
+ const exe = electronExeBeside(resolved)
67
+ if (exe !== undefined) return exe
68
+ }
69
+ } catch {
70
+ // continue probing
71
+ }
72
+ }
73
+ // 3. pnpm virtual store, walking up from several candidate roots: this
74
+ // module (its real path may differ from where the profile resolves it when
75
+ // installed via a junction/link), the process CWD, and the Node executable
76
+ // dir (DSH CLI typically runs from the workspace root).
77
+ const roots = new Set<string>([
78
+ fileURLToPath(new URL('.', import.meta.url)),
79
+ process.cwd(),
80
+ dirname(process.execPath),
81
+ ])
82
+ for (const root of roots) {
83
+ let dir = root
84
+ for (let depth = 0; depth < 8; depth++) {
85
+ const store = join(dir, 'node_modules', '.pnpm')
86
+ if (existsSync(store)) {
87
+ const exe = findElectronExe(store)
88
+ if (exe !== undefined) return exe
89
+ }
90
+ const parent = join(dir, '..')
91
+ if (parent === dir) break
92
+ dir = parent
93
+ }
94
+ }
95
+ // 4. Explicit override.
96
+ const override = process.env.ELECTRON_PATH
97
+ if (typeof override === 'string' && override.length > 0 && existsSync(override)) return override
98
+ throw new Error(
99
+ 'dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
100
+ '(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.',
101
+ )
102
+ }
103
+
104
+ /** From an electron package entry file, find the dist executable beside it. */
105
+ function electronExeBeside(entry: string): string | undefined {
106
+ const candidates = [
107
+ join(dirname(entry), 'dist', 'electron.exe'),
108
+ join(dirname(entry), 'dist', 'electron'),
109
+ join(dirname(entry), '..', 'dist', 'electron.exe'),
110
+ join(dirname(entry), '..', 'dist', 'electron'),
111
+ ]
112
+ for (const candidate of candidates) {
113
+ if (existsSync(candidate)) return candidate
114
+ }
115
+ return undefined
116
+ }
117
+
118
+ /** dirname without importing node:path's dirname separately. */
119
+ function dirname(p: string): string {
120
+ const i = p.lastIndexOf('/')
121
+ const j = p.lastIndexOf('\\')
122
+ const k = Math.max(i, j)
123
+ return k < 0 ? p : p.slice(0, k)
124
+ }
125
+
126
+ /** Search a pnpm virtual store for an Electron dist binary. */
127
+ function findElectronExe(store: string): string | undefined {
128
+ const entries = existsSync(store) ? readdirSync(store) : []
129
+ for (const entry of entries) {
130
+ if (!entry.startsWith('electron@')) continue
131
+ const candidates = [
132
+ join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
133
+ join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
134
+ ]
135
+ for (const candidate of candidates) {
136
+ if (existsSync(candidate)) return candidate
137
+ }
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ /** One RPC round-trip with the child. */
143
+ interface Pending {
144
+ resolve(result: unknown): void
145
+ reject(err: Error): void
146
+ }
147
+
148
+ /**
149
+ * Line-delimited JSON-RPC client over a local TCP socket. Electron's main
150
+ * process on Windows does not receive piped stdin, so the parent listens on a
151
+ * loopback port and passes it to the child via `--rpc-port`; the child
152
+ * connects back and speaks the same one-JSON-per-line protocol.
153
+ */
154
+ class ElectronChildClient {
155
+ private readonly child: ChildProcessByStdio<null, import('node:stream').Readable, import('node:stream').Readable>
156
+ private readonly pending = new Map<number, Pending>()
157
+ private nextId = 1
158
+ private buffer = ''
159
+ private socket: import('node:net').Socket | undefined
160
+ private connected = false
161
+ private outbox: string[] = []
162
+
163
+ constructor(
164
+ private readonly hostMainPath: string,
165
+ private readonly port: number,
166
+ ) {
167
+ const electron = resolveElectronPath()
168
+ // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
169
+ // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
170
+ // break the child. Rebuild the env without either.
171
+ const env: Record<string, string | undefined> = { ...process.env }
172
+ delete env.ELECTRON_RUN_AS_NODE
173
+ delete env.NODE_OPTIONS
174
+ this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port)], {
175
+ stdio: ['ignore', 'pipe', 'pipe'],
176
+ windowsHide: false,
177
+ env,
178
+ })
179
+ this.child.stderr.setEncoding('utf8')
180
+ this.child.stderr.on('data', chunk => {
181
+ // Diagnostics only; never parse stderr as protocol.
182
+ process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
183
+ })
184
+ this.child.on('exit', (code, signal) => {
185
+ const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`)
186
+ for (const pending of this.pending.values()) pending.reject(err)
187
+ this.pending.clear()
188
+ })
189
+ }
190
+
191
+ /** Accept the child's connection (called by the server). */
192
+ attach(socket: import('node:net').Socket): void {
193
+ this.socket = socket
194
+ this.connected = true
195
+ socket.setEncoding('utf8')
196
+ socket.on('data', chunk => this.onData(chunk))
197
+ socket.on('close', () => {
198
+ this.connected = false
199
+ const err = new Error('dsh-builtin-browser: browser host connection closed')
200
+ for (const pending of this.pending.values()) pending.reject(err)
201
+ this.pending.clear()
202
+ })
203
+ // Flush anything queued while disconnected.
204
+ if (this.outbox.length > 0) {
205
+ for (const line of this.outbox) socket.write(line + '\n')
206
+ this.outbox = []
207
+ }
208
+ }
209
+
210
+ private onData(chunk: string | Buffer): void {
211
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
212
+ let nl: number
213
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
214
+ const line = this.buffer.slice(0, nl).trim()
215
+ this.buffer = this.buffer.slice(nl + 1)
216
+ if (line === '') continue
217
+ let msg: { id?: number; ok?: boolean; result?: unknown; err?: string }
218
+ try {
219
+ msg = JSON.parse(line) as typeof msg
220
+ } catch {
221
+ // Non-protocol line; ignore.
222
+ continue
223
+ }
224
+ if (typeof msg.id !== 'number') continue
225
+ const pending = this.pending.get(msg.id)
226
+ if (pending === undefined) continue
227
+ this.pending.delete(msg.id)
228
+ if (msg.ok === true) pending.resolve(msg.result)
229
+ else pending.reject(new Error(msg.err ?? 'browser host command failed'))
230
+ }
231
+ }
232
+
233
+ /** Send one command and await the reply. */
234
+ call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
235
+ const id = this.nextId++
236
+ const line = JSON.stringify({ id, op, ...payload })
237
+ return new Promise<T>((resolve, reject) => {
238
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
239
+ if (this.connected && this.socket !== undefined) {
240
+ this.socket.write(line + '\n')
241
+ } else {
242
+ // Not connected yet: queue; attach() flushes on the child's arrival.
243
+ this.outbox.push(line)
244
+ }
245
+ })
246
+ }
247
+
248
+ /** Terminate the child. */
249
+ kill(): void {
250
+ try { this.socket?.destroy() } catch { /* already closed */ }
251
+ this.child.kill()
252
+ }
253
+ }
254
+
255
+ /** One view in the child: its id, used for every command. */
256
+ class RemoteView implements ElectronViewHandle {
257
+ constructor(readonly id: string, private readonly client: ElectronChildClient) {}
258
+
259
+ sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
260
+ return this.client.call<Record<string, unknown>>('command', {
261
+ viewId: this.id,
262
+ method,
263
+ params: params ?? {},
264
+ })
265
+ }
266
+
267
+ /** Ask the child to download a URL to a local file (keeps cookies/login). */
268
+ async download(url: string, savePath: string): Promise<void> {
269
+ const result = await this.client.call<{ base64: string }>('download', { viewId: this.id, url, savePath })
270
+ writeFileSync(savePath, Buffer.from(result.base64, 'base64'))
271
+ }
272
+
273
+ /** Export the session's cookies (login state). */
274
+ flushAuth(): Promise<ExportedCookie[]> {
275
+ return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }).then(r => r.cookies)
276
+ }
277
+
278
+ /** Import cookies into the session (restore login state). */
279
+ restoreAuth(cookies: ExportedCookie[]): Promise<number> {
280
+ return this.client.call<{ restored: number }>('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Self-hosted view host: spawns the plugin's Electron child on first use and
286
+ * keeps it alive until dispose(). Fallback when no desktop shell provides
287
+ * ctx.electronViewHost.
288
+ */
289
+ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
290
+ private client: ElectronChildClient | undefined
291
+ private server: Server | undefined
292
+ private pendingSocket: Socket | undefined
293
+ private readonly views = new Map<string, ElectronViewHandle>()
294
+ private readyPromise: Promise<void> | undefined
295
+
296
+ constructor(private readonly hostMainPath: string) {}
297
+
298
+ /** Ensure the child is up and ready (lazy on first use). */
299
+ private ready(): Promise<void> {
300
+ if (this.readyPromise !== undefined) return this.readyPromise
301
+ this.readyPromise = this.start()
302
+ return this.readyPromise
303
+ }
304
+
305
+ private async start(): Promise<void> {
306
+ // Listen on an ephemeral loopback port; the child connects back.
307
+ const server = createServer(socket => {
308
+ if (this.client !== undefined) this.client.attach(socket)
309
+ else this.pendingSocket = socket
310
+ })
311
+ await new Promise<void>((resolve, reject) => {
312
+ server.once('error', reject)
313
+ server.listen(0, '127.0.0.1', () => resolve())
314
+ })
315
+ const address = server.address()
316
+ const port = typeof address === 'object' && address !== null ? address.port : 0
317
+ this.server = server
318
+ this.client = new ElectronChildClient(this.hostMainPath, port)
319
+ if (this.pendingSocket !== undefined) {
320
+ this.client.attach(this.pendingSocket)
321
+ this.pendingSocket = undefined
322
+ }
323
+ // Wait for the child's connection + readiness ping.
324
+ await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready')
325
+ }
326
+
327
+ createView(): ElectronViewHandle {
328
+ // The seam is synchronous; the provider uses the handle immediately, so
329
+ // commands are deferred until the child is up and the view materialized.
330
+ const id = `view:${Math.random().toString(36).slice(2, 10)}`
331
+ const view = new DeferredRemoteView(id, () => this.ensureView(id))
332
+ this.views.set(id, view)
333
+ return view
334
+ }
335
+
336
+ private async ensureView(id: string): Promise<RemoteView> {
337
+ await this.ready()
338
+ const client = this.client
339
+ if (client === undefined) throw new Error('browser host unavailable')
340
+ await client.call('createView', { viewId: id })
341
+ const view = new RemoteView(id, client)
342
+ this.views.set(id, view)
343
+ return view
344
+ }
345
+
346
+ showView(handle: ElectronViewHandle): void {
347
+ void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }))
348
+ }
349
+
350
+ destroyView(handle: ElectronViewHandle): void {
351
+ const view = this.views.get(handle.id)
352
+ if (view === undefined) return
353
+ this.views.delete(handle.id)
354
+ void this.ready()
355
+ .then(() => this.client?.call('destroyView', { viewId: handle.id }))
356
+ .catch(() => { /* child already gone */ })
357
+ }
358
+
359
+ /** Shut the child and the RPC server down. */
360
+ dispose(): void {
361
+ this.client?.kill()
362
+ this.client = undefined
363
+ this.server?.close()
364
+ this.server = undefined
365
+ this.readyPromise = undefined
366
+ this.views.clear()
367
+ }
368
+ }
369
+
370
+ /** A view handle that waits for child readiness before issuing commands. */
371
+ class DeferredRemoteView implements ElectronViewHandle {
372
+ private materialized: Promise<RemoteView> | undefined
373
+
374
+ constructor(
375
+ readonly id: string,
376
+ private readonly materialize: () => Promise<RemoteView>,
377
+ ) {}
378
+
379
+ async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
380
+ // Materialize once and cache: every sendCommand on the same handle must
381
+ // target the SAME child view. Re-materializing would re-run createView
382
+ // (duplicate window) and drop the established view.
383
+ this.materialized ??= this.materialize()
384
+ const view = await this.materialized
385
+ return view.sendCommand(method, params)
386
+ }
387
+
388
+ async download(url: string, savePath: string): Promise<void> {
389
+ this.materialized ??= this.materialize()
390
+ const view = await this.materialized
391
+ return view.download(url, savePath)
392
+ }
393
+
394
+ async flushAuth(): Promise<ExportedCookie[]> {
395
+ this.materialized ??= this.materialize()
396
+ const view = await this.materialized
397
+ return view.flushAuth()
398
+ }
399
+
400
+ async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
401
+ this.materialized ??= this.materialize()
402
+ const view = await this.materialized
403
+ return view.restoreAuth(cookies)
404
+ }
405
+ }
406
+
407
+ /** Reject a promise if it does not settle within the budget. */
408
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
409
+ return new Promise<T>((resolve, reject) => {
410
+ const timer = setTimeout(() => reject(new Error(`${message} (${ms}ms)`)), ms)
411
+ promise.then(
412
+ value => { clearTimeout(timer); resolve(value) },
413
+ error => { clearTimeout(timer); reject(error) },
414
+ )
415
+ })
416
+ }
417
+
418
+ /** Default host-main path relative to this module's build output. */
419
+ export function defaultHostMainPath(): string {
420
+ return fileURLToPath(new URL('./host-main.js', import.meta.url))
421
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * dsh-browser plugin entry: aggregates the shared-browser capability pieces.
3
+ * The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
5
+ * - `dsh-browser/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-browser/tool-browser` -> the model-facing browser_* tools
7
+ * This root entry only re-exports for programmatic use; the loader rows are
8
+ * the composition surface.
9
+ * @module dsh-browser
10
+ */
11
+
12
+ export { BrowserError } from './browser/types.ts'
13
+ export type {
14
+ BrowserClickRequest,
15
+ BrowserContentFormat,
16
+ BrowserContentRequest,
17
+ BrowserContentResult,
18
+ BrowserExecuteRequest,
19
+ BrowserExecuteResult,
20
+ BrowserNavigateRequest,
21
+ BrowserOpenRequest,
22
+ BrowserProvider,
23
+ BrowserScreenshotRequest,
24
+ BrowserScreenshotResult,
25
+ BrowserSessionId,
26
+ BrowserSnapshotElement,
27
+ BrowserSnapshotResult,
28
+ BrowserTab,
29
+ BrowserTypeRequest,
30
+ } from './browser/types.ts'
31
+ export { BrowserRuntime } from './browser/runtime.ts'
32
+ export { ElectronBrowserProvider } from './browser-electron/provider.ts'
33
+ export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts'