dsh-browser-plus 0.0.0-stage → 0.5.1

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