dsh-browser-plus 0.5.0 → 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 (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -9,7 +9,8 @@
9
9
  * <- { id, op: 'ping' } | { id, op: 'createView', viewId, key?, label? } |
10
10
  * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
11
11
  * { id, op: 'label', viewId, label } | { id, op: 'listWindows' } |
12
- * { id, op: 'command', viewId, method, params }
12
+ * { id, op: 'command', viewId, method, params } |
13
+ * { id, op: 'printToPdf', viewId, options }
13
14
  * -> { id, ok: true, result? } | { id, ok: false, err }
14
15
  *
15
16
  * The parent never parses stderr, so diagnostics may go there freely.
@@ -28,7 +29,7 @@ import { taskSummaryUrl } from './task-summary.js'
28
29
  import { taskThumbnailDataUrl, type ThumbnailImage } from './task-thumbnail.js'
29
30
  import { exportCookiesForAuth, selectCookiesForClear } from './auth-cookies.js'
30
31
  import { resolveBrowserIconPath } from './icon.js'
31
- import { createBootstrap, createPatch, type ChromeBookmark, type ChromePatchOperation, type ChromeTabSummary, type ChromeTaskSummary, type ChromeTrailEntry, type ChromeWorkspaceState } from './chrome-state.js'
32
+ import { createBootstrap, createPatch, type ChromeBookmark, type ChromePatchOperation, type ChromeTabSummary, type ChromeTaskSummary, type ChromeTaskTodo, type ChromeTrailEntry, type ChromeWorkspaceState } from './chrome-state.js'
32
33
 
33
34
  // Isolate this host's profile from the DSH app's default Electron userData:
34
35
  // several Electron instances sharing Roaming\Electron fight over the GPU
@@ -274,6 +275,13 @@ function saveBookmarksToDisk(): void {
274
275
  */
275
276
  let chromeBookmarkBar = false
276
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
+
277
285
  let chromePrefsFile: string | undefined
278
286
 
279
287
  function loadPrefsFromDisk(): void {
@@ -282,12 +290,24 @@ function loadPrefsFromDisk(): void {
282
290
  const parsed: unknown = JSON.parse(readFileSync(chromePrefsFile, 'utf8'))
283
291
  if (typeof parsed !== 'object' || parsed === null) return
284
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
+ }
285
300
  } catch { /* first run, or an unreadable file */ }
286
301
  }
287
302
 
288
303
  function savePrefsToDisk(): void {
289
304
  if (chromePrefsFile === undefined) return
290
- try { writeFileSync(chromePrefsFile, JSON.stringify({ bookmarkBar: chromeBookmarkBar }, null, 2), 'utf8') } catch { /* read-only profile */ }
305
+ try {
306
+ writeFileSync(chromePrefsFile, JSON.stringify({
307
+ bookmarkBar: chromeBookmarkBar,
308
+ ...chromeOrbPosition !== undefined ? { orbPosition: chromeOrbPosition } : {},
309
+ }, null, 2), 'utf8')
310
+ } catch { /* read-only profile */ }
291
311
  }
292
312
 
293
313
  /** Only these raster types are admitted; anything else keeps the letter fallback. */
@@ -486,6 +506,25 @@ const dialogPolicies = new Map<string, { behavior: 'accept' | 'dismiss'; promptT
486
506
  * browser_network. A ring (not a stream) on purpose: the agent asks after the fact,
487
507
  * and an unbounded log would grow for the life of the tab.
488
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
+
489
528
  interface ConsoleEntry { level: string; text: string; at: string }
490
529
  interface NetworkEntry { method: string; url: string; status?: number; mime?: string; kind?: string; failed?: string; ms?: number; at: string }
491
530
  const CONSOLE_CAP = 200
@@ -507,6 +546,13 @@ const taskViewIds = new Map<string, Set<string>>()
507
546
  const taskThumbnails = new Map<string, string>()
508
547
  const taskThumbnailVersions = new Map<string, number>()
509
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[]>()
510
556
  const thumbnailTimers = new Map<string, ReturnType<typeof setTimeout>>()
511
557
  const thumbnailDirty = new Set<string>()
512
558
  let thumbnailCaptureInFlight = false
@@ -599,6 +645,17 @@ function makeWindow(): BrowserWindow {
599
645
  * the page and swallow clicks in the top 84px (the chrome is drawn there today).
600
646
  */
601
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
+ }
602
659
  let chromeFrame: WebContentsView | undefined
603
660
  /** Why the frame view could not be created, if it could not. Published to the chrome. */
604
661
  let chromeFrameError = ''
@@ -685,7 +742,7 @@ function handleChromeAction(view: WebContentsView, viewId: string, chromeToken:
685
742
  const binding = (params ?? {}) as { name?: unknown; payload?: unknown }
686
743
  if (binding.name === '__dshBrowserTaskAction' && typeof binding.payload === 'string') {
687
744
  try {
688
- const action = JSON.parse(binding.payload) as { type?: unknown; taskKey?: unknown; tabId?: unknown; tasks?: unknown; trail?: unknown; control?: unknown; factor?: unknown; url?: unknown; title?: unknown; visible?: unknown; tabs?: unknown; action?: unknown; id?: unknown; open?: unknown; left?: unknown; width?: unknown; toIndex?: unknown; cookies?: unknown; pinned?: unknown }
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 }
689
746
  // Authenticate before acting: only our injected chrome knows this
690
747
  // view's token, so a forged payload never reaches the dispatcher.
691
748
  if (!authorizeChromeAction(action, chromeToken)) return
@@ -699,7 +756,17 @@ function handleChromeAction(view: WebContentsView, viewId: string, chromeToken:
699
756
  && typeof action.trail === 'boolean') {
700
757
  workspacePanels = { tasks: action.tasks, trail: action.trail }
701
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() }
702
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()
703
770
  } else if (action.type === 'set-control-owner'
704
771
  && typeof action.taskKey === 'string'
705
772
  && (action.control === 'agent' || action.control === 'human')
@@ -727,6 +794,9 @@ function handleChromeAction(view: WebContentsView, viewId: string, chromeToken:
727
794
  // The bar is a profile-wide preference, so the host owns it and the
728
795
  // chrome reads it back from the bootstrap / patch stream.
729
796
  chromeBookmarkBar = action.visible
797
+ // 书签栏现在占的是帧视图的高度,所以开关一变就得重排:
798
+ // 只发 patch 的话,帧还是 84px 高、页面视图还从 84 开始 —— 书签栏就会压住页面。
799
+ layoutViews()
730
800
  savePrefsToDisk()
731
801
  queueChromePatch({ op: 'bookmarkbar.set', visible: chromeBookmarkBar })
732
802
  } else if (action.type === 'bookmark-remove' && typeof action.url === 'string') {
@@ -903,7 +973,7 @@ function ensureChromeFrame(): void {
903
973
  // did nothing (its binding was never installed).
904
974
  try { frame.webContents.debugger.attach('1.3') } catch { /* already attached */ }
905
975
  win.contentView.addChildView(frame)
906
- frame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT })
976
+ frame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: chromeFrameHeight() })
907
977
  // HIDDEN until the frame's chrome can drive the page. A visible frame sits on
908
978
  // top of the page view, so a person's clicks land on it — and the frame's
909
979
  // chrome can only do the things it can do *in its own document* today: its
@@ -955,11 +1025,12 @@ function layoutViews(): void {
955
1025
  try {
956
1026
  // Below the chrome frame: the page viewport is genuinely smaller now, so a
957
1027
  // sticky/fixed header lands at the top of the page instead of under the chrome.
1028
+ const frameHeight = chromeFrameHeight()
958
1029
  entry.webContentsView.setBounds({
959
1030
  x: 0,
960
- y: CHROME_FRAME_HEIGHT,
1031
+ y: frameHeight,
961
1032
  width: width ?? 0,
962
- height: Math.max(0, (height ?? 0) - CHROME_FRAME_HEIGHT),
1033
+ height: Math.max(0, (height ?? 0) - frameHeight),
963
1034
  })
964
1035
  } catch { /* destroyed */ }
965
1036
  }
@@ -975,7 +1046,7 @@ function raiseChromeFrame(): void {
975
1046
  try { win.contentView.removeChildView(chromeFrame) } catch { /* not attached */ }
976
1047
  try {
977
1048
  win.contentView.addChildView(chromeFrame)
978
- chromeFrame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT })
1049
+ chromeFrame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: chromeFrameHeight() })
979
1050
  } catch { /* destroyed */ }
980
1051
  }
981
1052
 
@@ -1282,6 +1353,7 @@ function chromeWorkspaceState(selectedTaskKey = visibleTaskKey): ChromeWorkspace
1282
1353
  trail: activeTraceForTask(selectedTaskKey),
1283
1354
  bookmarks: chromeBookmarks,
1284
1355
  bookmarkBar: chromeBookmarkBar,
1356
+ ...chromeOrbPosition !== undefined ? { orbPosition: chromeOrbPosition } : {},
1285
1357
  frameError: chromeFrameError,
1286
1358
  windowProbe: (() => {
1287
1359
  const win = window
@@ -1313,6 +1385,9 @@ function chromePatchScript(operations: readonly ChromePatchOperation[]): string
1313
1385
  const patch = createPatch(chromeEpoch, ++chromeRevision, operations)
1314
1386
  return ';window.__dshChromePatch = ' + JSON.stringify(patch)
1315
1387
  + ';try { window.__dshChromeApply?.(window.__dshChromePatch) } catch {}'
1388
+ // 补丁里含 Agent 的计划(task.todos),而默认主世界下页面脚本读得到 window ——
1389
+ // 应用完立刻把这份明文抹掉,别让它一直挂在页面上。
1390
+ + ';try { delete window.__dshChromePatch } catch { window.__dshChromePatch = undefined }'
1316
1391
  }
1317
1392
 
1318
1393
 
@@ -1405,6 +1480,50 @@ function queueFrameAddress(): void {
1405
1480
  runChromeScript(frame, `window.__dshChromeAddress && window.__dshChromeAddress(${JSON.stringify(url)})`)
1406
1481
  }
1407
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
+
1408
1527
  function scheduleVisibleTaskThumbnail(taskKey: string, delayMs = 360): void {
1409
1528
  if (taskKey !== visibleTaskKey) return
1410
1529
  thumbnailDirty.add(taskKey)
@@ -1494,6 +1613,10 @@ function switchVisibleTask(taskKey: string): void {
1494
1613
  // changed with it. Queued after the bootstrap: resetChromeDelivery() inside
1495
1614
  // pushVisibleChromeState() drops anything queued before it.
1496
1615
  queueTabsSet()
1616
+ // 新露出来的那个表面此前没收到过别的任务的图像(补丁只发给可见表面),所以把
1617
+ // 宿主手里已有的图都补给它 —— 否则切过去之后面板里除了当前任务全是占位符。
1618
+ pushCachedTaskThumbnails()
1619
+ pushCachedTaskTodos()
1497
1620
  // 必须在 pushVisibleChromeState() **之后**排队:它里面的 resetChromeDelivery() 会把
1498
1621
  // 先排的东西丢掉(切标签就是走这条路)。导航走 applyPageChrome,不会播。
1499
1622
  queueChromePatch({ op: 'reveal' })
@@ -1575,6 +1698,10 @@ function applyPageChrome(view: WebContentsView, viewId: string): void {
1575
1698
  // (whose own chrome is not on screen but whose title belongs in the strip).
1576
1699
  queueTabsSet()
1577
1700
  scheduleVisibleTaskThumbnail(taskKey, 550)
1701
+ // 每一次真导航都会重跑整个 chrome 脚本,页面侧那份 todo 缓存跟着归零。
1702
+ // 缩略图有 scheduleVisibleTaskThumbnail 兜住,计划没有对应物 —— 漏了它,
1703
+ // Agent 一导航球上就只剩状态圆点(这是本轮功能的卖点场景,必现)。
1704
+ pushCachedTaskTodos()
1578
1705
  }
1579
1706
  }
1580
1707
 
@@ -1642,7 +1769,7 @@ function authorizeChromeAction(action: unknown, token: string): boolean {
1642
1769
  }
1643
1770
 
1644
1771
  /** Handle one command. */
1645
- async function handle(op: string, msg: { id: number; viewId?: string; method?: string; params?: Record<string, unknown>; expression?: string; url?: string; savePath?: string; cookies?: unknown[]; entry?: unknown; key?: string; label?: string; task?: Record<string, unknown>; domain?: string; name?: string; all?: boolean; behavior?: string; promptText?: string; clear?: boolean }): Promise<void> {
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> {
1646
1773
  try {
1647
1774
  switch (op) {
1648
1775
  case 'ping':
@@ -1667,6 +1794,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
1667
1794
  const trail = activeTraceForTask(entryView.taskKey).at(-1)
1668
1795
  if (trail !== undefined) operations.push({ op: 'trail.append', taskKey: entryView.taskKey, entry: trail })
1669
1796
  scheduleVisibleTaskThumbnail(entryView.taskKey)
1797
+ pushCachedTaskTodos()
1670
1798
  }
1671
1799
  if (operations.length > 0) queueChromePatch(...operations)
1672
1800
  }
@@ -1918,6 +2046,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
1918
2046
  if (thumbnailTimer !== undefined) clearTimeout(thumbnailTimer)
1919
2047
  thumbnailTimers.delete(entry.taskKey)
1920
2048
  taskThumbnails.delete(entry.taskKey)
2049
+ taskTodos.delete(entry.taskKey)
1921
2050
  taskThumbnailVersions.delete(entry.taskKey)
1922
2051
  thumbnailDirty.delete(entry.taskKey)
1923
2052
  thumbnailLastCapturedAt.delete(entry.taskKey)
@@ -1984,8 +2113,16 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
1984
2113
  const expression = msg.expression
1985
2114
  if (typeof expression !== 'string') throw new Error('chromeEval: expression must be a string')
1986
2115
  try { frame.webContents.debugger.attach('1.3') } catch { /* already attached */ }
2116
+ // 隔离模式(默认)下,帧里的 chrome 也住在它自己的世界 —— 读它必须带上下文,
2117
+ // 否则读的是帧的主世界(那里什么都没有)。
2118
+ const frameContextId = CHROME_WORLD === 'isolated' ? await ensureChromeContext(frame) : undefined
1987
2119
  frame.webContents.debugger
1988
- .sendCommand('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
2120
+ .sendCommand('Runtime.evaluate', {
2121
+ expression,
2122
+ returnByValue: true,
2123
+ awaitPromise: true,
2124
+ ...frameContextId === undefined ? {} : { contextId: frameContextId },
2125
+ })
1989
2126
  .then(
1990
2127
  (result: unknown) => {
1991
2128
  // The client resolves with `msg.result`, so the value goes in there.
@@ -2014,6 +2151,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
2014
2151
  reply(msg.id, { ok: true })
2015
2152
  return
2016
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
+ }
2017
2167
  case 'label': {
2018
2168
  const viewId = msg.viewId
2019
2169
  const label = msg.label
@@ -2053,6 +2203,13 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
2053
2203
  reply(msg.id, { ok: true, result: { task: task ?? null } })
2054
2204
  return
2055
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
+ }
2056
2213
  case 'updateTask': {
2057
2214
  const key = msg.key
2058
2215
  if (typeof key !== 'string' || msg.task === undefined) throw new Error('updateTask missing key or task')
@@ -2073,6 +2230,28 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
2073
2230
  reply(msg.id, { ok: true, result })
2074
2231
  return
2075
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
+ }
2076
2255
  case 'capture': {
2077
2256
  const viewId = msg.viewId
2078
2257
  if (viewId === undefined) throw new Error('capture missing viewId')
@@ -2140,6 +2319,23 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
2140
2319
  reply(msg.id, { ok: true, result: { base64, width: 0, height: 0 } })
2141
2320
  return
2142
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
+ }
2143
2339
  case 'download': {
2144
2340
  const viewId = msg.viewId
2145
2341
  const url = msg.url
@@ -2303,7 +2499,7 @@ void app.whenReady().then(() => {
2303
2499
  rl.on('line', line => {
2304
2500
  const text = line.trim()
2305
2501
  if (text === '') return
2306
- let msg: { id: number; op?: string; viewId?: string; method?: string; params?: Record<string, unknown>; expression?: string; url?: string; savePath?: string; cookies?: unknown[]; key?: string; label?: string; task?: Record<string, unknown>; domain?: string; name?: string; all?: boolean }
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> }
2307
2503
  try {
2308
2504
  msg = JSON.parse(text) as typeof msg
2309
2505
  } catch {