dsh-browser-plus 0.0.0-stage → 0.5.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +100 -0
  5. package/README.md +99 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/cordis.patch.yml +17 -0
  13. package/docs/MIGRATION.md +48 -0
  14. package/docs/README.md +22 -0
  15. package/docs/SOAK-CHECKLIST.md +98 -0
  16. package/docs/architecture.md +88 -0
  17. package/docs/tool-reference.md +124 -0
  18. package/docs/user-guide.md +121 -0
  19. package/docs/why-browser.md +45 -0
  20. package/lib/browser/runtime.d.ts +225 -0
  21. package/lib/browser/runtime.js +302 -0
  22. package/lib/browser/types.d.ts +668 -0
  23. package/lib/browser/types.js +18 -0
  24. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  25. package/lib/browser-electron/auth-cookies.js +83 -0
  26. package/lib/browser-electron/chrome-state.d.ts +187 -0
  27. package/lib/browser-electron/chrome-state.js +12 -0
  28. package/lib/browser-electron/entry.d.ts +66 -0
  29. package/lib/browser-electron/entry.js +62 -0
  30. package/lib/browser-electron/fingerprint.d.ts +29 -0
  31. package/lib/browser-electron/fingerprint.js +42 -0
  32. package/lib/browser-electron/host-main.d.ts +18 -0
  33. package/lib/browser-electron/host-main.js +2494 -0
  34. package/lib/browser-electron/icon.d.ts +11 -0
  35. package/lib/browser-electron/icon.js +23 -0
  36. package/lib/browser-electron/page-chrome.d.ts +21 -0
  37. package/lib/browser-electron/page-chrome.js +2034 -0
  38. package/lib/browser-electron/provider.d.ts +709 -0
  39. package/lib/browser-electron/provider.js +2575 -0
  40. package/lib/browser-electron/remote-host.d.ts +143 -0
  41. package/lib/browser-electron/remote-host.js +952 -0
  42. package/lib/browser-electron/task-summary.d.ts +2 -0
  43. package/lib/browser-electron/task-summary.js +12 -0
  44. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  45. package/lib/browser-electron/task-thumbnail.js +9 -0
  46. package/lib/browser-electron/write-guard.d.ts +41 -0
  47. package/lib/browser-electron/write-guard.js +123 -0
  48. package/lib/index.d.ts +16 -0
  49. package/lib/index.js +14 -0
  50. package/lib/tool-browser/index.d.ts +31 -0
  51. package/lib/tool-browser/index.js +1931 -0
  52. package/package.json +95 -4
  53. package/screenshots.json +3 -0
  54. package/scripts/build-icons.mjs +80 -0
  55. package/scripts/capture-window.ps1 +79 -0
  56. package/scripts/crop-image.ps1 +20 -0
  57. package/scripts/smoke-browser-tools.mjs +1968 -0
  58. package/scripts/smoke-chrome-world.mjs +63 -0
  59. package/scripts/smoke-electron-host.mjs +50 -0
  60. package/src/browser/runtime.ts +470 -0
  61. package/src/browser/types.ts +649 -0
  62. package/src/browser-electron/auth-cookies.ts +125 -0
  63. package/src/browser-electron/chrome-state.ts +174 -0
  64. package/src/browser-electron/entry.ts +115 -0
  65. package/src/browser-electron/fingerprint.ts +45 -0
  66. package/src/browser-electron/host-main.ts +2330 -0
  67. package/src/browser-electron/icon.ts +26 -0
  68. package/src/browser-electron/page-chrome.ts +2046 -0
  69. package/src/browser-electron/provider.ts +3088 -0
  70. package/src/browser-electron/remote-host.ts +1004 -0
  71. package/src/browser-electron/task-summary.ts +10 -0
  72. package/src/browser-electron/task-thumbnail.ts +17 -0
  73. package/src/browser-electron/write-guard.ts +134 -0
  74. package/src/index.ts +52 -0
  75. package/src/tool-browser/index.ts +1974 -0
  76. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,2494 @@
1
+ /**
2
+ * Self-hosted Electron browser host (child side): the Electron main process
3
+ * spawned by {@link RemoteElectronViewHost}. Owns one shared `BrowserWindow`
4
+ * containing task-scoped `WebContentsView`s and their `webContents.debugger`
5
+ * (CDP), and answers
6
+ * line-delimited JSON-RPC on stdio.
7
+ *
8
+ * Protocol (one JSON object per line, both directions):
9
+ * <- { id, op: 'ping' } | { id, op: 'createView', viewId, key?, label? } |
10
+ * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
11
+ * { id, op: 'label', viewId, label } | { id, op: 'listWindows' } |
12
+ * { id, op: 'command', viewId, method, params }
13
+ * -> { id, ok: true, result? } | { id, ok: false, err }
14
+ *
15
+ * The parent never parses stderr, so diagnostics may go there freely.
16
+ * @module dsh-browser-plus/browser-electron/host-main
17
+ */
18
+ import { app, BrowserWindow, session, WebContentsView } from 'electron';
19
+ import { createInterface } from 'node:readline';
20
+ import { createConnection } from 'node:net';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { acceptLanguagesFor, chromeMajor, clientHintPlatform, secChUa, stripElectronToken } from './fingerprint.js';
25
+ import { buildPageChromeScript } from './page-chrome.js';
26
+ import { taskSummaryUrl } from './task-summary.js';
27
+ import { taskThumbnailDataUrl } from './task-thumbnail.js';
28
+ import { exportCookiesForAuth, selectCookiesForClear } from './auth-cookies.js';
29
+ import { resolveBrowserIconPath } from './icon.js';
30
+ import { createBootstrap, createPatch } from './chrome-state.js';
31
+ // Isolate this host's profile from the DSH app's default Electron userData:
32
+ // several Electron instances sharing Roaming\Electron fight over the GPU
33
+ // cache/session locks, which can leave the window without a display surface
34
+ // (capturePage then fails). A dedicated userData also persists cookies across
35
+ // host restarts (on top of browser_auth). Must run before app is ready.
36
+ //
37
+ // DSH_BROWSER_PLUS_USER_DATA overrides it. Chromium takes a singleton lock on a
38
+ // profile, so a second host cannot start while one is already running on the
39
+ // same directory — a verification run needs its own profile, and so does anyone
40
+ // who wants two independent browsers side by side.
41
+ try {
42
+ const override = process.env.DSH_BROWSER_PLUS_USER_DATA;
43
+ const base = process.env.DSH_HOME ?? app.getPath('appData');
44
+ app.setPath('userData', override !== undefined && override.trim() !== ''
45
+ ? override
46
+ : join(base, 'dsh-browser-plus-host'));
47
+ }
48
+ catch (error) {
49
+ process.stderr.write(`[dsh-browser-plus host] userData setup failed: ${String(error)}\n`);
50
+ }
51
+ /** CDP protocol version attached to every view's debugger. */
52
+ const CDP_VERSION = '1.3';
53
+ /**
54
+ * Download cap. The body is fetched inside the page, shipped as one base64 JSON
55
+ * line and decoded again here, so one download peaks at several times its size
56
+ * in memory; 64 MiB keeps that bounded while covering ordinary files.
57
+ */
58
+ const MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024;
59
+ /** Bound the in-page fetch independently of the parent's RPC transfer budget. */
60
+ const DOWNLOAD_FETCH_TIMEOUT_MS = 60_000;
61
+ /** capturePage can hang on a wedged compositor; bound it like every other call. */
62
+ const THUMBNAIL_CAPTURE_TIMEOUT_MS = 5_000;
63
+ /**
64
+ * Height of the injected tab strip, in CSS pixels.
65
+ *
66
+ * The window is frameless (see makeWindow), so this is also the height of the
67
+ * caption-button overlay and the room the chrome must leave free at the end of
68
+ * the strip. Keep it in step with `#tabstrip { height:40px }` in page-chrome.ts.
69
+ */
70
+ const TABSTRIP_HEIGHT = 40;
71
+ /**
72
+ * Whether this platform can draw the caption buttons over the content area.
73
+ *
74
+ * Windows and macOS both support `titleBarOverlay`; Linux does not, so it keeps
75
+ * its native frame rather than ending up with a window that cannot be closed.
76
+ */
77
+ const USES_OVERLAY_FRAME = process.platform === 'win32' || process.platform === 'darwin';
78
+ /** One line per host boot, so a stale-handle report can be tied to a process. */
79
+ function markHostBoot() {
80
+ try {
81
+ const path = diagLogPath();
82
+ const stat = statSync(path, { throwIfNoEntry: false });
83
+ if (stat !== undefined && stat.size > 262144)
84
+ writeFileSync(path, '');
85
+ appendFileSync(path, `=== boot pid=${process.pid} at ${new Date().toISOString()} ===\n`);
86
+ }
87
+ catch { /* diagnostics only */ }
88
+ }
89
+ /** Views by the id the parent assigned at createView time. */
90
+ const views = new Map();
91
+ /**
92
+ * Ring of recent view lifecycle events, dumped next to an `unknown view` failure.
93
+ * The parent can hold a handle this host no longer has (a tab that was just
94
+ * closed, a host that restarted), and the error on its own says nothing about
95
+ * how it got there - so the story is written down beside it.
96
+ */
97
+ const viewTrace = [];
98
+ function traceView(line) {
99
+ const stamped = `${new Date().toISOString().slice(11, 23)} ${line}`;
100
+ viewTrace.push(stamped);
101
+ if (viewTrace.length > 64)
102
+ viewTrace.splice(0, viewTrace.length - 64);
103
+ // Lifecycle events are rare and they are the whole point of the log: write them
104
+ // even when nothing fails, so 'did the parent re-create this view?' is answerable.
105
+ try {
106
+ appendFileSync(diagLogPath(), stamped + '\n');
107
+ }
108
+ catch { /* diagnostics only */ }
109
+ }
110
+ function diagLogPath() { return join(app.getPath('userData'), 'host-diag.log'); }
111
+ function dumpViewDiagnostics(op, message, msg) {
112
+ try {
113
+ const known = [...views.entries()].map(([id, entry]) => `${id}(${entry.taskKey})`).join(' ');
114
+ const params = JSON.stringify(msg.params ?? {}) ?? '';
115
+ const lines = [
116
+ `--- ${new Date().toISOString()} ${op} failed: ${message}`,
117
+ `viewId=${msg.viewId ?? '-'} method=${msg.method ?? '-'} params=${params.slice(0, 200)}`,
118
+ `known views: ${known === '' ? '(none)' : known}`,
119
+ ...viewTrace.map(line => ` ${line}`),
120
+ '',
121
+ ];
122
+ appendFileSync(diagLogPath(), lines.join('\n'));
123
+ }
124
+ catch { /* diagnostics must never take the host down */ }
125
+ }
126
+ /**
127
+ * Per-view secret authenticating page-emitted chrome control messages.
128
+ * `Runtime.addBinding` exposes the callback to every page script, so a payload
129
+ * is trusted only when it echoes the token that `buildPageChromeScript`
130
+ * captured in the injected chrome's closure.
131
+ */
132
+ const chromeTokens = new WeakMap();
133
+ /**
134
+ * Which world the injected chrome lives in. 'main' is the proven default; the
135
+ * child is told to use an isolated world via --chrome-world.
136
+ */
137
+ const CHROME_WORLD = (() => {
138
+ const index = process.argv.indexOf('--chrome-world');
139
+ return index >= 0 && process.argv[index + 1] === 'isolated' ? 'isolated' : 'main';
140
+ })();
141
+ /** Name of the isolated world that owns the chrome in isolated mode. */
142
+ const CHROME_WORLD_NAME = 'dshChrome';
143
+ /** Isolated-world execution context for a view's current document. */
144
+ const chromeContexts = new WeakMap();
145
+ /**
146
+ * Resolve the view's chrome context, creating the isolated world on demand. The
147
+ * context belongs to one document, so navigation drops it (see installPageChrome).
148
+ */
149
+ async function ensureChromeContext(view) {
150
+ const cached = chromeContexts.get(view);
151
+ if (cached !== undefined)
152
+ return cached;
153
+ const tree = await view.webContents.debugger.sendCommand('Page.getFrameTree');
154
+ const frameId = tree.frameTree?.frame?.id;
155
+ if (frameId === undefined)
156
+ return undefined;
157
+ const world = await view.webContents.debugger.sendCommand('Page.createIsolatedWorld', { frameId, worldName: CHROME_WORLD_NAME });
158
+ const contextId = world.executionContextId;
159
+ if (typeof contextId !== 'number')
160
+ return undefined;
161
+ chromeContexts.set(view, contextId);
162
+ // Scoped to this world, so page script never holds a callable it could forge
163
+ // task actions through.
164
+ await view.webContents.debugger.sendCommand('Runtime.addBinding', { name: '__dshBrowserTaskAction', executionContextName: CHROME_WORLD_NAME }).catch(() => undefined);
165
+ return contextId;
166
+ }
167
+ /**
168
+ * Run one chrome snippet in whichever world the chrome lives in. In isolated
169
+ * mode nothing the chrome stores — task labels, the trail, the binding token —
170
+ * is reachable from the page's own JavaScript context.
171
+ */
172
+ function runChromeScript(view, snippet) {
173
+ if (CHROME_WORLD === 'main') {
174
+ // CDP evaluate, not webContents.executeJavaScript.
175
+ //
176
+ // Electron defers executeJavaScript until the page has finished LOADING,
177
+ // not merely committed. The chrome is injected from did-navigate (the
178
+ // commit), so with the native call the toolbar and tab strip were missing
179
+ // for the whole of every load — measured against a page whose body took 6s:
180
+ // a screenshot taken 2.5s in showed the page and no chrome at all. A CDP
181
+ // evaluate runs as soon as the committed context exists, so the frame stays
182
+ // on screen (and can show its loading state) while the page streams.
183
+ //
184
+ // The native call stays as the fallback for the one case CDP is worse at: a
185
+ // context that is not ready yet, where an evaluate can hang rather than fail.
186
+ void (async () => {
187
+ try {
188
+ await view.webContents.debugger.sendCommand('Runtime.evaluate', { expression: snippet, returnByValue: true });
189
+ }
190
+ catch {
191
+ try {
192
+ void view.webContents.executeJavaScript(snippet).catch(() => undefined);
193
+ }
194
+ catch { /* closing */ }
195
+ }
196
+ })();
197
+ return;
198
+ }
199
+ void (async () => {
200
+ try {
201
+ const contextId = await ensureChromeContext(view);
202
+ if (contextId === undefined)
203
+ return;
204
+ await view.webContents.debugger.sendCommand('Runtime.evaluate', { expression: snippet, contextId, returnByValue: true });
205
+ }
206
+ catch { /* chrome is cosmetic */ }
207
+ })();
208
+ }
209
+ /**
210
+ * Favicons by view id, already re-encoded as data: URLs.
211
+ *
212
+ * A favicon is read once per navigation: Chromium reports the page's own icon
213
+ * URLs, this host fetches the first acceptable one through the view's session
214
+ * (so cookies and any proxy configuration apply, exactly as the page's own
215
+ * request would), and the bytes are capped before they ever reach the chrome.
216
+ * A tab that navigates loses its icon until the new document reports one, which
217
+ * is what Chrome does too.
218
+ */
219
+ const viewFavicons = new Map();
220
+ /**
221
+ * Views whose document is still loading.
222
+ *
223
+ * Chromium reports this per view; the strip shows it as a spinner where the
224
+ * favicon goes and as reload-into-stop in the toolbar, exactly like Chrome.
225
+ */
226
+ const loadingViews = new Set();
227
+ /**
228
+ * Saved pages, for the whole profile.
229
+ *
230
+ * These used to live in the page's localStorage, which is per ORIGIN: a bookmark
231
+ * saved on one site never appeared on another. The host owns them now and pushes
232
+ * them to every chrome (bootstrap and bookmarks.set), persisting to the profile
233
+ * directory so they survive a restart.
234
+ */
235
+ let chromeBookmarks = [];
236
+ /** Where the bookmark list lives. Set once the profile directory is known. */
237
+ let bookmarksFile;
238
+ function loadBookmarksFromDisk() {
239
+ try {
240
+ bookmarksFile = join(app.getPath('userData'), 'bookmarks.json');
241
+ const raw = readFileSync(bookmarksFile, 'utf8');
242
+ const parsed = JSON.parse(raw);
243
+ if (!Array.isArray(parsed))
244
+ return;
245
+ chromeBookmarks = parsed
246
+ .filter((item) => typeof item === 'object' && item !== null && typeof item.url === 'string')
247
+ .map(item => ({ url: item.url, title: typeof item.title === 'string' ? item.title : item.url }))
248
+ .slice(0, 500);
249
+ }
250
+ catch { /* first run, or an unreadable file */ }
251
+ }
252
+ function saveBookmarksToDisk() {
253
+ if (bookmarksFile === undefined)
254
+ return;
255
+ try {
256
+ writeFileSync(bookmarksFile, JSON.stringify(chromeBookmarks, null, 2), 'utf8');
257
+ }
258
+ catch { /* read-only profile */ }
259
+ }
260
+ /**
261
+ * Chrome's bookmark bar. Off by default, like a fresh Chrome profile.
262
+ *
263
+ * It lives here rather than in the page because every other kind of chrome state
264
+ * has to survive navigation, and localStorage is per origin — a toggle kept there
265
+ * would silently reset the moment the user visited another site.
266
+ */
267
+ let chromeBookmarkBar = false;
268
+ let chromePrefsFile;
269
+ function loadPrefsFromDisk() {
270
+ try {
271
+ chromePrefsFile = join(app.getPath('userData'), 'chrome-prefs.json');
272
+ const parsed = JSON.parse(readFileSync(chromePrefsFile, 'utf8'));
273
+ if (typeof parsed !== 'object' || parsed === null)
274
+ return;
275
+ chromeBookmarkBar = parsed.bookmarkBar === true;
276
+ }
277
+ catch { /* first run, or an unreadable file */ }
278
+ }
279
+ function savePrefsToDisk() {
280
+ if (chromePrefsFile === undefined)
281
+ return;
282
+ try {
283
+ writeFileSync(chromePrefsFile, JSON.stringify({ bookmarkBar: chromeBookmarkBar }, null, 2), 'utf8');
284
+ }
285
+ catch { /* read-only profile */ }
286
+ }
287
+ /** Only these raster types are admitted; anything else keeps the letter fallback. */
288
+ const FAVICON_TYPES = [
289
+ 'image/png',
290
+ 'image/jpeg',
291
+ 'image/webp',
292
+ 'image/gif',
293
+ 'image/bmp',
294
+ 'image/x-icon',
295
+ 'image/vnd.microsoft.icon',
296
+ ];
297
+ /** Cap on the fetched icon. Real favicons are 1-20 KB; 64 KB is already generous. */
298
+ const FAVICON_MAX_BYTES = 64 * 1024;
299
+ /** A slow or hanging icon host must never delay the tab strip. */
300
+ const FAVICON_FETCH_TIMEOUT_MS = 4_000;
301
+ /** Operation trail per view, newest last, bounded. */
302
+ const traces = new Map();
303
+ /** Last active flag pushed to each view's chrome, so unchanged views skip the IPC. */
304
+ const chromeActiveApplied = new WeakMap();
305
+ /**
306
+ * The empty state a fresh view shows before its first navigation — a Chrome-style
307
+ * new tab: the wordmark, a search box, and the saved pages as shortcuts. It is
308
+ * deliberately the chrome's own surface colour (#202124) and nothing else: no
309
+ * glow, no gradient, no external font, so the window reads as one piece.
310
+ *
311
+ * It has to be a committed document: a WebContentsView with no document paints
312
+ * white AND leaves CDP with no frame to evaluate against, so every browser_*
313
+ * call timed out on it. And it has to stay offline: a `data:` page with an
314
+ * opaque origin has no business reaching the network on every single new tab.
315
+ */
316
+ const START_PAGE_HTML = `
317
+ <!doctype html>
318
+ <html lang="zh-CN">
319
+ <head>
320
+ <meta charset="utf-8">
321
+ <meta name="color-scheme" content="dark">
322
+ <title>新标签页</title>
323
+ <style>
324
+ *{box-sizing:border-box}
325
+ html,body{height:100%;margin:0}
326
+ body{background:#202124;color:#e8eaed;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;-webkit-user-select:none;user-select:none;overflow:hidden}
327
+ main{display:flex;flex-direction:column;align-items:center;width:min(584px,calc(100vw - 64px));margin:0 auto;padding-top:min(23vh,180px);animation:dshIn .16s ease both}
328
+ @keyframes dshIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
329
+ @keyframes dshTileIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
330
+ .brand{display:block;margin-bottom:28px}
331
+ .search{display:flex;align-items:center;gap:12px;width:100%;height:46px;padding:0 16px;border-radius:23px;background:#303134;transition:background .12s ease,border-color .12s ease,box-shadow .12s ease;border:1px solid transparent;transition:background .12s ease,box-shadow .12s ease,border-color .12s ease}
332
+ .search:hover{background:#3c4043;box-shadow:0 1px 6px rgba(0,0,0,.28)}
333
+ .search:focus-within{background:#303134;border-color:#5f6368;box-shadow:0 1px 6px rgba(0,0,0,.35)}
334
+ .search svg{flex:none;width:19px;height:19px;color:#9aa0a6}
335
+ .search input{flex:1;min-width:0;height:100%;border:0;outline:none;background:transparent;color:#e8eaed;font:inherit;font-size:15px;padding:0;-webkit-user-select:text;user-select:text}
336
+ .search input::placeholder{color:#9aa0a6}
337
+ .tiles{display:flex;flex-wrap:wrap;justify-content:center;gap:2px;width:100%;margin-top:26px}
338
+ .tile{display:flex;flex-direction:column;align-items:center;gap:9px;width:104px;padding:12px 4px 10px;border-radius:10px;color:inherit;text-decoration:none;transition:background .12s ease;animation:dshTileIn .18s ease both}
339
+ .tile:hover{background:#2b2c2f}
340
+ .tile:focus-visible{outline:2px solid #8ab4f8;outline-offset:2px}
341
+ .tile .ico{display:grid;place-items:center;width:40px;height:40px;border-radius:20px;background:#303134;color:#e8eaed;font-size:17px;font-weight:500;transition:background .12s ease,transform .1s ease}
342
+ .tile:hover .ico{background:#3c4043}
343
+ .tile:active .ico{transform:scale(.94)}
344
+ .tile .cap{max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#9aa0a6}
345
+ @media (prefers-reduced-motion:reduce){main{animation:none}.tile{animation:none}.tile,.tile .ico{transition:background .12s ease}.search{transition:none}}
346
+ @media (max-height:460px){.tiles{display:none}}
347
+ @media (max-width:560px){.tile{width:88px}}
348
+ </style>
349
+ </head>
350
+ <body>
351
+ <main>
352
+ <svg class="brand" viewBox="0 0 24 24" width="54" height="54" fill="none" stroke="#e8eaed" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="DSH Browser">
353
+ <rect x="3.25" y="4.75" width="17.5" height="14.5" rx="3.25"/>
354
+ <path d="M3.25 9.4h17.5"/>
355
+ <circle cx="6.15" cy="7.05" r=".9" fill="#e8eaed" stroke="none"/>
356
+ <circle cx="8.75" cy="7.05" r=".9" fill="#e8eaed" stroke="none"/>
357
+ </svg>
358
+ <form class="search" id="f" autocomplete="off">
359
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M16.6 16.6 21 21"/></svg>
360
+ <input id="q" type="text" placeholder="搜索或输入网址" autocomplete="off" spellcheck="false" aria-label="搜索或输入网址">
361
+ </form>
362
+ <div class="tiles" id="tiles"></div>
363
+ </main>
364
+ <script>
365
+ (function(){
366
+ var form=document.getElementById('f'),input=document.getElementById('q'),tiles=document.getElementById('tiles');
367
+ // 和地址栏同一套口径(normalizeBrowserAddress):带空格的、没有点的都当搜索词。
368
+ function normalize(raw){
369
+ var v=String(raw||'').trim();
370
+ if(v==='')return '';
371
+ if(/^https?:[/][/]/i.test(v))return v;
372
+ if(/^[a-z][a-z0-9+.-]*:/i.test(v))return '';
373
+ if(v.indexOf(' ')>-1||v.indexOf('.')<0)return 'https://www.bing.com/search?q='+encodeURIComponent(v);
374
+ return 'https://'+v;
375
+ }
376
+ function go(){var t=normalize(input.value);if(t)location.assign(t)}
377
+ form.addEventListener('submit',function(e){e.preventDefault();go()});
378
+ // 收藏归宿主,chrome 注入后放在 window.__dshBookmarks 上。isolated 世界模式下页面读不到它,
379
+ // 那就只显示搜索框 —— 少几个磁贴,不影响用。
380
+ function host(url){try{return new URL(url).hostname.replace(/^www[.]/,'')}catch(e){return ''}}
381
+ function label(item){var t=String(item.title||'').trim();return t!==''?t:(host(item.url)||String(item.url||''))}
382
+ function render(list){
383
+ tiles.textContent='';
384
+ list.slice(0,10).forEach(function(item){
385
+ var a=document.createElement('a');
386
+ a.className='tile';
387
+ a.setAttribute('href',String(item.url));
388
+ var ico=document.createElement('div');ico.className='ico';
389
+ ico.textContent=(host(item.url)||label(item)).charAt(0).toUpperCase();
390
+ var cap=document.createElement('div');cap.className='cap';cap.textContent=label(item);
391
+ a.appendChild(ico);a.appendChild(cap);tiles.appendChild(a);
392
+ });
393
+ }
394
+ var seen='';
395
+ function sync(){
396
+ var list=Array.isArray(window.__dshBookmarks)?window.__dshBookmarks:[];
397
+ var links=list.filter(function(item){return item && typeof item.url==='string' && /^https?:[/][/]/i.test(item.url)});
398
+ var key=JSON.stringify(links);
399
+ if(key!==seen){seen=key;render(links)}
400
+ }
401
+ sync();
402
+ var tries=0;
403
+ var timer=setInterval(function(){sync();if(++tries>24)clearInterval(timer)},250);
404
+ })();
405
+ </script>
406
+ </body>
407
+ </html>
408
+ `;
409
+ /** data: URL for that empty state; its opaque origin simply has no bookmarks. */
410
+ const START_PAGE_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(START_PAGE_HTML);
411
+ /**
412
+ * Electron advertises itself in the User-Agent ("Electron/42.9.3"), which is one
413
+ * of the loudest automation signals a page or server can read. It also sends no
414
+ * client hints at all even though its own navigator.userAgentData reports
415
+ * Chromium, so a request claiming Chrome arrived with none of the sec-ch-ua
416
+ * headers Chrome always sends. Both are aligned with what the engine really is.
417
+ */
418
+ /** `--no-mask-automation` leaves the engine's own fingerprint alone. */
419
+ const MASK_AUTOMATION = !process.argv.includes('--no-mask-automation');
420
+ /** `--user-agent <ua>` replaces the derived one verbatim. */
421
+ const USER_AGENT_OVERRIDE = (() => {
422
+ const index = process.argv.indexOf('--user-agent');
423
+ return index >= 0 ? process.argv[index + 1] : undefined;
424
+ })();
425
+ /** Align the UA, the client-hint headers and Accept-Language with each other. */
426
+ function installRequestFingerprint() {
427
+ const browserSession = session.defaultSession;
428
+ if (!MASK_AUTOMATION) {
429
+ if (USER_AGENT_OVERRIDE !== undefined) {
430
+ app.userAgentFallback = USER_AGENT_OVERRIDE;
431
+ browserSession.setUserAgent(USER_AGENT_OVERRIDE);
432
+ }
433
+ return;
434
+ }
435
+ const clean = stripElectronToken(USER_AGENT_OVERRIDE ?? app.userAgentFallback);
436
+ // session.setUserAgent alone did not reach the views: a WebContentsView takes
437
+ // its UA from the app-wide fallback, so that is what has to change.
438
+ app.userAgentFallback = clean;
439
+ browserSession.setUserAgent(clean, acceptLanguagesFor(app.getLocale() || 'en-US'));
440
+ const major = chromeMajor(clean);
441
+ const hints = {
442
+ // Mirrors the brands this engine reports through navigator.userAgentData, so
443
+ // the header and the JS API tell the same story.
444
+ ...(major === undefined ? {} : { 'sec-ch-ua': secChUa([{ brand: 'Chromium', version: major }, { brand: 'Not/A)Brand', version: '99' }]) }),
445
+ 'sec-ch-ua-mobile': '?0',
446
+ 'sec-ch-ua-platform': clientHintPlatform(),
447
+ };
448
+ browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
449
+ const headers = { ...details.requestHeaders };
450
+ const existing = new Set(Object.keys(headers).map(key => key.toLowerCase()));
451
+ for (const [key, value] of Object.entries(hints)) {
452
+ // Never fight Chromium for a header it already decided to send.
453
+ if (!existing.has(key))
454
+ headers[key] = value;
455
+ }
456
+ callback({ requestHeaders: headers });
457
+ });
458
+ }
459
+ /** Latest unread JS dialog per view (auto-accepted; read by drainDialog). */
460
+ const dialogLogs = new Map();
461
+ /**
462
+ * How to answer the next JS dialog on a view. Default accept: a dialog freezes the
463
+ * renderer until it is answered, so automation must never leave one hanging. The
464
+ * provider can switch a view to `dismiss` (optionally with prompt text) when the
465
+ * page's confirmation is part of what it is testing.
466
+ */
467
+ const dialogPolicies = new Map();
468
+ const CONSOLE_CAP = 200;
469
+ const NETWORK_CAP = 200;
470
+ const consoleLogs = new Map();
471
+ const networkLogs = new Map();
472
+ const networkPending = new Map();
473
+ function pushBounded(map, key, entry, cap) {
474
+ const list = map.get(key) ?? [];
475
+ list.push(entry);
476
+ if (list.length > cap)
477
+ list.splice(0, list.length - cap);
478
+ map.set(key, list);
479
+ }
480
+ /** Display label and current tab for each isolated browser task. */
481
+ const taskLabels = new Map();
482
+ const activeViewByTask = new Map();
483
+ const taskViewIds = new Map();
484
+ const taskThumbnails = new Map();
485
+ const taskThumbnailVersions = new Map();
486
+ const taskStates = new Map();
487
+ const thumbnailTimers = new Map();
488
+ const thumbnailDirty = new Set();
489
+ let thumbnailCaptureInFlight = false;
490
+ const thumbnailLastCapturedAt = new Map();
491
+ /** The task the human currently sees in the one shared native window. */
492
+ let visibleTaskKey;
493
+ /** Current open state of the left task and right trail glass panels. */
494
+ let workspacePanels = { tasks: false, trail: false };
495
+ let window;
496
+ function taskTitle(taskKey) {
497
+ const label = taskLabels.get(taskKey) ?? '';
498
+ return label === '' ? 'dsh-browser-plus' : 'dsh-browser-plus — ' + label;
499
+ }
500
+ function makeWindow() {
501
+ const icon = resolveBrowserIconPath();
502
+ if (process.platform === 'darwin' && icon !== undefined) {
503
+ try {
504
+ app.dock?.setIcon(icon);
505
+ }
506
+ catch {
507
+ // The dock icon is cosmetic; a failure must never block window creation.
508
+ }
509
+ }
510
+ const win = new BrowserWindow({
511
+ width: 1400,
512
+ height: 900,
513
+ show: true,
514
+ title: 'dsh-browser-plus',
515
+ // Matches the chrome's palette: the window frame and any not-yet-painted
516
+ // area are dark instead of the default white.
517
+ backgroundColor: '#0e1218',
518
+ // Chrome-like frame: no OS title bar. The tab strip becomes the window's
519
+ // first row and the caption buttons are drawn over its right end, in the
520
+ // strip's own colour so the overlay reads as part of the chrome (the strip
521
+ // reserves that room — see page-chrome.ts).
522
+ ...USES_OVERLAY_FRAME
523
+ ? {
524
+ titleBarStyle: 'hidden',
525
+ titleBarOverlay: { color: '#202124', symbolColor: '#e8eaed', height: TABSTRIP_HEIGHT },
526
+ }
527
+ : {},
528
+ ...(icon === undefined ? {} : { icon }),
529
+ });
530
+ win.setMenu(null);
531
+ win.on('resize', layoutViews);
532
+ // A minimised window reports a content size of 0, so any view created while it
533
+ // is minimised is laid out 0x0 and stays that way -- restoring the window does
534
+ // not fire 'resize'. Measured: a view created while minimised still reported
535
+ // innerWidth 0 after ShowWindow(SW_RESTORE), while one created afterwards
536
+ // reported 1388. Re-layout on the events that make the window visible again.
537
+ win.on('restore', layoutViews);
538
+ win.on('show', layoutViews);
539
+ win.on('maximize', layoutViews);
540
+ win.on('unmaximize', layoutViews);
541
+ win.on('closed', () => {
542
+ window = undefined;
543
+ visibleTaskKey = undefined;
544
+ for (const timer of thumbnailTimers.values())
545
+ clearTimeout(timer);
546
+ thumbnailTimers.clear();
547
+ taskThumbnails.clear();
548
+ taskThumbnailVersions.clear();
549
+ thumbnailDirty.clear();
550
+ thumbnailLastCapturedAt.clear();
551
+ thumbnailCaptureInFlight = false;
552
+ activeViewByTask.clear();
553
+ taskViewIds.clear();
554
+ taskStates.clear();
555
+ taskLabels.clear();
556
+ views.clear();
557
+ viewFavicons.clear();
558
+ loadingViews.clear();
559
+ traces.clear();
560
+ dialogLogs.clear();
561
+ workspacePanels = { tasks: false, trail: false };
562
+ });
563
+ return win;
564
+ }
565
+ /**
566
+ * The host's own chrome view: tab strip and toolbar, 84px, above the pages.
567
+ *
568
+ * The chrome is injected into the page today, which forces the page to fake its
569
+ * own viewport with a 84px padding-top — and a site's `position: fixed` header
570
+ * ignores that padding, so it ends up hidden under the chrome. A view of its own
571
+ * is the only way to shrink the page viewport for real.
572
+ *
573
+ * Kept HIDDEN while it is being brought up: a visible view would sit on top of
574
+ * the page and swallow clicks in the top 84px (the chrome is drawn there today).
575
+ */
576
+ const CHROME_FRAME_HEIGHT = 84;
577
+ let chromeFrame;
578
+ /** Why the frame view could not be created, if it could not. Published to the chrome. */
579
+ let chromeFrameError = '';
580
+ /** Whether the frame's renderer has been told to emulate focus (see chromeInput). */
581
+ let chromeFrameFocused = false;
582
+ /**
583
+ * Apply an exported cookie list to a profile.
584
+ *
585
+ * Same normalization the tool-side restore uses: browser cookie editors
586
+ * (Cookie-Editor, EditThisCookie, Edge's own export) emit domain + path and no url,
587
+ * so one is derived — requiring url rejected exactly the files this exists for.
588
+ */
589
+ async function applyExportedCookies(target, cookies) {
590
+ let restored = 0;
591
+ let failed = 0;
592
+ for (const value of cookies) {
593
+ if (typeof value !== 'object' || value === null) {
594
+ failed += 1;
595
+ continue;
596
+ }
597
+ const record = value;
598
+ if (typeof record.name !== 'string' || typeof record.value !== 'string') {
599
+ failed += 1;
600
+ continue;
601
+ }
602
+ const path = typeof record.path === 'string' && record.path.startsWith('/') ? record.path : '/';
603
+ const url = typeof record.url === 'string' && record.url !== ''
604
+ ? record.url
605
+ : typeof record.domain === 'string' && record.domain !== ''
606
+ ? (record.secure === true ? 'https' : 'http') + '://' + record.domain.replace(/^[.]/, '') + path
607
+ : undefined;
608
+ if (url === undefined) {
609
+ failed += 1;
610
+ continue;
611
+ }
612
+ const sameSite = typeof record.sameSite === 'string' && ['no_restriction', 'lax', 'strict', 'unspecified'].includes(record.sameSite)
613
+ ? record.sameSite
614
+ : undefined;
615
+ try {
616
+ await target.cookies.set({
617
+ url,
618
+ name: record.name,
619
+ value: record.value,
620
+ ...typeof record.domain === 'string' ? { domain: record.domain } : {},
621
+ ...typeof record.path === 'string' ? { path: record.path } : {},
622
+ ...typeof record.secure === 'boolean' ? { secure: record.secure } : {},
623
+ ...typeof record.httpOnly === 'boolean' ? { httpOnly: record.httpOnly } : {},
624
+ ...typeof record.expirationDate === 'number' ? { expirationDate: record.expirationDate } : {},
625
+ ...sameSite === undefined ? {} : { sameSite },
626
+ });
627
+ restored += 1;
628
+ }
629
+ catch {
630
+ failed += 1;
631
+ }
632
+ }
633
+ return { restored, failed };
634
+ }
635
+ /**
636
+ * Pseudo view id for the chrome frame view.
637
+ *
638
+ * The frame is not a tab and has no `views` entry, but its chrome emits the same
639
+ * authenticated actions as a page's chrome, and those need *an* id to be logged
640
+ * and routed against.
641
+ */
642
+ const CHROME_FRAME_VIEW_ID = '__dsh_chrome_frame__';
643
+ function ensureWindow() {
644
+ if (window !== undefined && !window.isDestroyed())
645
+ return window;
646
+ window = makeWindow();
647
+ ensureChromeFrame();
648
+ return window;
649
+ }
650
+ /**
651
+ * Bring the frame view up, reporting (not swallowing) anything that goes wrong.
652
+ *
653
+ * The previous attempt at this refactor left the window at 158x26 with no clue
654
+ * why, so this one records the failure where it can be read back — the chrome
655
+ * surfaces it as `window.__dshChromeBootstrap.frameError`.
656
+ */
657
+ /**
658
+ * Handle one authenticated action from a view's injected chrome.
659
+ *
660
+ * Shared by the tab views and by the host's own chrome frame view: the frame is
661
+ * not a tab, so it has no entry in `views`, but its chrome issues the same actions
662
+ * (new-tab, close-tab, bookmark-add, set-zoom, ...).
663
+ */
664
+ function handleChromeAction(view, viewId, chromeToken, params) {
665
+ // Actions from the host's chrome frame view cannot act on the frame's own
666
+ // document (that is a data: page); they resolve the visible task's page view.
667
+ const pageView = view !== chromeFrame
668
+ ? view
669
+ : (visibleTaskKey === undefined ? undefined : views.get(activeViewByTask.get(visibleTaskKey) ?? '')?.webContentsView);
670
+ const binding = (params ?? {});
671
+ if (binding.name === '__dshBrowserTaskAction' && typeof binding.payload === 'string') {
672
+ try {
673
+ const action = JSON.parse(binding.payload);
674
+ // Authenticate before acting: only our injected chrome knows this
675
+ // view's token, so a forged payload never reaches the dispatcher.
676
+ if (!authorizeChromeAction(action, chromeToken))
677
+ return;
678
+ if (action.type === 'switch-task' && typeof action.taskKey === 'string' && activeViewByTask.has(action.taskKey)) {
679
+ switchVisibleTask(action.taskKey);
680
+ }
681
+ else if (action.type === 'request-chrome-bootstrap') {
682
+ // The frame is always on screen for the visible task, so it resyncs too.
683
+ if (viewId === CHROME_FRAME_VIEW_ID || views.get(viewId)?.taskKey === visibleTaskKey)
684
+ pushVisibleChromeState();
685
+ }
686
+ else if (action.type === 'set-workspace-panels'
687
+ && typeof action.tasks === 'boolean'
688
+ && typeof action.trail === 'boolean') {
689
+ workspacePanels = { tasks: action.tasks, trail: action.trail };
690
+ if (workspacePanels.tasks && visibleTaskKey !== undefined)
691
+ scheduleVisibleTaskThumbnail(visibleTaskKey);
692
+ queueChromePatch({ op: 'panels.set', panels: workspacePanels });
693
+ }
694
+ else if (action.type === 'set-control-owner'
695
+ && typeof action.taskKey === 'string'
696
+ && (action.control === 'agent' || action.control === 'human')
697
+ && activeViewByTask.has(action.taskKey)) {
698
+ updateTaskState(action.taskKey, action.control === 'human'
699
+ ? { control: 'human', status: 'waiting-user', latestAction: 'human took control' }
700
+ : { control: 'agent', status: 'idle', latestAction: 'agent resumed' });
701
+ const task = taskSummaries().find(candidate => candidate.key === action.taskKey);
702
+ if (task !== undefined)
703
+ queueChromePatch({ op: 'task.upsert', task });
704
+ }
705
+ else if (action.type === 'bookmark-add'
706
+ && typeof action.url === 'string'
707
+ && action.url !== '') {
708
+ // Bookmarks are profile-wide, so the host owns them and pushes the
709
+ // new list back rather than letting the page keep its own copy in
710
+ // localStorage (which is per origin).
711
+ const title = typeof action.title === 'string' && action.title !== '' ? action.title : action.url;
712
+ chromeBookmarks = [{ url: action.url, title }, ...chromeBookmarks.filter(item => item.url !== action.url)].slice(0, 500);
713
+ saveBookmarksToDisk();
714
+ queueChromePatch({ op: 'bookmarks.set', bookmarks: chromeBookmarks });
715
+ // The toolbar's star rides on the tab summary (the chrome only sees the
716
+ // origin, so the host decides), which means the strip has to be re-pushed
717
+ // too — otherwise the star stays stale until the next navigation.
718
+ queueTabsSet();
719
+ }
720
+ else if (action.type === 'bookmark-bar' && typeof action.visible === 'boolean') {
721
+ // The bar is a profile-wide preference, so the host owns it and the
722
+ // chrome reads it back from the bootstrap / patch stream.
723
+ chromeBookmarkBar = action.visible;
724
+ savePrefsToDisk();
725
+ queueChromePatch({ op: 'bookmarkbar.set', visible: chromeBookmarkBar });
726
+ }
727
+ else if (action.type === 'bookmark-remove' && typeof action.url === 'string') {
728
+ chromeBookmarks = chromeBookmarks.filter(item => item.url !== action.url);
729
+ saveBookmarksToDisk();
730
+ queueChromePatch({ op: 'bookmarks.set', bookmarks: chromeBookmarks });
731
+ queueTabsSet();
732
+ }
733
+ else if (action.type === 'panel-state' && typeof action.id === 'string' && typeof action.open === 'boolean') {
734
+ // The frame knows where its button is; the page draws the menu there.
735
+ queueChromePatch({
736
+ op: 'panel.state',
737
+ id: action.id,
738
+ open: action.open,
739
+ ...action.pinned === true ? { pinned: true } : {},
740
+ ...typeof action.left === 'number' ? { left: action.left } : {},
741
+ ...typeof action.width === 'number' ? { width: action.width } : {},
742
+ });
743
+ }
744
+ else if (action.type === 'import-cookies' && Array.isArray(action.cookies)) {
745
+ // From the chrome's ⋮ menu: the user picked a cookie export. Apply it to the
746
+ // profile the tabs already use, then tell them what happened.
747
+ const target = pageView?.webContents.session ?? session.defaultSession;
748
+ void applyExportedCookies(target, action.cookies).then(({ restored, failed }) => {
749
+ queueChromePatch(failed === 0
750
+ ? { op: 'notice', text: '已导入 ' + String(restored) + ' 个 cookie' }
751
+ : { op: 'notice', text: '导入 ' + String(restored) + ' 个,失败 ' + String(failed) + ' 个', level: 'warn' });
752
+ }).catch(() => {
753
+ queueChromePatch({ op: 'notice', text: '导入失败', level: 'warn' });
754
+ });
755
+ }
756
+ else if (action.type === 'frame-action' && typeof action.action === 'string') {
757
+ // The other direction: the page's chrome asks the frame's copy to do something
758
+ // (Ctrl+L belongs to the toolbar, which lives in the frame now).
759
+ const frame = chromeFrame;
760
+ if (frame !== undefined && !frame.webContents.isDestroyed()) {
761
+ if (action.action === 'focus-address') {
762
+ runChromeScript(frame, ';try { window.__dshChromeFocusAddress?.() } catch {}');
763
+ }
764
+ }
765
+ }
766
+ else if (action.type === 'page-action' && typeof action.action === 'string') {
767
+ // Relayed from the chrome frame view, which cannot act on the page itself.
768
+ const page = pageView;
769
+ if (page !== undefined && !page.webContents.isDestroyed()) {
770
+ const verb = action.action;
771
+ try {
772
+ if (verb === 'navigate' && typeof action.url === 'string' && action.url !== '') {
773
+ void page.webContents.loadURL(action.url).catch(() => undefined);
774
+ }
775
+ else if (verb === 'back') {
776
+ // Same route the page's own chrome uses, so history behaves identically.
777
+ runChromeScript(page, ';try { window.history.back() } catch {}');
778
+ }
779
+ else if (verb === 'forward') {
780
+ runChromeScript(page, ';try { window.history.forward() } catch {}');
781
+ }
782
+ else if (verb === 'reload') {
783
+ page.webContents.reload();
784
+ }
785
+ else if (verb === 'stop') {
786
+ page.webContents.stop();
787
+ }
788
+ else if (verb === 'home') {
789
+ // Home is this browser's own new-tab page, not a search engine: the
790
+ // button has to land on exactly the document a fresh tab shows. The
791
+ // load has to happen here — a page cannot navigate itself to a data:
792
+ // URL, Chromium blocks renderer-initiated navigation to one.
793
+ void page.webContents.loadURL(START_PAGE_URL).catch(() => undefined);
794
+ }
795
+ else if (verb === 'find') {
796
+ runChromeScript(page, ';try { window.__dshChromeFind?.open?.() } catch {}');
797
+ }
798
+ }
799
+ catch { /* closing */ }
800
+ }
801
+ }
802
+ else if (action.type === 'set-zoom'
803
+ && typeof action.factor === 'number'
804
+ && Number.isFinite(action.factor)) {
805
+ // Page zoom is a webContents property: it re-lays out the page
806
+ // (so vh and media queries follow), which is exactly why the
807
+ // chrome cannot fake it in CSS. The chrome compensates for its
808
+ // own share of the scale — see page-chrome.ts.
809
+ const factor = Math.min(3, Math.max(0.25, action.factor));
810
+ const zoomTarget = pageView ?? view;
811
+ try {
812
+ zoomTarget.webContents.setZoomFactor(factor);
813
+ // Echo it back: the chrome polls __dshZoom to correct drift, so
814
+ // a stale value there would undo the zoom the user just asked
815
+ // for on the very next tick.
816
+ // Both copies are told the factor: the page's chrome compensates its own
817
+ // scale with it, and the frame shows it in the ⋮ menu (it is a separate
818
+ // view, so it never scales — see the surface guard in page-chrome.ts).
819
+ if (view === chromeFrame && pageView !== undefined) {
820
+ runChromeScript(pageView, ';window.__dshZoom = ' + String(factor) + ';try { window.__dshChromeSetZoom?.(' + String(factor) + ') } catch {}');
821
+ }
822
+ runChromeScript(view, ';window.__dshZoom = ' + String(factor)
823
+ + ';try { window.__dshChromeSetZoom?.(' + String(factor) + ') } catch {}');
824
+ }
825
+ catch { /* closing */ }
826
+ }
827
+ else if (action.type === 'new-tab'
828
+ && typeof action.taskKey === 'string'
829
+ && activeViewByTask.has(action.taskKey)) {
830
+ // The provider creates the view; this host never invents a tab
831
+ // it does not own, or the strip and the session would diverge.
832
+ // 收藏点开的新标签:只放行 http(s),别把 javascript: 之类的东西交给 provider。
833
+ const target = typeof action.url === 'string' && /^https?:\/\//i.test(action.url) ? action.url : undefined;
834
+ emitChromeEvent(target === undefined
835
+ ? { type: 'new-tab', taskKey: action.taskKey }
836
+ : { type: 'new-tab', taskKey: action.taskKey, url: target });
837
+ }
838
+ else if (action.type === 'move-tab'
839
+ && typeof action.taskKey === 'string'
840
+ && typeof action.tabId === 'string'
841
+ && typeof action.toIndex === 'number') {
842
+ // Dragging a tab. The host owns the strip order (taskViewIds is a Set whose
843
+ // insertion order is the strip), so it reorders its own copy immediately and
844
+ // tells the provider, which keeps the session's tab list — the order
845
+ // browser_list_tabs reports — in step.
846
+ const ids = taskViewIds.get(action.taskKey);
847
+ const from = ids === undefined ? -1 : [...ids].indexOf(action.tabId);
848
+ if (ids !== undefined && from >= 0 && action.taskKey === visibleTaskKey) {
849
+ const ordered = [...ids];
850
+ const [moved] = ordered.splice(from, 1);
851
+ const to = Math.max(0, Math.min(Math.trunc(action.toIndex), ordered.length));
852
+ ordered.splice(to, 0, moved);
853
+ taskViewIds.set(action.taskKey, new Set(ordered));
854
+ queueTabsSet();
855
+ emitChromeEvent({ type: 'move-tab', taskKey: action.taskKey, tabId: action.tabId, toIndex: to });
856
+ }
857
+ }
858
+ else if (action.type === 'close-tab'
859
+ && typeof action.taskKey === 'string'
860
+ && typeof action.tabId === 'string') {
861
+ // Only a tab that really belongs to that task may be closed;
862
+ // the provider destroys the view, which removes the strip entry.
863
+ const closable = views.get(action.tabId);
864
+ if (closable !== undefined && closable.taskKey === action.taskKey) {
865
+ emitChromeEvent({ type: 'close-tab', taskKey: action.taskKey, tabId: action.tabId });
866
+ }
867
+ }
868
+ else if (action.type === 'switch-tab'
869
+ && typeof action.taskKey === 'string'
870
+ && typeof action.tabId === 'string') {
871
+ // The host applies the switch immediately (it owns which view
872
+ // is on screen) and tells the provider so the session's
873
+ // activeIndex follows: without that, the next browser_* call
874
+ // would operate on the tab the provider last activated rather
875
+ // than the one the human just picked.
876
+ const tabEntry = views.get(action.tabId);
877
+ // An unknown tabId, or a tab that belongs to another task, is
878
+ // ignored silently like the other page actions: the chrome must
879
+ // never be interrupted by a stale tab strip.
880
+ if (tabEntry !== undefined && tabEntry.taskKey === action.taskKey) {
881
+ const activeViewChanged = activeViewByTask.get(action.taskKey) !== action.tabId;
882
+ activeViewByTask.set(action.taskKey, action.tabId);
883
+ // Mirror the choice into the provider's session so its
884
+ // activeIndex stops disagreeing with what is on screen.
885
+ emitChromeEvent({ type: 'activate-tab', taskKey: action.taskKey, tabId: action.tabId });
886
+ if (activeViewChanged)
887
+ taskThumbnails.delete(action.taskKey);
888
+ // Only the visible task has a tab strip the human can click. A
889
+ // background task's choice is remembered and applied when the
890
+ // human switches to it (switchVisibleTask reads activeViewByTask).
891
+ if (action.taskKey === visibleTaskKey) {
892
+ // switchVisibleTask re-syncs visibility and pushes the new
893
+ // tab list (it is the "visible view switched" push point).
894
+ switchVisibleTask(action.taskKey);
895
+ }
896
+ }
897
+ }
898
+ }
899
+ catch { /* malformed page action */ }
900
+ }
901
+ return;
902
+ }
903
+ function ensureChromeFrame() {
904
+ const win = window;
905
+ if (win === undefined || win.isDestroyed() || chromeFrame !== undefined)
906
+ return;
907
+ try {
908
+ const frame = new WebContentsView({
909
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false },
910
+ });
911
+ chromeFrame = frame;
912
+ chromeTokens.set(frame, randomBytes(16).toString('hex'));
913
+ chromeFrameError = '';
914
+ // Attach BEFORE anything tries to add the binding: Runtime.addBinding goes
915
+ // through sendCommand, and a missing attach throws into a .catch — which is
916
+ // exactly how the frame ended up with a working-looking toolbar whose buttons
917
+ // did nothing (its binding was never installed).
918
+ try {
919
+ frame.webContents.debugger.attach('1.3');
920
+ }
921
+ catch { /* already attached */ }
922
+ win.contentView.addChildView(frame);
923
+ frame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT });
924
+ // HIDDEN until the frame's chrome can drive the page. A visible frame sits on
925
+ // top of the page view, so a person's clicks land on it — and the frame's
926
+ // chrome can only do the things it can do *in its own document* today: its
927
+ // address bar, back/forward/reload and find bar all act on the frame, not on
928
+ // the page. (The click tests did not catch this: CDP input is delivered to the
929
+ // target webContents whatever is on top.) Step 2 is the relay that fixes it.
930
+ frame.setVisible(true);
931
+ frame.setBackgroundColor('#202124');
932
+ // The frame's chrome speaks through the very same binding, and its actions go
933
+ // through the very same dispatcher; without this listener it could paint a
934
+ // toolbar whose buttons did nothing.
935
+ frame.webContents.debugger.on('message', (_event, method, params) => {
936
+ if (method === 'Runtime.bindingCalled') {
937
+ handleChromeAction(frame, CHROME_FRAME_VIEW_ID, chromeTokens.get(frame) ?? '', params);
938
+ return;
939
+ }
940
+ if (method !== 'Page.javascriptDialogOpening')
941
+ return;
942
+ const p = (params ?? {});
943
+ dialogLogs.set(CHROME_FRAME_VIEW_ID, {
944
+ type: String(p.type ?? ''),
945
+ message: String(p.message ?? ''),
946
+ ...typeof p.defaultPrompt === 'string' ? { prompt: p.defaultPrompt } : {},
947
+ });
948
+ });
949
+ frame.webContents.on('did-finish-load', () => {
950
+ const token = chromeTokens.get(frame) ?? '';
951
+ void ensureChromeBinding(frame).then(() => {
952
+ runChromeScript(frame, buildPageChromeScript(token, 'frame')
953
+ + ';window.__dshZoom = 1;window.__dshChromeActive = true'
954
+ + ';try { window.__dshChromeSetActive?.(true) } catch {}'
955
+ + chromeBootstrapScript());
956
+ }).catch(() => undefined);
957
+ });
958
+ void frame.webContents.loadURL('data:text/html;charset=utf-8,<!doctype html><meta charset="utf-8"><title>chrome frame</title>').catch((error) => {
959
+ chromeFrameError = 'load: ' + String(error instanceof Error ? error.message : error);
960
+ });
961
+ }
962
+ catch (error) {
963
+ chromeFrame = undefined;
964
+ chromeFrameError = 'create: ' + String(error instanceof Error ? error.message : error);
965
+ }
966
+ }
967
+ /** Keep every task view aligned with the one shared content surface. */
968
+ function layoutViews() {
969
+ const win = window;
970
+ if (win === undefined || win.isDestroyed())
971
+ return;
972
+ const [width, height] = win.getContentSize();
973
+ for (const entry of views.values()) {
974
+ try {
975
+ // Below the chrome frame: the page viewport is genuinely smaller now, so a
976
+ // sticky/fixed header lands at the top of the page instead of under the chrome.
977
+ entry.webContentsView.setBounds({
978
+ x: 0,
979
+ y: CHROME_FRAME_HEIGHT,
980
+ width: width ?? 0,
981
+ height: Math.max(0, (height ?? 0) - CHROME_FRAME_HEIGHT),
982
+ });
983
+ }
984
+ catch { /* destroyed */ }
985
+ }
986
+ // The frame sits above the pages, so it has to be re-appended whenever page
987
+ // views are added (child views stack in insertion order).
988
+ raiseChromeFrame();
989
+ }
990
+ /** Put the chrome frame back on top of the page views. */
991
+ function raiseChromeFrame() {
992
+ const win = window;
993
+ if (win === undefined || win.isDestroyed() || chromeFrame === undefined)
994
+ return;
995
+ try {
996
+ win.contentView.removeChildView(chromeFrame);
997
+ }
998
+ catch { /* not attached */ }
999
+ try {
1000
+ win.contentView.addChildView(chromeFrame);
1001
+ chromeFrame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT });
1002
+ }
1003
+ catch { /* destroyed */ }
1004
+ }
1005
+ /** Restore the one visible task after any operation that touched child views. */
1006
+ function syncVisibleTaskVisibility() {
1007
+ const viewId = visibleTaskKey === undefined ? undefined : activeViewByTask.get(visibleTaskKey);
1008
+ const target = viewId === undefined ? undefined : views.get(viewId);
1009
+ for (const entry of views.values()) {
1010
+ try {
1011
+ const active = entry === target;
1012
+ if (active)
1013
+ entry.webContentsView.setVisible(true);
1014
+ else
1015
+ entry.webContentsView.setVisible(false);
1016
+ // Only notify a renderer whose state actually changed: this loop runs for
1017
+ // every view on each task switch, and the IPC is the expensive part.
1018
+ if (chromeActiveApplied.get(entry.webContentsView) === active)
1019
+ continue;
1020
+ chromeActiveApplied.set(entry.webContentsView, active);
1021
+ runChromeScript(entry.webContentsView, ';window.__dshChromeActive = ' + String(active) + ';try { window.__dshChromeSetActive?.(' + String(active) + ') } catch {}');
1022
+ }
1023
+ catch { /* destroyed */ }
1024
+ }
1025
+ }
1026
+ function ensureTaskState(key) {
1027
+ const existing = taskStates.get(key);
1028
+ if (existing !== undefined)
1029
+ return existing;
1030
+ const state = { status: 'idle', control: 'agent', updatedAt: Date.now() };
1031
+ taskStates.set(key, state);
1032
+ return state;
1033
+ }
1034
+ function updateTaskState(key, update) {
1035
+ const state = ensureTaskState(key);
1036
+ if (update.status === 'idle' || update.status === 'running' || update.status === 'waiting-user' || update.status === 'failed')
1037
+ state.status = update.status;
1038
+ if (update.control === 'agent' || update.control === 'human')
1039
+ state.control = update.control;
1040
+ if (typeof update.latestAction === 'string')
1041
+ state.latestAction = update.latestAction.slice(0, 120);
1042
+ if (typeof update.error === 'string')
1043
+ state.error = update.error.slice(0, 180);
1044
+ else if (state.status !== 'failed')
1045
+ delete state.error;
1046
+ state.updatedAt = Date.now();
1047
+ return state;
1048
+ }
1049
+ function summarizeLatestTrace(entry) {
1050
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
1051
+ return undefined;
1052
+ const record = entry;
1053
+ const action = typeof record.action === 'string' ? record.action : undefined;
1054
+ const at = typeof record.at === 'number' ? record.at : undefined;
1055
+ return action === undefined || at === undefined ? undefined : { action, at };
1056
+ }
1057
+ function taskSummaries() {
1058
+ // Thumbnails are deliberately absent: these summaries are injected into every
1059
+ // visited page's main world, and shipping the JPEG here let any page read the
1060
+ // visible task's screen content. The image travels only through the targeted
1061
+ // 'task.thumbnail' patch, which is queued for the visible task alone.
1062
+ return [...activeViewByTask.entries()].flatMap(([key, viewId]) => {
1063
+ const activeView = views.get(viewId);
1064
+ if (activeView === undefined)
1065
+ return [];
1066
+ const latest = summarizeLatestTrace((traces.get(viewId) ?? []).at(-1));
1067
+ const state = ensureTaskState(key);
1068
+ let url = '';
1069
+ try {
1070
+ url = activeView.webContentsView.webContents.getURL();
1071
+ }
1072
+ catch { /* closing */ }
1073
+ return [{
1074
+ key,
1075
+ label: taskLabels.get(key) ?? '',
1076
+ active: key === visibleTaskKey,
1077
+ background: key !== visibleTaskKey,
1078
+ url: taskSummaryUrl(url),
1079
+ tabs: taskViewIds.get(key)?.size ?? 0,
1080
+ status: state.status,
1081
+ control: state.control,
1082
+ updatedAt: state.updatedAt,
1083
+ ...(latest === undefined ? {} : { latest: latest }),
1084
+ ...(state.error !== undefined ? { error: state.error } : {}),
1085
+ thumbnailVersion: taskThumbnailVersions.get(key) ?? 0,
1086
+ }];
1087
+ });
1088
+ }
1089
+ /**
1090
+ * Fetch one page-reported icon and remember it as a data: URL.
1091
+ *
1092
+ * The fetch runs on the view's own session, so an icon behind a login resolves
1093
+ * exactly as the page's own `<link rel=icon>` request would. Everything about it
1094
+ * is bounded: the content type must be an admitted raster type, the body must
1095
+ * stay under {@link FAVICON_MAX_BYTES}, and the whole read is abandoned after
1096
+ * {@link FAVICON_FETCH_TIMEOUT_MS}. A rejected icon simply leaves the letter
1097
+ * fallback in place — the tab strip is never worth a stalled navigation.
1098
+ */
1099
+ function rememberFavicon(view, viewId, rawUrl) {
1100
+ if (!/^https?:/i.test(rawUrl))
1101
+ return;
1102
+ const controller = new AbortController();
1103
+ const timer = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
1104
+ void (async () => {
1105
+ try {
1106
+ const response = await view.webContents.session.fetch(rawUrl, { signal: controller.signal });
1107
+ if (!response.ok)
1108
+ return;
1109
+ const type = (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
1110
+ if (!FAVICON_TYPES.includes(type))
1111
+ return;
1112
+ const declared = Number(response.headers.get('content-length') ?? '0');
1113
+ if (Number.isFinite(declared) && declared > FAVICON_MAX_BYTES)
1114
+ return;
1115
+ const body = Buffer.from(await response.arrayBuffer());
1116
+ if (body.length === 0 || body.length > FAVICON_MAX_BYTES)
1117
+ return;
1118
+ // A late icon must not overwrite the one belonging to a newer document.
1119
+ if (!views.has(viewId))
1120
+ return;
1121
+ viewFavicons.set(viewId, `data:${type};base64,${body.toString('base64')}`);
1122
+ const taskKey = views.get(viewId)?.taskKey;
1123
+ if (taskKey !== undefined && taskKey === visibleTaskKey)
1124
+ queueTabsSet();
1125
+ }
1126
+ catch {
1127
+ // Offline, aborted, opaque response, or a session torn down mid-read.
1128
+ }
1129
+ finally {
1130
+ clearTimeout(timer);
1131
+ }
1132
+ })();
1133
+ }
1134
+ /**
1135
+ * Tabs of one task, as the injected tab strip renders them.
1136
+ *
1137
+ * A tab IS a host view, so this is the task's view list in creation order
1138
+ * (taskViewIds is a Set, whose insertion order is stable across activations);
1139
+ * `active` marks the view currently shown for that task. Title and URL are read
1140
+ * live from the view, so the next push reflects a navigation.
1141
+ *
1142
+ * The URL is reduced to its origin by taskSummaryUrl(), exactly like the task
1143
+ * summaries: this state is injected into the page being displayed (and the strip
1144
+ * puts it in the button's title attribute), so a query string or an in-URL token
1145
+ * must never reach the page. The strip itself renders only the title.
1146
+ */
1147
+ /** Compare two page URLs ignoring a trailing slash (the tab URL is normalized). */
1148
+ function samePageUrl(left, right) {
1149
+ const trim = (value) => value.replace(/[/]$/, '');
1150
+ return trim(left) === trim(right);
1151
+ }
1152
+ function tabSummaries(taskKey) {
1153
+ if (taskKey === undefined)
1154
+ return [];
1155
+ const viewIds = taskViewIds.get(taskKey);
1156
+ if (viewIds === undefined)
1157
+ return [];
1158
+ const activeViewId = activeViewByTask.get(taskKey);
1159
+ const tabs = [];
1160
+ for (const viewId of viewIds) {
1161
+ const entry = views.get(viewId);
1162
+ if (entry === undefined)
1163
+ continue;
1164
+ let title = '';
1165
+ let url = '';
1166
+ let rawUrl = '';
1167
+ try {
1168
+ title = entry.webContentsView.webContents.getTitle();
1169
+ rawUrl = entry.webContentsView.webContents.getURL();
1170
+ // Origin only: the tab strip renders titles, and this value reaches the
1171
+ // page (the chrome lives in the page), so a full URL would hand the page
1172
+ // the query string and any token in it. Same redaction taskSummaries uses.
1173
+ url = taskSummaryUrl(rawUrl);
1174
+ }
1175
+ catch { /* closing */ }
1176
+ // The host's own start page is a data: URL, and Chromium falls back to that
1177
+ // URL as the title — a screenful of escaped markup in a tab. Chrome calls
1178
+ // this document "新标签页" and so does the strip.
1179
+ const internal = rawUrl === '' || rawUrl.startsWith('data:') || rawUrl.startsWith('about:');
1180
+ const favicon = viewFavicons.get(viewId);
1181
+ const history = entry.webContentsView.webContents.navigationHistory;
1182
+ tabs.push({
1183
+ id: viewId,
1184
+ title: internal || title === '' ? '新标签页' : title,
1185
+ url: url ?? '',
1186
+ active: viewId === activeViewId,
1187
+ // The chrome only sees the origin, so "is this page bookmarked" is decided
1188
+ // here — see ChromeTabSummary.starred.
1189
+ starred: rawUrl === '' ? false : chromeBookmarks.some(bookmark => samePageUrl(bookmark.url, rawUrl)),
1190
+ // 工具栏的前进/后退该不该变灰也只有宿主知道(frame 那份读不到页面的历史,
1191
+ // 页面那份的 history.length 又不可靠)—— 和 starred 一样由宿主算好下发。
1192
+ canGoBack: history.canGoBack(),
1193
+ canGoForward: history.canGoForward(),
1194
+ ...favicon === undefined ? {} : { favicon },
1195
+ ...loadingViews.has(viewId) ? { loading: true } : {},
1196
+ });
1197
+ }
1198
+ return tabs;
1199
+ }
1200
+ let chromeEpoch = 1;
1201
+ let chromeRevision = 0;
1202
+ let pendingChromeOperations = [];
1203
+ let chromePatchTimer;
1204
+ function activeTraceForTask(taskKey) {
1205
+ const viewId = taskKey === undefined ? undefined : activeViewByTask.get(taskKey);
1206
+ const entries = viewId === undefined ? [] : traces.get(viewId) ?? [];
1207
+ return entries.flatMap(entry => {
1208
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
1209
+ return [];
1210
+ const record = entry;
1211
+ if (typeof record.action !== 'string' || typeof record.at !== 'number')
1212
+ return [];
1213
+ return [{
1214
+ action: record.action,
1215
+ // The only funnel into the page-visible trail: the bootstrap and the
1216
+ // `trail.append` patch both read it back, so redaction belongs here.
1217
+ ...typeof record.params === 'object' && record.params !== null && !Array.isArray(record.params)
1218
+ ? { params: redactTraceParams(record.action, record.params) }
1219
+ : {},
1220
+ ...typeof record.ok === 'boolean' ? { ok: record.ok } : {},
1221
+ at: record.at,
1222
+ }];
1223
+ });
1224
+ }
1225
+ /**
1226
+ * Reduce one recorded action's params to what the in-page trail may show.
1227
+ *
1228
+ * The provider records replay-grade detail (full typed text, full executed
1229
+ * scripts, full URLs, upload and download paths). Every visited page can read
1230
+ * the injected trail, so a page could otherwise harvest what was typed on an
1231
+ * earlier site in the same task. Only the keys a human-readable description
1232
+ * needs survive; URLs and paths collapse to origin and basename, and typed text
1233
+ * to a character count.
1234
+ */
1235
+ function redactTraceParams(action, params) {
1236
+ const pageSafeKeys = {
1237
+ navigate: ['url'],
1238
+ back: ['navigated'],
1239
+ forward: ['navigated'],
1240
+ reload: [],
1241
+ stop: [],
1242
+ execute: [],
1243
+ snapshot: [],
1244
+ // `target` is the description of the element the page itself matched, so it
1245
+ // carries nothing the page does not already know.
1246
+ click: ['x', 'y', 'target', 'button', 'modifiers'],
1247
+ doubleClick: ['x', 'y', 'target', 'button', 'modifiers'],
1248
+ hover: ['x', 'y', 'target'],
1249
+ scroll: ['deltaX', 'deltaY'],
1250
+ clickRef: ['snapshotId', 'ref'],
1251
+ scrollIntoView: ['snapshotId', 'ref', 'block'],
1252
+ fill: ['fields', 'submit'],
1253
+ type: ['chars'],
1254
+ pressKey: ['modifiers'],
1255
+ screenshot: ['fullPage'],
1256
+ content: ['selector'],
1257
+ waitForElement: ['selector', 'timeoutMs', 'visible'],
1258
+ uploadFile: ['selector'],
1259
+ download: ['url', 'savePath'],
1260
+ flushAuth: [],
1261
+ restoreAuth: ['count'],
1262
+ importAuth: ['count'],
1263
+ setSpace: ['label'],
1264
+ dialog: ['type', 'message'],
1265
+ replay: ['seq', 'of', 'chars', 'x', 'y'],
1266
+ };
1267
+ const keys = pageSafeKeys[action] ?? [];
1268
+ const safe = {};
1269
+ for (const key of keys) {
1270
+ const value = params[key];
1271
+ if (value === undefined)
1272
+ continue;
1273
+ if (key === 'url') {
1274
+ if (typeof value === 'string')
1275
+ safe.url = taskSummaryUrl(value);
1276
+ continue;
1277
+ }
1278
+ if (key === 'savePath') {
1279
+ if (typeof value === 'string')
1280
+ safe.savePath = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1);
1281
+ continue;
1282
+ }
1283
+ safe[key] = value;
1284
+ }
1285
+ if (keys.includes('chars') && typeof params.text === 'string')
1286
+ safe.chars = params.text.length;
1287
+ return safe;
1288
+ }
1289
+ function chromeWorkspaceState(selectedTaskKey = visibleTaskKey) {
1290
+ return {
1291
+ epoch: chromeEpoch,
1292
+ revision: chromeRevision,
1293
+ ...selectedTaskKey !== undefined ? { selectedTaskKey } : {},
1294
+ panels: workspacePanels,
1295
+ tasks: taskSummaries(),
1296
+ tabs: tabSummaries(selectedTaskKey),
1297
+ trail: activeTraceForTask(selectedTaskKey),
1298
+ bookmarks: chromeBookmarks,
1299
+ bookmarkBar: chromeBookmarkBar,
1300
+ frameError: chromeFrameError,
1301
+ windowProbe: (() => {
1302
+ const win = window;
1303
+ if (win === undefined || win.isDestroyed())
1304
+ return 'no window';
1305
+ try {
1306
+ const b = win.getBounds();
1307
+ const cs = win.getContentSize();
1308
+ return JSON.stringify({ visible: win.isVisible(), minimized: win.isMinimized(), bounds: b, content: cs, frame: chromeFrame === undefined ? 'none' : String(chromeFrame.getBounds().width) + 'x' + String(chromeFrame.getBounds().height) });
1309
+ }
1310
+ catch (error) {
1311
+ return 'probe: ' + String(error instanceof Error ? error.message : error);
1312
+ }
1313
+ })(),
1314
+ };
1315
+ }
1316
+ function chromeBootstrapScript(selectedTaskKey = visibleTaskKey) {
1317
+ const bootstrap = createBootstrap(chromeWorkspaceState(selectedTaskKey));
1318
+ const json = JSON.stringify(bootstrap);
1319
+ return ';window.__dshChromeBootstrap = ' + json
1320
+ + ';window.__dshTrail = window.__dshChromeBootstrap.trail'
1321
+ + ';window.__dshBookmarks = window.__dshChromeBootstrap.bookmarks'
1322
+ + ';window.__dshTasks = window.__dshChromeBootstrap.tasks'
1323
+ + ';window.__dshWorkspacePanels = window.__dshChromeBootstrap.panels'
1324
+ + ';try { window.__dshChromeApply?.(window.__dshChromeBootstrap) } catch {}'
1325
+ + ';try { window.__dshTrailRender?.() } catch {}'
1326
+ + ';try { window.__dshTaskRender?.() } catch {}'
1327
+ + ';try { window.__dshWorkspaceRender?.() } catch {}';
1328
+ }
1329
+ function chromePatchScript(operations) {
1330
+ const patch = createPatch(chromeEpoch, ++chromeRevision, operations);
1331
+ return ';window.__dshChromePatch = ' + JSON.stringify(patch)
1332
+ + ';try { window.__dshChromeApply?.(window.__dshChromePatch) } catch {}';
1333
+ }
1334
+ function resetChromeDelivery() {
1335
+ chromeEpoch += 1;
1336
+ chromeRevision = 0;
1337
+ pendingChromeOperations = [];
1338
+ if (chromePatchTimer !== undefined) {
1339
+ clearTimeout(chromePatchTimer);
1340
+ chromePatchTimer = undefined;
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Every view that renders a copy of the chrome.
1345
+ *
1346
+ * The chrome is drawn twice while the frame view is being brought up: once in the
1347
+ * page (today's layout, and what the click tests drive) and once in the host's own
1348
+ * 84px view. Both get the same bootstrap and the same patches in the same order,
1349
+ * so the two copies can never disagree about which tab is active.
1350
+ */
1351
+ function chromeSurfaces() {
1352
+ const surfaces = [];
1353
+ const viewId = visibleTaskKey === undefined ? undefined : activeViewByTask.get(visibleTaskKey);
1354
+ const page = viewId === undefined ? undefined : views.get(viewId);
1355
+ if (page !== undefined)
1356
+ surfaces.push(page.webContentsView);
1357
+ if (chromeFrame !== undefined && !chromeFrame.webContents.isDestroyed())
1358
+ surfaces.push(chromeFrame);
1359
+ return surfaces;
1360
+ }
1361
+ function pushVisibleChromeState() {
1362
+ resetChromeDelivery();
1363
+ const script = chromeBootstrapScript(visibleTaskKey);
1364
+ for (const surfaceView of chromeSurfaces())
1365
+ runChromeScript(surfaceView, script);
1366
+ // A bootstrap carries the tab list but never the full URL — see queueFrameAddress.
1367
+ queueFrameAddress();
1368
+ }
1369
+ function flushChromePatches() {
1370
+ chromePatchTimer = undefined;
1371
+ const operations = pendingChromeOperations;
1372
+ pendingChromeOperations = [];
1373
+ if (operations.length === 0)
1374
+ return;
1375
+ // One script for every surface: the revision counter is shared, so each copy has
1376
+ // to see the same sequence or it will ask for a resync.
1377
+ const script = chromePatchScript(operations);
1378
+ const surfaces = chromeSurfaces();
1379
+ if (surfaces.length === 0)
1380
+ return;
1381
+ for (const surfaceView of surfaces)
1382
+ runChromeScript(surfaceView, script);
1383
+ }
1384
+ function queueChromePatch(...operations) {
1385
+ pendingChromeOperations.push(...operations);
1386
+ if (chromePatchTimer !== undefined)
1387
+ return;
1388
+ chromePatchTimer = setTimeout(flushChromePatches, 24);
1389
+ }
1390
+ /**
1391
+ * Push the visible task's tab list to the chrome.
1392
+ *
1393
+ * Only the visible task is ever pushed: a patch is delivered to the visible
1394
+ * view alone (flushChromePatches), so a background task's tabs would repaint
1395
+ * the on-screen tab strip with another task's tabs. A background task's choice
1396
+ * is instead carried by the next bootstrap, which always includes tabs.
1397
+ */
1398
+ function queueTabsSet() {
1399
+ queueChromePatch({ op: 'tabs.set', tabs: tabSummaries(visibleTaskKey) });
1400
+ queueFrameAddress();
1401
+ }
1402
+ /**
1403
+ * Hand the frame's copy of the chrome the visible tab's REAL url.
1404
+ *
1405
+ * The tab summary only carries the origin on purpose: the chrome also runs inside
1406
+ * the page, so a full URL would hand that page the query string and any token in
1407
+ * it. The frame is the host's own document, and it is the copy a person actually
1408
+ * reads — so the full address goes to it alone, through a direct call rather than
1409
+ * the patch stream (which is broadcast to every surface).
1410
+ */
1411
+ function queueFrameAddress() {
1412
+ const frame = chromeFrame;
1413
+ if (frame === undefined || frame.webContents.isDestroyed())
1414
+ return;
1415
+ const viewId = visibleTaskKey === undefined ? undefined : activeViewByTask.get(visibleTaskKey);
1416
+ const entry = viewId === undefined ? undefined : views.get(viewId);
1417
+ let url = '';
1418
+ try {
1419
+ url = entry?.webContentsView.webContents.getURL() ?? '';
1420
+ }
1421
+ catch { /* closing */ }
1422
+ // 起始页是一整条 data: URL —— 把它原样显示出来就是满屏的百分号编码(真机截图里很难看)。
1423
+ // Chrome 在新标签页的地址栏是空的,所以这里也推空串。
1424
+ if (url === START_PAGE_URL)
1425
+ url = '';
1426
+ runChromeScript(frame, `window.__dshChromeAddress && window.__dshChromeAddress(${JSON.stringify(url)})`);
1427
+ }
1428
+ function scheduleVisibleTaskThumbnail(taskKey, delayMs = 360) {
1429
+ if (taskKey !== visibleTaskKey)
1430
+ return;
1431
+ thumbnailDirty.add(taskKey);
1432
+ // A closed task panel does not need fresh pixels. Keep a dirty marker so the
1433
+ // next panel open or task switch refreshes just the selected task.
1434
+ if (!workspacePanels.tasks)
1435
+ return;
1436
+ const existing = thumbnailTimers.get(taskKey);
1437
+ if (existing !== undefined)
1438
+ clearTimeout(existing);
1439
+ const sinceLast = Date.now() - (thumbnailLastCapturedAt.get(taskKey) ?? 0);
1440
+ const effectiveDelay = Math.max(delayMs, Math.max(0, 2_000 - sinceLast));
1441
+ const timer = setTimeout(() => {
1442
+ thumbnailTimers.delete(taskKey);
1443
+ void refreshVisibleTaskThumbnail(taskKey);
1444
+ }, effectiveDelay);
1445
+ thumbnailTimers.set(taskKey, timer);
1446
+ }
1447
+ /** A capturePage that never settles would leave the single-flight flag set forever. */
1448
+ function capturePageWithTimeout(view) {
1449
+ return new Promise((resolve, reject) => {
1450
+ const timer = setTimeout(() => reject(new Error('thumbnail capture timed out')), THUMBNAIL_CAPTURE_TIMEOUT_MS);
1451
+ view.webContents.capturePage().then(image => { clearTimeout(timer); resolve(image); }, error => { clearTimeout(timer); reject(error); });
1452
+ });
1453
+ }
1454
+ async function refreshVisibleTaskThumbnail(taskKey) {
1455
+ if (taskKey !== visibleTaskKey || !workspacePanels.tasks || thumbnailCaptureInFlight)
1456
+ return;
1457
+ const viewId = activeViewByTask.get(taskKey);
1458
+ const entry = viewId === undefined ? undefined : views.get(viewId);
1459
+ if (entry === undefined)
1460
+ return;
1461
+ thumbnailCaptureInFlight = true;
1462
+ let produced = false;
1463
+ try {
1464
+ const image = await capturePageWithTimeout(entry.webContentsView);
1465
+ if (taskKey !== visibleTaskKey || activeViewByTask.get(taskKey) !== viewId || !workspacePanels.tasks)
1466
+ return;
1467
+ const thumbnail = taskThumbnailDataUrl(image);
1468
+ if (thumbnail === undefined)
1469
+ return;
1470
+ thumbnailLastCapturedAt.set(taskKey, Date.now());
1471
+ thumbnailDirty.delete(taskKey);
1472
+ if (taskThumbnails.get(taskKey) === thumbnail)
1473
+ return;
1474
+ taskThumbnails.delete(taskKey);
1475
+ taskThumbnails.set(taskKey, thumbnail);
1476
+ const version = (taskThumbnailVersions.get(taskKey) ?? 0) + 1;
1477
+ taskThumbnailVersions.set(taskKey, version);
1478
+ produced = true;
1479
+ while (taskThumbnails.size > 32) {
1480
+ const oldest = [...taskThumbnails.keys()].find(key => key !== visibleTaskKey);
1481
+ if (oldest === undefined)
1482
+ break;
1483
+ taskThumbnails.delete(oldest);
1484
+ taskThumbnailVersions.delete(oldest);
1485
+ }
1486
+ const task = taskSummaries().find(candidate => candidate.key === taskKey);
1487
+ const operations = [{ op: 'task.thumbnail', key: taskKey, version, dataUrl: thumbnail }];
1488
+ if (task !== undefined)
1489
+ operations.push({ op: 'task.upsert', task });
1490
+ queueChromePatch(...operations);
1491
+ }
1492
+ catch {
1493
+ // Thumbnails are cosmetic; capture or JPEG encoding failures are ignored.
1494
+ }
1495
+ finally {
1496
+ thumbnailCaptureInFlight = false;
1497
+ if (produced) {
1498
+ if (thumbnailDirty.has(taskKey) && taskKey === visibleTaskKey && workspacePanels.tasks) {
1499
+ scheduleVisibleTaskThumbnail(taskKey, 200);
1500
+ }
1501
+ }
1502
+ else {
1503
+ // A capture that produced nothing (or threw) must not re-arm the retry:
1504
+ // leaving the dirty flag set re-captured a window that cannot paint at
1505
+ // 5Hz for as long as the task panel stayed open.
1506
+ thumbnailDirty.delete(taskKey);
1507
+ }
1508
+ }
1509
+ }
1510
+ /** Select a task for the human without reparenting any page view. */
1511
+ function switchVisibleTask(taskKey) {
1512
+ const viewId = activeViewByTask.get(taskKey);
1513
+ const target = viewId === undefined ? undefined : views.get(viewId);
1514
+ if (target === undefined)
1515
+ throw new Error(`switch task: unknown task ${taskKey}`);
1516
+ const win = ensureWindow();
1517
+ visibleTaskKey = taskKey;
1518
+ syncVisibleTaskVisibility();
1519
+ try {
1520
+ win.setTitle(taskTitle(taskKey));
1521
+ }
1522
+ catch { /* closing */ }
1523
+ pushVisibleChromeState();
1524
+ // The visible task changed, so its tab strip (and which tab is marked active)
1525
+ // changed with it. Queued after the bootstrap: resetChromeDelivery() inside
1526
+ // pushVisibleChromeState() drops anything queued before it.
1527
+ queueTabsSet();
1528
+ // 必须在 pushVisibleChromeState() **之后**排队:它里面的 resetChromeDelivery() 会把
1529
+ // 先排的东西丢掉(切标签就是走这条路)。导航走 applyPageChrome,不会播。
1530
+ queueChromePatch({ op: 'reveal' });
1531
+ scheduleVisibleTaskThumbnail(taskKey, 550);
1532
+ }
1533
+ /** The RPC socket to the parent; set when the connection is established. */
1534
+ let rpcSocket;
1535
+ /**
1536
+ * Make sure the chrome's callback exists in this document.
1537
+ *
1538
+ * `Runtime.addBinding` is bound to the execution context that is current when it
1539
+ * is issued: a binding installed while the start page was loading is gone once
1540
+ * the first real navigation commits (measured — `typeof
1541
+ * window.__dshBrowserTaskAction` is 'function' on the start page and 'undefined'
1542
+ * on the next document). Without this every chrome control silently does
1543
+ * nothing on every real site: switch-task, handoff, workspace panels, tab
1544
+ * switching and the bootstrap resync all go through this one callable.
1545
+ *
1546
+ * In isolated mode the world itself is recreated per document (see
1547
+ * ensureChromeContext), which already re-registers the binding against it.
1548
+ */
1549
+ async function ensureChromeBinding(view) {
1550
+ try {
1551
+ if (CHROME_WORLD === 'isolated') {
1552
+ await ensureChromeContext(view);
1553
+ return;
1554
+ }
1555
+ await view.webContents.debugger.sendCommand('Runtime.addBinding', { name: '__dshBrowserTaskAction' });
1556
+ }
1557
+ catch {
1558
+ // Chrome is cosmetic: a document that cannot host the callback still paints.
1559
+ }
1560
+ }
1561
+ /**
1562
+ * Re-apply the chrome to one view's current document. The script is rebuilt with
1563
+ * the view's token, so the copy the page receives can still authenticate its
1564
+ * actions; the provider's own fallback injection has no token to offer.
1565
+ */
1566
+ function applyPageChrome(view, viewId) {
1567
+ // The page keeps the popups and nothing else: the tab strip and toolbar are drawn
1568
+ // by the chrome frame view, which is what lets the page's viewport really start at
1569
+ // y=84 (and a site's position:fixed header stop hiding under the toolbar).
1570
+ const source = buildPageChromeScript(chromeTokens.get(view) ?? '', 'page');
1571
+ try {
1572
+ // The ACTIVE VIEW of the visible task, not merely any view belonging to it:
1573
+ // a background tab of the visible task must not claim to be on screen.
1574
+ const pageTaskKey = views.get(viewId)?.taskKey;
1575
+ const active = pageTaskKey !== undefined && activeViewByTask.get(pageTaskKey) === viewId;
1576
+ if (active)
1577
+ resetChromeDelivery();
1578
+ // The zoom factor is the host's to know: it is a webContents property, it
1579
+ // survives navigation, and the chrome cannot recover it from the page (a
1580
+ // fresh document's devicePixelRatio is already scaled, so deriving it there
1581
+ // silently stopped compensating after the first navigation).
1582
+ let zoom = 1;
1583
+ try {
1584
+ zoom = view.webContents.getZoomFactor();
1585
+ // Re-apply rather than merely report it. Chromium keeps zoom per origin and
1586
+ // restores it asynchronously, so on the first load after a restart
1587
+ // getZoomFactor() already said 0.9 while the document was still rendering
1588
+ // at 1.0 — the chrome then over-compensated and drew 11% too small.
1589
+ // Setting it forces the value and the rendering to agree.
1590
+ if (Number.isFinite(zoom) && zoom > 0)
1591
+ view.webContents.setZoomFactor(zoom);
1592
+ }
1593
+ catch { /* closing */ }
1594
+ runChromeScript(view, source
1595
+ + ';window.__dshZoom = ' + String(Number.isFinite(zoom) && zoom > 0 ? zoom : 1)
1596
+ + ';window.__dshChromeActive = ' + String(active)
1597
+ + ';try { window.__dshChromeSetActive?.(' + String(active) + ') } catch {};'
1598
+ + chromeBootstrapScript());
1599
+ }
1600
+ catch {
1601
+ // Chrome is cosmetic; never fail a page for it.
1602
+ }
1603
+ const taskKey = views.get(viewId)?.taskKey;
1604
+ if (taskKey !== undefined && taskKey === visibleTaskKey) {
1605
+ // Navigation completed for a tab of the visible task: its title/url changed,
1606
+ // so the strip needs a new list. The bootstrap injected just above already
1607
+ // carries tabs; this patch is what the tab-bar contract asks for on
1608
+ // navigation, and it also covers a background tab of the visible task
1609
+ // (whose own chrome is not on screen but whose title belongs in the strip).
1610
+ queueTabsSet();
1611
+ scheduleVisibleTaskThumbnail(taskKey, 550);
1612
+ }
1613
+ }
1614
+ /** Install human browser chrome without creating or reparenting a child view. */
1615
+ function installPageChrome(view, viewId) {
1616
+ // Electron's native executeJavaScript waits for a committed document, unlike
1617
+ // a CDP evaluate issued before commit, which can hang. Re-run on every
1618
+ // committed navigation so the toolbar follows each document.
1619
+ const apply = () => {
1620
+ // A committed document has a new execution context, so the chrome's world
1621
+ // must be created for it rather than reused from the previous one, and the
1622
+ // callback the chrome talks back through must be re-registered for it.
1623
+ chromeContexts.delete(view);
1624
+ void ensureChromeBinding(view).then(() => applyPageChrome(view, viewId));
1625
+ // did-navigate-in-page lands here too, so the frame's address bar follows
1626
+ // hash and history changes as well as full loads.
1627
+ if (views.get(viewId)?.taskKey === visibleTaskKey)
1628
+ queueFrameAddress();
1629
+ };
1630
+ view.webContents.on('did-navigate', apply);
1631
+ view.webContents.on('did-navigate-in-page', apply);
1632
+ // did-navigate fires on commit, usually before the document's <title> is
1633
+ // known, so a tab would read as '新标签页' until the next switch. Re-push the
1634
+ // strip when the title actually settles (only for the task on screen).
1635
+ view.webContents.on('page-title-updated', () => {
1636
+ if (views.get(viewId)?.taskKey === visibleTaskKey)
1637
+ queueTabsSet();
1638
+ });
1639
+ apply();
1640
+ }
1641
+ /** Reply to the parent over the RPC socket. */
1642
+ function reply(id, payload) {
1643
+ if (rpcSocket === undefined) {
1644
+ process.stderr.write(`[dsh-browser-plus host] reply without socket (id=${id})\n`);
1645
+ return;
1646
+ }
1647
+ rpcSocket.write(JSON.stringify({ id, ...payload }) + '\n');
1648
+ }
1649
+ /**
1650
+ * Tell the parent about a tab request the human made in the injected chrome.
1651
+ *
1652
+ * This is the one message the child sends without being asked: the host can
1653
+ * show a different view by itself, but the tab LIST belongs to the provider
1654
+ * (DSH process), which owns the session's tabs and active index. Sent as a
1655
+ * line with no `id`, which the parent's client routes to the chrome listener.
1656
+ */
1657
+ function emitChromeEvent(action) {
1658
+ if (rpcSocket === undefined)
1659
+ return;
1660
+ try {
1661
+ rpcSocket.write(JSON.stringify({ event: 'chrome', action }) + '\n');
1662
+ }
1663
+ catch {
1664
+ // Parent gone; the view state this host already applied still stands.
1665
+ }
1666
+ }
1667
+ /**
1668
+ * True when a page-emitted `__dshBrowserTaskAction` payload carries this view's
1669
+ * token. The binding is callable by every page script, so without this check any
1670
+ * visited page could switch the visible task or set control to "human" and
1671
+ * freeze the Agent. Payloads without a matching token are ignored.
1672
+ */
1673
+ function authorizeChromeAction(action, token) {
1674
+ if (typeof action !== 'object' || action === null || Array.isArray(action))
1675
+ return false;
1676
+ return action.token === token;
1677
+ }
1678
+ /** Handle one command. */
1679
+ async function handle(op, msg) {
1680
+ try {
1681
+ switch (op) {
1682
+ case 'ping':
1683
+ reply(msg.id, { ok: true });
1684
+ return;
1685
+ case 'trace': {
1686
+ const viewId = msg.viewId;
1687
+ const entry = msg.entry;
1688
+ if (viewId === undefined || entry === undefined)
1689
+ throw new Error('trace missing viewId/entry');
1690
+ const list = traces.get(viewId) ?? [];
1691
+ list.push(entry);
1692
+ if (list.length > 500)
1693
+ list.splice(0, list.length - 500);
1694
+ traces.set(viewId, list);
1695
+ const entryView = views.get(viewId);
1696
+ if (entryView !== undefined) {
1697
+ const latest = summarizeLatestTrace(entry);
1698
+ if (latest !== undefined)
1699
+ updateTaskState(entryView.taskKey, { latestAction: latest.action });
1700
+ const task = taskSummaries().find(candidate => candidate.key === entryView.taskKey);
1701
+ const operations = [];
1702
+ if (task !== undefined)
1703
+ operations.push({ op: 'task.upsert', task });
1704
+ if (entryView.taskKey === visibleTaskKey && activeViewByTask.get(entryView.taskKey) === viewId) {
1705
+ const trail = activeTraceForTask(entryView.taskKey).at(-1);
1706
+ if (trail !== undefined)
1707
+ operations.push({ op: 'trail.append', taskKey: entryView.taskKey, entry: trail });
1708
+ scheduleVisibleTaskThumbnail(entryView.taskKey);
1709
+ }
1710
+ if (operations.length > 0)
1711
+ queueChromePatch(...operations);
1712
+ }
1713
+ reply(msg.id, { ok: true });
1714
+ return;
1715
+ }
1716
+ case 'readConsole': {
1717
+ const viewId = msg.viewId;
1718
+ if (viewId === undefined)
1719
+ throw new Error('readConsole missing viewId');
1720
+ if (!views.has(viewId))
1721
+ throw new Error(`readConsole: unknown view ${viewId}`);
1722
+ const messages = consoleLogs.get(viewId) ?? [];
1723
+ if (msg.clear === true)
1724
+ consoleLogs.delete(viewId);
1725
+ reply(msg.id, { ok: true, result: { messages } });
1726
+ return;
1727
+ }
1728
+ case 'readNetwork': {
1729
+ const viewId = msg.viewId;
1730
+ if (viewId === undefined)
1731
+ throw new Error('readNetwork missing viewId');
1732
+ if (!views.has(viewId))
1733
+ throw new Error(`readNetwork: unknown view ${viewId}`);
1734
+ const requests = networkLogs.get(viewId) ?? [];
1735
+ if (msg.clear === true) {
1736
+ networkLogs.delete(viewId);
1737
+ networkPending.delete(viewId);
1738
+ }
1739
+ reply(msg.id, { ok: true, result: { requests } });
1740
+ return;
1741
+ }
1742
+ case 'setDialogPolicy': {
1743
+ const viewId = msg.viewId;
1744
+ if (viewId === undefined)
1745
+ throw new Error('setDialogPolicy missing viewId');
1746
+ if (!views.has(viewId))
1747
+ throw new Error(`setDialogPolicy: unknown view ${viewId}`);
1748
+ const behavior = msg.behavior === 'dismiss' ? 'dismiss' : 'accept';
1749
+ const promptText = typeof msg.promptText === 'string' ? msg.promptText : undefined;
1750
+ dialogPolicies.set(viewId, promptText === undefined ? { behavior } : { behavior, promptText });
1751
+ reply(msg.id, { ok: true, result: { behavior, ...promptText === undefined ? {} : { promptText } } });
1752
+ return;
1753
+ }
1754
+ case 'drainDialog': {
1755
+ const viewId = msg.viewId;
1756
+ if (viewId === undefined)
1757
+ throw new Error('drainDialog missing viewId');
1758
+ const entry = views.get(viewId);
1759
+ if (entry === undefined)
1760
+ throw new Error(`drainDialog: unknown view ${viewId}`);
1761
+ const latest = dialogLogs.get(viewId);
1762
+ if (latest !== undefined)
1763
+ dialogLogs.delete(viewId);
1764
+ reply(msg.id, { ok: true, result: latest ?? null });
1765
+ return;
1766
+ }
1767
+ case 'createView': {
1768
+ const viewId = msg.viewId;
1769
+ if (viewId === undefined)
1770
+ throw new Error('createView missing viewId');
1771
+ const taskKey = typeof msg.key === 'string' ? msg.key : 'default';
1772
+ const label = typeof msg.label === 'string' ? msg.label : undefined;
1773
+ const win = ensureWindow();
1774
+ if (label !== undefined)
1775
+ taskLabels.set(taskKey, label);
1776
+ const view = new WebContentsView();
1777
+ // Attach the debugger BEFORE the view can be seen: an attach failure
1778
+ // then leaves nothing in the window (no visible ghost view).
1779
+ view.webContents.debugger.attach(CDP_VERSION);
1780
+ // Per-view secret for the page chrome. It belongs to the view rather than
1781
+ // to a document, so re-injecting the chrome after a navigation reuses it.
1782
+ const chromeToken = randomBytes(24).toString('hex');
1783
+ chromeTokens.set(view, chromeToken);
1784
+ // Register the listener before enabling domains. Runtime.addBinding
1785
+ // exposes a callable function in the page, while Runtime.bindingCalled
1786
+ // is the only channel back to this host for workspace controls.
1787
+ // JS dialogs (alert/confirm/prompt) would freeze the page until
1788
+ // answered. Auto-accept immediately so automation never stalls, and
1789
+ // stash the detail for the provider to surface via drainDialog.
1790
+ view.webContents.debugger.on('message', (_event, method, params) => {
1791
+ if (method === 'Runtime.bindingCalled') {
1792
+ handleChromeAction(view, viewId, chromeToken, params);
1793
+ return;
1794
+ }
1795
+ if (method === 'Runtime.consoleAPICalled' || method === 'Runtime.exceptionThrown') {
1796
+ const p = (params ?? {});
1797
+ const text = method === 'Runtime.exceptionThrown'
1798
+ ? String(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'uncaught exception')
1799
+ : (Array.isArray(p.args) ? p.args : []).map((raw) => {
1800
+ const arg = (raw ?? {});
1801
+ if (arg.value !== undefined)
1802
+ return typeof arg.value === 'string' ? arg.value : JSON.stringify(arg.value);
1803
+ if (typeof arg.description === 'string')
1804
+ return arg.description;
1805
+ return String(arg.type ?? '');
1806
+ }).join(' ');
1807
+ pushBounded(consoleLogs, viewId, {
1808
+ level: method === 'Runtime.exceptionThrown' ? 'error' : String(p.type ?? 'log'),
1809
+ text: text.slice(0, 2000),
1810
+ at: new Date().toISOString(),
1811
+ }, CONSOLE_CAP);
1812
+ return;
1813
+ }
1814
+ if (method === 'Network.requestWillBeSent') {
1815
+ const p = (params ?? {});
1816
+ const requestId = String(p.requestId ?? '');
1817
+ if (requestId === '')
1818
+ return;
1819
+ const entry = {
1820
+ method: String(p.request?.method ?? 'GET'),
1821
+ url: String(p.request?.url ?? ''),
1822
+ at: new Date().toISOString(),
1823
+ };
1824
+ const pending = networkPending.get(viewId) ?? new Map();
1825
+ pending.set(requestId, { at: Date.now(), entry });
1826
+ networkPending.set(viewId, pending);
1827
+ pushBounded(networkLogs, viewId, entry, NETWORK_CAP);
1828
+ return;
1829
+ }
1830
+ if (method === 'Network.responseReceived' || method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
1831
+ const p = (params ?? {});
1832
+ const pending = networkPending.get(viewId)?.get(String(p.requestId ?? ''));
1833
+ if (pending === undefined)
1834
+ return;
1835
+ if (method === 'Network.responseReceived') {
1836
+ if (typeof p.response?.status === 'number')
1837
+ pending.entry.status = p.response.status;
1838
+ if (typeof p.response?.mimeType === 'string')
1839
+ pending.entry.mime = p.response.mimeType;
1840
+ if (typeof p.response?.type === 'string')
1841
+ pending.entry.kind = p.response.type;
1842
+ return;
1843
+ }
1844
+ pending.entry.ms = Date.now() - pending.at;
1845
+ if (method === 'Network.loadingFailed')
1846
+ pending.entry.failed = String(p.errorText ?? 'failed');
1847
+ networkPending.get(viewId)?.delete(String(p.requestId ?? ''));
1848
+ return;
1849
+ }
1850
+ if (method !== 'Page.javascriptDialogOpening')
1851
+ return;
1852
+ const p = (params ?? {});
1853
+ const info = {
1854
+ type: String(p.type ?? ''),
1855
+ message: String(p.message ?? ''),
1856
+ ...typeof p.defaultPrompt === 'string' ? { prompt: p.defaultPrompt } : {},
1857
+ };
1858
+ const policy = dialogPolicies.get(viewId) ?? { behavior: 'accept' };
1859
+ const accept = policy.behavior !== 'dismiss';
1860
+ dialogLogs.set(viewId, {
1861
+ ...info,
1862
+ answered: accept ? 'accept' : 'dismiss',
1863
+ ...accept && policy.promptText !== undefined ? { promptText: policy.promptText } : {},
1864
+ });
1865
+ try {
1866
+ void view.webContents.debugger.sendCommand('Page.handleJavaScriptDialog', {
1867
+ accept,
1868
+ ...accept && policy.promptText !== undefined ? { promptText: policy.promptText } : {},
1869
+ }).catch(() => undefined);
1870
+ }
1871
+ catch { /* closing */ }
1872
+ });
1873
+ // Keep protocol-domain setup non-blocking. Electron 42 can leave a
1874
+ // later Page.navigate unresolved when domain setup is awaited during
1875
+ // WebContentsView creation. Runtime.addBinding itself installs the
1876
+ // page callback and emits bindingCalled through Electron's debugger.
1877
+ try {
1878
+ void view.webContents.debugger.sendCommand('Page.enable').catch(() => undefined);
1879
+ }
1880
+ catch { /* closed */ }
1881
+ try {
1882
+ void view.webContents.debugger.sendCommand('DOM.enable').catch(() => undefined);
1883
+ }
1884
+ catch { /* closed */ }
1885
+ // Runtime gives console messages and uncaught exceptions; Network gives the
1886
+ // request list. Both feed the diagnostics tools and both are bounded.
1887
+ try {
1888
+ void view.webContents.debugger.sendCommand('Runtime.enable').catch(() => undefined);
1889
+ }
1890
+ catch { /* closed */ }
1891
+ try {
1892
+ void view.webContents.debugger.sendCommand('Network.enable').catch(() => undefined);
1893
+ }
1894
+ catch { /* closed */ }
1895
+ // Isolated mode registers the binding against its own world instead.
1896
+ if (CHROME_WORLD === 'main') {
1897
+ try {
1898
+ void view.webContents.debugger.sendCommand('Runtime.addBinding', { name: '__dshBrowserTaskAction' }).catch(() => undefined);
1899
+ }
1900
+ catch { /* closed */ }
1901
+ }
1902
+ // Keep window.open / target=_blank navigations inside this shared view
1903
+ // instead of spawning a second native window. Only HTTP(S) targets are admitted.
1904
+ view.webContents.setWindowOpenHandler(({ url }) => {
1905
+ try {
1906
+ // loadURL returns a promise; an unhandled rejection here would crash
1907
+ // the host, so it is ignored exactly like the domain setup above.
1908
+ if (/^https?:\/\//i.test(url))
1909
+ void view.webContents.loadURL(url).catch(() => undefined);
1910
+ }
1911
+ catch { /* closing */ }
1912
+ return { action: 'deny' };
1913
+ });
1914
+ // All later task views remain hidden until the human selects their task.
1915
+ view.setVisible(false);
1916
+ win.contentView.addChildView(view);
1917
+ views.set(viewId, { webContentsView: view, taskKey });
1918
+ traceView(`create ${viewId} task=${taskKey}`);
1919
+ const viewIds = taskViewIds.get(taskKey) ?? new Set();
1920
+ viewIds.add(viewId);
1921
+ taskViewIds.set(taskKey, viewIds);
1922
+ ensureTaskState(taskKey);
1923
+ const activeViewChanged = activeViewByTask.get(taskKey) !== viewId;
1924
+ activeViewByTask.set(taskKey, viewId);
1925
+ if (activeViewChanged)
1926
+ taskThumbnails.delete(taskKey);
1927
+ layoutViews();
1928
+ if (visibleTaskKey === undefined)
1929
+ switchVisibleTask(taskKey);
1930
+ // Fire-and-forget chrome registration: chrome must never block first paint.
1931
+ void installPageChrome(view, viewId);
1932
+ // Chromium reports the document's own icon URLs; the host does the read
1933
+ // so a page can never hand the chrome bytes it did not fetch itself.
1934
+ view.webContents.on('page-favicon-updated', (_event, favicons) => {
1935
+ const first = Array.isArray(favicons) ? favicons[0] : undefined;
1936
+ if (typeof first === 'string' && first !== '')
1937
+ rememberFavicon(view, viewId, first);
1938
+ });
1939
+ // A committed navigation invalidates the previous document's icon. The
1940
+ // strip keeps the letter fallback until the new document reports one.
1941
+ view.webContents.on('did-navigate', () => {
1942
+ if (viewFavicons.delete(viewId) && views.get(viewId)?.taskKey === visibleTaskKey)
1943
+ queueTabsSet();
1944
+ });
1945
+ // Loading state: the strip turns the favicon into a spinner and the
1946
+ // toolbar turns reload into stop, so both ends need the transition.
1947
+ const setLoading = (loading) => {
1948
+ const changed = loading ? !loadingViews.has(viewId) : loadingViews.has(viewId);
1949
+ if (loading)
1950
+ loadingViews.add(viewId);
1951
+ else
1952
+ loadingViews.delete(viewId);
1953
+ if (changed && views.get(viewId)?.taskKey === visibleTaskKey)
1954
+ queueTabsSet();
1955
+ };
1956
+ view.webContents.on('did-start-loading', () => setLoading(true));
1957
+ view.webContents.on('did-stop-loading', () => setLoading(false));
1958
+ // Commit a document immediately. Until something is loaded the view has
1959
+ // no frame, which is what made the empty window white and made every
1960
+ // CDP call hang; the start page is inert (no interactive elements), so a
1961
+ // snapshot of it is empty.
1962
+ void view.webContents.loadURL(START_PAGE_URL).catch(() => undefined);
1963
+ pushVisibleChromeState();
1964
+ // A new view is a new tab of its task. Only the visible task's strip is
1965
+ // on screen, and patches reach the visible view alone, so a background
1966
+ // task's new tab is picked up by the bootstrap when it becomes visible.
1967
+ if (taskKey === visibleTaskKey)
1968
+ queueTabsSet();
1969
+ reply(msg.id, { ok: true });
1970
+ return;
1971
+ }
1972
+ case 'destroyView': {
1973
+ const viewId = msg.viewId;
1974
+ if (viewId === undefined)
1975
+ throw new Error('destroyView missing viewId');
1976
+ const entry = views.get(viewId);
1977
+ if (entry !== undefined) {
1978
+ const wasActive = activeViewByTask.get(entry.taskKey) === viewId;
1979
+ views.delete(viewId);
1980
+ traceView(`destroy ${viewId} task=${entry.taskKey}`);
1981
+ const viewIds = taskViewIds.get(entry.taskKey);
1982
+ viewIds?.delete(viewId);
1983
+ if (viewIds !== undefined && viewIds.size === 0)
1984
+ taskViewIds.delete(entry.taskKey);
1985
+ dialogLogs.delete(viewId);
1986
+ dialogPolicies.delete(viewId);
1987
+ consoleLogs.delete(viewId);
1988
+ networkLogs.delete(viewId);
1989
+ networkPending.delete(viewId);
1990
+ traces.delete(viewId);
1991
+ viewFavicons.delete(viewId);
1992
+ loadingViews.delete(viewId);
1993
+ try {
1994
+ window?.contentView.removeChildView(entry.webContentsView);
1995
+ }
1996
+ catch { /* already removed */ }
1997
+ try {
1998
+ entry.webContentsView.webContents.debugger.detach();
1999
+ }
2000
+ catch { /* already detached */ }
2001
+ entry.webContentsView.webContents.close();
2002
+ const replacementView = [...views.entries()].find(([, candidate]) => candidate.taskKey === entry.taskKey);
2003
+ if (wasActive) {
2004
+ if (replacementView !== undefined)
2005
+ activeViewByTask.set(entry.taskKey, replacementView[0]);
2006
+ else {
2007
+ activeViewByTask.delete(entry.taskKey);
2008
+ taskLabels.delete(entry.taskKey);
2009
+ taskStates.delete(entry.taskKey);
2010
+ }
2011
+ }
2012
+ if (replacementView === undefined) {
2013
+ const thumbnailTimer = thumbnailTimers.get(entry.taskKey);
2014
+ if (thumbnailTimer !== undefined)
2015
+ clearTimeout(thumbnailTimer);
2016
+ thumbnailTimers.delete(entry.taskKey);
2017
+ taskThumbnails.delete(entry.taskKey);
2018
+ taskThumbnailVersions.delete(entry.taskKey);
2019
+ thumbnailDirty.delete(entry.taskKey);
2020
+ thumbnailLastCapturedAt.delete(entry.taskKey);
2021
+ }
2022
+ if (visibleTaskKey === entry.taskKey) {
2023
+ if (activeViewByTask.has(entry.taskKey))
2024
+ switchVisibleTask(entry.taskKey);
2025
+ else {
2026
+ const fallbackTask = activeViewByTask.keys().next().value;
2027
+ if (fallbackTask !== undefined)
2028
+ switchVisibleTask(fallbackTask);
2029
+ else {
2030
+ visibleTaskKey = undefined;
2031
+ try {
2032
+ window?.setTitle('dsh-browser-plus');
2033
+ }
2034
+ catch { /* closing */ }
2035
+ }
2036
+ }
2037
+ }
2038
+ }
2039
+ pushVisibleChromeState();
2040
+ // Closing a tab of the visible task shortens its strip. When the task
2041
+ // itself is gone, visibleTaskKey has already moved on via
2042
+ // switchVisibleTask (which pushed its own list), so this is a no-op there.
2043
+ if (entry !== undefined && entry.taskKey === visibleTaskKey)
2044
+ queueTabsSet();
2045
+ reply(msg.id, { ok: true });
2046
+ return;
2047
+ }
2048
+ case 'chromeInput': {
2049
+ // An Input.* command aimed at the chrome frame view.
2050
+ //
2051
+ // The frame is a view of its own, so page-directed input (which is what
2052
+ // every browser_* tool sends) never reaches it — and CDP input targets a
2053
+ // webContents regardless of which view is on top, so the page cannot be
2054
+ // used as a proxy either. This is the only way to drive the toolbar.
2055
+ const frame = chromeFrame;
2056
+ if (frame === undefined || frame.webContents.isDestroyed())
2057
+ throw new Error('chromeInput: no chrome frame');
2058
+ const method = msg.method;
2059
+ if (typeof method !== 'string' || !method.startsWith('Input.')) {
2060
+ throw new Error('chromeInput: only Input.* commands are accepted');
2061
+ }
2062
+ try {
2063
+ frame.webContents.debugger.attach('1.3');
2064
+ }
2065
+ catch { /* already attached */ }
2066
+ const params = typeof msg.params === 'object' && msg.params !== null ? msg.params : {};
2067
+ // Same reason the provider does this for pages: a renderer that believes it
2068
+ // is unfocused drops synthesized mouse presses on the floor.
2069
+ if (!chromeFrameFocused) {
2070
+ chromeFrameFocused = true;
2071
+ // Awaited, not fired and forgotten: the very first synthesized press must
2072
+ // not race the switch that lets it through.
2073
+ await frame.webContents.debugger.sendCommand('Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => undefined);
2074
+ }
2075
+ frame.webContents.debugger.sendCommand(method, params).then(() => reply(msg.id, { ok: true }), (error) => reply(msg.id, { ok: false, err: String(error instanceof Error ? error.message : error) }));
2076
+ return;
2077
+ }
2078
+ case 'chromeEval': {
2079
+ // Read state back out of the chrome frame's own document.
2080
+ //
2081
+ // The frame is a view of its own, so nothing that targets the page can see
2082
+ // it: the toolbar's own animations were previously only observable through
2083
+ // whatever the page's copy of the chrome happened to log. Runtime.evaluate
2084
+ // is deliberately the only command this accepts — the frame is our own
2085
+ // document, and the tests need to read it back.
2086
+ const frame = chromeFrame;
2087
+ if (frame === undefined || frame.webContents.isDestroyed())
2088
+ throw new Error('chromeEval: no chrome frame');
2089
+ const expression = msg.expression;
2090
+ if (typeof expression !== 'string')
2091
+ throw new Error('chromeEval: expression must be a string');
2092
+ try {
2093
+ frame.webContents.debugger.attach('1.3');
2094
+ }
2095
+ catch { /* already attached */ }
2096
+ frame.webContents.debugger
2097
+ .sendCommand('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
2098
+ .then((result) => {
2099
+ // The client resolves with `msg.result`, so the value goes in there.
2100
+ const value = result?.result?.value;
2101
+ reply(msg.id, { ok: true, result: value ?? null });
2102
+ }, (error) => reply(msg.id, { ok: false, err: String(error instanceof Error ? error.message : error) }));
2103
+ return;
2104
+ }
2105
+ case 'showView': {
2106
+ const viewId = msg.viewId;
2107
+ if (viewId === undefined)
2108
+ throw new Error('showView missing viewId');
2109
+ const entry = views.get(viewId);
2110
+ if (entry === undefined)
2111
+ throw new Error(`showView: unknown view ${viewId}`);
2112
+ const activeViewChanged = activeViewByTask.get(entry.taskKey) !== viewId;
2113
+ activeViewByTask.set(entry.taskKey, viewId);
2114
+ if (activeViewChanged)
2115
+ taskThumbnails.delete(entry.taskKey);
2116
+ if (visibleTaskKey === undefined)
2117
+ switchVisibleTask(entry.taskKey);
2118
+ else if (entry.taskKey !== visibleTaskKey) {
2119
+ // Background task tab changes stay in the background.
2120
+ pushVisibleChromeState();
2121
+ reply(msg.id, { ok: true });
2122
+ return;
2123
+ }
2124
+ else
2125
+ switchVisibleTask(entry.taskKey);
2126
+ reply(msg.id, { ok: true });
2127
+ return;
2128
+ }
2129
+ case 'label': {
2130
+ const viewId = msg.viewId;
2131
+ const label = msg.label;
2132
+ if (viewId === undefined || typeof label !== 'string')
2133
+ throw new Error('label missing viewId/label');
2134
+ const entry = views.get(viewId);
2135
+ if (entry === undefined)
2136
+ throw new Error(`label: unknown view ${viewId}`);
2137
+ taskLabels.set(entry.taskKey, label);
2138
+ if (entry.taskKey === visibleTaskKey) {
2139
+ try {
2140
+ ensureWindow().setTitle(taskTitle(entry.taskKey));
2141
+ }
2142
+ catch { /* closing */ }
2143
+ }
2144
+ pushVisibleChromeState();
2145
+ reply(msg.id, { ok: true });
2146
+ return;
2147
+ }
2148
+ case 'reinstallChrome': {
2149
+ const viewId = msg.viewId;
2150
+ if (viewId === undefined)
2151
+ throw new Error('reinstallChrome missing viewId');
2152
+ const entry = views.get(viewId);
2153
+ if (entry === undefined)
2154
+ throw new Error(`reinstallChrome: unknown view ${viewId}`);
2155
+ applyPageChrome(entry.webContentsView, viewId);
2156
+ reply(msg.id, { ok: true });
2157
+ return;
2158
+ }
2159
+ case 'listWindows': {
2160
+ const windows = [...activeViewByTask.keys()].map(key => ({ key, label: taskLabels.get(key) ?? '' }));
2161
+ reply(msg.id, { ok: true, result: { windows } });
2162
+ return;
2163
+ }
2164
+ case 'listTasks': {
2165
+ reply(msg.id, { ok: true, result: { tasks: taskSummaries() } });
2166
+ return;
2167
+ }
2168
+ case 'getTask': {
2169
+ const key = msg.key;
2170
+ if (typeof key !== 'string')
2171
+ throw new Error('getTask missing key');
2172
+ const task = taskSummaries().find(candidate => candidate.key === key);
2173
+ reply(msg.id, { ok: true, result: { task: task ?? null } });
2174
+ return;
2175
+ }
2176
+ case 'updateTask': {
2177
+ const key = msg.key;
2178
+ if (typeof key !== 'string' || msg.task === undefined)
2179
+ throw new Error('updateTask missing key or task');
2180
+ updateTaskState(key, msg.task);
2181
+ const task = taskSummaries().find(candidate => candidate.key === key);
2182
+ if (task !== undefined)
2183
+ queueChromePatch({ op: 'task.upsert', task });
2184
+ reply(msg.id, { ok: true, result: { task: task ?? null } });
2185
+ return;
2186
+ }
2187
+ case 'command': {
2188
+ const viewId = msg.viewId;
2189
+ if (viewId === undefined)
2190
+ throw new Error('command missing viewId');
2191
+ const entry = views.get(viewId);
2192
+ if (entry === undefined)
2193
+ throw new Error(`command: unknown view ${viewId}`);
2194
+ const method = msg.method;
2195
+ if (typeof method !== 'string')
2196
+ throw new Error('command missing method');
2197
+ const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
2198
+ reply(msg.id, { ok: true, result });
2199
+ return;
2200
+ }
2201
+ case 'capture': {
2202
+ const viewId = msg.viewId;
2203
+ if (viewId === undefined)
2204
+ throw new Error('capture missing viewId');
2205
+ const entry = views.get(viewId);
2206
+ if (entry === undefined)
2207
+ throw new Error(`capture: unknown view ${viewId}`);
2208
+ const win = ensureWindow();
2209
+ // Two complementary paths, because each has a failure mode:
2210
+ // - capturePage: fast and reliable with several WebContentsViews in
2211
+ // the window, but needs a live display surface (fails when the
2212
+ // window is minimized/occluded/unpainted).
2213
+ // - CDP Page.captureScreenshot: works without a display surface, but
2214
+ // can hang when another hidden WebContentsView exists in the window.
2215
+ // Try capturePage first (show/focus/restore + one retry), then CDP.
2216
+ try {
2217
+ if (!win.isVisible())
2218
+ win.show();
2219
+ }
2220
+ catch { /* closing */ }
2221
+ try {
2222
+ win.restore();
2223
+ }
2224
+ catch { /* not minimized */ }
2225
+ try {
2226
+ win.focus();
2227
+ }
2228
+ catch { /* closing */ }
2229
+ let base64 = '';
2230
+ try {
2231
+ let image;
2232
+ try {
2233
+ image = await entry.webContentsView.webContents.capturePage();
2234
+ }
2235
+ catch (error) {
2236
+ process.stderr.write(`[dsh-browser-plus host] capturePage failed: ${String(error)}\n`);
2237
+ await new Promise(resolve => setTimeout(resolve, 400));
2238
+ image = await entry.webContentsView.webContents.capturePage();
2239
+ }
2240
+ const png = image.toPNG();
2241
+ if (png.length > 0)
2242
+ base64 = png.toString('base64');
2243
+ }
2244
+ catch (error) {
2245
+ const state = JSON.stringify({
2246
+ win: { visible: win.isVisible(), minimized: win.isMinimized(), focused: win.isFocused() },
2247
+ });
2248
+ process.stderr.write(`[dsh-browser-plus host] capturePage retry failed: ${String(error)} state=${state}\n`);
2249
+ base64 = '';
2250
+ }
2251
+ if (base64 === '') {
2252
+ // CDP fallback. Page.captureScreenshot can hang when OTHER views
2253
+ // (especially hidden attach-first ones) are in the window, so
2254
+ // temporarily detach the siblings, capture in single-view state,
2255
+ // then restore them (target stays on top).
2256
+ const siblings = [...views.values()].filter(v => v !== entry);
2257
+ for (const v of siblings) {
2258
+ try {
2259
+ win.contentView.removeChildView(v.webContentsView);
2260
+ }
2261
+ catch { /* already gone */ }
2262
+ }
2263
+ try {
2264
+ const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {});
2265
+ const data = shot.data;
2266
+ if (typeof data === 'string' && data.length > 0)
2267
+ base64 = data;
2268
+ }
2269
+ finally {
2270
+ for (const v of siblings) {
2271
+ try {
2272
+ win.contentView.addChildView(v.webContentsView);
2273
+ }
2274
+ catch { /* destroyed */ }
2275
+ }
2276
+ if (entry.taskKey === visibleTaskKey) {
2277
+ try {
2278
+ win.contentView.removeChildView(entry.webContentsView);
2279
+ win.contentView.addChildView(entry.webContentsView);
2280
+ }
2281
+ catch { /* closing */ }
2282
+ }
2283
+ syncVisibleTaskVisibility();
2284
+ }
2285
+ }
2286
+ if (base64 === '') {
2287
+ throw new Error('capture produced no image (view not painted)');
2288
+ }
2289
+ reply(msg.id, { ok: true, result: { base64, width: 0, height: 0 } });
2290
+ return;
2291
+ }
2292
+ case 'download': {
2293
+ const viewId = msg.viewId;
2294
+ const url = msg.url;
2295
+ const savePath = msg.savePath;
2296
+ if (viewId === undefined || typeof url !== 'string' || typeof savePath !== 'string') {
2297
+ throw new Error('download missing viewId/url/savePath');
2298
+ }
2299
+ const entry = views.get(viewId);
2300
+ if (entry === undefined)
2301
+ throw new Error(`download: unknown view ${viewId}`);
2302
+ // Fetch the URL inside the page context (keeps cookies/login), read
2303
+ // the body as base64, and return it; the parent writes the file. This
2304
+ // avoids Electron's download pipeline entirely (CDP debugger attach
2305
+ // can interfere with will-download).
2306
+ const result = await entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
2307
+ // The cap is enforced while streaming: reading the whole body first
2308
+ // let an oversized response exhaust the renderer before it was
2309
+ // rejected, which made the limit decorative.
2310
+ expression: `(async () => {
2311
+ const limit = ${String(MAX_DOWNLOAD_BYTES)}
2312
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include', signal: AbortSignal.timeout(${String(DOWNLOAD_FETCH_TIMEOUT_MS)}) })
2313
+ if (!r.ok) throw new Error('HTTP ' + r.status)
2314
+ const declared = Number(r.headers.get('content-length'))
2315
+ if (Number.isFinite(declared) && declared > limit) throw new Error('download too large (limit ' + limit + ' bytes, declared ' + declared + ')')
2316
+ if (!r.body) throw new Error('download has no readable body')
2317
+ const reader = r.body.getReader()
2318
+ const chunks = []
2319
+ let total = 0
2320
+ for (;;) {
2321
+ const step = await reader.read()
2322
+ if (step.done) break
2323
+ total += step.value.length
2324
+ if (total > limit) {
2325
+ try { await reader.cancel() } catch (ignored) { /* already gone */ }
2326
+ throw new Error('download too large (limit ' + limit + ' bytes)')
2327
+ }
2328
+ chunks.push(step.value)
2329
+ }
2330
+ const bytes = new Uint8Array(total)
2331
+ let offset = 0
2332
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
2333
+ let bin = ''
2334
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
2335
+ return btoa(bin)
2336
+ })()`,
2337
+ awaitPromise: true,
2338
+ returnByValue: true,
2339
+ });
2340
+ const value = result.result?.value;
2341
+ if (typeof value !== 'string') {
2342
+ const detail = result.exceptionDetails;
2343
+ throw new Error(`download failed: ${detail?.exception?.description ?? 'no data'}`);
2344
+ }
2345
+ // Write here rather than shipping base64 back over the RPC line: the
2346
+ // parent then records only a byte count, so a download body never crosses
2347
+ // the socket (nor its line buffer) at all.
2348
+ const bytes = Buffer.from(value, 'base64');
2349
+ writeFileSync(savePath, bytes);
2350
+ reply(msg.id, { ok: true, result: { bytes: bytes.length } });
2351
+ return;
2352
+ }
2353
+ case 'flushAuth': {
2354
+ const viewId = msg.viewId;
2355
+ if (viewId === undefined)
2356
+ throw new Error('flushAuth missing viewId');
2357
+ const entry = views.get(viewId);
2358
+ if (entry === undefined)
2359
+ throw new Error(`flushAuth: unknown view ${viewId}`);
2360
+ // Export the session's cookies so login state can be saved/restored
2361
+ // across browser hosts (or shared with another machine).
2362
+ const cookies = await entry.webContentsView.webContents.session.cookies.get({});
2363
+ const exported = exportCookiesForAuth(cookies);
2364
+ reply(msg.id, { ok: true, result: { cookies: exported } });
2365
+ return;
2366
+ }
2367
+ case 'restoreAuth': {
2368
+ const viewId = msg.viewId;
2369
+ const cookies = msg.cookies;
2370
+ if (viewId === undefined)
2371
+ throw new Error('restoreAuth missing viewId');
2372
+ const entry = views.get(viewId);
2373
+ if (entry === undefined)
2374
+ throw new Error(`restoreAuth: unknown view ${viewId}`);
2375
+ if (!Array.isArray(cookies))
2376
+ throw new Error('restoreAuth missing cookies array');
2377
+ let restored = 0;
2378
+ let failed = 0;
2379
+ for (const c of cookies) {
2380
+ if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') {
2381
+ failed += 1;
2382
+ continue;
2383
+ }
2384
+ try {
2385
+ await entry.webContentsView.webContents.session.cookies.set({
2386
+ url: c.url,
2387
+ name: c.name,
2388
+ value: c.value,
2389
+ ...typeof c.domain === 'string' ? { domain: c.domain } : {},
2390
+ ...typeof c.path === 'string' ? { path: c.path } : {},
2391
+ ...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
2392
+ ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
2393
+ ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
2394
+ ...typeof c.sameSite === 'string' ? { sameSite: c.sameSite } : {},
2395
+ });
2396
+ restored += 1;
2397
+ }
2398
+ catch {
2399
+ // One malformed cookie must not discard the rest of the batch, and the
2400
+ // caller still learns how many landed.
2401
+ failed += 1;
2402
+ }
2403
+ }
2404
+ reply(msg.id, { ok: true, result: { restored, failed } });
2405
+ return;
2406
+ }
2407
+ case 'clearCookies': {
2408
+ const viewId = msg.viewId;
2409
+ if (viewId === undefined)
2410
+ throw new Error('clearCookies missing viewId');
2411
+ const entry = views.get(viewId);
2412
+ if (entry === undefined)
2413
+ throw new Error('clearCookies: unknown view ' + viewId);
2414
+ // Remove the cookies the caller scoped by domain and/or name. An
2415
+ // unscoped request must pass all: true, so a missing filter can never
2416
+ // wipe every login in the profile.
2417
+ const filter = {
2418
+ ...typeof msg.domain === 'string' && msg.domain !== '' ? { domain: msg.domain } : {},
2419
+ ...typeof msg.name === 'string' && msg.name !== '' ? { name: msg.name } : {},
2420
+ ...msg.all === true ? { all: true } : {},
2421
+ };
2422
+ const cookies = await entry.webContentsView.webContents.session.cookies.get({});
2423
+ const targets = selectCookiesForClear(cookies, filter);
2424
+ const names = [];
2425
+ for (const target of targets) {
2426
+ await entry.webContentsView.webContents.session.cookies.remove(target.url, target.name);
2427
+ names.push(target.name);
2428
+ }
2429
+ reply(msg.id, { ok: true, result: { removed: names.length, names } });
2430
+ return;
2431
+ }
2432
+ default:
2433
+ throw new Error(`unknown op ${op}`);
2434
+ }
2435
+ }
2436
+ catch (error) {
2437
+ const message = String(error);
2438
+ if (message.includes('unknown view'))
2439
+ dumpViewDiagnostics(op, message, msg);
2440
+ reply(msg.id, { ok: false, err: message });
2441
+ }
2442
+ }
2443
+ /**
2444
+ * Electron entry: connect back to the parent's RPC server (port from
2445
+ * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
2446
+ * cleared by the parent so `require('electron')` works; this file is loaded as
2447
+ * the app entry so `app` is available immediately.
2448
+ */
2449
+ void app.whenReady().then(() => {
2450
+ markHostBoot();
2451
+ installRequestFingerprint();
2452
+ loadBookmarksFromDisk();
2453
+ loadPrefsFromDisk();
2454
+ const portArg = process.argv.indexOf('--rpc-port');
2455
+ const port = portArg >= 0 ? Number(process.argv[portArg + 1]) : NaN;
2456
+ if (!Number.isFinite(port)) {
2457
+ process.stderr.write('[dsh-browser-plus host] missing --rpc-port\n');
2458
+ app.exit(1);
2459
+ return;
2460
+ }
2461
+ const socket = createConnection({ host: '127.0.0.1', port });
2462
+ rpcSocket = socket;
2463
+ socket.setEncoding('utf8');
2464
+ const rl = createInterface({ input: socket });
2465
+ rl.on('line', line => {
2466
+ const text = line.trim();
2467
+ if (text === '')
2468
+ return;
2469
+ let msg;
2470
+ try {
2471
+ msg = JSON.parse(text);
2472
+ }
2473
+ catch {
2474
+ return; // non-protocol noise
2475
+ }
2476
+ if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
2477
+ return;
2478
+ void handle(msg.op, msg).catch(() => { });
2479
+ });
2480
+ socket.on('error', error => {
2481
+ process.stderr.write(`[dsh-browser-plus host] socket error: ${String(error)}\n`);
2482
+ });
2483
+ // The parent owns our lifetime: when it closes the socket (dispose) or dies
2484
+ // without cleanup, exit so no zombie Electron window is left behind.
2485
+ socket.on('close', () => {
2486
+ process.stderr.write('[dsh-browser-plus host] parent connection closed, exiting\n');
2487
+ app.exit(0);
2488
+ });
2489
+ // Keep the process alive until the parent closes the socket or kills us.
2490
+ });
2491
+ // Diagnostics go to stderr, which the parent never parses as protocol.
2492
+ process.on('uncaughtException', error => {
2493
+ process.stderr.write(`[dsh-browser-plus host] uncaught: ${String(error)}\n`);
2494
+ });