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.
- package/README.en.md +21 -3
- package/README.md +30 -4
- package/lib/browser/runtime.d.ts +24 -8
- package/lib/browser/runtime.js +35 -7
- package/lib/browser/types.d.ts +120 -7
- package/lib/browser/types.js +1 -1
- package/lib/browser-electron/host-main.js +85 -0
- package/lib/browser-electron/provider.d.ts +49 -8
- package/lib/browser-electron/provider.js +355 -34
- package/lib/browser-electron/remote-host.js +29 -1
- package/lib/tool-browser/index.d.ts +13 -7
- package/lib/tool-browser/index.js +509 -34
- package/package.json +5 -3
- package/src/browser/runtime.ts +260 -0
- package/src/browser/types.ts +338 -0
- package/src/browser-electron/entry.ts +58 -0
- package/src/browser-electron/host-main.ts +229 -0
- package/src/browser-electron/provider.ts +922 -0
- package/src/browser-electron/remote-host.ts +421 -0
- package/src/index.ts +33 -0
- package/src/tool-browser/index.ts +862 -0
- package/src/types/electron-shim.d.ts +82 -0
|
@@ -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
|
+
})
|