dsh-browser-plus 0.0.0-stage → 0.5.1

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