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,229 @@
1
+ /**
2
+ * Self-hosted Electron browser host (child side): the Electron main process
3
+ * spawned by {@link RemoteElectronViewHost}. Owns one `BrowserWindow` plus
4
+ * `WebContentsView`s and their `webContents.debugger` (CDP), and answers
5
+ * line-delimited JSON-RPC on stdio.
6
+ *
7
+ * Protocol (one JSON object per line, both directions):
8
+ * <- { id, op: 'ping' } | { id, op: 'createView', viewId } |
9
+ * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
10
+ * { id, op: 'command', viewId, method, params }
11
+ * -> { id, ok: true, result? } | { id, ok: false, err }
12
+ *
13
+ * The parent never parses stderr, so diagnostics may go there freely.
14
+ * @module dsh-browser/browser-electron/host-main
15
+ */
16
+
17
+ import { app, BrowserWindow, WebContentsView } from 'electron'
18
+ import { createInterface } from 'node:readline'
19
+ import { createConnection } from 'node:net'
20
+
21
+ /** CDP protocol version attached to every view's debugger. */
22
+ const CDP_VERSION = '1.3'
23
+
24
+ /** One view: the Electron object plus its CDP-backed surface. */
25
+ interface HostView {
26
+ readonly webContentsView: WebContentsView
27
+ }
28
+
29
+ /** Views by the id the parent assigned at createView time. */
30
+ const views = new Map<string, HostView>()
31
+
32
+ /** The single browser window; created lazily on first createView. */
33
+ let window: BrowserWindow | undefined
34
+
35
+ /** The RPC socket to the parent; set when the connection is established. */
36
+ let rpcSocket: import('node:net').Socket | undefined
37
+
38
+ /** Reply to the parent over the RPC socket. */
39
+ function reply(id: number, payload: Record<string, unknown>): void {
40
+ if (rpcSocket === undefined) {
41
+ process.stderr.write(`[dsh-browser host] reply without socket (id=${id})\n`)
42
+ return
43
+ }
44
+ rpcSocket.write(JSON.stringify({ id, ...payload }) + '\n')
45
+ }
46
+
47
+ /** Handle one command. */
48
+ async function handle(op: string, msg: { id: number; viewId?: string; method?: string; params?: Record<string, unknown>; url?: string; savePath?: string; cookies?: unknown[] }): Promise<void> {
49
+ try {
50
+ switch (op) {
51
+ case 'ping':
52
+ reply(msg.id, { ok: true })
53
+ return
54
+ case 'createView': {
55
+ const viewId = msg.viewId
56
+ if (viewId === undefined) throw new Error('createView missing viewId')
57
+ if (window === undefined) {
58
+ window = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' })
59
+ window.on('closed', () => { window = undefined })
60
+ }
61
+ const view = new WebContentsView()
62
+ view.setVisible(false)
63
+ window.contentView.addChildView(view)
64
+ const [width, height] = window.getContentSize()
65
+ view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
66
+ view.setVisible(true)
67
+ view.webContents.debugger.attach(CDP_VERSION)
68
+ views.set(viewId, { webContentsView: view })
69
+ reply(msg.id, { ok: true })
70
+ return
71
+ }
72
+ case 'destroyView': {
73
+ const viewId = msg.viewId
74
+ if (viewId === undefined) throw new Error('destroyView missing viewId')
75
+ const entry = views.get(viewId)
76
+ if (entry !== undefined) {
77
+ views.delete(viewId)
78
+ try { entry.webContentsView.webContents.debugger.detach() } catch { /* already detached */ }
79
+ entry.webContentsView.webContents.close()
80
+ window?.contentView.removeChildView(entry.webContentsView)
81
+ }
82
+ reply(msg.id, { ok: true })
83
+ return
84
+ }
85
+ case 'showView': {
86
+ const viewId = msg.viewId
87
+ if (viewId === undefined) throw new Error('showView missing viewId')
88
+ const entry = views.get(viewId)
89
+ if (entry !== undefined) entry.webContentsView.setVisible(true)
90
+ reply(msg.id, { ok: true })
91
+ return
92
+ }
93
+ case 'command': {
94
+ const viewId = msg.viewId
95
+ if (viewId === undefined) throw new Error('command missing viewId')
96
+ const entry = views.get(viewId)
97
+ if (entry === undefined) throw new Error(`command: unknown view ${viewId}`)
98
+ const method = msg.method
99
+ if (typeof method !== 'string') throw new Error('command missing method')
100
+ const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {})
101
+ reply(msg.id, { ok: true, result })
102
+ return
103
+ }
104
+ case 'download': {
105
+ const viewId = msg.viewId
106
+ const url = msg.url
107
+ const savePath = msg.savePath
108
+ if (viewId === undefined || typeof url !== 'string' || typeof savePath !== 'string') {
109
+ throw new Error('download missing viewId/url/savePath')
110
+ }
111
+ const entry = views.get(viewId)
112
+ if (entry === undefined) throw new Error(`download: unknown view ${viewId}`)
113
+ // Fetch the URL inside the page context (keeps cookies/login), read
114
+ // the body as base64, and return it; the parent writes the file. This
115
+ // avoids Electron's download pipeline entirely (CDP debugger attach
116
+ // can interfere with will-download).
117
+ const result = await entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
118
+ expression: `(async () => {
119
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' })
120
+ if (!r.ok) throw new Error('HTTP ' + r.status)
121
+ const b = await r.arrayBuffer()
122
+ const bytes = new Uint8Array(b)
123
+ let bin = ''
124
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
125
+ return btoa(bin)
126
+ })()`,
127
+ awaitPromise: true,
128
+ returnByValue: true,
129
+ })
130
+ const value = (result as { result?: { value?: unknown } }).result?.value
131
+ if (typeof value !== 'string') {
132
+ const detail = (result as { exceptionDetails?: { exception?: { description?: string } } }).exceptionDetails
133
+ throw new Error(`download failed: ${detail?.exception?.description ?? 'no data'}`)
134
+ }
135
+ reply(msg.id, { ok: true, result: { base64: value, savePath } })
136
+ return
137
+ }
138
+ case 'flushAuth': {
139
+ const viewId = msg.viewId
140
+ if (viewId === undefined) throw new Error('flushAuth missing viewId')
141
+ const entry = views.get(viewId)
142
+ if (entry === undefined) throw new Error(`flushAuth: unknown view ${viewId}`)
143
+ // Export the session's cookies so login state can be saved/restored
144
+ // across browser hosts (or shared with another machine).
145
+ const cookies = await entry.webContentsView.webContents.session.cookies.get({})
146
+ const exported = cookies.map(c => ({
147
+ url: `http${c.secure ? 's' : ''}://${c.domain.startsWith('.') ? c.domain.slice(1) : c.domain}${c.path}`,
148
+ name: c.name,
149
+ value: c.value,
150
+ domain: c.domain,
151
+ path: c.path,
152
+ secure: c.secure,
153
+ httpOnly: c.httpOnly,
154
+ expirationDate: c.expirationDate,
155
+ }))
156
+ reply(msg.id, { ok: true, result: { cookies: exported } })
157
+ return
158
+ }
159
+ case 'restoreAuth': {
160
+ const viewId = msg.viewId
161
+ const cookies = msg.cookies
162
+ if (viewId === undefined) throw new Error('restoreAuth missing viewId')
163
+ const entry = views.get(viewId)
164
+ if (entry === undefined) throw new Error(`restoreAuth: unknown view ${viewId}`)
165
+ if (!Array.isArray(cookies)) throw new Error('restoreAuth missing cookies array')
166
+ for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
167
+ if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
168
+ await entry.webContentsView.webContents.session.cookies.set({
169
+ url: c.url,
170
+ name: c.name,
171
+ value: c.value,
172
+ ...typeof c.domain === 'string' ? { domain: c.domain } : {},
173
+ ...typeof c.path === 'string' ? { path: c.path } : {},
174
+ ...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
175
+ ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
176
+ ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
177
+ })
178
+ }
179
+ reply(msg.id, { ok: true, result: { restored: (cookies as unknown[]).length } })
180
+ return
181
+ }
182
+ default:
183
+ throw new Error(`unknown op ${op}`)
184
+ }
185
+ } catch (error) {
186
+ reply(msg.id, { ok: false, err: String(error) })
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Electron entry: connect back to the parent's RPC server (port from
192
+ * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
193
+ * cleared by the parent so `require('electron')` works; this file is loaded as
194
+ * the app entry so `app` is available immediately.
195
+ */
196
+ void app.whenReady().then(() => {
197
+ const portArg = process.argv.indexOf('--rpc-port')
198
+ const port = portArg >= 0 ? Number(process.argv[portArg + 1]) : NaN
199
+ if (!Number.isFinite(port)) {
200
+ process.stderr.write('[dsh-browser host] missing --rpc-port\n')
201
+ app.exit(1)
202
+ return
203
+ }
204
+ const socket = createConnection({ host: '127.0.0.1', port })
205
+ rpcSocket = socket
206
+ socket.setEncoding('utf8')
207
+ const rl = createInterface({ input: socket })
208
+ rl.on('line', line => {
209
+ const text = line.trim()
210
+ if (text === '') return
211
+ let msg: { id: number; op?: string; viewId?: string; method?: string; params?: Record<string, unknown>; url?: string; savePath?: string; cookies?: unknown[] }
212
+ try {
213
+ msg = JSON.parse(text) as typeof msg
214
+ } catch {
215
+ return // non-protocol noise
216
+ }
217
+ if (typeof msg.id !== 'number' || typeof msg.op !== 'string') return
218
+ void handle(msg.op, msg).catch(() => { /* reply already sent inside handle */ })
219
+ })
220
+ socket.on('error', error => {
221
+ process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
222
+ })
223
+ // Keep the process alive until the parent closes the socket or kills us.
224
+ })
225
+
226
+ // Diagnostics go to stderr, which the parent never parses as protocol.
227
+ process.on('uncaughtException', error => {
228
+ process.stderr.write(`[dsh-browser host] uncaught: ${String(error)}\n`)
229
+ })