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
package/lib/browser/runtime.d.ts
CHANGED
|
@@ -94,7 +94,8 @@ export declare class BrowserRuntime extends Service {
|
|
|
94
94
|
flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]>;
|
|
95
95
|
/** Import cookies into the session through the provider. */
|
|
96
96
|
restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>;
|
|
97
|
-
/** Close the session through the selected provider. Idempotent
|
|
97
|
+
/** Close the session through the selected provider. Idempotent; a missing
|
|
98
|
+
* provider is treated as already-closed so teardown paths stay no-ops. */
|
|
98
99
|
close(session: BrowserSessionId): Promise<void>;
|
|
99
100
|
}
|
|
100
101
|
export default BrowserRuntime;
|
package/lib/browser/runtime.js
CHANGED
|
@@ -158,9 +158,22 @@ export class BrowserRuntime extends Service {
|
|
|
158
158
|
async restoreAuth(session, cookies) {
|
|
159
159
|
return this.resolveProvider().restoreAuth(session, cookies);
|
|
160
160
|
}
|
|
161
|
-
/** Close the session through the selected provider. Idempotent
|
|
161
|
+
/** Close the session through the selected provider. Idempotent; a missing
|
|
162
|
+
* provider is treated as already-closed so teardown paths stay no-ops. */
|
|
162
163
|
async close(session) {
|
|
163
|
-
|
|
164
|
+
try {
|
|
165
|
+
await this.resolveProvider().close(session);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const code = error instanceof BrowserError ? error.code : undefined;
|
|
169
|
+
if (code === 'BROWSER_PROVIDER_UNAVAILABLE'
|
|
170
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_MISSING'
|
|
171
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_UNAVAILABLE'
|
|
172
|
+
|| code === 'BROWSER_PROVIDER_AMBIGUOUS') {
|
|
173
|
+
return; // provider gone; nothing to close
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
export default BrowserRuntime;
|
package/lib/browser/types.d.ts
CHANGED
|
@@ -294,7 +294,8 @@ export interface BrowserProvider {
|
|
|
294
294
|
export interface BrowserHistoryEntry {
|
|
295
295
|
/** Monotonic sequence number within the session. */
|
|
296
296
|
readonly seq: number;
|
|
297
|
-
/** The operation name: navigate | execute | click | type
|
|
297
|
+
/** The operation name. Replayable: navigate | execute | click | type.
|
|
298
|
+
* Also recorded (non-replayable): fill | download | flushAuth | restoreAuth. */
|
|
298
299
|
readonly action: string;
|
|
299
300
|
/** The operation's arguments. */
|
|
300
301
|
readonly params: Record<string, unknown>;
|
|
@@ -28,10 +28,15 @@ export function apply(ctx, config) {
|
|
|
28
28
|
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
29
29
|
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
30
30
|
const host = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath());
|
|
31
|
+
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
32
|
+
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
33
|
+
// row would otherwise collide with the still-registered provider
|
|
34
|
+
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
35
|
+
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }));
|
|
31
36
|
ctx.effect(() => () => {
|
|
37
|
+
unregister();
|
|
32
38
|
if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
|
|
33
39
|
host.dispose();
|
|
34
40
|
}
|
|
35
41
|
});
|
|
36
|
-
ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }));
|
|
37
42
|
}
|
|
@@ -18,6 +18,8 @@ import { createInterface } from 'node:readline';
|
|
|
18
18
|
import { createConnection } from 'node:net';
|
|
19
19
|
/** CDP protocol version attached to every view's debugger. */
|
|
20
20
|
const CDP_VERSION = '1.3';
|
|
21
|
+
/** Download cap: the body is shipped base64 as one JSON line; bound the memory. */
|
|
22
|
+
const MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024;
|
|
21
23
|
/** Views by the id the parent assigned at createView time. */
|
|
22
24
|
const views = new Map();
|
|
23
25
|
/** The single browser window; created lazily on first createView. */
|
|
@@ -48,12 +50,17 @@ async function handle(op, msg) {
|
|
|
48
50
|
window.on('closed', () => { window = undefined; });
|
|
49
51
|
}
|
|
50
52
|
const view = new WebContentsView();
|
|
53
|
+
// Attach the debugger BEFORE the view can be seen: an attach failure
|
|
54
|
+
// then leaves nothing in the window (no visible ghost view).
|
|
55
|
+
view.webContents.debugger.attach(CDP_VERSION);
|
|
56
|
+
// New views start hidden: with several sessions/tabs only the shown
|
|
57
|
+
// one may be visible (they stack in contentView child order).
|
|
51
58
|
view.setVisible(false);
|
|
52
59
|
window.contentView.addChildView(view);
|
|
53
60
|
const [width, height] = window.getContentSize();
|
|
54
61
|
view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 });
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
if (views.size === 0)
|
|
63
|
+
view.setVisible(true);
|
|
57
64
|
views.set(viewId, { webContentsView: view });
|
|
58
65
|
reply(msg.id, { ok: true });
|
|
59
66
|
return;
|
|
@@ -80,8 +87,23 @@ async function handle(op, msg) {
|
|
|
80
87
|
if (viewId === undefined)
|
|
81
88
|
throw new Error('showView missing viewId');
|
|
82
89
|
const entry = views.get(viewId);
|
|
83
|
-
if (entry !== undefined)
|
|
90
|
+
if (entry !== undefined) {
|
|
91
|
+
// Hide every other view, then show and RAISE the target so the
|
|
92
|
+
// human actually sees the active tab/session (topmost child wins).
|
|
93
|
+
for (const v of views.values()) {
|
|
94
|
+
if (v === entry)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
v.webContentsView.setVisible(false);
|
|
98
|
+
}
|
|
99
|
+
catch { /* destroyed */ }
|
|
100
|
+
}
|
|
84
101
|
entry.webContentsView.setVisible(true);
|
|
102
|
+
if (window !== undefined) {
|
|
103
|
+
window.contentView.removeChildView(entry.webContentsView);
|
|
104
|
+
window.contentView.addChildView(entry.webContentsView);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
85
107
|
reply(msg.id, { ok: true });
|
|
86
108
|
return;
|
|
87
109
|
}
|
|
@@ -119,6 +141,7 @@ async function handle(op, msg) {
|
|
|
119
141
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
120
142
|
const b = await r.arrayBuffer()
|
|
121
143
|
const bytes = new Uint8Array(b)
|
|
144
|
+
if (bytes.length > ${String(MAX_DOWNLOAD_BYTES)}) throw new Error('download too large (limit ' + ${String(MAX_DOWNLOAD_BYTES)} + ' bytes, got ' + bytes.length + ')')
|
|
122
145
|
let bin = ''
|
|
123
146
|
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
|
|
124
147
|
return btoa(bin)
|
|
@@ -144,16 +167,21 @@ async function handle(op, msg) {
|
|
|
144
167
|
// Export the session's cookies so login state can be saved/restored
|
|
145
168
|
// across browser hosts (or shared with another machine).
|
|
146
169
|
const cookies = await entry.webContentsView.webContents.session.cookies.get({});
|
|
147
|
-
const exported = cookies.map(c =>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
const exported = cookies.map(c => {
|
|
171
|
+
const host = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
|
172
|
+
// IPv6 literal domains need brackets in a URL (e.g. http://[::1]/).
|
|
173
|
+
const hostPart = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
174
|
+
return {
|
|
175
|
+
url: `http${c.secure ? 's' : ''}://${hostPart}${c.path}`,
|
|
176
|
+
name: c.name,
|
|
177
|
+
value: c.value,
|
|
178
|
+
domain: c.domain,
|
|
179
|
+
path: c.path,
|
|
180
|
+
secure: c.secure,
|
|
181
|
+
httpOnly: c.httpOnly,
|
|
182
|
+
expirationDate: c.expirationDate,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
157
185
|
reply(msg.id, { ok: true, result: { cookies: exported } });
|
|
158
186
|
return;
|
|
159
187
|
}
|
|
@@ -167,6 +195,7 @@ async function handle(op, msg) {
|
|
|
167
195
|
throw new Error(`restoreAuth: unknown view ${viewId}`);
|
|
168
196
|
if (!Array.isArray(cookies))
|
|
169
197
|
throw new Error('restoreAuth missing cookies array');
|
|
198
|
+
let restored = 0;
|
|
170
199
|
for (const c of cookies) {
|
|
171
200
|
if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string')
|
|
172
201
|
continue;
|
|
@@ -180,8 +209,9 @@ async function handle(op, msg) {
|
|
|
180
209
|
...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
|
|
181
210
|
...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
|
|
182
211
|
});
|
|
212
|
+
restored++;
|
|
183
213
|
}
|
|
184
|
-
reply(msg.id, { ok: true, result: { restored
|
|
214
|
+
reply(msg.id, { ok: true, result: { restored } });
|
|
185
215
|
return;
|
|
186
216
|
}
|
|
187
217
|
default:
|
|
@@ -228,6 +258,12 @@ void app.whenReady().then(() => {
|
|
|
228
258
|
socket.on('error', error => {
|
|
229
259
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
|
|
230
260
|
});
|
|
261
|
+
// The parent owns our lifetime: when it closes the socket (dispose) or dies
|
|
262
|
+
// without cleanup, exit so no zombie Electron window is left behind.
|
|
263
|
+
socket.on('close', () => {
|
|
264
|
+
process.stderr.write('[dsh-browser host] parent connection closed, exiting\n');
|
|
265
|
+
app.exit(0);
|
|
266
|
+
});
|
|
231
267
|
// Keep the process alive until the parent closes the socket or kills us.
|
|
232
268
|
});
|
|
233
269
|
// Diagnostics go to stderr, which the parent never parses as protocol.
|
|
@@ -21,11 +21,11 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
|
21
21
|
const lower = (title + '\\n' + bodyText).toLowerCase()
|
|
22
22
|
const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
|
|
23
23
|
const framesLower = frameSrcs.toLowerCase()
|
|
24
|
-
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl
|
|
24
|
+
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
|
|
25
25
|
|| !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
|
|
26
26
|
const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
|
|
27
27
|
const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
|
|
28
|
-
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
|
|
28
|
+
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
|
|
29
29
|
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)
|
|
30
30
|
if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
|
|
31
31
|
if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
|
|
@@ -80,7 +80,7 @@ export class ElectronBrowserProvider {
|
|
|
80
80
|
open() {
|
|
81
81
|
const handle = this.host.createView();
|
|
82
82
|
const id = `browser:${randomUUID()}`;
|
|
83
|
-
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] });
|
|
83
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 });
|
|
84
84
|
return Promise.resolve(id);
|
|
85
85
|
}
|
|
86
86
|
/** Open a URL in the active tab (default) or a new tab. */
|
|
@@ -175,15 +175,19 @@ export class ElectronBrowserProvider {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
signal?.throwIfAborted();
|
|
178
|
-
|
|
178
|
+
// Page.navigate can hang on an unreachable/slow host; bound it like the
|
|
179
|
+
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
180
|
+
// blocking the tool call forever.
|
|
181
|
+
const timeoutMs = 30_000;
|
|
182
|
+
const result = await withTimeout(handle.sendCommand(CDP_PAGE_NAVIGATE, { url }), timeoutMs, signal, `browser: navigation timed out after ${timeoutMs}ms`);
|
|
179
183
|
// Page.navigate resolves even when the navigation fails; surface the
|
|
180
184
|
// failure instead of leaving a silent white screen.
|
|
181
185
|
const errorText = result.errorText;
|
|
182
186
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
183
|
-
this.record(s, 'navigate', { url }, false, { error: errorText });
|
|
184
187
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
|
|
185
188
|
}
|
|
186
189
|
this.record(s, 'navigate', { url }, true);
|
|
190
|
+
this.showActive(s);
|
|
187
191
|
}
|
|
188
192
|
catch (error) {
|
|
189
193
|
if (!(error instanceof BrowserError && error.code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
@@ -230,10 +234,16 @@ export class ElectronBrowserProvider {
|
|
|
230
234
|
return { ok: false, exception };
|
|
231
235
|
}
|
|
232
236
|
const value = result.result?.value ?? null;
|
|
233
|
-
this.record(s, 'execute', {
|
|
237
|
+
this.record(s, 'execute', {
|
|
238
|
+
script: request.script,
|
|
239
|
+
...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
|
|
240
|
+
}, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) });
|
|
234
241
|
return { ok: true, value };
|
|
235
242
|
}
|
|
236
243
|
catch (error) {
|
|
244
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
245
|
+
throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error });
|
|
246
|
+
}
|
|
237
247
|
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
|
|
238
248
|
}
|
|
239
249
|
}
|
|
@@ -333,13 +343,10 @@ export class ElectronBrowserProvider {
|
|
|
333
343
|
return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
|
|
334
344
|
})()`;
|
|
335
345
|
// Honor a per-call timeout: content evaluation can hang on a heavy page,
|
|
336
|
-
// so a caller-supplied budget bounds it.
|
|
337
|
-
//
|
|
346
|
+
// so a caller-supplied budget bounds it. Unlike a bare signal entry check,
|
|
347
|
+
// withTimeout also interrupts a call already in flight.
|
|
338
348
|
const timeoutMs = request.timeoutMs ?? 30_000;
|
|
339
|
-
const
|
|
340
|
-
const result = signal !== undefined
|
|
341
|
-
? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
|
|
342
|
-
: await handleSendEvaluate(tab.handle, script, timeoutSignal);
|
|
349
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: content timed out after ${timeoutMs}ms`);
|
|
343
350
|
if (!result.ok)
|
|
344
351
|
throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED');
|
|
345
352
|
const value = result.value;
|
|
@@ -362,7 +369,9 @@ export class ElectronBrowserProvider {
|
|
|
362
369
|
const { handle } = this.activeTab(s);
|
|
363
370
|
signal?.throwIfAborted();
|
|
364
371
|
await handle.sendCommand('Input.insertText', { text: request.text });
|
|
365
|
-
|
|
372
|
+
// Store the full text so replay re-issues the same input; the history
|
|
373
|
+
// tool truncates long values when rendering.
|
|
374
|
+
this.record(s, 'type', { text: request.text }, true);
|
|
366
375
|
}
|
|
367
376
|
/**
|
|
368
377
|
* Fill a form's fields in one batch. Runs one page-context script that
|
|
@@ -421,7 +430,15 @@ export class ElectronBrowserProvider {
|
|
|
421
430
|
return vis.length > 0 ? vis : all
|
|
422
431
|
}
|
|
423
432
|
for (const spec of specs) {
|
|
424
|
-
|
|
433
|
+
let els
|
|
434
|
+
try {
|
|
435
|
+
els = candidates(spec)
|
|
436
|
+
} catch (e) {
|
|
437
|
+
// A malformed selector must not abort the whole batch; report the
|
|
438
|
+
// field as failed and continue with the rest.
|
|
439
|
+
out.push({ ok: false, error: String(e), target: describe(spec) })
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
425
442
|
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
426
443
|
const el = els[0]
|
|
427
444
|
const tag = el.tagName
|
|
@@ -481,7 +498,9 @@ export class ElectronBrowserProvider {
|
|
|
481
498
|
let submitted = false
|
|
482
499
|
if (${submitFlag}) {
|
|
483
500
|
let last = null
|
|
484
|
-
for (const spec of specs) {
|
|
501
|
+
for (const spec of specs) {
|
|
502
|
+
try { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } } catch { /* skip */ }
|
|
503
|
+
}
|
|
485
504
|
const form = last && (last.form || last.closest('form'))
|
|
486
505
|
if (form) { form.requestSubmit(); submitted = true }
|
|
487
506
|
}
|
|
@@ -512,7 +531,10 @@ export class ElectronBrowserProvider {
|
|
|
512
531
|
if (typeof downloadable.download !== 'function') {
|
|
513
532
|
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
|
|
514
533
|
}
|
|
515
|
-
|
|
534
|
+
// The child fetches in-page with awaitPromise; a slow/hung network can
|
|
535
|
+
// block it well past the tool budget, so bound it like every other call.
|
|
536
|
+
const timeoutMs = 60_000;
|
|
537
|
+
await withTimeout(downloadable.download(request.url, request.savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
|
|
516
538
|
this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
|
|
517
539
|
return { path: request.savePath };
|
|
518
540
|
}
|
|
@@ -527,7 +549,8 @@ export class ElectronBrowserProvider {
|
|
|
527
549
|
if (typeof host.flushAuth !== 'function') {
|
|
528
550
|
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
529
551
|
}
|
|
530
|
-
const
|
|
552
|
+
const timeoutMs = 30_000;
|
|
553
|
+
const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
|
|
531
554
|
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
|
|
532
555
|
return cookies;
|
|
533
556
|
}
|
|
@@ -539,7 +562,8 @@ export class ElectronBrowserProvider {
|
|
|
539
562
|
if (typeof host.restoreAuth !== 'function') {
|
|
540
563
|
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
541
564
|
}
|
|
542
|
-
const
|
|
565
|
+
const timeoutMs = 30_000;
|
|
566
|
+
const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
|
|
543
567
|
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
|
|
544
568
|
return restored;
|
|
545
569
|
}
|
|
@@ -553,7 +577,10 @@ export class ElectronBrowserProvider {
|
|
|
553
577
|
// a clip this yields the full-page image (CDP default is the viewport).
|
|
554
578
|
params.captureBeyondViewport = true;
|
|
555
579
|
}
|
|
556
|
-
|
|
580
|
+
// Bound the capture: a view that has not painted can wedge
|
|
581
|
+
// Page.captureScreenshot indefinitely (observed after view re-showing).
|
|
582
|
+
const timeoutMs = 30_000;
|
|
583
|
+
const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
|
|
557
584
|
const data = result.data;
|
|
558
585
|
if (typeof data !== 'string') {
|
|
559
586
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
|
|
@@ -576,7 +603,7 @@ export class ElectronBrowserProvider {
|
|
|
576
603
|
/** Append one operation to the session's history. */
|
|
577
604
|
record(s, action, params, ok, detail) {
|
|
578
605
|
const entry = {
|
|
579
|
-
seq: s.
|
|
606
|
+
seq: s.nextSeq++,
|
|
580
607
|
action,
|
|
581
608
|
params,
|
|
582
609
|
ok,
|
|
@@ -636,8 +663,10 @@ export class ElectronBrowserProvider {
|
|
|
636
663
|
const script = entry.params.script;
|
|
637
664
|
if (typeof script !== 'string')
|
|
638
665
|
throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID');
|
|
639
|
-
const
|
|
640
|
-
|
|
666
|
+
const recordedArgs = entry.params.args;
|
|
667
|
+
const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a) => typeof a === 'string') : undefined;
|
|
668
|
+
const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} });
|
|
669
|
+
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 });
|
|
641
670
|
return;
|
|
642
671
|
}
|
|
643
672
|
default:
|
|
@@ -682,7 +711,9 @@ export class ElectronBrowserProvider {
|
|
|
682
711
|
}
|
|
683
712
|
/** Read the current URL of a view through CDP. */
|
|
684
713
|
async currentUrl(handle) {
|
|
685
|
-
|
|
714
|
+
// Bound the read: a wedged renderer would otherwise hang listTabs.
|
|
715
|
+
const timeoutMs = 10_000;
|
|
716
|
+
const result = await withTimeout(handleSendEvaluate(handle, 'location.href'), timeoutMs, undefined, `browser: url read timed out after ${timeoutMs}ms`);
|
|
686
717
|
return result.ok && typeof result.value === 'string' ? result.value : '';
|
|
687
718
|
}
|
|
688
719
|
}
|
|
@@ -703,7 +734,13 @@ function withTimeout(promise, ms, signal, message) {
|
|
|
703
734
|
if (done)
|
|
704
735
|
return;
|
|
705
736
|
done = true;
|
|
706
|
-
|
|
737
|
+
// A fired timeout must also release the abort listener; { once: true }
|
|
738
|
+
// only releases it on the next abort, which may never come.
|
|
739
|
+
if (signal !== undefined)
|
|
740
|
+
signal.removeEventListener('abort', onAbort);
|
|
741
|
+
const error = new Error(message);
|
|
742
|
+
error.name = 'TimeoutError';
|
|
743
|
+
reject(error);
|
|
707
744
|
}, ms);
|
|
708
745
|
const finish = (fn) => {
|
|
709
746
|
if (done)
|
|
@@ -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,10 +28,13 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
28
28
|
private pendingSocket;
|
|
29
29
|
private readonly views;
|
|
30
30
|
private readyPromise;
|
|
31
|
+
private disposed;
|
|
31
32
|
constructor(hostMainPath: string);
|
|
32
|
-
/** Ensure the child is up and ready (lazy on first use). */
|
|
33
|
+
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
33
34
|
private ready;
|
|
34
35
|
private start;
|
|
36
|
+
/** The child died: tear down so the next use starts a fresh child. */
|
|
37
|
+
private onChildExit;
|
|
35
38
|
createView(): ElectronViewHandle;
|
|
36
39
|
private ensureView;
|
|
37
40
|
showView(handle: ElectronViewHandle): void;
|