dsh-builtin-browser 0.1.8 → 0.1.10
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/lib/browser/runtime.d.ts +2 -1
- package/lib/browser/runtime.js +15 -2
- package/lib/browser/types.d.ts +2 -1
- package/lib/browser-electron/entry.js +6 -1
- package/lib/browser-electron/host-main.js +50 -14
- package/lib/browser-electron/provider.js +61 -24
- package/lib/browser-electron/remote-host.d.ts +7 -4
- package/lib/browser-electron/remote-host.js +117 -28
- package/lib/index.d.ts +8 -7
- package/lib/index.js +7 -6
- package/lib/tool-browser/index.d.ts +1 -1
- package/lib/tool-browser/index.js +44 -20
- package/package.json +1 -1
- package/src/browser/runtime.ts +14 -2
- package/src/browser/types.ts +2 -1
- package/src/browser-electron/entry.ts +6 -1
- package/src/browser-electron/host-main.ts +47 -14
- package/src/browser-electron/provider.ts +88 -25
- package/src/browser-electron/remote-host.ts +115 -25
- package/src/index.ts +12 -6
- package/src/tool-browser/index.ts +44 -21
|
@@ -21,6 +21,9 @@ import { createConnection } from 'node:net'
|
|
|
21
21
|
/** CDP protocol version attached to every view's debugger. */
|
|
22
22
|
const CDP_VERSION = '1.3'
|
|
23
23
|
|
|
24
|
+
/** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
|
|
25
|
+
const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
|
|
26
|
+
|
|
24
27
|
/** One view: the Electron object plus its CDP-backed surface. */
|
|
25
28
|
interface HostView {
|
|
26
29
|
readonly webContentsView: WebContentsView
|
|
@@ -59,12 +62,16 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
59
62
|
window.on('closed', () => { window = undefined })
|
|
60
63
|
}
|
|
61
64
|
const view = new WebContentsView()
|
|
65
|
+
// Attach the debugger BEFORE the view can be seen: an attach failure
|
|
66
|
+
// then leaves nothing in the window (no visible ghost view).
|
|
67
|
+
view.webContents.debugger.attach(CDP_VERSION)
|
|
68
|
+
// New views start hidden: with several sessions/tabs only the shown
|
|
69
|
+
// one may be visible (they stack in contentView child order).
|
|
62
70
|
view.setVisible(false)
|
|
63
71
|
window.contentView.addChildView(view)
|
|
64
72
|
const [width, height] = window.getContentSize()
|
|
65
73
|
view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
|
|
66
|
-
view.setVisible(true)
|
|
67
|
-
view.webContents.debugger.attach(CDP_VERSION)
|
|
74
|
+
if (views.size === 0) view.setVisible(true)
|
|
68
75
|
views.set(viewId, { webContentsView: view })
|
|
69
76
|
reply(msg.id, { ok: true })
|
|
70
77
|
return
|
|
@@ -86,7 +93,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
86
93
|
const viewId = msg.viewId
|
|
87
94
|
if (viewId === undefined) throw new Error('showView missing viewId')
|
|
88
95
|
const entry = views.get(viewId)
|
|
89
|
-
if (entry !== undefined)
|
|
96
|
+
if (entry !== undefined) {
|
|
97
|
+
// Hide every other view, then show and RAISE the target so the
|
|
98
|
+
// human actually sees the active tab/session (topmost child wins).
|
|
99
|
+
for (const v of views.values()) {
|
|
100
|
+
if (v === entry) continue
|
|
101
|
+
try { v.webContentsView.setVisible(false) } catch { /* destroyed */ }
|
|
102
|
+
}
|
|
103
|
+
entry.webContentsView.setVisible(true)
|
|
104
|
+
if (window !== undefined) {
|
|
105
|
+
window.contentView.removeChildView(entry.webContentsView)
|
|
106
|
+
window.contentView.addChildView(entry.webContentsView)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
90
109
|
reply(msg.id, { ok: true })
|
|
91
110
|
return
|
|
92
111
|
}
|
|
@@ -120,6 +139,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
120
139
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
121
140
|
const b = await r.arrayBuffer()
|
|
122
141
|
const bytes = new Uint8Array(b)
|
|
142
|
+
if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
|
|
123
143
|
let bin = ''
|
|
124
144
|
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
|
|
125
145
|
return btoa(bin)
|
|
@@ -143,16 +163,21 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
143
163
|
// Export the session's cookies so login state can be saved/restored
|
|
144
164
|
// across browser hosts (or shared with another machine).
|
|
145
165
|
const cookies = await entry.webContentsView.webContents.session.cookies.get({})
|
|
146
|
-
const exported = cookies.map(c =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
const exported = cookies.map(c => {
|
|
167
|
+
const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain
|
|
168
|
+
// IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
|
|
169
|
+
const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
|
|
170
|
+
return {
|
|
171
|
+
url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
|
|
172
|
+
name: c.name,
|
|
173
|
+
value: c.value,
|
|
174
|
+
domain: c.domain,
|
|
175
|
+
path: c.path,
|
|
176
|
+
secure: c.secure,
|
|
177
|
+
httpOnly: c.httpOnly,
|
|
178
|
+
expirationDate: c.expirationDate,
|
|
179
|
+
}
|
|
180
|
+
})
|
|
156
181
|
reply(msg.id, { ok: true, result: { cookies: exported } })
|
|
157
182
|
return
|
|
158
183
|
}
|
|
@@ -163,6 +188,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
163
188
|
const entry = views.get(viewId)
|
|
164
189
|
if (entry === undefined) throw new Error(`restoreAuth: unknown view ${viewId}`)
|
|
165
190
|
if (!Array.isArray(cookies)) throw new Error('restoreAuth missing cookies array')
|
|
191
|
+
let restored = 0
|
|
166
192
|
for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
|
|
167
193
|
if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
|
|
168
194
|
await entry.webContentsView.webContents.session.cookies.set({
|
|
@@ -175,8 +201,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
175
201
|
...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
|
|
176
202
|
...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
|
|
177
203
|
})
|
|
204
|
+
restored++
|
|
178
205
|
}
|
|
179
|
-
reply(msg.id, { ok: true, result: { restored
|
|
206
|
+
reply(msg.id, { ok: true, result: { restored } })
|
|
180
207
|
return
|
|
181
208
|
}
|
|
182
209
|
default:
|
|
@@ -220,6 +247,12 @@ void app.whenReady().then(() => {
|
|
|
220
247
|
socket.on('error', error => {
|
|
221
248
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|
|
222
249
|
})
|
|
250
|
+
// The parent owns our lifetime: when it closes the socket (dispose) or dies
|
|
251
|
+
// without cleanup, exit so no zombie Electron window is left behind.
|
|
252
|
+
socket.on('close', () => {
|
|
253
|
+
process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
|
|
254
|
+
app.exit(0)
|
|
255
|
+
})
|
|
223
256
|
// Keep the process alive until the parent closes the socket or kills us.
|
|
224
257
|
})
|
|
225
258
|
|
|
@@ -39,11 +39,11 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
|
39
39
|
const lower = (title + '\\n' + bodyText).toLowerCase()
|
|
40
40
|
const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
|
|
41
41
|
const framesLower = frameSrcs.toLowerCase()
|
|
42
|
-
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl
|
|
42
|
+
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
|
|
43
43
|
|| !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
|
|
44
44
|
const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
|
|
45
45
|
const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
|
|
46
|
-
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
|
|
46
|
+
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
|
|
47
47
|
const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
|
|
48
48
|
if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
|
|
49
49
|
if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
|
|
@@ -114,8 +114,10 @@ interface Session {
|
|
|
114
114
|
readonly id: BrowserSessionId
|
|
115
115
|
readonly tabs: Tab[]
|
|
116
116
|
activeIndex: number
|
|
117
|
-
/** Chronological operation log (navigate/execute/click/type/
|
|
117
|
+
/** Chronological operation log (navigate/execute/click/type/fill/download/auth). */
|
|
118
118
|
readonly history: BrowserHistoryEntry[]
|
|
119
|
+
/** Monotonic sequence counter for history entries (survives truncation). */
|
|
120
|
+
nextSeq: number
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
@@ -207,7 +209,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
207
209
|
open(): Promise<BrowserSessionId> {
|
|
208
210
|
const handle = this.host.createView()
|
|
209
211
|
const id = `browser:${randomUUID()}`
|
|
210
|
-
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
|
|
212
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
211
213
|
return Promise.resolve(id)
|
|
212
214
|
}
|
|
213
215
|
|
|
@@ -302,15 +304,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
signal?.throwIfAborted()
|
|
305
|
-
|
|
307
|
+
// Page.navigate can hang on an unreachable/slow host; bound it like the
|
|
308
|
+
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
309
|
+
// blocking the tool call forever.
|
|
310
|
+
const timeoutMs = 30_000
|
|
311
|
+
const result = await withTimeout(
|
|
312
|
+
handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
|
|
313
|
+
timeoutMs,
|
|
314
|
+
signal,
|
|
315
|
+
`browser: navigation timed out after ${timeoutMs}ms`,
|
|
316
|
+
)
|
|
306
317
|
// Page.navigate resolves even when the navigation fails; surface the
|
|
307
318
|
// failure instead of leaving a silent white screen.
|
|
308
319
|
const errorText = (result as { errorText?: string }).errorText
|
|
309
320
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
310
|
-
this.record(s, 'navigate', { url }, false, { error: errorText })
|
|
311
321
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
|
|
312
322
|
}
|
|
313
323
|
this.record(s, 'navigate', { url }, true)
|
|
324
|
+
this.showActive(s)
|
|
314
325
|
} catch (error) {
|
|
315
326
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
316
327
|
this.record(s, 'navigate', { url }, false, { error: String(error) })
|
|
@@ -362,9 +373,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
362
373
|
return { ok: false, exception }
|
|
363
374
|
}
|
|
364
375
|
const value = (result.result as { value?: unknown } | undefined)?.value ?? null
|
|
365
|
-
this.record(s, 'execute', {
|
|
376
|
+
this.record(s, 'execute', {
|
|
377
|
+
script: request.script,
|
|
378
|
+
...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
|
|
379
|
+
}, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
|
|
366
380
|
return { ok: true, value }
|
|
367
381
|
} catch (error) {
|
|
382
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
383
|
+
throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
|
|
384
|
+
}
|
|
368
385
|
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
|
|
369
386
|
}
|
|
370
387
|
}
|
|
@@ -476,13 +493,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
476
493
|
return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
|
|
477
494
|
})()`
|
|
478
495
|
// Honor a per-call timeout: content evaluation can hang on a heavy page,
|
|
479
|
-
// so a caller-supplied budget bounds it.
|
|
480
|
-
//
|
|
496
|
+
// so a caller-supplied budget bounds it. Unlike a bare signal entry check,
|
|
497
|
+
// withTimeout also interrupts a call already in flight.
|
|
481
498
|
const timeoutMs = request.timeoutMs ?? 30_000
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
499
|
+
const result = await withTimeout(
|
|
500
|
+
handleSendEvaluate(tab.handle, script),
|
|
501
|
+
timeoutMs,
|
|
502
|
+
signal,
|
|
503
|
+
`browser: content timed out after ${timeoutMs}ms`,
|
|
504
|
+
)
|
|
486
505
|
if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
|
|
487
506
|
const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
|
|
488
507
|
if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
|
|
@@ -505,7 +524,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
505
524
|
const { handle } = this.activeTab(s)
|
|
506
525
|
signal?.throwIfAborted()
|
|
507
526
|
await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
|
|
508
|
-
|
|
527
|
+
// Store the full text so replay re-issues the same input; the history
|
|
528
|
+
// tool truncates long values when rendering.
|
|
529
|
+
this.record(s, 'type', { text: request.text }, true)
|
|
509
530
|
}
|
|
510
531
|
|
|
511
532
|
/**
|
|
@@ -565,7 +586,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
565
586
|
return vis.length > 0 ? vis : all
|
|
566
587
|
}
|
|
567
588
|
for (const spec of specs) {
|
|
568
|
-
|
|
589
|
+
let els
|
|
590
|
+
try {
|
|
591
|
+
els = candidates(spec)
|
|
592
|
+
} catch (e) {
|
|
593
|
+
// A malformed selector must not abort the whole batch; report the
|
|
594
|
+
// field as failed and continue with the rest.
|
|
595
|
+
out.push({ ok: false, error: String(e), target: describe(spec) })
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
569
598
|
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
570
599
|
const el = els[0]
|
|
571
600
|
const tag = el.tagName
|
|
@@ -625,7 +654,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
625
654
|
let submitted = false
|
|
626
655
|
if (${submitFlag}) {
|
|
627
656
|
let last = null
|
|
628
|
-
for (const spec of specs) {
|
|
657
|
+
for (const spec of specs) {
|
|
658
|
+
try { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } } catch { /* skip */ }
|
|
659
|
+
}
|
|
629
660
|
const form = last && (last.form || last.closest('form'))
|
|
630
661
|
if (form) { form.requestSubmit(); submitted = true }
|
|
631
662
|
}
|
|
@@ -662,7 +693,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
662
693
|
if (typeof downloadable.download !== 'function') {
|
|
663
694
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
|
|
664
695
|
}
|
|
665
|
-
|
|
696
|
+
// The child fetches in-page with awaitPromise; a slow/hung network can
|
|
697
|
+
// block it well past the tool budget, so bound it like every other call.
|
|
698
|
+
const timeoutMs = 60_000
|
|
699
|
+
await withTimeout(
|
|
700
|
+
downloadable.download(request.url, request.savePath),
|
|
701
|
+
timeoutMs,
|
|
702
|
+
signal,
|
|
703
|
+
`browser: download timed out after ${timeoutMs}ms`,
|
|
704
|
+
)
|
|
666
705
|
this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
|
|
667
706
|
return { path: request.savePath }
|
|
668
707
|
}
|
|
@@ -678,7 +717,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
678
717
|
if (typeof host.flushAuth !== 'function') {
|
|
679
718
|
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
680
719
|
}
|
|
681
|
-
const
|
|
720
|
+
const timeoutMs = 30_000
|
|
721
|
+
const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
|
|
682
722
|
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
|
|
683
723
|
return cookies
|
|
684
724
|
}
|
|
@@ -691,7 +731,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
691
731
|
if (typeof host.restoreAuth !== 'function') {
|
|
692
732
|
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
|
|
693
733
|
}
|
|
694
|
-
const
|
|
734
|
+
const timeoutMs = 30_000
|
|
735
|
+
const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
|
|
695
736
|
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
|
|
696
737
|
return restored
|
|
697
738
|
}
|
|
@@ -710,7 +751,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
710
751
|
// a clip this yields the full-page image (CDP default is the viewport).
|
|
711
752
|
params.captureBeyondViewport = true
|
|
712
753
|
}
|
|
713
|
-
|
|
754
|
+
// Bound the capture: a view that has not painted can wedge
|
|
755
|
+
// Page.captureScreenshot indefinitely (observed after view re-showing).
|
|
756
|
+
const timeoutMs = 30_000
|
|
757
|
+
const result = await withTimeout(
|
|
758
|
+
handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
|
|
759
|
+
timeoutMs,
|
|
760
|
+
signal,
|
|
761
|
+
`browser: screenshot timed out after ${timeoutMs}ms`,
|
|
762
|
+
)
|
|
714
763
|
const data = result.data
|
|
715
764
|
if (typeof data !== 'string') {
|
|
716
765
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
|
|
@@ -739,7 +788,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
739
788
|
detail?: { result?: string; error?: string },
|
|
740
789
|
): void {
|
|
741
790
|
const entry: BrowserHistoryEntry = {
|
|
742
|
-
seq: s.
|
|
791
|
+
seq: s.nextSeq++,
|
|
743
792
|
action,
|
|
744
793
|
params,
|
|
745
794
|
ok,
|
|
@@ -796,8 +845,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
796
845
|
case 'execute': {
|
|
797
846
|
const script = entry.params.script
|
|
798
847
|
if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
|
|
799
|
-
const
|
|
800
|
-
|
|
848
|
+
const recordedArgs = entry.params.args
|
|
849
|
+
const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a): a is string => typeof a === 'string') : undefined
|
|
850
|
+
const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} })
|
|
851
|
+
this.record(s, 'replay', { seq, of: entry.action, script, ...args !== undefined && args.length > 0 ? { args } : {} }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
|
|
801
852
|
return
|
|
802
853
|
}
|
|
803
854
|
default:
|
|
@@ -846,7 +897,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
846
897
|
|
|
847
898
|
/** Read the current URL of a view through CDP. */
|
|
848
899
|
private async currentUrl(handle: ElectronViewHandle): Promise<string> {
|
|
849
|
-
|
|
900
|
+
// Bound the read: a wedged renderer would otherwise hang listTabs.
|
|
901
|
+
const timeoutMs = 10_000
|
|
902
|
+
const result = await withTimeout(
|
|
903
|
+
handleSendEvaluate(handle, 'location.href'),
|
|
904
|
+
timeoutMs,
|
|
905
|
+
undefined,
|
|
906
|
+
`browser: url read timed out after ${timeoutMs}ms`,
|
|
907
|
+
)
|
|
850
908
|
return result.ok && typeof result.value === 'string' ? result.value : ''
|
|
851
909
|
}
|
|
852
910
|
}
|
|
@@ -872,7 +930,12 @@ function withTimeout<T>(
|
|
|
872
930
|
const timer = setTimeout(() => {
|
|
873
931
|
if (done) return
|
|
874
932
|
done = true
|
|
875
|
-
|
|
933
|
+
// A fired timeout must also release the abort listener; { once: true }
|
|
934
|
+
// only releases it on the next abort, which may never come.
|
|
935
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
936
|
+
const error = new Error(message)
|
|
937
|
+
error.name = 'TimeoutError'
|
|
938
|
+
reject(error)
|
|
876
939
|
}, ms)
|
|
877
940
|
const finish = (fn: () => void): void => {
|
|
878
941
|
if (done) return
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Self-hosted Electron browser host (parent side): an
|
|
3
3
|
* {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
|
|
4
4
|
* Electron child process (host-main.js) and drives it over line-delimited
|
|
5
|
-
* JSON-RPC on
|
|
6
|
-
* desktop shell's electronViewHost (plain dsh web):
|
|
7
|
-
* enough — the browser window appears on first use.
|
|
5
|
+
* JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
|
|
6
|
+
* surfaces without a desktop shell's electronViewHost (plain dsh web):
|
|
7
|
+
* installing the plugin is enough — the browser window appears on first use.
|
|
8
8
|
*
|
|
9
9
|
* Protocol (one JSON object per line, both directions):
|
|
10
10
|
* -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
|
|
@@ -28,6 +28,9 @@ import type { ExportedCookie } from '../browser/types.ts'
|
|
|
28
28
|
/** How long to wait for the child to signal readiness before failing. */
|
|
29
29
|
const READY_TIMEOUT_MS = 20_000
|
|
30
30
|
|
|
31
|
+
/** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
|
|
32
|
+
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
|
|
33
|
+
|
|
31
34
|
/**
|
|
32
35
|
* Locate the Electron binary, trying, in order:
|
|
33
36
|
* 1. require('electron') from this module (optional peer dependency),
|
|
@@ -159,10 +162,13 @@ class ElectronChildClient {
|
|
|
159
162
|
private socket: import('node:net').Socket | undefined
|
|
160
163
|
private connected = false
|
|
161
164
|
private outbox: string[] = []
|
|
165
|
+
/** Set once the child has exited; further calls fail fast instead of queueing. */
|
|
166
|
+
private dead = false
|
|
162
167
|
|
|
163
168
|
constructor(
|
|
164
169
|
private readonly hostMainPath: string,
|
|
165
170
|
private readonly port: number,
|
|
171
|
+
private readonly onExit?: () => void,
|
|
166
172
|
) {
|
|
167
173
|
const electron = resolveElectronPath()
|
|
168
174
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
@@ -181,24 +187,45 @@ class ElectronChildClient {
|
|
|
181
187
|
// Diagnostics only; never parse stderr as protocol.
|
|
182
188
|
process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
|
|
183
189
|
})
|
|
190
|
+
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
191
|
+
// that would crash the whole DSH process.
|
|
192
|
+
this.child.on('error', error => {
|
|
193
|
+
process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
|
|
194
|
+
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
|
|
195
|
+
})
|
|
184
196
|
this.child.on('exit', (code, signal) => {
|
|
185
|
-
|
|
186
|
-
for (const pending of this.pending.values()) pending.reject(err)
|
|
187
|
-
this.pending.clear()
|
|
197
|
+
this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
|
|
188
198
|
})
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
/** Reject everything in flight, mark the client dead, and notify the host. */
|
|
202
|
+
private fail(err: Error): void {
|
|
203
|
+
if (this.dead) return
|
|
204
|
+
this.dead = true
|
|
205
|
+
this.connected = false
|
|
206
|
+
for (const pending of this.pending.values()) pending.reject(err)
|
|
207
|
+
this.pending.clear()
|
|
208
|
+
this.outbox = []
|
|
209
|
+
this.onExit?.()
|
|
210
|
+
}
|
|
211
|
+
|
|
191
212
|
/** Accept the child's connection (called by the server). */
|
|
192
213
|
attach(socket: import('node:net').Socket): void {
|
|
193
214
|
this.socket = socket
|
|
194
215
|
this.connected = true
|
|
195
216
|
socket.setEncoding('utf8')
|
|
217
|
+
// Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
|
|
218
|
+
// uncaught 'error' event and crashes the whole DSH process; 'close' below
|
|
219
|
+
// does the cleanup.
|
|
220
|
+
socket.on('error', error => {
|
|
221
|
+
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|
|
222
|
+
})
|
|
196
223
|
socket.on('data', chunk => this.onData(chunk))
|
|
197
224
|
socket.on('close', () => {
|
|
198
225
|
this.connected = false
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
if (!this.dead) {
|
|
227
|
+
this.fail(new Error('dsh-builtin-browser: browser host connection closed'))
|
|
228
|
+
}
|
|
202
229
|
})
|
|
203
230
|
// Flush anything queued while disconnected.
|
|
204
231
|
if (this.outbox.length > 0) {
|
|
@@ -209,6 +236,14 @@ class ElectronChildClient {
|
|
|
209
236
|
|
|
210
237
|
private onData(chunk: string | Buffer): void {
|
|
211
238
|
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
239
|
+
// Safety net: a pathological child (or a reply larger than expected)
|
|
240
|
+
// must not grow the parent's memory without bound. The child caps
|
|
241
|
+
// downloads at 256 MiB, so a healthy stream never approaches this.
|
|
242
|
+
if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
|
|
243
|
+
this.buffer = ''
|
|
244
|
+
this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`))
|
|
245
|
+
return
|
|
246
|
+
}
|
|
212
247
|
let nl: number
|
|
213
248
|
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
214
249
|
const line = this.buffer.slice(0, nl).trim()
|
|
@@ -232,6 +267,9 @@ class ElectronChildClient {
|
|
|
232
267
|
|
|
233
268
|
/** Send one command and await the reply. */
|
|
234
269
|
call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
|
|
270
|
+
if (this.dead) {
|
|
271
|
+
return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'))
|
|
272
|
+
}
|
|
235
273
|
const id = this.nextId++
|
|
236
274
|
const line = JSON.stringify({ id, op, ...payload })
|
|
237
275
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -292,14 +330,29 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
292
330
|
private pendingSocket: Socket | undefined
|
|
293
331
|
private readonly views = new Map<string, ElectronViewHandle>()
|
|
294
332
|
private readyPromise: Promise<void> | undefined
|
|
333
|
+
private disposed = false
|
|
295
334
|
|
|
296
335
|
constructor(private readonly hostMainPath: string) {}
|
|
297
336
|
|
|
298
|
-
/** Ensure the child is up and ready (lazy on first use). */
|
|
337
|
+
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
299
338
|
private ready(): Promise<void> {
|
|
300
339
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
301
|
-
|
|
302
|
-
|
|
340
|
+
const started = this.start()
|
|
341
|
+
const wrapped = started.catch(error => {
|
|
342
|
+
// A failed startup must not poison the host forever: tear down whatever
|
|
343
|
+
// was half-created and let the next call retry from scratch.
|
|
344
|
+
if (this.readyPromise === wrapped) {
|
|
345
|
+
this.readyPromise = undefined
|
|
346
|
+
this.client?.kill()
|
|
347
|
+
this.client = undefined
|
|
348
|
+
this.server?.close()
|
|
349
|
+
this.server = undefined
|
|
350
|
+
this.pendingSocket = undefined
|
|
351
|
+
}
|
|
352
|
+
throw error
|
|
353
|
+
})
|
|
354
|
+
this.readyPromise = wrapped
|
|
355
|
+
return wrapped
|
|
303
356
|
}
|
|
304
357
|
|
|
305
358
|
private async start(): Promise<void> {
|
|
@@ -312,10 +365,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
312
365
|
server.once('error', reject)
|
|
313
366
|
server.listen(0, '127.0.0.1', () => resolve())
|
|
314
367
|
})
|
|
368
|
+
// A later server error (rare on a loopback ephemeral port) must not crash
|
|
369
|
+
// the process; the client's fail path handles the actual recovery.
|
|
370
|
+
server.on('error', error => {
|
|
371
|
+
process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`)
|
|
372
|
+
})
|
|
315
373
|
const address = server.address()
|
|
316
374
|
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
317
375
|
this.server = server
|
|
318
|
-
this.client = new ElectronChildClient(this.hostMainPath, port)
|
|
376
|
+
this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit())
|
|
319
377
|
if (this.pendingSocket !== undefined) {
|
|
320
378
|
this.client.attach(this.pendingSocket)
|
|
321
379
|
this.pendingSocket = undefined
|
|
@@ -324,6 +382,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
324
382
|
await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready')
|
|
325
383
|
}
|
|
326
384
|
|
|
385
|
+
/** The child died: tear down so the next use starts a fresh child. */
|
|
386
|
+
private onChildExit(): void {
|
|
387
|
+
if (this.disposed) return
|
|
388
|
+
this.client = undefined
|
|
389
|
+
this.server?.close()
|
|
390
|
+
this.server = undefined
|
|
391
|
+
this.pendingSocket = undefined
|
|
392
|
+
this.readyPromise = undefined
|
|
393
|
+
// Keep the views map: handles still resolve to ids; a fresh child simply
|
|
394
|
+
// has no such views yet, and reset_session reopens clean sessions.
|
|
395
|
+
}
|
|
396
|
+
|
|
327
397
|
createView(): ElectronViewHandle {
|
|
328
398
|
// The seam is synchronous; the provider uses the handle immediately, so
|
|
329
399
|
// commands are deferred until the child is up and the view materialized.
|
|
@@ -338,13 +408,22 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
338
408
|
const client = this.client
|
|
339
409
|
if (client === undefined) throw new Error('browser host unavailable')
|
|
340
410
|
await client.call('createView', { viewId: id })
|
|
411
|
+
// If the view was destroyed while the createView RPC was in flight, do
|
|
412
|
+
// not re-insert a stale entry that would resurrect a dead child view.
|
|
413
|
+
if (this.views.get(id) === undefined) {
|
|
414
|
+
throw new Error('browser: view destroyed while starting')
|
|
415
|
+
}
|
|
341
416
|
const view = new RemoteView(id, client)
|
|
342
417
|
this.views.set(id, view)
|
|
343
418
|
return view
|
|
344
419
|
}
|
|
345
420
|
|
|
346
421
|
showView(handle: ElectronViewHandle): void {
|
|
347
|
-
|
|
422
|
+
// Fire-and-forget by design (visibility is best-effort), but a rejected
|
|
423
|
+
// promise must not become an unhandled rejection (crash on Node >= 15).
|
|
424
|
+
void this.ready()
|
|
425
|
+
.then(() => this.client?.call('showView', { viewId: handle.id }))
|
|
426
|
+
.catch(() => { /* host unavailable */ })
|
|
348
427
|
}
|
|
349
428
|
|
|
350
429
|
destroyView(handle: ElectronViewHandle): void {
|
|
@@ -358,6 +437,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
358
437
|
|
|
359
438
|
/** Shut the child and the RPC server down. */
|
|
360
439
|
dispose(): void {
|
|
440
|
+
this.disposed = true
|
|
361
441
|
this.client?.kill()
|
|
362
442
|
this.client = undefined
|
|
363
443
|
this.server?.close()
|
|
@@ -376,30 +456,40 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
376
456
|
private readonly materialize: () => Promise<RemoteView>,
|
|
377
457
|
) {}
|
|
378
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Materialize once and cache: every sendCommand on the same handle must
|
|
461
|
+
* target the SAME child view (re-materializing would re-run createView and
|
|
462
|
+
* duplicate the window). A FAILED materialization is reset so a later call
|
|
463
|
+
* (e.g. after the host restarted) can retry instead of being poisoned.
|
|
464
|
+
*/
|
|
465
|
+
private materializeOnce(): Promise<RemoteView> {
|
|
466
|
+
if (this.materialized === undefined) {
|
|
467
|
+
const pending = this.materialize()
|
|
468
|
+
this.materialized = pending.catch(error => {
|
|
469
|
+
if (this.materialized === pending) this.materialized = undefined
|
|
470
|
+
throw error
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
return this.materialized
|
|
474
|
+
}
|
|
475
|
+
|
|
379
476
|
async sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
380
|
-
|
|
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
|
|
477
|
+
const view = await this.materializeOnce()
|
|
385
478
|
return view.sendCommand(method, params)
|
|
386
479
|
}
|
|
387
480
|
|
|
388
481
|
async download(url: string, savePath: string): Promise<void> {
|
|
389
|
-
|
|
390
|
-
const view = await this.materialized
|
|
482
|
+
const view = await this.materializeOnce()
|
|
391
483
|
return view.download(url, savePath)
|
|
392
484
|
}
|
|
393
485
|
|
|
394
486
|
async flushAuth(): Promise<ExportedCookie[]> {
|
|
395
|
-
|
|
396
|
-
const view = await this.materialized
|
|
487
|
+
const view = await this.materializeOnce()
|
|
397
488
|
return view.flushAuth()
|
|
398
489
|
}
|
|
399
490
|
|
|
400
491
|
async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
|
|
401
|
-
|
|
402
|
-
const view = await this.materialized
|
|
492
|
+
const view = await this.materializeOnce()
|
|
403
493
|
return view.restoreAuth(cookies)
|
|
404
494
|
}
|
|
405
495
|
}
|