dsh-builtin-browser 0.1.21 → 0.1.22
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 +18 -10
- package/README.md +18 -10
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/tool-reference.md +2 -2
- package/docs/user-guide.md +6 -6
- package/lib/browser-electron/host-main.js +97 -15
- package/lib/browser-electron/provider.d.ts +82 -8
- package/lib/browser-electron/provider.js +256 -38
- package/lib/browser-electron/remote-host.d.ts +25 -0
- package/lib/browser-electron/remote-host.js +97 -15
- package/lib/tool-browser/index.js +2 -2
- package/package.json +1 -1
- package/src/browser-electron/host-main.ts +106 -9
- package/src/browser-electron/provider.ts +284 -38
- package/src/browser-electron/remote-host.ts +102 -15
- package/src/tool-browser/index.ts +2 -2
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @module dsh-browser/browser-electron/remote-host
|
|
26
26
|
*/
|
|
27
27
|
import { execFileSync, spawn } from 'node:child_process';
|
|
28
|
+
import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
28
29
|
import { randomBytes } from 'node:crypto';
|
|
29
30
|
import { createRequire } from 'node:module';
|
|
30
31
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
@@ -33,6 +34,8 @@ import { createServer } from 'node:net';
|
|
|
33
34
|
import { fileURLToPath } from 'node:url';
|
|
34
35
|
/** How long to wait for the child to signal readiness before failing. */
|
|
35
36
|
const READY_TIMEOUT_MS = 20_000;
|
|
37
|
+
/** Budget for the present/barrier round-trips (see presentView). */
|
|
38
|
+
const PRESENT_TIMEOUT_MS = 10_000;
|
|
36
39
|
/** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
|
|
37
40
|
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
38
41
|
function resolveElectronPath() {
|
|
@@ -316,23 +319,31 @@ function compareVersions(a, b) {
|
|
|
316
319
|
}
|
|
317
320
|
return 0;
|
|
318
321
|
}
|
|
319
|
-
/**
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
join(
|
|
325
|
-
|
|
322
|
+
/** Candidate executable layouts used by the Electron npm package on one platform. */
|
|
323
|
+
function electronDistCandidates(pkgRoot, platform = process.platform) {
|
|
324
|
+
// macOS keeps the runnable binary inside the Electron.app bundle; unlike
|
|
325
|
+
// Windows/Linux there is no dist/electron(.exe) sibling to probe.
|
|
326
|
+
if (platform === 'darwin') {
|
|
327
|
+
return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')];
|
|
328
|
+
}
|
|
329
|
+
return [
|
|
330
|
+
join(pkgRoot, 'dist', 'electron.exe'),
|
|
331
|
+
join(pkgRoot, 'dist', 'electron'),
|
|
326
332
|
];
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
}
|
|
334
|
+
/** From an electron package entry file, find the dist executable beside it. */
|
|
335
|
+
function electronExeBeside(entry, platform = process.platform) {
|
|
336
|
+
const packageRoots = [dirname(entry), join(dirname(entry), '..')];
|
|
337
|
+
for (const packageRoot of packageRoots) {
|
|
338
|
+
const candidate = electronDistExe(packageRoot, platform);
|
|
339
|
+
if (candidate !== undefined)
|
|
329
340
|
return candidate;
|
|
330
341
|
}
|
|
331
342
|
return undefined;
|
|
332
343
|
}
|
|
333
344
|
/** From an electron package root, find its dist executable. */
|
|
334
|
-
function electronDistExe(pkgRoot) {
|
|
335
|
-
for (const candidate of
|
|
345
|
+
function electronDistExe(pkgRoot, platform = process.platform) {
|
|
346
|
+
for (const candidate of electronDistCandidates(pkgRoot, platform)) {
|
|
336
347
|
if (existsSync(candidate))
|
|
337
348
|
return candidate;
|
|
338
349
|
}
|
|
@@ -411,8 +422,13 @@ class ElectronChildClient {
|
|
|
411
422
|
});
|
|
412
423
|
this.child.stderr.setEncoding('utf8');
|
|
413
424
|
this.child.stderr.on('data', chunk => {
|
|
414
|
-
// Diagnostics only; never parse stderr as protocol.
|
|
415
|
-
|
|
425
|
+
// Diagnostics only; never parse stderr as protocol. Mirror to a log file
|
|
426
|
+
// as well: under a plain `dsh web` self-host these lines only ever reach
|
|
427
|
+
// the DSH process's stderr, so a host that crashes in a restart loop left
|
|
428
|
+
// the user with a string of timeouts and no stack to act on.
|
|
429
|
+
const text = String(chunk);
|
|
430
|
+
process.stderr.write(`[dsh-browser host] ${text}`);
|
|
431
|
+
appendHostLog(text);
|
|
416
432
|
});
|
|
417
433
|
// Send the spawn token over stdin (first line). The child reads it from
|
|
418
434
|
// stdin instead of argv so the token is never visible in the process
|
|
@@ -423,11 +439,17 @@ class ElectronChildClient {
|
|
|
423
439
|
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
424
440
|
// that would crash the whole DSH process.
|
|
425
441
|
this.child.on('error', error => {
|
|
426
|
-
|
|
442
|
+
const line = `spawn error: ${String(error)}`;
|
|
443
|
+
process.stderr.write(`[dsh-browser host] ${line}\n`);
|
|
444
|
+
appendHostLog(`${line}\n`);
|
|
427
445
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
|
|
428
446
|
});
|
|
429
447
|
this.child.on('exit', (code, signal) => {
|
|
430
|
-
|
|
448
|
+
// The exit code/signal is the other half of a crash report; without it a
|
|
449
|
+
// restart loop is indistinguishable from a clean stop.
|
|
450
|
+
const line = `browser host exited (code=${String(code)} signal=${String(signal)})`;
|
|
451
|
+
appendHostLog(`${line}\n`);
|
|
452
|
+
this.fail(new Error(`dsh-builtin-browser: ${line}`));
|
|
431
453
|
});
|
|
432
454
|
}
|
|
433
455
|
/** Reject everything in flight, mark the client dead, and notify the host. */
|
|
@@ -823,6 +845,35 @@ export class RemoteElectronViewHost {
|
|
|
823
845
|
.then(() => this.client?.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }))
|
|
824
846
|
.catch(() => { });
|
|
825
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* (Re)present a view and WAIT for the child to confirm it, so the provider
|
|
850
|
+
* can dispatch CDP `Input.*` knowing the view has a display surface.
|
|
851
|
+
*
|
|
852
|
+
* Two things this fixes, both silent before:
|
|
853
|
+
* - showing a view that does not exist yet is a no-op in the child, so the
|
|
854
|
+
* view must be materialized first (a fresh tab's createView races the
|
|
855
|
+
* provider's showActive);
|
|
856
|
+
* - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
|
|
857
|
+
* path, leaving the new renderer without a surface.
|
|
858
|
+
*
|
|
859
|
+
* The barrier ping is meaningful because the child handles messages strictly
|
|
860
|
+
* in order: its reply is queued behind the showView and so only arrives once
|
|
861
|
+
* the visibility change has been applied.
|
|
862
|
+
*/
|
|
863
|
+
async presentView(handle, label) {
|
|
864
|
+
await this.ready();
|
|
865
|
+
const client = this.client;
|
|
866
|
+
if (client === undefined)
|
|
867
|
+
throw new HostGoneError('browser host unavailable');
|
|
868
|
+
// A view created after the last createView does not exist in the child yet.
|
|
869
|
+
// ensureMaterialized also rebuilds it against a fresh child after a host
|
|
870
|
+
// death, which is the path that used to leave sessions timing out forever.
|
|
871
|
+
if (handle instanceof DeferredRemoteView) {
|
|
872
|
+
await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view');
|
|
873
|
+
}
|
|
874
|
+
await withTimeout(client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }), PRESENT_TIMEOUT_MS, 'browser host did not confirm the view presentation');
|
|
875
|
+
await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering');
|
|
876
|
+
}
|
|
826
877
|
/** Route a view into its session's own window (one window per session). */
|
|
827
878
|
groupView(handle, windowId, label) {
|
|
828
879
|
this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} });
|
|
@@ -933,6 +984,34 @@ class DeferredRemoteView {
|
|
|
933
984
|
async restoreAuth(cookies) {
|
|
934
985
|
return this.withRecovery(view => view.restoreAuth(cookies));
|
|
935
986
|
}
|
|
987
|
+
/** Materialize this view in the child (no-op when already there). */
|
|
988
|
+
async ensureMaterialized() {
|
|
989
|
+
await this.withRecovery(async () => undefined);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
|
|
994
|
+
* Best-effort and self-limiting: diagnostics must never break the host or grow
|
|
995
|
+
* without bound, so failures are swallowed and the file is truncated when it
|
|
996
|
+
* gets large (a crash loop writes quickly).
|
|
997
|
+
*/
|
|
998
|
+
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
999
|
+
function appendHostLog(text) {
|
|
1000
|
+
const home = process.env.DSH_HOME;
|
|
1001
|
+
if (home === undefined || home === '')
|
|
1002
|
+
return;
|
|
1003
|
+
try {
|
|
1004
|
+
const dir = join(home, 'logs');
|
|
1005
|
+
const file = join(dir, 'dsh-builtin-browser-host.log');
|
|
1006
|
+
try {
|
|
1007
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1008
|
+
writeFileSync(file, '');
|
|
1009
|
+
}
|
|
1010
|
+
catch { /* first write, or unreadable: just append */ }
|
|
1011
|
+
mkdirSync(dir, { recursive: true });
|
|
1012
|
+
appendFileSync(file, text);
|
|
1013
|
+
}
|
|
1014
|
+
catch { /* diagnostics are best-effort only */ }
|
|
936
1015
|
}
|
|
937
1016
|
/** Reject a promise if it does not settle within the budget. */
|
|
938
1017
|
function withTimeout(promise, ms, message) {
|
|
@@ -951,6 +1030,9 @@ export function defaultHostMainPath() {
|
|
|
951
1030
|
* steps; `resolveElectronPath` is the full resolution order.
|
|
952
1031
|
*/
|
|
953
1032
|
export const internals = {
|
|
1033
|
+
electronDistCandidates,
|
|
1034
|
+
electronDistExe,
|
|
1035
|
+
electronExeBeside,
|
|
954
1036
|
isBareElectron,
|
|
955
1037
|
resolveElectronPath,
|
|
956
1038
|
resolveElectronPathImpl,
|
|
@@ -1042,7 +1042,7 @@ export function apply(ctx, config = {}) {
|
|
|
1042
1042
|
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
|
|
1043
1043
|
parameters: {
|
|
1044
1044
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1045
|
-
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location).' },
|
|
1045
|
+
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
1046
1046
|
format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg is self-hosted native path only).' },
|
|
1047
1047
|
quality: { type: 'number', description: 'JPEG quality 1-100 (default 80); ignored for PNG.' },
|
|
1048
1048
|
maxWidth: { type: 'number', description: 'Downscale to fit within this width (aspect preserved).' },
|
|
@@ -1312,7 +1312,7 @@ export function apply(ctx, config = {}) {
|
|
|
1312
1312
|
description: 'Download a URL to a local file, keeping the browser session\'s cookies and login state. Use for fetching files behind authentication or from the current page context. Available on the self-hosted browser; the desktop shell delegates downloads to the real browser UI.',
|
|
1313
1313
|
parameters: {
|
|
1314
1314
|
url: { type: 'string', required: true, description: 'The URL to download.' },
|
|
1315
|
-
savePath: { type: 'string', required: true, description: 'Absolute path of the file to write.' },
|
|
1315
|
+
savePath: { type: 'string', required: true, description: 'Absolute path of the file to write. Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
1316
1316
|
},
|
|
1317
1317
|
output: {
|
|
1318
1318
|
schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true } } },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -34,6 +34,7 @@ import { dirname, join } from 'node:path'
|
|
|
34
34
|
* The spawn token the parent sent via stdin (first line). Must be echoed in
|
|
35
35
|
* our hello. Read from stdin so the token never appears in argv (WMI /
|
|
36
36
|
* Process Explorer / /proc/*).
|
|
37
|
+
*/
|
|
37
38
|
|
|
38
39
|
// Isolate this host's profile from the DSH app's default Electron userData:
|
|
39
40
|
// several Electron instances sharing Roaming\Electron fight over the GPU
|
|
@@ -85,6 +86,14 @@ const TOOLBAR_HEIGHT = 64
|
|
|
85
86
|
/** One view: the Electron object plus its CDP-backed surface. */
|
|
86
87
|
interface HostView {
|
|
87
88
|
readonly webContentsView: WebContentsView
|
|
89
|
+
/**
|
|
90
|
+
* Set when the view's renderer process was replaced by a navigation. The
|
|
91
|
+
* fresh renderer has no display surface, and Chromium silently drops CDP
|
|
92
|
+
* synthesized input for a view without one — so the next show/input must
|
|
93
|
+
* force the visible-again dance even if this view is already the visible
|
|
94
|
+
* one. Cleared once the surface has been re-established.
|
|
95
|
+
*/
|
|
96
|
+
needsRepresent: boolean
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/** One window: a session's views + its toolbar. */
|
|
@@ -176,7 +185,17 @@ button.tool:hover { background: rgba(255,255,255,.12); }
|
|
|
176
185
|
<div class="strip" id="strip"></div>
|
|
177
186
|
<div id="err"></div>
|
|
178
187
|
<script>
|
|
179
|
-
|
|
188
|
+
// The body runs inside an IIFE on purpose. The preload above exposes
|
|
189
|
+
// window.bridge via contextBridge.exposeInMainWorld(), which installs a
|
|
190
|
+
// NON-CONFIGURABLE global property. A top-level const/let named bridge here
|
|
191
|
+
// would hit HasRestrictedGlobalProperty and throw a SyntaxError at PARSE time
|
|
192
|
+
// — an early error, so the whole script is discarded: nothing gets bound, the
|
|
193
|
+
// address bar and nav buttons go silently dead, and the user sees no error.
|
|
194
|
+
// Function scope sidesteps that check (the tb name below stops the collision
|
|
195
|
+
// from returning by habit) and keeps these bindings off the page global.
|
|
196
|
+
// Keep this block free of backticks — it lives inside a TS template literal.
|
|
197
|
+
(() => {
|
|
198
|
+
const tb = window.bridge
|
|
180
199
|
const addr = document.getElementById('addr')
|
|
181
200
|
const strip = document.getElementById('strip')
|
|
182
201
|
const errBox = document.getElementById('err')
|
|
@@ -187,7 +206,7 @@ function showErr(text) {
|
|
|
187
206
|
clearTimeout(showErrT)
|
|
188
207
|
showErrT = setTimeout(() => errBox.classList.remove('show'), 5000)
|
|
189
208
|
}
|
|
190
|
-
function post(action, payload) {
|
|
209
|
+
function post(action, payload) { tb.post(action, payload || {}) }
|
|
191
210
|
document.getElementById('back').onclick = () => post('back')
|
|
192
211
|
document.getElementById('fwd').onclick = () => post('forward')
|
|
193
212
|
document.getElementById('reload').onclick = () => post('reload')
|
|
@@ -202,7 +221,7 @@ addr.addEventListener('keydown', e => {
|
|
|
202
221
|
if (v !== '') { post('navigate', { url: v }); addr.blur() }
|
|
203
222
|
}
|
|
204
223
|
})
|
|
205
|
-
|
|
224
|
+
tb.onTabs(payload => {
|
|
206
225
|
const tabs = (payload && payload.tabs) || []
|
|
207
226
|
strip.textContent = ''
|
|
208
227
|
for (const t of tabs) {
|
|
@@ -224,7 +243,8 @@ bridge.onTabs(payload => {
|
|
|
224
243
|
const act = tabs.find(t => t.active)
|
|
225
244
|
if (act && document.activeElement !== addr) addr.value = act.url || ''
|
|
226
245
|
})
|
|
227
|
-
|
|
246
|
+
tb.onError(text => showErr(text))
|
|
247
|
+
})()
|
|
228
248
|
</script>
|
|
229
249
|
</body>
|
|
230
250
|
</html>
|
|
@@ -243,6 +263,48 @@ function ensureToolbarFiles(): void {
|
|
|
243
263
|
writeFileSync(join(TOOLBAR_DIR, 'toolbar.html'), TOOLBAR_HTML)
|
|
244
264
|
}
|
|
245
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Bound an await with a timeout. Used so a wedged renderer surfaces as an
|
|
268
|
+
* explicit error instead of a command that never settles.
|
|
269
|
+
*/
|
|
270
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
271
|
+
return new Promise<T>((resolve, reject) => {
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
reject(new Error(`${label} timed out after ${ms}ms`))
|
|
274
|
+
}, ms)
|
|
275
|
+
promise.then(
|
|
276
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
277
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* CDP commands are bounded here as well as in the parent: a view whose
|
|
284
|
+
* renderer stopped responding must produce a clear error, not a hang that
|
|
285
|
+
* only the tool layer's 10–30s timeout eventually cuts off.
|
|
286
|
+
*/
|
|
287
|
+
const COMMAND_TIMEOUT_MS = 20000
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A brand-new WebContentsView has no renderer process until something loads,
|
|
291
|
+
* and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
|
|
292
|
+
* settle. That is what made a host restart unrecoverable: the provider
|
|
293
|
+
* rebuilds the session with a fresh blank view whose very first action is a
|
|
294
|
+
* location.href read, which hung until the tool timeout.
|
|
295
|
+
*
|
|
296
|
+
* Loading about:blank spawns the renderer, so the view answers the moment it
|
|
297
|
+
* exists; a real navigation replaces it right after. Bounded + best-effort —
|
|
298
|
+
* the caller still gets its reply if this fails.
|
|
299
|
+
*/
|
|
300
|
+
const RENDERER_PRIME_TIMEOUT_MS = 3000
|
|
301
|
+
|
|
302
|
+
async function primeRenderer(view: WebContentsView): Promise<void> {
|
|
303
|
+
try {
|
|
304
|
+
await withTimeout(view.webContents.loadURL('about:blank'), RENDERER_PRIME_TIMEOUT_MS, 'renderer prime')
|
|
305
|
+
} catch { /* best effort: the first real navigation creates the renderer */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
246
308
|
/** Reply to the parent over the RPC socket. */
|
|
247
309
|
function reply(id: number, payload: Record<string, unknown>): void {
|
|
248
310
|
if (rpcSocket === undefined) {
|
|
@@ -449,7 +511,16 @@ function updateWindowTitle(win: HostWindow, viewId: string | undefined, label: s
|
|
|
449
511
|
function showViewInWindow(win: HostWindow, viewId: string, label?: string): void {
|
|
450
512
|
const entry = win.views.get(viewId)
|
|
451
513
|
if (entry === undefined) return
|
|
452
|
-
|
|
514
|
+
// A navigation replaced this view's renderer, and the new one has no
|
|
515
|
+
// display surface yet. Chromium drops CDP synthesized input (Input.*) for a
|
|
516
|
+
// view without a surface, which silently swallowed the FIRST click/keystroke
|
|
517
|
+
// after every navigation — and because the view was already the visible one,
|
|
518
|
+
// the dance below used to be skipped entirely. Force it once.
|
|
519
|
+
const mustRepresent = entry.needsRepresent
|
|
520
|
+
if (mustRepresent) {
|
|
521
|
+
try { entry.webContentsView.setVisible(false) } catch { /* destroyed */ }
|
|
522
|
+
}
|
|
523
|
+
if (win.visibleViewId !== viewId || mustRepresent) {
|
|
453
524
|
// Hide every other view in THIS window, then show and RAISE the target
|
|
454
525
|
// (topmost child wins). When the target is already visible, skip the
|
|
455
526
|
// remove/re-add dance — doing it on every operation flickered.
|
|
@@ -463,6 +534,7 @@ function showViewInWindow(win: HostWindow, viewId: string, label?: string): void
|
|
|
463
534
|
win.window.contentView.addChildView(entry.webContentsView)
|
|
464
535
|
} catch { /* window closing */ }
|
|
465
536
|
win.visibleViewId = viewId
|
|
537
|
+
entry.needsRepresent = false
|
|
466
538
|
syncToolbar(win)
|
|
467
539
|
}
|
|
468
540
|
// Always refresh the title: it reflects the CURRENT page of the visible
|
|
@@ -549,11 +621,18 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
549
621
|
// New views start hidden: only the shown one may be visible.
|
|
550
622
|
view.setVisible(false)
|
|
551
623
|
win.window.contentView.addChildView(view)
|
|
552
|
-
win.views.set(viewId, { webContentsView: view })
|
|
624
|
+
win.views.set(viewId, { webContentsView: view, needsRepresent: false })
|
|
553
625
|
// Keep the window title and the toolbar tab strip live as the page
|
|
554
626
|
// changes (navigations, title updates).
|
|
555
627
|
view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
|
|
556
|
-
view.webContents.on('did-navigate', () => {
|
|
628
|
+
view.webContents.on('did-navigate', () => {
|
|
629
|
+
// The renderer process was just replaced: mark the view so the next
|
|
630
|
+
// show forces a fresh display surface (see showViewInWindow).
|
|
631
|
+
const current = win.views.get(viewId)
|
|
632
|
+
if (current !== undefined) current.needsRepresent = true
|
|
633
|
+
updateWindowTitle(win, viewId, undefined)
|
|
634
|
+
syncToolbar(win)
|
|
635
|
+
})
|
|
557
636
|
view.webContents.on('did-navigate-in-page', () => syncToolbar(win))
|
|
558
637
|
wireFocusRouting(win, view, viewId)
|
|
559
638
|
const first = win.views.size === 1
|
|
@@ -564,6 +643,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
564
643
|
layoutWindow(win)
|
|
565
644
|
if (first) updateWindowTitle(win, viewId, undefined)
|
|
566
645
|
syncToolbar(win)
|
|
646
|
+
// Prime the renderer BEFORE replying: the parent's next command
|
|
647
|
+
// (a location.href read) would otherwise hang on a view that has no
|
|
648
|
+
// renderer yet — the root of "host restarted and never recovers".
|
|
649
|
+
await primeRenderer(view)
|
|
567
650
|
reply(msg.id, { ok: true })
|
|
568
651
|
return
|
|
569
652
|
}
|
|
@@ -623,7 +706,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
623
706
|
if (win === undefined || entry === undefined) throw new Error(`command: unknown view ${viewId}`)
|
|
624
707
|
const method = msg.method
|
|
625
708
|
if (typeof method !== 'string') throw new Error('command missing method')
|
|
626
|
-
const result = await
|
|
709
|
+
const result = await withTimeout(
|
|
710
|
+
entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {}),
|
|
711
|
+
COMMAND_TIMEOUT_MS,
|
|
712
|
+
`CDP ${method}`,
|
|
713
|
+
)
|
|
627
714
|
reply(msg.id, { ok: true, result })
|
|
628
715
|
return
|
|
629
716
|
}
|
|
@@ -894,6 +981,13 @@ void app.whenReady().then(() => {
|
|
|
894
981
|
}
|
|
895
982
|
}
|
|
896
983
|
const rl = createInterface({ input: socket })
|
|
984
|
+
// Handle messages STRICTLY in order. Ops depend on each other completing:
|
|
985
|
+
// a `command` for a view must not overtake the `createView` that makes it,
|
|
986
|
+
// and the parent's present-barrier (`showView` then `ping`) is only
|
|
987
|
+
// meaningful if the child cannot answer the ping mid-show. The previous
|
|
988
|
+
// fire-and-forget loop let all of them race, which is how a fresh tab's
|
|
989
|
+
// first click could be dispatched before its view was ever on screen.
|
|
990
|
+
let queue: Promise<void> = Promise.resolve()
|
|
897
991
|
rl.on('line', line => {
|
|
898
992
|
const text = line.trim()
|
|
899
993
|
if (text === '') return
|
|
@@ -904,7 +998,10 @@ void app.whenReady().then(() => {
|
|
|
904
998
|
return // non-protocol noise
|
|
905
999
|
}
|
|
906
1000
|
if (typeof msg.id !== 'number' || typeof msg.op !== 'string') return
|
|
907
|
-
|
|
1001
|
+
// Bind the narrowed op now: the check above does not survive into the
|
|
1002
|
+
// deferred callback (msg is a mutable binding).
|
|
1003
|
+
const op = msg.op
|
|
1004
|
+
queue = queue.then(() => handle(op, msg)).catch(() => { /* reply already sent inside handle */ })
|
|
908
1005
|
})
|
|
909
1006
|
socket.on('error', error => {
|
|
910
1007
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|