dsh-builtin-browser 0.1.20 → 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 +22 -11
- package/README.md +22 -11
- 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 +127 -22
- 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 +133 -16
- 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) {
|
|
@@ -327,8 +389,11 @@ function windowFor(windowId: string | undefined): HostWindow {
|
|
|
327
389
|
// regaining focus (alt-tab, click) does not automatically refocus any view
|
|
328
390
|
// (electron#28163) — the last addChildView keeps the focus. Restore the view
|
|
329
391
|
// the human was last using (click steering via wireFocusRouting), falling
|
|
330
|
-
// back to the visible page view.
|
|
392
|
+
// back to the visible page view. Windows-only: on macOS/Linux the OS routes
|
|
393
|
+
// keyboard input to the clicked view natively, and forcing focus() on window
|
|
394
|
+
// focus fights the platform — it can leave page inputs untypeable (issue #7).
|
|
331
395
|
w.on('focus', () => {
|
|
396
|
+
if (process.platform !== 'win32') return
|
|
332
397
|
const last = win.lastFocusedViewId
|
|
333
398
|
const wc = last === TOOLBAR_FOCUS ? win.toolbarView?.webContents
|
|
334
399
|
: last !== undefined ? win.views.get(last)?.webContentsView.webContents
|
|
@@ -361,8 +426,14 @@ function layoutWindow(win: HostWindow): void {
|
|
|
361
426
|
* regaining focus does not refocus any view, electron#28163). Focusing the
|
|
362
427
|
* clicked view on mousedown makes the toolbar and pages behave like a real
|
|
363
428
|
* browser: click the URL bar and type, click the page and type/scroll.
|
|
429
|
+
*
|
|
430
|
+
* Windows-only by design (issue #7): macOS/Linux already route keyboard input
|
|
431
|
+
* to the clicked view natively; calling webContents.focus() from inside the
|
|
432
|
+
* mousedown input-event there interferes with native click-to-focus and can
|
|
433
|
+
* leave login/page inputs unable to receive typed characters.
|
|
364
434
|
*/
|
|
365
435
|
function wireFocusRouting(win: HostWindow, view: WebContentsView, focusKey: string): void {
|
|
436
|
+
if (process.platform !== 'win32') return
|
|
366
437
|
view.webContents.on('input-event', (_event, input) => {
|
|
367
438
|
if (input.type !== 'mouseDown') return
|
|
368
439
|
win.lastFocusedViewId = focusKey
|
|
@@ -440,7 +511,16 @@ function updateWindowTitle(win: HostWindow, viewId: string | undefined, label: s
|
|
|
440
511
|
function showViewInWindow(win: HostWindow, viewId: string, label?: string): void {
|
|
441
512
|
const entry = win.views.get(viewId)
|
|
442
513
|
if (entry === undefined) return
|
|
443
|
-
|
|
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) {
|
|
444
524
|
// Hide every other view in THIS window, then show and RAISE the target
|
|
445
525
|
// (topmost child wins). When the target is already visible, skip the
|
|
446
526
|
// remove/re-add dance — doing it on every operation flickered.
|
|
@@ -454,6 +534,7 @@ function showViewInWindow(win: HostWindow, viewId: string, label?: string): void
|
|
|
454
534
|
win.window.contentView.addChildView(entry.webContentsView)
|
|
455
535
|
} catch { /* window closing */ }
|
|
456
536
|
win.visibleViewId = viewId
|
|
537
|
+
entry.needsRepresent = false
|
|
457
538
|
syncToolbar(win)
|
|
458
539
|
}
|
|
459
540
|
// Always refresh the title: it reflects the CURRENT page of the visible
|
|
@@ -509,14 +590,25 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
509
590
|
const assignment = assignments.get(viewId)
|
|
510
591
|
const win = windowFor(assignment?.windowId)
|
|
511
592
|
const view = new WebContentsView()
|
|
512
|
-
// Route popups (window.open / target=_blank)
|
|
513
|
-
// instead of letting Electron open untracked native windows
|
|
514
|
-
// diverge from the session/tab model.
|
|
593
|
+
// Route popups (window.open / target=_blank) into the session/tab
|
|
594
|
+
// model instead of letting Electron open untracked native windows.
|
|
515
595
|
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
516
|
-
// HTTP(S) popups
|
|
517
|
-
//
|
|
596
|
+
// HTTP(S) popups open as a NEW TAB of this view's session: the
|
|
597
|
+
// parent (provider) owns the session model and its newTab action
|
|
598
|
+
// creates the tab in the same window, navigates it, and records it
|
|
599
|
+
// in the session history. Overriding THIS view with loadURL()
|
|
600
|
+
// destroyed the opener page — pages that need their opener context
|
|
601
|
+
// (portal dashboards carrying token/referer to the new window)
|
|
602
|
+
// lost it and the target page then 403'd (issue #8); the tab strip
|
|
603
|
+
// now keeps the original page alive like a real browser.
|
|
518
604
|
if (/^https?:/i.test(url)) {
|
|
519
|
-
|
|
605
|
+
if (win.windowId !== SHARED_WINDOW_ID) {
|
|
606
|
+
sendUserAction({ type: 'newTab', windowId: win.windowId, url })
|
|
607
|
+
} else {
|
|
608
|
+
// Ungrouped view (no live session to attach a tab to,
|
|
609
|
+
// defensive only): fall back to navigating this view.
|
|
610
|
+
void view.webContents.loadURL(url).catch(() => { /* navigation failures surface on the next snapshot */ })
|
|
611
|
+
}
|
|
520
612
|
return { action: 'deny' }
|
|
521
613
|
}
|
|
522
614
|
// Non-HTTP popups (OAuth redirects, mailto:, custom schemes):
|
|
@@ -529,11 +621,18 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
529
621
|
// New views start hidden: only the shown one may be visible.
|
|
530
622
|
view.setVisible(false)
|
|
531
623
|
win.window.contentView.addChildView(view)
|
|
532
|
-
win.views.set(viewId, { webContentsView: view })
|
|
624
|
+
win.views.set(viewId, { webContentsView: view, needsRepresent: false })
|
|
533
625
|
// Keep the window title and the toolbar tab strip live as the page
|
|
534
626
|
// changes (navigations, title updates).
|
|
535
627
|
view.webContents.on('page-title-updated', () => { updateWindowTitle(win, viewId, undefined); syncToolbar(win) })
|
|
536
|
-
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
|
+
})
|
|
537
636
|
view.webContents.on('did-navigate-in-page', () => syncToolbar(win))
|
|
538
637
|
wireFocusRouting(win, view, viewId)
|
|
539
638
|
const first = win.views.size === 1
|
|
@@ -544,6 +643,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
544
643
|
layoutWindow(win)
|
|
545
644
|
if (first) updateWindowTitle(win, viewId, undefined)
|
|
546
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)
|
|
547
650
|
reply(msg.id, { ok: true })
|
|
548
651
|
return
|
|
549
652
|
}
|
|
@@ -603,7 +706,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
|
|
|
603
706
|
if (win === undefined || entry === undefined) throw new Error(`command: unknown view ${viewId}`)
|
|
604
707
|
const method = msg.method
|
|
605
708
|
if (typeof method !== 'string') throw new Error('command missing method')
|
|
606
|
-
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
|
+
)
|
|
607
714
|
reply(msg.id, { ok: true, result })
|
|
608
715
|
return
|
|
609
716
|
}
|
|
@@ -874,6 +981,13 @@ void app.whenReady().then(() => {
|
|
|
874
981
|
}
|
|
875
982
|
}
|
|
876
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()
|
|
877
991
|
rl.on('line', line => {
|
|
878
992
|
const text = line.trim()
|
|
879
993
|
if (text === '') return
|
|
@@ -884,7 +998,10 @@ void app.whenReady().then(() => {
|
|
|
884
998
|
return // non-protocol noise
|
|
885
999
|
}
|
|
886
1000
|
if (typeof msg.id !== 'number' || typeof msg.op !== 'string') return
|
|
887
|
-
|
|
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 */ })
|
|
888
1005
|
})
|
|
889
1006
|
socket.on('error', error => {
|
|
890
1007
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|