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
@@ -30,7 +30,7 @@ export const Config = z.object({
30
30
  // meaning "the documented defaults" while an explicit [] still denies all.
31
31
  writeRoots: z.array(z.string()).default(defaultWriteRoots()),
32
32
  readRoots: z.array(z.string()).default(defaultWriteRoots()),
33
- chromeWorld: z.union(['main', 'isolated']).default('main'),
33
+ chromeWorld: z.union(['main', 'isolated']).default('isolated'),
34
34
  userAgent: z.string(),
35
35
  maskAutomation: z.boolean().default(true),
36
36
  });
@@ -40,7 +40,10 @@ export function apply(ctx, config) {
40
40
  // child is disposed with the fiber, mirroring the shell's lifetime.
41
41
  const host = config.viewHost
42
42
  ?? new RemoteElectronViewHost(defaultHostMainPath(), {
43
- chromeWorld: config.chromeWorld,
43
+ // `?? 'isolated'` 不只是为了类型:入口可能拿到**未经 schema 解析**的配置
44
+ // (测试探针、其它调用方直接调 apply),那时缺省值不会自动出现,
45
+ // 而 remote-host 把 undefined 当成「不加 --chrome-world」→ 子进程退回 main ✗。
46
+ chromeWorld: config.chromeWorld ?? 'isolated',
44
47
  maskAutomation: config.maskAutomation,
45
48
  ...config.userAgent === undefined ? {} : { userAgent: config.userAgent },
46
49
  });
@@ -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.
@@ -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.
@@ -265,6 +266,12 @@ function saveBookmarksToDisk() {
265
266
  * would silently reset the moment the user visited another site.
266
267
  */
267
268
  let chromeBookmarkBar = false;
269
+ /**
270
+ * Where the human parked the floating orb. Remembered in the chrome prefs file
271
+ * rather than in the page: a page-side position is per ORIGIN and would jump
272
+ * back to the corner on the next site.
273
+ */
274
+ let chromeOrbPosition;
268
275
  let chromePrefsFile;
269
276
  function loadPrefsFromDisk() {
270
277
  try {
@@ -273,6 +280,13 @@ function loadPrefsFromDisk() {
273
280
  if (typeof parsed !== 'object' || parsed === null)
274
281
  return;
275
282
  chromeBookmarkBar = parsed.bookmarkBar === true;
283
+ const orb = parsed.orbPosition;
284
+ if (typeof orb === 'object' && orb !== null) {
285
+ const { x, y } = orb;
286
+ if (typeof x === 'number' && typeof y === 'number' && Number.isFinite(x) && Number.isFinite(y)) {
287
+ chromeOrbPosition = { x: Math.round(x), y: Math.round(y) };
288
+ }
289
+ }
276
290
  }
277
291
  catch { /* first run, or an unreadable file */ }
278
292
  }
@@ -280,7 +294,10 @@ function savePrefsToDisk() {
280
294
  if (chromePrefsFile === undefined)
281
295
  return;
282
296
  try {
283
- writeFileSync(chromePrefsFile, JSON.stringify({ bookmarkBar: chromeBookmarkBar }, null, 2), 'utf8');
297
+ writeFileSync(chromePrefsFile, JSON.stringify({
298
+ bookmarkBar: chromeBookmarkBar,
299
+ ...chromeOrbPosition !== undefined ? { orbPosition: chromeOrbPosition } : {},
300
+ }, null, 2), 'utf8');
284
301
  }
285
302
  catch { /* read-only profile */ }
286
303
  }
@@ -484,6 +501,13 @@ const taskViewIds = new Map();
484
501
  const taskThumbnails = new Map();
485
502
  const taskThumbnailVersions = new Map();
486
503
  const taskStates = new Map();
504
+ /**
505
+ * The Agent's todo list per task, mirrored from the DSH session projection by the
506
+ * parent (see `setTaskTodos`). Kept here so the visible surface can be handed the
507
+ * current plan the moment it becomes visible — the list itself never rides in a
508
+ * task summary, because summaries reach every page's main world.
509
+ */
510
+ const taskTodos = new Map();
487
511
  const thumbnailTimers = new Map();
488
512
  const thumbnailDirty = new Set();
489
513
  let thumbnailCaptureInFlight = false;
@@ -574,6 +598,17 @@ function makeWindow() {
574
598
  * the page and swallow clicks in the top 84px (the chrome is drawn there today).
575
599
  */
576
600
  const CHROME_FRAME_HEIGHT = 84;
601
+ /** Height of the bookmark bar when it is shown. It becomes part of the chrome frame. */
602
+ const BOOKMARK_BAR_HEIGHT = 34;
603
+ /**
604
+ * How tall the chrome frame is right now: tab strip + toolbar, plus the bookmark
605
+ * bar when it is on. The page view starts below ALL of it, so a site's
606
+ * `position: fixed` header can never end up hidden under the bar — the same
607
+ * reason the toolbar itself lives in a view instead of a page padding.
608
+ */
609
+ function chromeFrameHeight() {
610
+ return CHROME_FRAME_HEIGHT + (chromeBookmarkBar ? BOOKMARK_BAR_HEIGHT : 0);
611
+ }
577
612
  let chromeFrame;
578
613
  /** Why the frame view could not be created, if it could not. Published to the chrome. */
579
614
  let chromeFrameError = '';
@@ -689,8 +724,22 @@ function handleChromeAction(view, viewId, chromeToken, params) {
689
724
  workspacePanels = { tasks: action.tasks, trail: action.trail };
690
725
  if (workspacePanels.tasks && visibleTaskKey !== undefined)
691
726
  scheduleVisibleTaskThumbnail(visibleTaskKey);
727
+ // Opening the panel is the moment the other rows become visible, so hand
728
+ // the surface every image we already have for them.
729
+ if (workspacePanels.tasks) {
730
+ pushCachedTaskThumbnails();
731
+ pushCachedTaskTodos();
732
+ }
692
733
  queueChromePatch({ op: 'panels.set', panels: workspacePanels });
693
734
  }
735
+ else if (action.type === 'orb-move'
736
+ && typeof action.x === 'number'
737
+ && typeof action.y === 'number'
738
+ && Number.isFinite(action.x)
739
+ && Number.isFinite(action.y)) {
740
+ chromeOrbPosition = { x: Math.round(action.x), y: Math.round(action.y) };
741
+ savePrefsToDisk();
742
+ }
694
743
  else if (action.type === 'set-control-owner'
695
744
  && typeof action.taskKey === 'string'
696
745
  && (action.control === 'agent' || action.control === 'human')
@@ -721,6 +770,9 @@ function handleChromeAction(view, viewId, chromeToken, params) {
721
770
  // The bar is a profile-wide preference, so the host owns it and the
722
771
  // chrome reads it back from the bootstrap / patch stream.
723
772
  chromeBookmarkBar = action.visible;
773
+ // 书签栏现在占的是帧视图的高度,所以开关一变就得重排:
774
+ // 只发 patch 的话,帧还是 84px 高、页面视图还从 84 开始 —— 书签栏就会压住页面。
775
+ layoutViews();
724
776
  savePrefsToDisk();
725
777
  queueChromePatch({ op: 'bookmarkbar.set', visible: chromeBookmarkBar });
726
778
  }
@@ -920,7 +972,7 @@ function ensureChromeFrame() {
920
972
  }
921
973
  catch { /* already attached */ }
922
974
  win.contentView.addChildView(frame);
923
- frame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT });
975
+ frame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: chromeFrameHeight() });
924
976
  // HIDDEN until the frame's chrome can drive the page. A visible frame sits on
925
977
  // top of the page view, so a person's clicks land on it — and the frame's
926
978
  // chrome can only do the things it can do *in its own document* today: its
@@ -974,11 +1026,12 @@ function layoutViews() {
974
1026
  try {
975
1027
  // Below the chrome frame: the page viewport is genuinely smaller now, so a
976
1028
  // sticky/fixed header lands at the top of the page instead of under the chrome.
1029
+ const frameHeight = chromeFrameHeight();
977
1030
  entry.webContentsView.setBounds({
978
1031
  x: 0,
979
- y: CHROME_FRAME_HEIGHT,
1032
+ y: frameHeight,
980
1033
  width: width ?? 0,
981
- height: Math.max(0, (height ?? 0) - CHROME_FRAME_HEIGHT),
1034
+ height: Math.max(0, (height ?? 0) - frameHeight),
982
1035
  });
983
1036
  }
984
1037
  catch { /* destroyed */ }
@@ -998,7 +1051,7 @@ function raiseChromeFrame() {
998
1051
  catch { /* not attached */ }
999
1052
  try {
1000
1053
  win.contentView.addChildView(chromeFrame);
1001
- chromeFrame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: CHROME_FRAME_HEIGHT });
1054
+ chromeFrame.setBounds({ x: 0, y: 0, width: win.getContentSize()[0] ?? 0, height: chromeFrameHeight() });
1002
1055
  }
1003
1056
  catch { /* destroyed */ }
1004
1057
  }
@@ -1297,6 +1350,7 @@ function chromeWorkspaceState(selectedTaskKey = visibleTaskKey) {
1297
1350
  trail: activeTraceForTask(selectedTaskKey),
1298
1351
  bookmarks: chromeBookmarks,
1299
1352
  bookmarkBar: chromeBookmarkBar,
1353
+ ...chromeOrbPosition !== undefined ? { orbPosition: chromeOrbPosition } : {},
1300
1354
  frameError: chromeFrameError,
1301
1355
  windowProbe: (() => {
1302
1356
  const win = window;
@@ -1329,7 +1383,10 @@ function chromeBootstrapScript(selectedTaskKey = visibleTaskKey) {
1329
1383
  function chromePatchScript(operations) {
1330
1384
  const patch = createPatch(chromeEpoch, ++chromeRevision, operations);
1331
1385
  return ';window.__dshChromePatch = ' + JSON.stringify(patch)
1332
- + ';try { window.__dshChromeApply?.(window.__dshChromePatch) } catch {}';
1386
+ + ';try { window.__dshChromeApply?.(window.__dshChromePatch) } catch {}'
1387
+ // 补丁里含 Agent 的计划(task.todos),而默认主世界下页面脚本读得到 window ——
1388
+ // 应用完立刻把这份明文抹掉,别让它一直挂在页面上。
1389
+ + ';try { delete window.__dshChromePatch } catch { window.__dshChromePatch = undefined }';
1333
1390
  }
1334
1391
  function resetChromeDelivery() {
1335
1392
  chromeEpoch += 1;
@@ -1425,6 +1482,51 @@ function queueFrameAddress() {
1425
1482
  url = '';
1426
1483
  runChromeScript(frame, `window.__dshChromeAddress && window.__dshChromeAddress(${JSON.stringify(url)})`);
1427
1484
  }
1485
+ /**
1486
+ * Replay every task's last known image to the visible surface.
1487
+ *
1488
+ * A task's picture never travels in the task summaries: those are injected into
1489
+ * every visited page's main world, and the picture is screen content. So a
1490
+ * chrome surface only ever learns an image from a targeted 'task.thumbnail'
1491
+ * patch, and the capture path queues those for the VISIBLE task alone. Without
1492
+ * this replay, the surface that shows the panel paints its own task's image and
1493
+ * a placeholder for every other one — even for tasks the human already looked
1494
+ * at, which is exactly what the panel looked like before this existed.
1495
+ *
1496
+ * Safe by construction: the patches go to {@link chromeSurfaces}, i.e. the
1497
+ * visible task's page and the chrome frame, which are the only surfaces that
1498
+ * can show the panel in the first place.
1499
+ */
1500
+ /** Store one task's plan and push it to the surface that shows the panel. */
1501
+ function updateTaskTodos(key, todos) {
1502
+ if (!Array.isArray(todos))
1503
+ return;
1504
+ const clean = [];
1505
+ for (const item of todos) {
1506
+ const entry = item;
1507
+ if (typeof entry?.content !== 'string' || entry.content === '')
1508
+ continue;
1509
+ const status = entry.status === 'completed' || entry.status === 'in_progress' ? entry.status : 'pending';
1510
+ clean.push({ content: entry.content.slice(0, 160), status });
1511
+ if (clean.length >= 40)
1512
+ break;
1513
+ }
1514
+ taskTodos.set(key, clean);
1515
+ queueChromePatch({ op: 'task.todos', key, todos: clean });
1516
+ }
1517
+ /**
1518
+ * Hand every known plan to the visible surface. The orb is always on screen, so
1519
+ * a surface that has just become visible has never seen any of them.
1520
+ */
1521
+ function pushCachedTaskTodos() {
1522
+ for (const [key, todos] of taskTodos)
1523
+ queueChromePatch({ op: 'task.todos', key, todos });
1524
+ }
1525
+ function pushCachedTaskThumbnails() {
1526
+ for (const [key, dataUrl] of taskThumbnails) {
1527
+ queueChromePatch({ op: 'task.thumbnail', key, version: taskThumbnailVersions.get(key) ?? 0, dataUrl });
1528
+ }
1529
+ }
1428
1530
  function scheduleVisibleTaskThumbnail(taskKey, delayMs = 360) {
1429
1531
  if (taskKey !== visibleTaskKey)
1430
1532
  return;
@@ -1525,6 +1627,10 @@ function switchVisibleTask(taskKey) {
1525
1627
  // changed with it. Queued after the bootstrap: resetChromeDelivery() inside
1526
1628
  // pushVisibleChromeState() drops anything queued before it.
1527
1629
  queueTabsSet();
1630
+ // 新露出来的那个表面此前没收到过别的任务的图像(补丁只发给可见表面),所以把
1631
+ // 宿主手里已有的图都补给它 —— 否则切过去之后面板里除了当前任务全是占位符。
1632
+ pushCachedTaskThumbnails();
1633
+ pushCachedTaskTodos();
1528
1634
  // 必须在 pushVisibleChromeState() **之后**排队:它里面的 resetChromeDelivery() 会把
1529
1635
  // 先排的东西丢掉(切标签就是走这条路)。导航走 applyPageChrome,不会播。
1530
1636
  queueChromePatch({ op: 'reveal' });
@@ -1609,6 +1715,10 @@ function applyPageChrome(view, viewId) {
1609
1715
  // (whose own chrome is not on screen but whose title belongs in the strip).
1610
1716
  queueTabsSet();
1611
1717
  scheduleVisibleTaskThumbnail(taskKey, 550);
1718
+ // 每一次真导航都会重跑整个 chrome 脚本,页面侧那份 todo 缓存跟着归零。
1719
+ // 缩略图有 scheduleVisibleTaskThumbnail 兜住,计划没有对应物 —— 漏了它,
1720
+ // Agent 一导航球上就只剩状态圆点(这是本轮功能的卖点场景,必现)。
1721
+ pushCachedTaskTodos();
1612
1722
  }
1613
1723
  }
1614
1724
  /** Install human browser chrome without creating or reparenting a child view. */
@@ -1706,6 +1816,7 @@ async function handle(op, msg) {
1706
1816
  if (trail !== undefined)
1707
1817
  operations.push({ op: 'trail.append', taskKey: entryView.taskKey, entry: trail });
1708
1818
  scheduleVisibleTaskThumbnail(entryView.taskKey);
1819
+ pushCachedTaskTodos();
1709
1820
  }
1710
1821
  if (operations.length > 0)
1711
1822
  queueChromePatch(...operations);
@@ -2015,6 +2126,7 @@ async function handle(op, msg) {
2015
2126
  clearTimeout(thumbnailTimer);
2016
2127
  thumbnailTimers.delete(entry.taskKey);
2017
2128
  taskThumbnails.delete(entry.taskKey);
2129
+ taskTodos.delete(entry.taskKey);
2018
2130
  taskThumbnailVersions.delete(entry.taskKey);
2019
2131
  thumbnailDirty.delete(entry.taskKey);
2020
2132
  thumbnailLastCapturedAt.delete(entry.taskKey);
@@ -2093,8 +2205,16 @@ async function handle(op, msg) {
2093
2205
  frame.webContents.debugger.attach('1.3');
2094
2206
  }
2095
2207
  catch { /* already attached */ }
2208
+ // 隔离模式(默认)下,帧里的 chrome 也住在它自己的世界 —— 读它必须带上下文,
2209
+ // 否则读的是帧的主世界(那里什么都没有)。
2210
+ const frameContextId = CHROME_WORLD === 'isolated' ? await ensureChromeContext(frame) : undefined;
2096
2211
  frame.webContents.debugger
2097
- .sendCommand('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
2212
+ .sendCommand('Runtime.evaluate', {
2213
+ expression,
2214
+ returnByValue: true,
2215
+ awaitPromise: true,
2216
+ ...frameContextId === undefined ? {} : { contextId: frameContextId },
2217
+ })
2098
2218
  .then((result) => {
2099
2219
  // The client resolves with `msg.result`, so the value goes in there.
2100
2220
  const value = result?.result?.value;
@@ -2126,6 +2246,31 @@ async function handle(op, msg) {
2126
2246
  reply(msg.id, { ok: true });
2127
2247
  return;
2128
2248
  }
2249
+ case 'focusWindow': {
2250
+ const viewId = msg.viewId;
2251
+ if (viewId === undefined)
2252
+ throw new Error('focusWindow missing viewId');
2253
+ if (!views.has(viewId))
2254
+ throw new Error(`focusWindow: unknown view ${viewId}`);
2255
+ const win = ensureWindow();
2256
+ // The same three steps capture uses: a minimized window has to be
2257
+ // restored, and a window behind another one only comes forward on focus.
2258
+ try {
2259
+ if (!win.isVisible())
2260
+ win.show();
2261
+ }
2262
+ catch { /* closing */ }
2263
+ try {
2264
+ win.restore();
2265
+ }
2266
+ catch { /* not minimized */ }
2267
+ try {
2268
+ win.focus();
2269
+ }
2270
+ catch { /* closing */ }
2271
+ reply(msg.id, { ok: true });
2272
+ return;
2273
+ }
2129
2274
  case 'label': {
2130
2275
  const viewId = msg.viewId;
2131
2276
  const label = msg.label;
@@ -2173,6 +2318,14 @@ async function handle(op, msg) {
2173
2318
  reply(msg.id, { ok: true, result: { task: task ?? null } });
2174
2319
  return;
2175
2320
  }
2321
+ case 'setTaskTodos': {
2322
+ const key = msg.key;
2323
+ if (typeof key !== 'string' || msg.todos === undefined)
2324
+ throw new Error('setTaskTodos missing key or todos');
2325
+ updateTaskTodos(key, msg.todos);
2326
+ reply(msg.id, { ok: true, result: {} });
2327
+ return;
2328
+ }
2176
2329
  case 'updateTask': {
2177
2330
  const key = msg.key;
2178
2331
  if (typeof key !== 'string' || msg.task === undefined)
@@ -2198,6 +2351,31 @@ async function handle(op, msg) {
2198
2351
  reply(msg.id, { ok: true, result });
2199
2352
  return;
2200
2353
  }
2354
+ case 'chromeWorldEval': {
2355
+ // Run a snippet in the world the *page's* injected chrome lives in.
2356
+ //
2357
+ // In isolated mode the chrome's globals are not reachable from the page
2358
+ // world, and creating a world with the same name from outside yields a
2359
+ // different, empty context — so the host, which owns the real one, has to
2360
+ // resolve it here. In main mode this is a plain page-world evaluate.
2361
+ const viewId = msg.viewId;
2362
+ if (viewId === undefined)
2363
+ throw new Error('chromeWorldEval missing viewId');
2364
+ const entry = views.get(viewId);
2365
+ if (entry === undefined)
2366
+ throw new Error(`chromeWorldEval: unknown view ${viewId}`);
2367
+ if (typeof msg.expression !== 'string')
2368
+ throw new Error('chromeWorldEval missing expression');
2369
+ const contextId = CHROME_WORLD === 'isolated' ? await ensureChromeContext(entry.webContentsView) : undefined;
2370
+ const result = await entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
2371
+ expression: msg.expression,
2372
+ returnByValue: true,
2373
+ awaitPromise: true,
2374
+ ...contextId === undefined ? {} : { contextId },
2375
+ });
2376
+ reply(msg.id, { ok: true, result });
2377
+ return;
2378
+ }
2201
2379
  case 'capture': {
2202
2380
  const viewId = msg.viewId;
2203
2381
  if (viewId === undefined)
@@ -2289,6 +2467,25 @@ async function handle(op, msg) {
2289
2467
  reply(msg.id, { ok: true, result: { base64, width: 0, height: 0 } });
2290
2468
  return;
2291
2469
  }
2470
+ case 'printToPdf': {
2471
+ const viewId = msg.viewId;
2472
+ if (viewId === undefined)
2473
+ throw new Error('printToPdf missing viewId');
2474
+ const entry = views.get(viewId);
2475
+ if (entry === undefined)
2476
+ throw new Error(`printToPdf: unknown view ${viewId}`);
2477
+ // Electron's debugger does not implement CDP `Page.printToPDF`; this
2478
+ // native path is the one that actually produces a document.
2479
+ const options = (msg.options ?? {});
2480
+ // `printToPDF` and its option type are Electron's; Electron is an optional
2481
+ // dependency (CI installs with --omit=optional, so neither the type nor the
2482
+ // `Electron` namespace exists there). Name the shape locally instead of
2483
+ // reaching for the namespace, and let this child — which only ever runs
2484
+ // inside Electron — be the one that calls it.
2485
+ const data = await entry.webContentsView.webContents.printToPDF(options);
2486
+ reply(msg.id, { ok: true, result: { base64: data.toString('base64') } });
2487
+ return;
2488
+ }
2292
2489
  case 'download': {
2293
2490
  const viewId = msg.viewId;
2294
2491
  const url = msg.url;