dsh-browser-plus 0.0.0-stage → 0.5.0

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