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,2269 @@
1
+ /**
2
+ * Human-facing browser chrome injected into the top-level page document.
3
+ * Keeping this UI in the page renderer avoids a second Electron
4
+ * WebContentsView and leaves the host composition tree stable.
5
+ */
6
+ export const PAGE_CHROME_HOST_ID = '__dsh_browser_chrome_host__';
7
+ export const PAGE_CHROME_ATTRIBUTE = 'data-dsh-browser-chrome';
8
+ /** Generated once; stable across documents and reused by the provider. */
9
+ export const PAGE_CHROME_SCRIPT = buildPageChromeScript();
10
+ /** Convert human address-bar text into an allowed HTTP(S) navigation target. */
11
+ export function normalizeBrowserAddress(raw) {
12
+ const value = raw.trim();
13
+ if (value === '')
14
+ return '';
15
+ if (/^https?:\/\//i.test(value))
16
+ return value;
17
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value))
18
+ return '';
19
+ if (value.includes(' ') || !value.includes('.')) {
20
+ return 'https://www.bing.com/search?q=' + encodeURIComponent(value);
21
+ }
22
+ return 'https://' + value;
23
+ }
24
+ export function buildPageChromeScript(bindingToken = '', surface = 'full') {
25
+ const normalizer = normalizeBrowserAddress.toString();
26
+ // 样式与标记分开:样式走**构造样式表**(见 mount),标记里一个 <style> 都不放。
27
+ // 页面的 CSP 只要写了 style-src 又没有 unsafe-inline,<style> 会被整块拦掉。
28
+ const toolbarCss = [
29
+ '#toolbarRevealZone { position:absolute; top:0; left:50%; transform:translateX(-50%); width:280px; height:56px; pointer-events:auto; }',
30
+ '#toolbarReveal { position:absolute; top:-30px; left:50%; transform:translateX(-50%) scale(.84); box-sizing:border-box; width:28px; height:28px; padding:0; border:1px solid rgba(159,199,255,.34); border-radius:50%; background:rgba(24,43,69,.78); backdrop-filter:blur(16px) saturate(140%); box-shadow:0 8px 18px rgba(5,13,28,.24),inset 0 1px 0 rgba(255,255,255,.12); color:#e6f0ff; opacity:0; pointer-events:none; transition:top .20s ease,opacity .16s ease,transform .20s ease,background .16s ease; }',
31
+ '#toolbarRevealZone.armed #toolbarReveal, #toolbarRevealZone:hover #toolbarReveal, #toolbarReveal:focus-visible { top:5px; transform:translateX(-50%) scale(1); opacity:1; pointer-events:auto; }',
32
+ '#toolbarReveal:hover { background:rgba(48,82,129,.90); }',
33
+ '#bar { position:absolute; top:0; left:50%; transform:translateX(-50%) translateY(calc(-100% - 8px)); box-sizing:border-box; width:min(940px,calc(100vw - 24px)); min-height:42px; display:flex; align-items:center; gap:6px; padding:6px 8px; border:1px solid rgba(255,255,255,.20); border-radius:0 0 18px 18px; background:rgba(20,28,40,.56); box-shadow:0 18px 42px rgba(8,15,29,.30),0 4px 12px rgba(65,116,181,.18),inset 0 1px 0 rgba(255,255,255,.12); color:#f0f5ff; font:13px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; opacity:0; pointer-events:none; transition:transform .24s cubic-bezier(.2,.8,.2,1),opacity .16s ease; }',
34
+ '#bar.open { transform:translateX(-50%) translateY(8px); opacity:1; pointer-events:auto; }',
35
+ 'button { width:30px; height:28px; border:0; border-radius:8px; background:transparent; color:inherit; cursor:pointer; font:18px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; transition:background .18s ease,transform .18s ease,opacity .18s ease,color .18s ease; }',
36
+ 'button:hover { background:rgba(255,255,255,.13); }',
37
+ 'button:active { transform:scale(.94); }',
38
+ 'button:focus-visible { outline:2px solid rgba(132,181,255,.88); outline-offset:2px; }',
39
+ 'button:disabled { opacity:.42; cursor:default; }',
40
+ 'button:disabled:hover { background:transparent; }',
41
+ '#bar button svg { display:block; margin:auto; }',
42
+ '#bar button.spinning { animation:dshSpin .5s linear; }',
43
+ '@keyframes dshSpin { to { transform:rotate(360deg); } }',
44
+ '#address { box-sizing:border-box; flex:1; min-width:80px; height:28px; padding:0 11px; border:1px solid rgba(255,255,255,.16); border-radius:14px; outline:none; background:rgba(5,12,24,.24); color:inherit; font:inherit; }',
45
+ '#address:focus { border-color:#83b7ff; box-shadow:0 0 0 2px rgba(131,183,255,.24); }',
46
+ '/* 二级菜单(收藏/操作轨迹/任务)统一按 Chrome 的样子做:贴着触发按钮、',
47
+ ' 从按钮下方展开;left/top/max-height 在打开时由 anchorPopup() 内联算出来。 */',
48
+ '#panel { position:absolute; top:92px; left:12px; box-sizing:border-box; width:340px; max-height:min(70vh,420px); overflow:auto; display:none; border:1px solid #3c4043; border-radius:8px; background:#292a2d; box-shadow:0 1px 3px rgba(0,0,0,.40),0 10px 28px rgba(0,0,0,.45); padding:8px; color:#e8eaed; font:13px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; pointer-events:auto; }',
49
+ '#panel.open { display:block; }',
50
+ '#reveal { position:fixed; inset:0; background:#202124; opacity:0; pointer-events:none; }',
51
+ '#panel .save { width:auto; padding:0 12px; height:30px; border:1px solid rgba(160,200,255,.28); border-radius:8px; background:rgba(99,157,236,.16); color:#dceaff; font:13px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; cursor:pointer; }',
52
+ '#panel .item { display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:8px; cursor:pointer; transition:background .12s ease; }',
53
+ '#panel .item:hover { background:rgba(255,255,255,.09); }',
54
+ '#panel .item .t { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
55
+ '#panel .item .u { color:#a8c9ff; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:180px; }',
56
+ '#panel .item .x { width:24px; height:24px; font:14px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
57
+ '.empty { padding:12px; color:#aeb9ca; font:13px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
58
+ '.glass-panel { position:absolute; top:92px; left:12px; display:none; flex-direction:column; border:1px solid #3c4043; border-radius:8px; background:#292a2d; box-shadow:0 1px 3px rgba(0,0,0,.40),0 10px 28px rgba(0,0,0,.45); overflow:hidden; color:#e8eaed; font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; pointer-events:auto; }',
59
+ '#trail { width:360px; }',
60
+ '#trail.open, #taskPanel.open { display:flex; }',
61
+ '#taskPanel { width:380px; }',
62
+ '#taskPanel .task-head, #trail .t-head { display:flex; align-items:center; justify-content:space-between; padding:11px 13px; border-bottom:1px solid rgba(255,255,255,.12); color:#f2f6ff; font:600 13px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; flex:none; }',
63
+ '#bar #taskControl { flex:none; box-sizing:border-box; width:auto; min-width:64px; height:28px; padding:0 10px; border:1px solid rgba(151,192,255,.34); border-radius:8px; background:rgba(40,77,126,.80); box-shadow:inset 0 1px 0 rgba(255,255,255,.12); color:#e7f1ff; cursor:pointer; font:600 12px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
64
+ '#bar #taskControl:hover { background:rgba(60,104,166,.88); }',
65
+ '#bar #taskControl[data-control="human"] { border-color:rgba(237,183,98,.48); background:rgba(137,88,27,.88); color:#fff0c9; }',
66
+ '#bar #taskControl[data-control="human"]:hover { background:rgba(170,110,34,.92); }',
67
+ '#bar #taskControl:disabled { opacity:.5; cursor:default; }',
68
+ '#taskPanel .task-close, #trail .t-close { width:24px; height:24px; border:0; border-radius:7px; background:transparent; color:#b7c4d5; cursor:pointer; font:15px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
69
+ '#taskPanel .task-close:hover, #trail .t-close:hover { background:rgba(255,255,255,.13); color:#fff; }',
70
+ '#taskList { flex:1; overflow-y:auto; padding:8px; }',
71
+ '#taskPanel .task-row { box-sizing:border-box; width:100%; min-height:80px; display:grid; grid-template-columns:94px minmax(0,1fr); grid-template-rows:auto auto auto auto; column-gap:10px; row-gap:3px; align-items:center; padding:8px; border:1px solid transparent; border-radius:14px; background:transparent; color:#e5edf9; text-align:left; font:13px/1.35 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
72
+ // 状态圆点和「当前任务」那圈高亮原来都是硬切(圆点换色、边框与内阴影凭空出现)。
73
+ '#taskPanel .task-row { transition:background .18s ease,border-color .18s ease,box-shadow .18s ease; }',
74
+ '#taskPanel .task-row:hover { background:rgba(255,255,255,.07); }',
75
+ '#taskPanel .task-row:disabled { cursor:default; opacity:.48; }',
76
+ '#taskPanel .task-row:disabled:hover { background:transparent; }',
77
+ '#taskPanel .task-row.active { border-color:rgba(124,178,255,.62); background:rgba(85,143,224,.13); box-shadow:inset 0 1px 0 rgba(255,255,255,.06); }',
78
+ '#taskPanel .task-thumb { grid-row:1 / 5; width:94px; height:64px; display:grid; place-items:center; overflow:hidden; border-radius:14px; background:rgba(90,116,151,.30); color:#c9dcf8; font:600 11px -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; letter-spacing:.04em; }',
79
+ '#taskPanel .task-thumb canvas.task-thumb-canvas { display:block; width:100%; height:100%; }',
80
+ '#taskPanel .task-thumb > * { grid-area:1 / 1; }',
81
+ '#taskPanel .task-title { display:flex; align-items:center; gap:7px; min-width:0; color:#f3f7ff; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
82
+ '#taskPanel .task-state { width:7px; height:7px; flex:none; border-radius:50%; background:#8792a1; transition:background .18s ease; }',
83
+ '#taskPanel .task-row.active .task-state { background:#77d59a; }',
84
+ '#taskPanel .task-row.status-running .task-state { background:#71b8ff; animation:dshPulse 1.1s ease-in-out infinite; }',
85
+ '#taskPanel .task-row.status-waiting-user .task-state { background:#f0bd69; }',
86
+ '#taskPanel .task-row.status-failed .task-state { background:#f18a8a; }',
87
+ '@keyframes dshPulse { 50% { opacity:.42; } }',
88
+ '#taskPanel .task-meta, #taskPanel .task-latest, #taskPanel .task-error { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; }',
89
+ '#taskPanel .task-meta { color:#b8c5d6; }',
90
+ '#taskPanel .task-latest { color:#acd0ff; }',
91
+ '#taskPanel .task-error { color:#ffb1b1; }',
92
+ // ── 悬浮球:Agent 现在在做什么 ─────────────────────────────────────────────
93
+ // 宿主整层是 pointer-events:none,交互元素各自开回来 —— 漏了这一句球就只是画在那里:
94
+ // 拖不动、悬停也不出卡片(用户实测报「拖不动」就是它)。
95
+ '#orb { pointer-events:auto; position:fixed; right:18px; bottom:18px; width:44px; height:44px; z-index:2147483000; display:grid; place-items:center; box-sizing:border-box; border:1px solid rgba(255,255,255,.14); border-radius:50%; background:#292a2d; color:#e8eaed; cursor:pointer; box-shadow:0 1px 2px rgba(0,0,0,.5),0 8px 20px rgba(0,0,0,.34); opacity:.62; transition:opacity .18s ease,background .18s ease,border-color .18s ease; }',
96
+ '#orb:hover, #orb:focus-visible, #orb.dragging { opacity:1; background:#35363a; border-color:rgba(255,255,255,.24); }',
97
+ '#orb .orb-mark { width:21px; height:21px; opacity:.88; }',
98
+ '#orb .orb-ring { position:absolute; inset:-3px; border-radius:50%; border:2px solid transparent; border-top-color:#8ab4f8; border-right-color:#8ab4f8; opacity:0; transition:opacity .18s ease; animation:dshOrbSpin .85s linear infinite; }',
99
+ '#orb.working .orb-ring { opacity:1; }',
100
+ '@keyframes dshOrbSpin { to { transform:rotate(360deg); } }',
101
+ '#orb .orb-badge { position:absolute; right:-3px; bottom:-3px; box-sizing:border-box; min-width:17px; height:17px; padding:0 4px; border-radius:9px; background:#5f6368; color:#fff; font:600 10px/17px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; text-align:center; box-shadow:0 0 0 2px #202124; }',
102
+ '#orb .orb-badge:empty { display:none; }',
103
+ '#orb.done .orb-badge { background:#34a853; }',
104
+ '#orb.failed .orb-badge { background:#d93025; }',
105
+ '#orb.waiting .orb-badge { background:#f9ab00; color:#202124; }',
106
+ '#orbCard { pointer-events:auto; position:fixed; right:18px; bottom:74px; width:308px; max-height:342px; z-index:2147483000; display:none; flex-direction:column; overflow:hidden; border:1px solid #3c4043; border-radius:10px; background:#292a2d; color:#e8eaed; box-shadow:0 8px 24px rgba(0,0,0,.5); font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; }',
107
+ '#orbCard.open { display:flex; }',
108
+ '#orbCard .orb-head { flex:none; display:flex; align-items:baseline; justify-content:space-between; gap:8px; padding:9px 12px; border-bottom:1px solid #3c4043; font-weight:500; }',
109
+ '#orbCard .orb-task { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#9aa0a6; font-weight:400; font-size:11px; }',
110
+ '#orbCard .orb-list { overflow:auto; padding:6px 0; }',
111
+ '#orbCard .orb-item { display:grid; grid-template-columns:16px minmax(0,1fr); column-gap:8px; align-items:start; padding:5px 12px; }',
112
+ '#orbCard .orb-mark { display:grid; place-items:center; width:16px; height:16px; color:#9aa0a6; font-size:11px; }',
113
+ '#orbCard .orb-item.in_progress .orb-mark { color:#8ab4f8; }',
114
+ '#orbCard .orb-item.completed .orb-mark { color:#81c995; }',
115
+ '#orbCard .orb-item.completed .orb-text { color:#9aa0a6; }',
116
+ '#orbCard .orb-text { min-width:0; overflow-wrap:anywhere; }',
117
+ '#orbCard .orb-spin { width:10px; height:10px; border:1.5px solid transparent; border-top-color:#8ab4f8; border-right-color:#8ab4f8; border-radius:50%; animation:dshOrbSpin .9s linear infinite; }',
118
+ '#orbCard .orb-foot { flex:none; padding:8px 12px; border-top:1px solid #3c4043; color:#8ab4f8; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
119
+ '#orbCard .orb-empty { padding:10px 12px; color:#9aa0a6; }',
120
+ '#trail #trailList { flex:1; overflow-y:auto; padding:8px 12px; }',
121
+ '#trail .activity-day { color:#accbff; font:11px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; text-align:center; margin:6px 0 5px; }',
122
+ '#trail .activity-item { transition:background .12s ease; position:relative; display:grid; grid-template-columns:54px 16px minmax(0,1fr); column-gap:7px; align-items:start; padding:8px 0; }',
123
+ '#trail .activity-item:hover { background:rgba(255,255,255,.045); }',
124
+ '#trail .timeline-rail { position:relative; width:16px; min-height:30px; }',
125
+ '#trail .timeline-rail::before { content:""; position:absolute; top:4px; bottom:-14px; left:7px; width:1px; background:rgba(181,208,245,.28); }',
126
+ '#trail .activity-item:last-child .timeline-rail::before { display:none; }',
127
+ '#trail .t-time { color:#a8caff; font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; font-variant-numeric:tabular-nums; }',
128
+ '#trail .t-flag { position:relative; z-index:1; display:grid; place-items:center; width:14px; height:14px; border-radius:50%; background:rgba(78,111,154,.72); color:#f2f7ff; font-size:9px; line-height:1; }',
129
+ '#trail .t-flag.ok { color:#a6efbc; }',
130
+ '#trail .t-flag.err { color:#ffb3b3; }',
131
+ '#trail .t-text { color:#e3ebf8; word-break:break-word; font:12px/1.45 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
132
+ '@media (hover:none) { #toolbarReveal { top:5px; transform:translateX(-50%) scale(1); opacity:1; pointer-events:auto; } }',
133
+ '/* 窄屏:二级菜单的宽度由 anchorPopup() 按视口算,这里只兜一个上限。 */',
134
+ '@media (max-width:760px) { #panel, .glass-panel, #mainMenu { max-width:calc(100vw - 16px); } }',
135
+ '/* --- Chrome 风格外壳:常驻顶栏 + 分组 + 胶囊地址栏 --- */',
136
+ '#bar { width:min(1180px,calc(100vw - 20px)); height:46px; padding:0 8px; gap:2px; align-items:center; border-radius:12px; border:1px solid rgba(255,255,255,.13); background:rgba(24,30,40,.94); box-shadow:0 10px 34px rgba(0,0,0,.46); backdrop-filter:blur(18px); }',
137
+ '#bar.open { transform:translateX(-50%) translateY(6px); }',
138
+ '#bar .nav-group, #bar .right-group { display:flex; align-items:center; gap:2px; flex:none; }',
139
+ '#bar .sep { flex:none; width:1px; height:20px; margin:0 6px; background:rgba(255,255,255,.15); }',
140
+ '#bar button { width:32px; height:32px; border-radius:50%; }',
141
+ '#addressWrap { flex:1; min-width:120px; display:flex; align-items:center; gap:8px; box-sizing:border-box; height:32px; padding:0 12px; border-radius:16px; background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.10); }',
142
+ '#addressWrap:hover { background:rgba(255,255,255,.10); }',
143
+ '#addressWrap:focus-within { background:rgba(12,17,25,.86); border-color:#7cb0ff; box-shadow:0 0 0 2px rgba(124,176,255,.22); }',
144
+ '#secIcon { flex:none; width:14px; height:14px; color:#9db2cc; }',
145
+ '/* 图标换面时用 scale 弹一下:SVG 默认以 0,0 为原点,会把图标甩出去。 */',
146
+ '#secIcon, #bookmarks svg { transform-box:fill-box; transform-origin:center; }',
147
+ '#secIcon .info { display:none; }',
148
+ '#secIcon.insecure { color:#f0bd69; }',
149
+ '#secIcon.insecure .lock { display:none; }',
150
+ '#secIcon.insecure .info { display:block; }',
151
+ '#address { flex:1; min-width:0; height:auto; padding:0; border:0; background:transparent; font-size:13px; }',
152
+ '#address:focus { border:0; box-shadow:none; }',
153
+ '#bar #taskControl { width:auto; min-width:62px; height:32px; border-radius:16px; padding:0 12px; }',
154
+ '/* --- Chrome 风格标签栏:常驻在工具栏上方(Chrome 也是上标签、下工具栏) --- */',
155
+ '#tabstrip { position:absolute; top:0; left:50%; transform:translateX(-50%); box-sizing:border-box; width:min(1180px,calc(100vw - 20px)); height:34px; display:none; align-items:flex-end; gap:4px; padding:0 4px; overflow:hidden; pointer-events:auto; }',
156
+ '#tabstrip.open { display:flex; }',
157
+ '#tabstrip .tab { box-sizing:border-box; display:flex; align-items:center; min-width:44px; max-width:230px; height:30px; padding:0 12px; border:0; border-radius:10px; background:rgba(255,255,255,.05); color:#c2cfdf; cursor:pointer; font:12px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; transition:background .15s ease,color .15s ease; }',
158
+ '#tabstrip .tab:hover { background:rgba(255,255,255,.11); color:#e9f1fb; }',
159
+ '#tabstrip .tab.active { background:rgba(255,255,255,.17); color:#fff; }',
160
+ '#tabstrip .tab .t { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
161
+ '/* 关闭按钮:Chrome 只在悬停/选中时显示,避免一排标签都是叉号。 */',
162
+ '#tabstrip .tab .close { flex:none; box-sizing:border-box; width:18px; height:18px; margin-left:6px; display:grid; place-items:center; border-radius:50%; color:#9aa0a6; font:12px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; opacity:0; cursor:pointer; transition:opacity .12s ease,background .12s ease,color .12s ease; }',
163
+ '#tabstrip .tab:hover .close, #tabstrip .tab.active .close { opacity:1; }',
164
+ '#tabstrip .tab .close:hover { background:rgba(255,255,255,.16); color:#fff; }',
165
+ '/* 新标签页按钮:紧跟最后一个标签,Chrome 的 + 也在这里。 */',
166
+ '#tabstrip .newtab { flex:none; box-sizing:border-box; width:28px; height:28px; margin:0 0 3px 4px; display:grid; place-items:center; border:0; border-radius:50%; background:transparent; color:#9aa0a6; cursor:pointer; transition:background .15s ease,color .15s ease; }',
167
+ '#tabstrip .newtab:hover { background:rgba(255,255,255,.13); color:#e8eaed; }',
168
+ '#tabstrip .newtab:active { transform:scale(.94); }',
169
+ '#tabstrip .newtab:focus-visible { outline:2px solid rgba(138,180,248,.9); outline-offset:1px; }',
170
+ '/* 标签必须自带不透明底色:整条 chrome 是浮在页面之上的,只给 5% 白的话,',
171
+ ' 在白色页面上标签会完全看不见(实测 example.com / iana.org 就是这样)。 */',
172
+ '/* flex-basis 200px:只给 min/max-width 时按钮会被压到 44px 最小宽度,',
173
+ ' 标题只剩 "E…",多个标签根本分不清。给一个可收缩的基准宽度。 */',
174
+ '#tabstrip .tab { flex:0 1 200px; background:rgba(24,30,40,.94); border:1px solid rgba(255,255,255,.10); box-shadow:0 4px 14px rgba(0,0,0,.32); }',
175
+ '#tabstrip .tab:hover { background:rgba(38,48,62,.96); }',
176
+ '#tabstrip .tab.active { background:rgba(58,72,92,.97); border-color:rgba(255,255,255,.20); }',
177
+ '/* 标签栏占位:工具栏展开时整体下移,收起时标签栏与工具栏一起滑出。 */',
178
+ '#bar.open { transform:translateX(-50%) translateY(42px); }',
179
+ '@media (max-width:760px) { #tabstrip { width:calc(100vw - 16px); } }',
180
+ '/* --- Chrome 式窗框:整条 chrome 贴满窗口宽度、上下两行齐平、不浮动 --- */',
181
+ '/* 之前是「居中的浮动小部件」(1180px 宽 + 圆角 + 阴影 + 两行之间留缝),',
182
+ ' 那是它看着不像 Chrome 的根本原因。Chrome 的窗框是贴边的、齐平的。 */',
183
+ '/* 取消抽屉:顶栏常驻,不再有悬停热区与「收起」按钮。 */',
184
+ '#toolbarRevealZone, #toolbarHide { display:none !important; }',
185
+ '#tabstrip { left:0; right:0; width:auto; transform:none; height:40px; padding:0 8px; gap:2px; background:#202124; }',
186
+ '#bar { left:0; right:0; width:auto; transform:translateY(40px); height:44px; padding:0 8px; gap:6px; border:0; border-radius:0; background:#35363a; box-shadow:none; backdrop-filter:none; }',
187
+ '#bar.open { transform:translateX(0) translateY(40px); }',
188
+ '/* 拖动排序:跟手 + 让位动画。 */',
189
+ '#tabstrip .tab.dragging { z-index:3; opacity:.85; box-shadow:0 6px 16px rgba(0,0,0,.45); transition:none; }',
190
+ '#tabstrip .tab { transition:transform .12s ease; flex:0 1 240px; min-width:60px; height:34px; padding:0 10px; border:0; border-radius:8px 8px 0 0; background:transparent; color:#9aa0a6; box-shadow:none; }',
191
+ '#tabstrip .tab:hover { background:rgba(255,255,255,.06); color:#e8eaed; }',
192
+ '#tabstrip .tab.active:hover { background:#3c4043; }',
193
+ '#tabstrip .tab.active { background:#35363a; color:#e8eaed; }',
194
+ '/* 标签图标:16px 的圆角方块,没图标时退化成站点首字母(Chrome 也是这个回退)。 */',
195
+ '#tabstrip .tab .fav { flex:none; box-sizing:border-box; width:16px; height:16px; margin-right:8px; display:grid; place-items:center; overflow:hidden; border-radius:4px; color:#c3ccd9; font:600 9px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; }',
196
+ '#tabstrip .tab .fav canvas { display:block; width:16px; height:16px; }',
197
+ '#tabstrip .tab.active .fav { color:#e8eaed; }',
198
+ '/* --- 加载状态:标签里转圈、工具栏的刷新变停止(Chrome 就是这一个按钮来回换)--- */',
199
+ '#bar #stop { display:none; }',
200
+ '#bar.loading #reload { display:none; }',
201
+ '#bar.loading #stop { display:block; }',
202
+ '#tabstrip .tab .fav.loading { border:2px solid rgba(154,160,166,.32); border-top-color:#8ab4f8; border-radius:50%; animation:dshSpin .8s linear infinite; }',
203
+ '/* --- 无边框窗口:标签栏就是窗口的第一行 --- */',
204
+ '/* 宿主用 titleBarStyle:hidden + titleBarOverlay 去掉了系统标题栏,把最小化/',
205
+ ' 最大化/关闭画在内容区右上角。所以标签栏右侧必须留出它们的位置,并且整条',
206
+ ' 标签栏要能拖动窗口(标签和 + 自己排除掉,否则点不动)。 */',
207
+ '#tabstrip { padding-right:148px; -webkit-app-region:drag; }',
208
+ '#tabstrip .tab, #tabstrip .newtab { -webkit-app-region:no-drag; }',
209
+ '/* macOS 的红绿灯在左边。 */',
210
+ ':host([data-dsh-caption="left"]) #tabstrip { padding-left:82px; padding-right:8px; }',
211
+ '#addressWrap { height:30px; border-radius:18px; background:#202124; border:1px solid #5f6368; }',
212
+ '#address::placeholder { color:#9aa0a6; }',
213
+ '#addressWrap:focus-within { background:#202124; border-color:#8ab4f8; box-shadow:0 0 0 1px #8ab4f8; }',
214
+ '#address { color:#e8eaed; }',
215
+ '/* --- Chrome 式二级菜单:不透明深色面板 + 8px 圆角 + 34px 菜单行 --- */',
216
+ '/* 之前是「毛玻璃浮层」(半透明 + 24/30px 模糊 + 16-20px 大圆角)。Chrome 的菜单',
217
+ ' 是不透明的、圆角更小、行高固定的面板,这一条是用户点名要的「二级菜单风格」。 */',
218
+ '#panel, .glass-panel, #mainMenu { border:1px solid #3c4043; border-radius:8px; background:#292a2d; backdrop-filter:none; box-shadow:0 1px 3px rgba(0,0,0,.40),0 10px 28px rgba(0,0,0,.45); color:#e8eaed; }',
219
+ '#panel { padding:8px; }',
220
+ '/* ⋮ 主菜单:Chrome 的「更多」菜单,行高 34px、分组之间一条分隔线。 */',
221
+ '#mainMenu { position:absolute; top:92px; left:12px; box-sizing:border-box; width:264px; padding:6px 0; display:none; flex-direction:column; pointer-events:auto; }',
222
+ '#mainMenu.open { display:flex; }',
223
+ '#mmZoom { display:flex; align-items:center; justify-content:space-between; gap:4px; height:34px; padding:0 8px 0 16px; }',
224
+ '#mmZoom button { flex:none; display:flex; align-items:center; justify-content:center; width:28px; height:28px; border:0; border-radius:50%; background:transparent; color:#e8eaed; font:inherit; font-size:16px; line-height:1; cursor:pointer; }',
225
+ '#mmZoom #zoomLevel { width:auto; min-width:56px; border-radius:14px; font-size:12px; color:#9aa0a6; }',
226
+ '#mmZoom button:hover { background:#3c4043; }',
227
+ '.menu-item { box-sizing:border-box; width:100%; height:34px; display:flex; align-items:center; gap:14px; padding:0 16px; border:0; border-radius:4px; background:transparent; color:#e8eaed; font:13px/1 -apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif; text-align:left; cursor:pointer; }',
228
+ '.menu-item:hover { background:#3c4043; }',
229
+ '.menu-item:active { background:#4a4e54; }',
230
+ '.menu-item[disabled] { opacity:.5; cursor:default; }',
231
+ '.menu-item[disabled]:hover { background:transparent; }',
232
+ '.menu-item .mi { flex:none; width:16px; height:16px; color:#9aa0a6; }',
233
+ '.menu-sep { flex:none; height:1px; margin:6px 0; background:#3c4043; }',
234
+ '/* 收藏列表行用同一套菜单行度量。 */',
235
+ '#panel .item { min-height:34px; padding:0 8px; border-radius:4px; }',
236
+ '#panel .item:hover { background:#3c4043; }',
237
+ '#panel .item .u { color:#9aa0a6; }',
238
+ '#panel .save { height:34px; border:0; border-radius:4px; background:#8ab4f8; color:#202124; font-weight:500; }',
239
+ '#panel .save:hover { background:#aecbfa; }',
240
+ '.empty { color:#9aa0a6; }',
241
+ '#taskPanel .task-head, #trail .t-head { padding:10px 14px; border-bottom:1px solid #3c4043; color:#e8eaed; font-weight:500; }',
242
+ '#trail .activity-day { color:#9aa0a6; }',
243
+ '#trail .t-time { color:#9aa0a6; }',
244
+ '#trail .t-flag { background:#3c4043; }',
245
+ '#trail .timeline-rail::before { background:#3c4043; }',
246
+ '#taskPanel .task-row:hover { background:#3c4043; }',
247
+ '#taskPanel .task-row.active { border-color:#8ab4f8; background:rgba(138,180,248,.14); }',
248
+ '#taskPanel .task-thumb { border-radius:6px; background:#3c4043; color:#9aa0a6; }',
249
+ '#taskPanel .task-meta { color:#9aa0a6; }',
250
+ '#taskPanel .task-latest { color:#8ab4f8; }',
251
+ '/* ⋮ 按钮本身:Chrome 的更多按钮是一个竖排三点图标。 */',
252
+ '#mainMenuBtn { flex:none; }',
253
+ '/* 查找栏:贴内容区右上角,和菜单同一套表面(不透明 #292a2d + 8px 圆角)。 */',
254
+ '/* 书签栏:贴在工具栏正下方(84px 处),和工具栏同色。 */',
255
+ '#bookmarkBar { position:fixed; top:84px; left:0; right:0; box-sizing:border-box; height:34px; display:none; align-items:center; gap:2px; padding:0 8px; background:#292a2d; border-bottom:1px solid #3c4043; overflow:hidden; pointer-events:auto; }',
256
+ '#bookmarkBar.open { display:block; white-space:nowrap; }',
257
+ '#bookmarkBar.open.animate { animation:dshBarIn .16s cubic-bezier(.2,.8,.2,1); }',
258
+ '#bookmarkBar .bm { box-sizing:border-box; display:inline-block; vertical-align:middle; width:max-content; max-width:180px; height:28px; line-height:28px; padding:0 8px; border:0; border-radius:4px; background:transparent; color:#e8eaed; font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; cursor:pointer; white-space:nowrap; overflow:hidden; }',
259
+ '#bookmarkBar .bm:hover { background:#3c4043; }',
260
+ '#bookmarkBar .bm .bm-icon { display:inline-block; vertical-align:middle; width:16px; height:16px; margin-right:6px; }',
261
+ '#bookmarkBar .bm svg { width:16px; height:16px; }',
262
+ '#toast { position:fixed; top:12px; left:50%; transform:translateX(-50%); box-sizing:border-box; max-width:min(520px,60vw); padding:9px 14px; display:none; border:1px solid #3c4043; border-radius:8px; background:#292a2d; color:#e8eaed; font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; box-shadow:0 6px 20px rgba(0,0,0,.45); pointer-events:none; z-index:5; }',
263
+ '#toast.open { display:block; }',
264
+ '#toast.warn { border-color:#8a6d3b; color:#fdd663; }',
265
+ '#findBar { position:fixed; top:92px; right:16px; display:none; align-items:center; gap:6px; box-sizing:border-box; padding:6px 8px; border:1px solid #3c4043; border-radius:8px; background:#292a2d; color:#e8eaed; box-shadow:0 1px 3px rgba(0,0,0,.40),0 10px 28px rgba(0,0,0,.45); pointer-events:auto; }',
266
+ '#findBar.open { display:flex; }',
267
+ '#findInput { width:200px; height:26px; box-sizing:border-box; padding:0 8px; border:1px solid #5f6368; border-radius:4px; background:#202124; color:#e8eaed; font:inherit; font-size:13px; outline:none; }',
268
+ '#findInput:focus { border-color:#8ab4f8; }',
269
+ '#findCount { min-width:46px; text-align:right; font-size:12px; color:#9aa0a6; }',
270
+ '#findBar button { width:26px; height:26px; border:0; border-radius:4px; background:transparent; color:#e8eaed; cursor:pointer; font:inherit; font-size:11px; line-height:1; }',
271
+ '#findBar button:hover { background:#3c4043; }',
272
+ '/* --- 动效:Chrome 的界面几乎每个状态切换都有 ~120-160ms 的过渡,这里补齐。',
273
+ ' 一律只动 opacity/transform(合成器友好、不触发布局),并且尊重 prefers-reduced-motion。 */',
274
+ '@keyframes dshPopIn { from { opacity:0; transform:translateY(-6px) scale(.98) } to { opacity:1; transform:none } }',
275
+ '@keyframes dshDropIn { from { opacity:0; transform:translateX(-50%) translateY(-10px) } to { opacity:1; transform:translateX(-50%) } }',
276
+ '@keyframes dshToastOut { from { opacity:1; transform:translateX(-50%) } to { opacity:0; transform:translateX(-50%) translateY(-10px) } }',
277
+ '@keyframes dshBarIn { from { opacity:0; transform:translateY(-100%) } to { opacity:1; transform:none } }',
278
+ '/* 新标签从 0 宽长出来(Chrome 就是这样),所以标签自己必须裁掉内容。 */',
279
+ '@keyframes dshTabIn { from { opacity:0; min-width:0; max-width:0 } to { opacity:1; min-width:60px; max-width:230px } }',
280
+ '/* 二级菜单/查找栏:从按钮下方浮出来(display:none -> block 时动画会自己重播)。 */',
281
+ '#panel.open, .glass-panel.open, #mainMenu.open, #findBar.open { animation:dshPopIn .14s cubic-bezier(.2,.8,.2,1); }',
282
+ '#toast.open { animation:dshDropIn .16s cubic-bezier(.2,.8,.2,1); }',
283
+ '@keyframes dshPopOut { from { opacity:1; transform:none } to { opacity:0; transform:translateY(-4px) scale(.99) } }',
284
+ '/* 关闭:先播退场动画,150ms 后 JS 才摘 open(逻辑上已经关了,见 isPanelOpen)。 */',
285
+ '#panel.closing, .glass-panel.closing, #mainMenu.closing, #findBar.closing { animation:dshPopOut .13s cubic-bezier(.2,.8,.2,1) forwards; }',
286
+ '/* 收起时先淡出,再由 JS 摘掉 open 真正 display:none。 */',
287
+ '#toast.closing { animation:dshToastOut .16s cubic-bezier(.2,.8,.2,1) forwards; }',
288
+ '/* 新标签页从下方轻微浮入(只在「本文档第一次渲染之后新增」的标签上播,见 renderTabs)。 */',
289
+ '#tabstrip .tab.enter { animation:dshTabIn .16s cubic-bezier(.2,.8,.2,1); }',
290
+ '/* 地址栏聚焦/悬停:背景、描边、焦点环一起过渡,别硬切。 */',
291
+ '#addressWrap { transition:background .15s ease,border-color .15s ease,box-shadow .15s ease; }',
292
+ '#tabstrip .tab { transition:transform .16s cubic-bezier(.2,.8,.2,1),background .15s ease,color .15s ease; overflow:hidden; }',
293
+ '/* 系统开了「减少动态效果」就全部退化成瞬时(转圈除外,它表达的是状态)。 */',
294
+ '@media (prefers-reduced-motion:reduce) {',
295
+ ' #panel.open, .glass-panel.open, #mainMenu.open, #findBar.open, #toast.open, #tabstrip .tab.enter, #bookmarkBar.open, #toast.closing, #panel.closing, .glass-panel.closing, #mainMenu.closing, #findBar.closing { animation:none; }',
296
+ ' #addressWrap, #tabstrip .tab, button, #taskPanel .task-state { transition:none; }',
297
+ '}',
298
+ ].join('\n');
299
+ // Three surfaces share one stylesheet and one markup tree, separated by
300
+ // visibility: 'full' is today's layout (chrome injected in the page, kept as the
301
+ // revert switch), 'frame' is the host's own 84px view (tab strip + toolbar only)
302
+ // and 'page' is the copy that stays in the page (the popups). Popups cannot live
303
+ // in the frame: it is 84px tall and a menu would be clipped.
304
+ const chromeCssText = toolbarCss + '\n#toolbarRevealZone,#toolbarHide{display:none!important}'
305
+ // #bookmarkBar 现在由 frame 那份画:帧视图会跟着开关长到 118px(84 + 34),
306
+ // 书签栏正好落在 84..118。页面视图从 118 开始 —— 所以页面**一点都不用让位**,
307
+ // 站点的 position:fixed 头部也就永远不会被压住(这正是这一改动要修的)。
308
+ + (surface === 'frame' ? '\n#panel, .glass-panel, #mainMenu, #findBar, #toast, #orb, #orbCard { display:none !important }' : '')
309
+ + (surface === 'page'
310
+ // 书签栏由 frame 那份画,页面这份连标签栏一起藏掉,弹层挂在帧下方。
311
+ ? '\n#tabstrip, #bar, #bookmarkBar, #toolbarRevealZone, #toolbarHide { display:none !important }'
312
+ + '\n#panel, .glass-panel, #mainMenu, #findBar { top:8px !important }'
313
+ : '');
314
+ const toolbarHtml = [
315
+ '<div id="toolbarRevealZone"><button id="toolbarReveal" type="button" aria-label="展开工具栏" title="展开工具栏"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button></div>',
316
+ '<div id="tabstrip" role="tablist" aria-label="标签页"></div>',
317
+ '<div id="reveal" aria-hidden="true"></div>',
318
+ '<div id="bar" role="toolbar" aria-label="DSH browser controls">',
319
+ '<div class="nav-group">',
320
+ '<button id="back" type="button" aria-label="后退" title="后退"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>',
321
+ '<button id="forward" type="button" aria-label="前进" title="前进"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></button>',
322
+ '<button id="reload" type="button" aria-label="刷新" title="刷新"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>',
323
+ '<button id="stop" type="button" aria-label="停止加载" title="停止加载"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>',
324
+ '<button id="home" type="button" aria-label="主页" title="主页"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.75V21h14V9.75"/></svg></button>',
325
+ '<button id="bookmarks" type="button" aria-label="收藏" title="收藏"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l2.6 5.3 5.9.9-4.2 4.1 1 5.8-5.3-2.8-5.3 2.8 1-5.8-4.2-4.1 5.9-.9z"/></svg></button>',
326
+ '<button id="trailBtn" type="button" aria-label="操作轨迹" title="操作轨迹"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></button>',
327
+ '<button id="tasksBtn" type="button" aria-label="任务" title="任务"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg></button>',
328
+ '</div>',
329
+ '<div class="sep"></div>',
330
+ '<!-- 安全指示:Chrome 在 https 上给锁、在 http 上给「不安全」的提示图标,',
331
+ ' 永远挂一把锁会误导人。两套图形都在,用 class 切换。 -->',
332
+ '<div id="addressWrap"><svg id="secIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><g class="lock"><rect x="4" y="10.5" width="16" height="10" rx="2"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></g><g class="info"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><path d="M12 7.5v1"/></g></svg><input id="address" type="text" spellcheck="false" aria-label="地址栏" /></div>',
333
+ '<div class="sep"></div>',
334
+ '<div class="right-group">',
335
+ '<button id="taskControl" type="button" aria-label="接管当前任务" disabled>接管</button>',
336
+ '<button id="mainMenuBtn" type="button" aria-label="更多" title="更多"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg></button>',
337
+ '</div>',
338
+ '<button id="toolbarHide" type="button" aria-label="收起工具栏" title="收起工具栏"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 15 6-6 6 6"/></svg></button>',
339
+ '</div>',
340
+ '<!-- 书签栏:Chrome 工具栏下面那一条。默认关(⋮ → 显示书签栏)。 -->',
341
+ '<div id="bookmarkBar" role="toolbar" aria-label="书签栏"></div>',
342
+ '<!-- 查找栏:Chrome 把它放在内容区右上角、工具栏下方。 -->',
343
+ '<!-- 一次性提示条:导入结果之类的反馈(Chrome 没有这个,但没有它用户不知道成没成)。 -->',
344
+ '<div id="toast" role="status" aria-live="polite"></div>',
345
+ '<div id="findBar" role="search" aria-label="在页面中查找">',
346
+ '<input id="findInput" type="text" placeholder="在页面中查找" spellcheck="false" aria-label="在页面中查找" />',
347
+ '<span id="findCount"></span>',
348
+ '<button id="findPrev" type="button" title="上一个" aria-label="上一个">▲</button>',
349
+ '<button id="findNext" type="button" title="下一个" aria-label="下一个">▼</button>',
350
+ '<button id="findClose" type="button" title="关闭" aria-label="关闭">✕</button>',
351
+ '</div>',
352
+ '<div id="panel" role="menu" aria-label="收藏列表">',
353
+ '<button id="saveBookmark" class="save" type="button">★ 收藏当前页</button>',
354
+ '<div id="bookmarkList"></div>',
355
+ '</div>',
356
+ '<!-- Chrome 的「更多」菜单:这里的每一项都接到插件已有的能力上,不是新造的浏览器功能。 -->',
357
+ '<div id="mainMenu" role="menu" aria-label="更多功能">',
358
+ '<button id="mmNewTab" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg><span>新建标签页</span></button>',
359
+ '<button id="mmBookmark" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5l2.6 5.3 5.9.9-4.2 4.1 1 5.8-5.3-2.8-5.3 2.8 1-5.8-4.2-4.1 5.9-.9z"/></svg><span>收藏当前页</span></button>',
360
+ '<button id="mmFind" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6"/><path d="M15.5 15.5L20 20"/></svg><span>查找</span></button>',
361
+ '<button id="mmBookmarkBar" class="menu-item" type="button">显示书签栏</button>',
362
+ '<!-- 导入登录状态:读一个 cookie JSON(Cookie-Editor / EditThisCookie / 我们自己导出的),',
363
+ ' 交给宿主写进会话。文件用 <input type=file> 读,chrome 不需要任何磁盘权限。 -->',
364
+ '<button id="mmImportCookies" class="menu-item" type="button">导入登录状态…</button>',
365
+ '<button id="mmBookmarks" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-6-4-6 4z"/></svg><span>管理收藏</span></button>',
366
+ '<div class="menu-sep"></div>',
367
+ '<!-- 缩放:Chrome 的 ⋮ 菜单里就是 − / 百分比 / + 一行,点百分比回 100%。 -->',
368
+ '<div id="mmZoom" class="menu-zoom"><button id="zoomOut" type="button" aria-label="缩小" title="缩小">−</button><button id="zoomLevel" type="button" title="重置为 100%">100%</button><button id="zoomIn" type="button" aria-label="放大" title="放大">+</button></div>',
369
+ '<div class="menu-sep"></div>',
370
+ '<button id="mmTrail" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>操作轨迹</span></button>',
371
+ '<button id="mmTasks" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg><span>浏览器任务</span></button>',
372
+ '<div class="menu-sep"></div>',
373
+ '<button id="mmFullscreen" class="menu-item" type="button"><svg class="mi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"/></svg><span>全屏</span></button>',
374
+ '</div>',
375
+ '<div id="trail" class="glass-panel" role="menu" aria-label="操作轨迹">',
376
+ '<div class="t-head"><span>操作轨迹</span><button id="trailClose" class="t-close" type="button" aria-label="关闭轨迹">×</button></div>',
377
+ '<div id="trailList"></div>',
378
+ '</div>',
379
+ '<div id="taskPanel" class="glass-panel" role="menu" aria-label="浏览器任务">',
380
+ '<div class="task-head"><span>浏览器任务</span><button id="taskClose" class="task-close" type="button" aria-label="关闭任务">×</button></div>',
381
+ '<div id="taskList"></div>',
382
+ '</div>',
383
+ // 悬浮球(右下角):Agent 正在做什么。它不属于 menus —— 页面上的点击不该关掉它。
384
+ '<button id="orb" type="button" aria-label="Agent 任务"><span class="orb-ring"></span>'
385
+ + '<svg class="orb-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.25" y="4.75" width="17.5" height="14.5" rx="3.25"></rect><path d="M3.25 9.4h17.5"></path><circle cx="6.15" cy="7.05" r=".9" fill="currentColor" stroke="none"></circle><circle cx="8.75" cy="7.05" r=".9" fill="currentColor" stroke="none"></circle></svg>'
386
+ + '<span class="orb-badge"></span></button>',
387
+ '<div id="orbCard" role="menu" aria-label="Agent 任务"><div class="orb-head"><span>Agent 任务</span><span class="orb-task"></span></div><div class="orb-list"></div><div class="orb-foot"></div></div>',
388
+ ].join('\n');
389
+ return [
390
+ '(() => {',
391
+ ' if (window.top !== window) return',
392
+ '',
393
+ ' const hostId = ' + JSON.stringify(PAGE_CHROME_HOST_ID),
394
+ ' const chromeAttribute = ' + JSON.stringify(PAGE_CHROME_ATTRIBUTE),
395
+ ' const bindingToken = ' + JSON.stringify(bindingToken),
396
+ ' // Residual risk: a page that hooks JSON.stringify before this chrome is',
397
+ ' // injected can still capture the token on the next emit. The complete fix is',
398
+ ' // an isolated-world injection (CDP Runtime.evaluate with a worldName), which',
399
+ ' // keeps the token out of the page realm entirely. Follow-up work.',
400
+ ' const normalizeAddress = (' + normalizer + ')',
401
+ '',
402
+ ' const mount = () => {',
403
+ ' if (document.getElementById(hostId) || !document.documentElement) return',
404
+ " const host = document.createElement('div')",
405
+ ' host.id = hostId',
406
+ " host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none'",
407
+ " // frame 只有 84px 高,而书签栏之类的表面挂在它下面 —— 只要文档能滚,浏览器就会在右边画一条",
408
+ " // 滚动条(真机截图里那条带箭头的)。影子根里的 html { } 够不到外层文档,只能在 JS 里设。",
409
+ " // 版本标记:宿主元素在 light DOM 里,即使 chrome 用 closed shadow root,",
410
+ " // 外部也能读到它——用来判断页面里跑的是哪一版 chrome。",
411
+ " host.dataset.dshChrome = '3'",
412
+ " // 系统的最小化/最大化/关闭画在哪一侧:Windows/Linux 在右,macOS 在左。",
413
+ " // 标签栏要按这个留出位置(见 #tabstrip 的 padding)。",
414
+ " host.dataset.dshCaption = /Mac|iPhone|iPad/i.test(String(navigator.platform || '') + ' ' + String(navigator.userAgent || '')) ? 'left' : 'right'",
415
+ " const root = host.attachShadow({ mode: 'closed' })",
416
+ ' root.innerHTML = ' + JSON.stringify(toolbarHtml),
417
+ ' document.documentElement.append(host)',
418
+ " // 样式必须走**构造样式表**(CSSOM),不能放 <style> 元素:",
419
+ " // 页面只要下了 style-src 又没有 unsafe-inline,<style> 会被整块拦掉,",
420
+ " // chrome 就退化成没有样式的裸按钮(实测 style-src 'none':标签栏、工具栏、",
421
+ " // 配色、布局全没了)。CSSOM 不受 CSP 管辖。",
422
+ " // 取消抽屉:顶栏常驻,不再有悬停热区与「收起」按钮。",
423
+ " const CHROME_SURFACE = " + JSON.stringify(surface) + ";",
424
+ " // frame 只有 84px 高,而书签栏之类的表面挂在它下面 —— 只要文档能滚,浏览器就会在右边画一条",
425
+ " // 滚动条(真机截图里那条带箭头的)。影子根里的 html { } 够不到外层文档,只能在 JS 里设。",
426
+ " // ⚠️ 必须写在 CHROME_SURFACE 声明**之后**:写在前面会 TDZ 抛异常,把整个 mount 打挂(两个表面一起没)。",
427
+ " if (CHROME_SURFACE === 'frame') {",
428
+ " document.documentElement.style.overflow = 'hidden'",
429
+ " }",
430
+ " const chromeCss = " + JSON.stringify(chromeCssText) + ";",
431
+ " let chromeStyled = false",
432
+ " try {",
433
+ " const chromeSheet = new CSSStyleSheet()",
434
+ " chromeSheet.replaceSync(chromeCss)",
435
+ " root.adoptedStyleSheets = [chromeSheet]",
436
+ " chromeStyled = true",
437
+ " } catch { /* 老引擎没有构造样式表 */ }",
438
+ " if (!chromeStyled) { const fallbackStyle = document.createElement('style'); fallbackStyle.textContent = chromeCss; root.append(fallbackStyle) }",
439
+ " // 不遮挡内容:Chrome 的页面从工具栏下方开始。这里是注入式 chrome,",
440
+ " // 用同样的语义把文档整体下移顶栏高度(标签行 40 + 工具栏 44)。",
441
+ " // important 是因为页面自己的样式常常优先级更高。",
442
+ " // box-sizing + height:100% 是配套的:只加 padding 会让根元素比视口高 84px,",
443
+ " // 于是**每个页面都凭空多出一条滚动条**(实测 deScrollH = clientH + 84)。",
444
+ " // 根元素高度锁成视口高、padding 算在内部,滚动条就只由真实内容决定。",
445
+ " // 书签栏开着时顶栏会多 34px,下移量要跟着变(否则页面会被压住一截)。",
446
+ " let bookmarksVisible = false",
447
+ " // page 表面上工具栏和书签栏都画在 frame 视图里,页面视图本身就从它们下面开始,",
448
+ " // 所以页面一点也不用让位(以前要让 34px,position:fixed 的头部于是被压住)。",
449
+ " const chromeInset = () => CHROME_SURFACE === 'page' ? 0 : 84 + (bookmarksVisible ? 34 : 0)",
450
+ " // 下移量。**过渡只在第二次应用之后才补上**:chrome 每次导航都会重新注入,第一次就带",
451
+ " // 过渡的话,每个页面加载(书签栏开着时 0 -> 34px)都会看到内容自己往下滑一下。",
452
+ " let insetApplied = false",
453
+ " const applyPageInset = value => {",
454
+ " document.documentElement.style.setProperty('padding-top', value + 'px', 'important')",
455
+ " if (insetApplied) return",
456
+ " insetApplied = true",
457
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
458
+ " document.documentElement.style.transition = 'padding-top .16s ease'",
459
+ " }",
460
+ " applyPageInset(chromeInset())",
461
+ " document.documentElement.style.setProperty('box-sizing', 'border-box', 'important')",
462
+ " document.documentElement.style.setProperty('height', '100%', 'important')",
463
+ '',
464
+ " const bar = root.getElementById('bar')",
465
+ " const tabstrip = root.getElementById('tabstrip')",
466
+ " const toolbarRevealZone = root.getElementById('toolbarRevealZone')",
467
+ " const toolbarReveal = root.getElementById('toolbarReveal')",
468
+ " const toolbarHide = root.getElementById('toolbarHide')",
469
+ " const back = root.getElementById('back')",
470
+ " const forward = root.getElementById('forward')",
471
+ " const reload = root.getElementById('reload')",
472
+ " const stop = root.getElementById('stop')",
473
+ " const home = root.getElementById('home')",
474
+ " const bookmarks = root.getElementById('bookmarks')",
475
+ " const trail = root.getElementById('trailBtn')",
476
+ " const trailPanel = root.getElementById('trail')",
477
+ " const tasksBtn = root.getElementById('tasksBtn')",
478
+ " const taskPanel = root.getElementById('taskPanel')",
479
+ " const taskList = root.getElementById('taskList')",
480
+ " const orb = root.getElementById('orb')",
481
+ " const orbCard = root.getElementById('orbCard')",
482
+ "",
483
+ " // ── 悬浮球 ──────────────────────────────────────────────────────────────",
484
+ " // 两个数据源:任务摘要(status / latestAction)与定向的 task.todos 补丁",
485
+ " // (Agent 的 todo 清单)。清单**不进摘要** —— 摘要会被注入每个访问过的页面的主世界。",
486
+ " const taskTodosCache = new Map()",
487
+ " const orbBadge = orb instanceof HTMLElement ? orb.querySelector('.orb-badge') : null",
488
+ " const orbCardTask = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-task') : null",
489
+ " const orbCardList = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-list') : null",
490
+ " const orbCardFoot = orbCard instanceof HTMLElement ? orbCard.querySelector('.orb-foot') : null",
491
+ " let orbCardTimer = null",
492
+ " const orbTask = () => {",
493
+ " const list = Array.isArray(window.__dshTasks) ? window.__dshTasks : []",
494
+ " return list.find(task => task && task.key === chromeSelectedTaskKey) || list.find(task => task && task.active === true) || list[0]",
495
+ " }",
496
+ " let orbRenderSignature = null",
497
+ " const renderOrb = () => {",
498
+ " if (!(orb instanceof HTMLElement)) return",
499
+ " const task = orbTask()",
500
+ " const key = task && typeof task.key === 'string' ? task.key : ''",
501
+ " const todos = key !== '' && Array.isArray(taskTodosCache.get(key)) ? taskTodosCache.get(key) : []",
502
+ " const status = task && typeof task.status === 'string' ? task.status : 'idle'",
503
+ " // 清单整表重建会把人的滚动位置弹回顶部、让转圈动画从头开始 —— 内容没变就整段跳过。",
504
+ " const signature = key + '\u0001' + status + '\u0001' + (task && task.latestAction ? task.latestAction : '') + '\u0001' + todos.map(item => (item && item.status) + ':' + (item && item.content)).join('\u0002')",
505
+ " if (signature === orbRenderSignature) return",
506
+ " orbRenderSignature = signature",
507
+ " const running = todos.some(item => item && item.status === 'in_progress')",
508
+ " // 终态优先:Agent 的清单里通常有一条 in_progress,若让 running 盖过 failed/waiting-user,",
509
+ " // 失败和「等你操作」就永远看不到(球一直转圈)。",
510
+ " const settled = status === 'failed' || status === 'waiting-user'",
511
+ " const working = status === 'running' || (running && !settled)",
512
+ " const completed = todos.filter(item => item && item.status === 'completed').length",
513
+ " // 清单被截断到上限时(与桥接 normalize 的 40 一致),最后那些可能没送进来 ——",
514
+ " // 不能凭前 40 条就宣布「全部完成」。",
515
+ " const capped = todos.length >= 40",
516
+ " const allDone = todos.length > 0 && completed === todos.length && !capped",
517
+ " orb.classList.toggle('working', working)",
518
+ " orb.classList.toggle('done', !working && allDone)",
519
+ " orb.classList.toggle('failed', !working && status === 'failed')",
520
+ " orb.classList.toggle('waiting', !working && status === 'waiting-user')",
521
+ " if (orbBadge) {",
522
+ " // 角标只在有话要说时出现:进度、完成、失败、等你。空闲就是干净的一个球。",
523
+ " if (working) orbBadge.textContent = todos.length > 0 ? String(completed) + '/' + String(todos.length) + (capped ? '+' : '') : ''",
524
+ " else if (status === 'failed') orbBadge.textContent = '!'",
525
+ " else if (status === 'waiting-user') orbBadge.textContent = '‖'",
526
+ " else if (allDone) orbBadge.textContent = '✓'",
527
+ " else orbBadge.textContent = ''",
528
+ " }",
529
+ " // 没有 label 时**不要**退回原始 key(session-6012f8c2-… 是内部键,给人看的东西)。",
530
+ " if (orbCardTask) orbCardTask.textContent = task && typeof task.label === 'string' ? task.label : ''",
531
+ " if (orbCardList) {",
532
+ " orbCardList.textContent = ''",
533
+ " if (todos.length === 0) {",
534
+ " const empty = document.createElement('div')",
535
+ " empty.className = 'orb-empty'",
536
+ " empty.textContent = working ? '正在执行浏览器操作…' : '暂无任务清单'",
537
+ " orbCardList.append(empty)",
538
+ " } else {",
539
+ " for (const item of todos) {",
540
+ " const state = item && item.status === 'completed' ? 'completed' : item && item.status === 'in_progress' ? 'in_progress' : 'pending'",
541
+ " const row = document.createElement('div')",
542
+ " row.className = 'orb-item ' + state",
543
+ " const mark = document.createElement('span')",
544
+ " mark.className = 'orb-mark'",
545
+ " if (state === 'in_progress') { const spin = document.createElement('span'); spin.className = 'orb-spin'; mark.append(spin) }",
546
+ " else mark.textContent = state === 'completed' ? '✓' : '○'",
547
+ " const text = document.createElement('span')",
548
+ " text.className = 'orb-text'",
549
+ " text.textContent = item && typeof item.content === 'string' ? item.content : ''",
550
+ " row.append(mark, text)",
551
+ " orbCardList.append(row)",
552
+ " }",
553
+ " }",
554
+ " }",
555
+ " if (orbCardFoot) {",
556
+ " const latest = task && typeof task.latestAction === 'string' ? task.latestAction : ''",
557
+ " orbCardFoot.textContent = working && latest !== '' ? '现在执行 · ' + latest : allDone ? '全部完成' : status === 'waiting-user' ? '等待你操作' : status === 'failed' ? '上次执行失败' : ''",
558
+ " orbCardFoot.style.display = orbCardFoot.textContent === '' ? 'none' : ''",
559
+ " }",
560
+ " // 列表重建后卡片高度可能变了(缩略图补丁 5Hz 也会走到这里)—— 开着的话重新贴一次球,",
561
+ " // 否则卡片长高会往下压住球(c1f6291 修的那个现象换了个触发路径)。",
562
+ " try { if (orbCard instanceof HTMLElement && orbCard.classList.contains('open')) placeOrbCard() } catch { /* placeOrbCard 还没定义 */ }",
563
+ " }",
564
+ " // 悬浮球可拖动,位置交给宿主保存(chrome-prefs.json)—— 存在页面里会按源丢。",
565
+ " let orbPos = null",
566
+ " let orbDraggedAt = 0",
567
+ " // 悬浮球**不随页面缩放变大变小**:它的大小是本地 px,宿主那层 zoom=1/缩放 正好抵消掉",
568
+ " // (实测 100% 与 130% 下屏幕上都是 44px)。但坐标有两套 —— 事件与 rect 是页面 px,",
569
+ " // style.left/top 是本地 px —— 混用就会拖动跳、夹取偏、卡片错位。",
570
+ " const orbScale = () => { try { return typeof chromeScale === 'function' ? chromeScale() : 1 } catch { return 1 } }",
571
+ " const clampOrbPos = (x, y) => {",
572
+ " const s = orbScale()",
573
+ " const maxX = Math.max(8, window.innerWidth * s - (orb instanceof HTMLElement ? orb.offsetWidth : 44) - 8)",
574
+ " const maxY = Math.max(8, window.innerHeight * s - (orb instanceof HTMLElement ? orb.offsetHeight : 44) - 8)",
575
+ " return { x: Math.min(Math.max(8, x), maxX), y: Math.min(Math.max(8, y), maxY) }",
576
+ " }",
577
+ " const applyOrbPos = () => {",
578
+ " if (!(orb instanceof HTMLElement)) return",
579
+ " if (orbPos === null) { orb.style.left = ''; orb.style.top = ''; orb.style.right = ''; orb.style.bottom = ''; return }",
580
+ " // 存下来的坐标是在**当时那个窗口**里量出来的:窗口变小、换显示器、恢复最大化之后",
581
+ " // 它可能已经在屏幕外 —— 那就再也拖不回来了(而且位置还会一直存在 chrome-prefs.json 里)。",
582
+ " orbPos = clampOrbPos(orbPos.x, orbPos.y)",
583
+ " orb.style.left = Math.round(orbPos.x) + 'px'",
584
+ " orb.style.top = Math.round(orbPos.y) + 'px'",
585
+ " orb.style.right = 'auto'",
586
+ " orb.style.bottom = 'auto'",
587
+ " }",
588
+ " const emitOrbMove = (x, y) => {",
589
+ " if (bindingToken === '') return",
590
+ " try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'orb-move', x, y, token: bindingToken })) } catch { /* host unavailable */ }",
591
+ " }",
592
+ " let orbDrag = null",
593
+ " if (orb instanceof HTMLElement) {",
594
+ " orb.addEventListener('pointerdown', event => {",
595
+ " if (event.button !== 0) return",
596
+ " // moved 要比「按下时的起点」:拿 orb.offsetLeft 比的话,它是上一帧刚被 applyOrbPos 写进去的值,",
597
+ " // 于是慢速拖动(每帧 <3px)永远不算拖动 —— 位置不落盘,松手还会误触 click。",
598
+ " const s = orbScale()",
599
+ " orbDrag = { startX: event.clientX, startY: event.clientY, localX: event.clientX * s, localY: event.clientY * s, dx: event.clientX * s - orb.offsetLeft, dy: event.clientY * s - orb.offsetTop, moved: false, pressed: false }",
600
+ " orb.classList.add('dragging')",
601
+ " // 不用 setPointerCapture:窗口级的 pointermove/pointerup 已经够了,而捕获会把后续的",
602
+ " // click 重定向到球本身(标签拖动那条同款教训)。",
603
+ " })",
604
+ " window.addEventListener('pointermove', event => {",
605
+ " // 在窗口外松手收不到 pointerup(pointercancel 也不保证)—— 不看 buttons 的话,",
606
+ " // 鼠标一回到页面,球就会在没按任何键的情况下跟着光标跑。",
607
+ " // 但**不能**见 0 就收尾:合成输入(离屏测试、CDP)的 pointermove 常常不带 buttons,",
608
+ " // 那会把正常拖动也打断。所以只在「这次拖动曾经观察到按下」之后才认这个信号。",
609
+ " if (orbDrag === null) return",
610
+ " if (event.buttons !== 0) orbDrag.pressed = true",
611
+ " else if (orbDrag.pressed) { endOrbDrag(); return }",
612
+ " const s = orbScale()",
613
+ " const spot = clampOrbPos(event.clientX * s - orbDrag.dx, event.clientY * s - orbDrag.dy)",
614
+ " const x = spot.x",
615
+ " const y = spot.y",
616
+ " if (Math.abs(event.clientX * s - orbDrag.localX) > 3 || Math.abs(event.clientY * s - orbDrag.localY) > 3) orbDrag.moved = true",
617
+ " orbPos = { x, y }",
618
+ " applyOrbPos()",
619
+ " if (orbDrag.moved && orbCard instanceof HTMLElement) orbCard.classList.remove('open')",
620
+ " })",
621
+ " const endOrbDrag = () => {",
622
+ " if (orbDrag === null) return",
623
+ " const moved = orbDrag.moved",
624
+ " orbDrag = null",
625
+ " orb.classList.remove('dragging')",
626
+ " if (!moved) return",
627
+ " orbDraggedAt = Date.now()",
628
+ " if (orbPos !== null) emitOrbMove(orbPos.x, orbPos.y)",
629
+ " }",
630
+ " window.addEventListener('pointerup', endOrbDrag)",
631
+ " window.addEventListener('pointercancel', endOrbDrag)",
632
+ " // 在别的窗口/桌面上松手时,失焦是最可靠的信号(那时连 pointerup 都收不到)。",
633
+ " window.addEventListener('blur', endOrbDrag)",
634
+ " // 窗口一变形:已存的坐标要重新夹一次;卡片开着的话它也要跟着球重新定位。",
635
+ " window.addEventListener('resize', () => { applyOrbPos(); if (orbCard instanceof HTMLElement && orbCard.classList.contains('open')) placeOrbCard() })",
636
+ " }",
637
+ " const placeOrbCard = () => {",
638
+ " if (!(orb instanceof HTMLElement) || !(orbCard instanceof HTMLElement)) return",
639
+ " const s = orbScale()",
640
+ " const vw = window.innerWidth * s",
641
+ " const vh = window.innerHeight * s",
642
+ " // 视口比卡片还矮/还窄时(小窗口、分屏),先把卡片压到放得下再量 —— 否则它会",
643
+ " // 被夹在 8..vh-8 之间,正好盖住贴底的球(review 报的 #10)。",
644
+ " orbCard.style.maxHeight = Math.max(120, vh - 24) + 'px'",
645
+ " orbCard.style.width = Math.max(160, Math.min(308, vw - 16)) + 'px'",
646
+ " const box = orb.getBoundingClientRect()",
647
+ " const width = orbCard.offsetWidth > 0 ? orbCard.offsetWidth : 308",
648
+ " const height = orbCard.offsetHeight",
649
+ " const left = Math.min(Math.max(8, box.left * s + (box.width * s) / 2 - width / 2), Math.max(8, window.innerWidth * s - width - 8))",
650
+ " // 球拖到上方时,卡片再往上放就会被夹到视口顶、**盖住球自己** —— 上面放不下就翻到下面。",
651
+ " const gap = 8",
652
+ " const above = box.top * s - gap - height",
653
+ " const top = above >= gap ? above : Math.min(box.bottom * s + gap, Math.max(gap, window.innerHeight * s - height - gap))",
654
+ " orbCard.style.left = Math.round(left) + 'px'",
655
+ " orbCard.style.top = Math.round(top) + 'px'",
656
+ " orbCard.style.right = 'auto'",
657
+ " orbCard.style.bottom = 'auto'",
658
+ " }",
659
+ " const openOrbCard = () => {",
660
+ " if (orbCardTimer !== null) { clearTimeout(orbCardTimer); orbCardTimer = null }",
661
+ " if (orbCard instanceof HTMLElement) { orbCard.classList.add('open'); placeOrbCard() }",
662
+ " }",
663
+ " const closeOrbCardSoon = () => {",
664
+ " if (orbCardTimer !== null) clearTimeout(orbCardTimer)",
665
+ " orbCardTimer = setTimeout(() => { if (orbCard instanceof HTMLElement) orbCard.classList.remove('open') }, 150)",
666
+ " }",
667
+ " if (orb instanceof HTMLElement && orbCard instanceof HTMLElement) {",
668
+ " orb.addEventListener('pointerenter', openOrbCard)",
669
+ " orb.addEventListener('pointerleave', closeOrbCardSoon)",
670
+ " orbCard.addEventListener('pointerenter', openOrbCard)",
671
+ " orbCard.addEventListener('pointerleave', closeOrbCardSoon)",
672
+ " orb.addEventListener('click', () => { if (Date.now() - orbDraggedAt < 350) return; renderOrb(); if (orbCard.classList.contains('open')) orbCard.classList.remove('open'); else openOrbCard() })",
673
+ " }",
674
+ " const taskControl = root.getElementById('taskControl')",
675
+ " const taskClose = root.getElementById('taskClose')",
676
+ " const trailList = root.getElementById('trailList')",
677
+ " const reveal = root.getElementById('reveal')",
678
+ " const trailClose = root.getElementById('trailClose')",
679
+ " const address = root.getElementById('address')",
680
+ " const panel = root.getElementById('panel')",
681
+ " const saveBookmark = root.getElementById('saveBookmark')",
682
+ " const bookmarkList = root.getElementById('bookmarkList')",
683
+ " const secIcon = root.getElementById('secIcon')",
684
+ " const findBar = root.getElementById('findBar')",
685
+ " const findInput = root.getElementById('findInput')",
686
+ " const findCount = root.getElementById('findCount')",
687
+ " const findPrev = root.getElementById('findPrev')",
688
+ " const findNext = root.getElementById('findNext')",
689
+ " const findClose = root.getElementById('findClose')",
690
+ " const zoomOut = root.getElementById('zoomOut')",
691
+ " const zoomIn = root.getElementById('zoomIn')",
692
+ " const zoomLevel = root.getElementById('zoomLevel')",
693
+ " const mainMenuBtn = root.getElementById('mainMenuBtn')",
694
+ " const mainMenu = root.getElementById('mainMenu')",
695
+ " const mmNewTab = root.getElementById('mmNewTab')",
696
+ " const mmFind = root.getElementById('mmFind')",
697
+ " const mmBookmark = root.getElementById('mmBookmark')",
698
+ " const mmBookmarks = root.getElementById('mmBookmarks')",
699
+ " const mmBookmarkBar = root.getElementById('mmBookmarkBar')",
700
+ " const mmImportCookies = root.getElementById('mmImportCookies')",
701
+ " const toast = root.getElementById('toast')",
702
+ " const bookmarkBar = root.getElementById('bookmarkBar')",
703
+ " const mmTrail = root.getElementById('mmTrail')",
704
+ " const mmTasks = root.getElementById('mmTasks')",
705
+ " const mmFullscreen = root.getElementById('mmFullscreen')",
706
+ " if (!(secIcon instanceof SVGElement)) return",
707
+ " if (!(findBar instanceof HTMLElement) || !(findInput instanceof HTMLInputElement) || !(findCount instanceof HTMLElement) || !(findPrev instanceof HTMLButtonElement) || !(findNext instanceof HTMLButtonElement) || !(findClose instanceof HTMLButtonElement)) return",
708
+ " if (!(zoomOut instanceof HTMLButtonElement) || !(zoomIn instanceof HTMLButtonElement) || !(zoomLevel instanceof HTMLButtonElement)) return",
709
+ " if (!(mmBookmarkBar instanceof HTMLButtonElement) || !(bookmarkBar instanceof HTMLElement)) return",
710
+ " if (!(mmImportCookies instanceof HTMLButtonElement) || !(toast instanceof HTMLElement)) return",
711
+ " if (!(mainMenuBtn instanceof HTMLButtonElement) || !(mainMenu instanceof HTMLElement) || !(mmNewTab instanceof HTMLButtonElement) || !(mmBookmark instanceof HTMLButtonElement) || !(mmBookmarks instanceof HTMLButtonElement) || !(mmTrail instanceof HTMLButtonElement) || !(mmTasks instanceof HTMLButtonElement) || !(mmFullscreen instanceof HTMLButtonElement)) return",
712
+ ' if (!(bar instanceof HTMLElement) || !(tabstrip instanceof HTMLElement) || !(toolbarRevealZone instanceof HTMLElement) || !(toolbarReveal instanceof HTMLButtonElement) || !(toolbarHide instanceof HTMLButtonElement) || !(back instanceof HTMLButtonElement) || !(forward instanceof HTMLButtonElement) || !(reload instanceof HTMLButtonElement) || !(stop instanceof HTMLButtonElement) || !(home instanceof HTMLButtonElement) || !(bookmarks instanceof HTMLButtonElement) || !(address instanceof HTMLInputElement) || !(panel instanceof HTMLElement) || !(saveBookmark instanceof HTMLButtonElement) || !(bookmarkList instanceof HTMLElement) || !(trail instanceof HTMLButtonElement) || !(trailPanel instanceof HTMLElement) || !(trailList instanceof HTMLElement) || !(trailClose instanceof HTMLButtonElement) || !(tasksBtn instanceof HTMLButtonElement) || !(taskPanel instanceof HTMLElement) || !(taskList instanceof HTMLElement) || !(taskControl instanceof HTMLButtonElement) || !(taskClose instanceof HTMLButtonElement)) return',
713
+ '',
714
+ " // 内部文档(宿主起始页是 data: URL)不该把整段转义标记摊在地址栏里。",
715
+ " // Chrome 对新标签页就是空地址栏 + 占位提示,这里照做。",
716
+ " const isInternalLocation = () => location.protocol === 'data:' || location.href === '' || location.href === 'about:blank'",
717
+ " // Chrome 的地址栏没聚焦时把 https:// 藏起来(只显示 www.iana.org/...),聚焦时给完整 URL。",
718
+ " const prettifyAddress = href => href.replace(/^https?:[/][/]/, '').replace(/[/]$/, '')",
719
+ " // 由 chrome 自己记录「动画真的跑了」——冒烟据此断言动效存在(把动画去掉这条就会红)。",
720
+ " // 放在这里(而不是文件下半部分)是因为下面的图标动效要用它,而 refreshSecurity 在",
721
+ " // 挂载过程中就会被调用一次 —— 声明晚了会撞上 TDZ,把整条挂载脚本炸掉。",
722
+ " const motionLog = []",
723
+ " // 真实的 CSS 过渡也记进日志:只记 WAAPI 的话,「CSS 里到底有没有过渡」就只能靠读",
724
+ " // 字符串判断,而那是**声明**不是**行为**(第 41 轮的教训)。监听器挂在影子根**内部**,",
725
+ " // 所以 event.target 是真实元素 —— 闭影子根外面是读不到的。",
726
+ " root.addEventListener('transitionstart', event => {",
727
+ " const target = event.target",
728
+ " if (!(target instanceof HTMLElement)) return",
729
+ " const id = String(target.className || target.tagName || '').split(' ')[0].slice(0, 24)",
730
+ " motionLog.push({ id, phase: 'start', name: 'css:' + String(event.propertyName || ''), el: String(target.id || '') })",
731
+ " }, true)",
732
+ " // 真实的 CSS **动画**同理:入场动画是 keyframes,不是 transition,只记过渡会漏掉它。",
733
+ " root.addEventListener('animationstart', event => {",
734
+ " const target = event.target",
735
+ " if (!(target instanceof HTMLElement)) return",
736
+ " const id = String(target.className || target.tagName || '').split(' ')[0].slice(0, 24)",
737
+ " motionLog.push({ id, phase: 'start', name: 'anim:' + String(event.animationName || ''), el: String(target.id || '') })",
738
+ " }, true)",
739
+ " window.__dshChromeMotion = () => motionLog.slice(-20)",
740
+ " window.__dshChromeMotionClear = () => { motionLog.length = 0 }",
741
+ " // 图标换面时的一下短促反馈(星标 / 安全指示共用):记「决定」,动画本身走 WAAPI。",
742
+ " // 首帧不播(调用方用 dataset 里的上一次状态判断),减少动态效果时也不播。",
743
+ " const popIcon = (element, id) => {",
744
+ " motionLog.push({ id, phase: 'start', name: 'dshIconIn' })",
745
+ " if (typeof Element.prototype.animate !== 'function') return",
746
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
747
+ " element.animate(",
748
+ " [{ opacity: 0, transform: 'scale(.8)' }, { opacity: 1, transform: 'none' }],",
749
+ " { duration: 180, easing: 'cubic-bezier(.2,.8,.2,1)' },",
750
+ " )",
751
+ " }",
752
+ " // 安全指示:http 页面挂一把锁是在骗人。https 给锁,其它给提示图标。",
753
+ " const refreshSecurity = () => {",
754
+ " // 和地址栏一样:frame 表面读标签摘要,不读自己的 location。",
755
+ " const secUrl = CHROME_SURFACE === 'frame' ? activeTabUrl() : location.href",
756
+ " const internal = CHROME_SURFACE === 'frame' ? secUrl === '' : isInternalLocation()",
757
+ " const insecure = !internal && !/^https:/i.test(secUrl)",
758
+ " const previous = secIcon.dataset.insecure",
759
+ " secIcon.classList.toggle('insecure', insecure)",
760
+ " secIcon.dataset.insecure = insecure ? '1' : '0'",
761
+ " // 锁 ↔ 琥珀提示不要硬切(首次渲染不播,否则每次导航都闪一下)。",
762
+ " if (previous !== undefined && previous !== secIcon.dataset.insecure) popIcon(secIcon, 'security')",
763
+ " const label = internal ? '新标签页' : (insecure ? '不安全连接' : '安全连接')",
764
+ " secIcon.setAttribute('aria-label', label)",
765
+ " secIcon.setAttribute('title', label)",
766
+ " }",
767
+ " // 地址栏文字换地址时淡入(不是每次刷新都播:焦点/失焦那种只换显示形态的不算)。",
768
+ " let lastAddressUrl",
769
+ " const fadeAddress = () => {",
770
+ " motionLog.push({ id: 'address', phase: 'start', name: 'dshAddressIn' })",
771
+ " if (typeof Element.prototype.animate !== 'function') return",
772
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
773
+ " address.animate([{ opacity: .35 }, { opacity: 1 }], { duration: 180, easing: 'cubic-bezier(.2,.8,.2,1)' })",
774
+ " }",
775
+ " const refreshAddress = () => {",
776
+ " refreshSecurity()",
777
+ " // 缩放补偿挂在 750ms 的轮询上,而不是只靠 resize —— 实测页面缩放不一定派发 resize。",
778
+ " syncZoomFromHost()",
779
+ " if (root.activeElement === address) return",
780
+ " // frame 表面里**不能读自己的 location** —— 那个文档是宿主的 data: URL,",
781
+ " // 真实地址只能从标签摘要里拿(宿主随 bootstrap / patch 下发)。",
782
+ " // frame 优先用宿主单独塞过来的**完整地址**(标签摘要里只有 origin —— 那是给页面那份的",
783
+ " // 隐私设计),拿不到时才退回 origin。",
784
+ " const shownUrl = CHROME_SURFACE === 'frame' ? (frameAddressUrl || activeTabUrl()) : location.href",
785
+ " const internal = CHROME_SURFACE === 'frame' ? shownUrl === '' : isInternalLocation()",
786
+ " if (lastAddressUrl !== undefined && lastAddressUrl !== shownUrl && !internal) fadeAddress()",
787
+ " lastAddressUrl = shownUrl",
788
+ " address.value = internal ? '' : prettifyAddress(shownUrl)",
789
+ " address.placeholder = internal ? '搜索或输入网址' : ''",
790
+ " }",
791
+ " // 宿主把可见标签的**真实地址**直接塞给 frame 这份(只有它自己的 webContents 收得到,",
792
+ " // 页面里那份永远拿不到 —— 见 host-main 的 queueFrameAddress)。",
793
+ " let frameAddressUrl = ''",
794
+ " window.__dshChromeAddress = url => { frameAddressUrl = typeof url === 'string' ? url : ''; refreshAddress() }",
795
+ " // --- 查找(Ctrl+F)---",
796
+ " // 高亮走 CSS Custom Highlight API + **文档级构造样式表**:不用 <style>",
797
+ " // (页面的 CSP 会拦),也不动页面 DOM(改 DOM 会打断页面自己的脚本)。",
798
+ " // 没有 Highlight API 时至少还能滚动定位。",
799
+ " const FIND_LIMIT = 500",
800
+ " let findQuery = ''",
801
+ " let findRanges = []",
802
+ " let findIndex = 0",
803
+ " let findTimer = 0",
804
+ " try {",
805
+ " const findSheet = new CSSStyleSheet()",
806
+ " findSheet.replaceSync('::highlight(dsh-find){background:#f0bd69;color:#202124}::highlight(dsh-find-current){background:#f28b25;color:#202124}')",
807
+ " document.adoptedStyleSheets = document.adoptedStyleSheets.concat([findSheet])",
808
+ " } catch { /* 没有 Highlight API */ }",
809
+ " const clearFindHighlights = () => {",
810
+ " try { CSS.highlights.delete('dsh-find'); CSS.highlights.delete('dsh-find-current') } catch { /* 没有 Highlight API */ }",
811
+ " }",
812
+ " const collectFindRanges = query => {",
813
+ " const found = []",
814
+ " if (query === '') return found",
815
+ " const needle = query.toLowerCase()",
816
+ " const rootNode = document.body || document.documentElement",
817
+ " if (!rootNode) return found",
818
+ " const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, {",
819
+ " acceptNode: node => {",
820
+ " const parent = node.parentElement",
821
+ " if (parent === null) return NodeFilter.FILTER_REJECT",
822
+ " const tag = parent.tagName",
823
+ " if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') return NodeFilter.FILTER_REJECT",
824
+ " return NodeFilter.FILTER_ACCEPT",
825
+ " },",
826
+ " })",
827
+ " let node = walker.nextNode()",
828
+ " while (node !== null && found.length < FIND_LIMIT) {",
829
+ " const text = String(node.nodeValue || '')",
830
+ " const hay = text.toLowerCase()",
831
+ " let at = hay.indexOf(needle)",
832
+ " while (at !== -1 && found.length < FIND_LIMIT) {",
833
+ " const range = document.createRange()",
834
+ " range.setStart(node, at)",
835
+ " range.setEnd(node, at + needle.length)",
836
+ " found.push(range)",
837
+ " at = hay.indexOf(needle, at + needle.length)",
838
+ " }",
839
+ " node = walker.nextNode()",
840
+ " }",
841
+ " return found",
842
+ " }",
843
+ " const paintFind = () => {",
844
+ " if (findRanges.length === 0) {",
845
+ " clearFindHighlights()",
846
+ " findCount.textContent = findQuery === '' ? '' : '0/0'",
847
+ " return",
848
+ " }",
849
+ " const current = findRanges[findIndex]",
850
+ " try {",
851
+ " CSS.highlights.set('dsh-find', new Highlight(...findRanges))",
852
+ " CSS.highlights.set('dsh-find-current', new Highlight(current))",
853
+ " } catch { /* 没有 Highlight API */ }",
854
+ " findCount.textContent = String(findIndex + 1) + '/' + String(findRanges.length)",
855
+ " try {",
856
+ " const rect = current.getBoundingClientRect()",
857
+ " // 留出顶栏的高度,别让命中项贴在 chrome 底下。",
858
+ " window.scrollTo({ top: Math.max(0, rect.top + window.scrollY - 140) })",
859
+ " } catch { /* range 已失效 */ }",
860
+ " }",
861
+ " const runFind = () => {",
862
+ " findQuery = findInput.value",
863
+ " findRanges = collectFindRanges(findQuery)",
864
+ " findIndex = 0",
865
+ " paintFind()",
866
+ " }",
867
+ " const stepFind = delta => {",
868
+ " if (findRanges.length === 0) { runFind(); if (findRanges.length === 0) return }",
869
+ " const total = findRanges.length",
870
+ " findIndex = ((findIndex + delta) % total + total) % total",
871
+ " paintFind()",
872
+ " }",
873
+ " // 表面(弹层 / 查找栏)的「逻辑开关」和「视觉开关」是两件事:关的时候先播 130ms 的退场",
874
+ " // 动画,\\`open\\` 类要留到动画结束才摘;但测试缝和工作区面板同步读的必须是**逻辑**状态,",
875
+ " // 否则冒烟里「点了菜单项,菜单就该关了」会被这 130ms 骗到(第 31/32 轮那套状态机)。",
876
+ " const isPanelOpen = element => element.classList.contains('open') && !element.classList.contains('closing')",
877
+ " const closingState = new Map()",
878
+ " const cancelPanelClose = element => {",
879
+ " const state = closingState.get(element)",
880
+ " if (state !== undefined) {",
881
+ " window.clearTimeout(state.timer)",
882
+ " element.removeEventListener('animationend', state.onEnd)",
883
+ " closingState.delete(element)",
884
+ " }",
885
+ " element.classList.remove('closing')",
886
+ " }",
887
+ " // 收尾交给 animationend,不是死等一个固定时长:机器忙的时候动画可能还没跑完就被摘掉,",
888
+ " // 那样表面会直接消失(看起来就是没动画)。400ms 兜底保证它不会卡住。",
889
+ " const beginSurfaceClose = element => {",
890
+ " element.classList.add('closing')",
891
+ " const finish = () => { cancelPanelClose(element); element.classList.remove('open') }",
892
+ " const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches",
893
+ " const onEnd = event => { if (String(event.animationName || '') === 'dshPopOut') finish() }",
894
+ " if (!reduced) element.addEventListener('animationend', onEnd)",
895
+ " const timer = window.setTimeout(finish, reduced ? 0 : 400)",
896
+ " closingState.set(element, { timer, onEnd })",
897
+ " }",
898
+ " // 打开一律走这里:先把「正在退场」作废。否则一个还在淡出的表面会被重新打开却带着",
899
+ " // closing,400ms 后被兜底计时器收掉 —— 看起来就是「刚打开又自己关了」(第 41 轮踩到)。",
900
+ " const showSurface = element => { cancelPanelClose(element); element.classList.add('open') }",
901
+ " const openFind = () => {",
902
+ " // 查找栏要访问页面 DOM,所以它留在页面里;frame 只能请宿主去开。",
903
+ " if (onFrameSurface) { emitPageAction('find'); return }",
904
+ " // 正在退场时又被叫开:把还没到点的关闭作废。",
905
+ " cancelPanelClose(findBar)",
906
+ " findBar.classList.add('open')",
907
+ " findInput.focus()",
908
+ " findInput.select()",
909
+ " if (findInput.value !== '') runFind()",
910
+ " }",
911
+ " const closeFind = () => {",
912
+ " // 关也要有动画(和第 40 轮的菜单同款):先播退场,动画结束才真的 display:none。",
913
+ " if (isPanelOpen(findBar)) beginSurfaceClose(findBar)",
914
+ " findInput.value = ''",
915
+ " findQuery = ''",
916
+ " findRanges = []",
917
+ " findIndex = 0",
918
+ " findCount.textContent = ''",
919
+ " clearFindHighlights()",
920
+ " }",
921
+ " findInput.addEventListener('input', () => {",
922
+ " if (findTimer !== 0) window.clearTimeout(findTimer)",
923
+ " findTimer = window.setTimeout(() => { findTimer = 0; runFind() }, 160)",
924
+ " })",
925
+ " findInput.addEventListener('keydown', event => {",
926
+ " if (event.key === 'Enter') { event.preventDefault(); stepFind(event.shiftKey ? -1 : 1) }",
927
+ " else if (event.key === 'Escape') { event.preventDefault(); closeFind() }",
928
+ " })",
929
+ " findPrev.addEventListener('click', () => stepFind(-1))",
930
+ " findNext.addEventListener('click', () => stepFind(1))",
931
+ " findClose.addEventListener('click', closeFind)",
932
+ " // 和其它 chrome 钩子一样暴露出来:冒烟和排查要能直接驱动它(closed shadow root 里点不到)。",
933
+ " window.__dshChromeFind = { open: openFind, close: closeFind, count: () => findRanges.length, index: () => findIndex }",
934
+ " // 测试用:哪些弹层开着(闭影子根外面看不见)。",
935
+ " window.__dshChromePanels = () => menus.map(entry => ({ id: entry.id, open: isPanelOpen(entry.popup), left: Math.round(entry.popup.getBoundingClientRect().left), width: Math.round(entry.popup.getBoundingClientRect().width), items: Array.from(entry.popup.querySelectorAll('button')).map(button => ({ id: button.id || String(button.className || ''), left: Math.round(button.getBoundingClientRect().left), top: Math.round(button.getBoundingClientRect().top), height: Math.round(button.getBoundingClientRect().height) })), rows: Array.from(entry.popup.querySelectorAll('.item, .activity-item, .task-row')).map(row => ({ cls: String(row.className || '').split(' ')[0], left: Math.round(row.getBoundingClientRect().left), top: Math.round(row.getBoundingClientRect().top), width: Math.round(row.getBoundingClientRect().width), height: Math.round(row.getBoundingClientRect().height) })) }))",
936
+ " // 测试用:chrome 闭包里的关键状态(外面读不到)。",
937
+ " window.__dshChromeState = () => ({ surface: CHROME_SURFACE, token: bindingToken === '' ? 'empty' : 'set', taskKey: String(chromeSelectedTaskKey), tabs: (window.__dshTabs || []).length, address: String(address.value || ''), zoom: String(zoomLevel.textContent || ''), starred: starSvg ? String(starSvg.dataset.starred) : 'none', insecure: String(secIcon.dataset.insecure), canBack: !back.disabled, canForward: !forward.disabled, iconAnimations: (starSvg ? starSvg.getAnimations().length : 0) + secIcon.getAnimations().length })",
938
+ " // 测试用:绕过系统文件选择框,直接把一份 cookie 列表交给导入链路(选择框本身没法自动化)。",
939
+ " window.__dshChromeImport = list => { emitImportCookies(list); return Array.isArray(list) ? list.length : 0 }",
940
+ " // --- 页面缩放 ---",
941
+ " // setZoomFactor 是 webContents 级的,页面会真正重排(vh / 媒体查询都跟着变),",
942
+ " // 代价是**注入的 chrome 也会被一起放大**。所以给 host 一个反向 zoom 把它压回",
943
+ " // 1:1,并且把页面下移量按比例换算(padding 是页面 CSS px,缩放后物理高度会变)。",
944
+ " // 真值由宿主给(window.__dshZoom):缩放是 webContents 级的、跨导航保留,",
945
+ " // 而页面自己推不出来 —— 新文档的 devicePixelRatio 已经被缩放过了。",
946
+ " let zoomFactor = 1",
947
+ " const applyZoomFactor = factor => {",
948
+ " const next = typeof factor === 'number' && Number.isFinite(factor) && factor > 0 ? factor : 1",
949
+ " if (Math.abs(next - zoomFactor) < 0.005) return",
950
+ " zoomFactor = next",
951
+ " // frame 是**另一个视图**,页面缩放不会缩放它,所以它既不补偿自身缩放、",
952
+ " // 也不下移文档;它只是把百分比显示出来(按钮按同一个值加减)。",
953
+ " if (CHROME_SURFACE !== 'frame') {",
954
+ " host.style.zoom = next === 1 ? '' : String(1 / next)",
955
+ " applyPageInset(chromeInset() / next)",
956
+ " }",
957
+ " const zoomLabel = Math.round(next * 100) + '%'",
958
+ " // 首帧不播(挂载时本来就要写一次),之后每次真的变了才弹一下。",
959
+ " if (zoomLevel.textContent !== '' && zoomLevel.textContent !== zoomLabel) {",
960
+ " flashChange(zoomLevel, 'zoom', 'dshZoomIn', [{ opacity: .35 }, { opacity: 1 }])",
961
+ " }",
962
+ " zoomLevel.textContent = zoomLabel",
963
+ " }",
964
+ " window.__dshChromeSetZoom = applyZoomFactor",
965
+ " const syncZoomFromHost = () => { applyZoomFactor(window.__dshZoom) }",
966
+ " const setZoom = next => {",
967
+ " const clamped = Math.min(3, Math.max(0.25, Math.round(next * 100) / 100))",
968
+ " // 本地先应用,不等宿主那一轮;宿主回填 __dshZoom 时会再校正一次。",
969
+ " applyZoomFactor(clamped)",
970
+ " emitZoom(clamped)",
971
+ " }",
972
+ " window.addEventListener('resize', syncZoomFromHost)",
973
+ " // 聚焦时展开成完整 URL(Chrome 也是这个行为),失焦后回到精简形式。",
974
+ " address.addEventListener('focus', () => { if (!isInternalLocation()) address.value = location.href })",
975
+ " address.addEventListener('blur', refreshAddress)",
976
+ " // frame 表面(宿主自己的 84px 视图)里,这些控件**不能操作自己那个文档** ——",
977
+ " // 那是宿主的一张 data: 页。地址栏、前进后退、刷新、主页、停止、查找都转成",
978
+ " // page-action 交给宿主,由它作用在真正的页面视图上。",
979
+ " const onFrameSurface = CHROME_SURFACE === 'frame'",
980
+ " // 反向中继:工具栏现在画在 frame 里,所以页面这份收到 Ctrl+L 时只能请 frame 去聚焦。",
981
+ " const emitFrameAction = action => {",
982
+ " if (bindingToken === '') return",
983
+ " try {",
984
+ " const binding = window.__dshBrowserTaskAction",
985
+ " if (typeof binding === 'function') binding(JSON.stringify({ type: 'frame-action', action, token: bindingToken }))",
986
+ " } catch { /* host unavailable */ }",
987
+ " }",
988
+ " const focusAddressBar = () => {",
989
+ " if (CHROME_SURFACE === 'page') { emitFrameAction('focus-address'); return }",
990
+ " address.focus()",
991
+ " address.select()",
992
+ " }",
993
+ " window.__dshChromeFocusAddress = () => { address.focus(); address.select() }",
994
+ " const emitPageAction = (action, extra) => {",
995
+ " if (bindingToken === '') return",
996
+ " try {",
997
+ " const binding = window.__dshBrowserTaskAction",
998
+ " if (typeof binding === 'function') binding(JSON.stringify({ type: 'page-action', action, token: bindingToken, ...(extra || {}) }))",
999
+ " } catch { /* host unavailable */ }",
1000
+ " }",
1001
+ " const navigate = (typed) => {",
1002
+ " // 允许调用方先把输入框里的值取出来再失焦:blur 会触发 refreshAddress 把 value 改回",
1003
+ " // 当前地址,之后再读 value 就晚了(实测:回车后导航到的是上一页)。",
1004
+ " const target = normalizeAddress(typeof typed === 'string' ? typed : address.value)",
1005
+ " if (target === '') return",
1006
+ " if (onFrameSurface) emitPageAction('navigate', { url: target })",
1007
+ " else location.assign(target)",
1008
+ " }",
1009
+ " // 收藏存在宿主侧(整个 profile 共用),不放 localStorage —— 那是**按源**的:",
1010
+ " // 在 A 站收藏的页面到 B 站就看不见了(实测:iana.org 存进去,example.com 读出来是 null)。",
1011
+ " " + "",
1012
+ " const loadBookmarks = () => Array.isArray(window.__dshBookmarks) ? window.__dshBookmarks : []",
1013
+ " const emitOpenTab = url => { if (bindingToken === '' || typeof url !== 'string') return false; try { const binding = window.__dshBrowserTaskAction; if (typeof binding !== 'function') return false; binding(JSON.stringify({ type: 'new-tab', taskKey: chromeSelectedTaskKey, token: bindingToken, url })); return true } catch { return false } }",
1014
+ " const emitBookmarkAdd = () => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'bookmark-add', taskKey: chromeSelectedTaskKey, url: location.href, title: document.title || location.href, token: bindingToken })) } catch { /* host unavailable */ } }",
1015
+ " const emitBookmarkRemove = url => { if (bindingToken === '' || typeof url !== 'string') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'bookmark-remove', taskKey: chromeSelectedTaskKey, url, token: bindingToken })) } catch { /* host unavailable */ } }",
1016
+ ' // --- 书签栏(Chrome 工具栏下面那一条)---',
1017
+ ' const barTitle = item => typeof item.title === \'string\' && item.title !== \'\' ? item.title : item.url',
1018
+ ' const barIcon = () => {',
1019
+ ' const svg = document.createElementNS(\'http://www.w3.org/2000/svg\', \'svg\')',
1020
+ ' svg.setAttribute(\'viewBox\', \'0 0 24 24\')',
1021
+ ' svg.setAttribute(\'fill\', \'none\')',
1022
+ ' svg.setAttribute(\'stroke\', \'currentColor\')',
1023
+ ' svg.setAttribute(\'stroke-width\', \'1.8\')',
1024
+ ' const circle = document.createElementNS(\'http://www.w3.org/2000/svg\', \'circle\')',
1025
+ ' circle.setAttribute(\'cx\', \'12\')',
1026
+ ' circle.setAttribute(\'cy\', \'12\')',
1027
+ ' circle.setAttribute(\'r\', \'9\')',
1028
+ ' const path = document.createElementNS(\'http://www.w3.org/2000/svg\', \'path\')',
1029
+ ' path.setAttribute(\'d\', \'M3 12h18M12 3c2.6 3.2 2.6 14.8 0 18M12 3c-2.6 3.2-2.6 14.8 0 18\')',
1030
+ ' svg.append(circle, path)',
1031
+ ' return svg',
1032
+ ' }',
1033
+ " // 列表按 key 复用节点 + FLIP —— 和标签栏是同一个病:原来每次 replaceChildren() 重画,",
1034
+ " // 删掉中间一项时后面的会**瞬移** ✗,新节点也没有「上一刻的位置」可播。",
1035
+ " // 「谁该滑」用列表顺序判断(page 表面那份可能没有布局),rect 只用来算帧。",
1036
+ " const reconcileList = (container, keys, create, fill, id) => {",
1037
+ " const keyed = () => Array.from(container.children).filter(child => child instanceof HTMLElement && typeof child.dataset.dshKey === 'string')",
1038
+ " const before = new Map()",
1039
+ " for (const child of keyed()) { const box = child.getBoundingClientRect(); before.set(child.dataset.dshKey, { left: box.left, top: box.top }) }",
1040
+ " const previous = Array.from(before.keys())",
1041
+ " const nodes = new Map()",
1042
+ " for (const child of keyed()) nodes.set(child.dataset.dshKey, child)",
1043
+ " for (const [key, node] of Array.from(nodes)) if (!keys.includes(key)) { node.remove(); nodes.delete(key) }",
1044
+ " keys.forEach((key, index) => {",
1045
+ " let node = nodes.get(key)",
1046
+ " if (node === undefined) { node = create(key); node.dataset.dshKey = key; nodes.set(key, node) }",
1047
+ " fill(node, key)",
1048
+ " const at = keyed()[index]",
1049
+ " if (at !== node) container.insertBefore(node, at || null)",
1050
+ " })",
1051
+ " const moved = keys.filter(key => before.has(key) && previous.indexOf(key) !== keys.indexOf(key))",
1052
+ " if (moved.length === 0) return",
1053
+ " motionLog.push({ id, phase: 'slide', name: String(moved.length) })",
1054
+ " if (typeof Element.prototype.animate !== 'function') return",
1055
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
1056
+ " for (const key of moved) {",
1057
+ " const node = nodes.get(key)",
1058
+ " const was = before.get(key)",
1059
+ " if (node === undefined || was === undefined) continue",
1060
+ " const box = node.getBoundingClientRect()",
1061
+ " // 两个轴都要:书签栏是横排(left 变),面板列表是竖排(top 变)。",
1062
+ " const dx = Math.round(was.left - box.left)",
1063
+ " const dy = Math.round(was.top - box.top)",
1064
+ " if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue",
1065
+ " node.animate([{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }], { duration: 160, easing: 'cubic-bezier(.2,.8,.2,1)' })",
1066
+ " }",
1067
+ " }",
1068
+ " const renderBookmarkBar = () => {",
1069
+ " bookmarkBar.classList.toggle('open', bookmarksVisible)",
1070
+ " if (!bookmarksVisible) { bookmarkBar.replaceChildren(); return }",
1071
+ " const items = loadBookmarks().filter(item => item && typeof item.url === 'string' && item.url !== '')",
1072
+ " const byUrl = new Map(items.map(item => [item.url, item]))",
1073
+ " reconcileList(bookmarkBar, items.map(item => item.url), () => {",
1074
+ " const chip = document.createElement('button')",
1075
+ " chip.type = 'button'",
1076
+ " chip.className = 'bm'",
1077
+ " // 点的是**当前**这一格(reuse 之后闭包里的旧 item 会过期)。",
1078
+ " chip.addEventListener('click', () => { const url = chip.dataset.dshKey; if (url === location.href) return; if (!emitOpenTab(url)) location.href = url })",
1079
+ " return chip",
1080
+ " }, (chip, url) => {",
1081
+ " const item = byUrl.get(url)",
1082
+ " const title = barTitle(item)",
1083
+ " if (chip.dataset.title === title) return",
1084
+ " chip.dataset.title = title",
1085
+ " chip.title = title",
1086
+ " // 地球占位 + 一个**裸文本节点**:span 包一层在 flex 里会被压成 0 宽(实测)。",
1087
+ " const icon = barIcon()",
1088
+ " icon.setAttribute('class', 'bm-icon')",
1089
+ " chip.replaceChildren(icon, document.createTextNode(title))",
1090
+ " }, 'bookmark-bar')",
1091
+ " }",
1092
+ ' const applyBookmarksVisible = (visible, animate) => {',
1093
+ " // ⚠️ 只有**人真的去开**才播入场:chrome 每次导航都会重新注入,而书签栏的开关是",
1094
+ " // 宿主级的 —— 不挡的话,偏好开着的人**每加载一个页面书签栏都会再滑一次** ✗",
1095
+ " // (和标签栏第 36 轮那个 bug 同源)。宿主推来的状态永远不播 ✓。",
1096
+ ' const wasVisible = bookmarksVisible',
1097
+ ' bookmarksVisible = visible === true',
1098
+ " if (animate === true && bookmarksVisible && !wasVisible) { bookmarkBar.classList.add('animate'); bookmarkBar.addEventListener('animationend', () => bookmarkBar.classList.remove('animate'), { once: true }) }",
1099
+ " else if (!bookmarksVisible) bookmarkBar.classList.remove('animate')",
1100
+ ' window.__dshBookmarkBar = bookmarksVisible',
1101
+ ' renderBookmarkBar()',
1102
+ ' mmBookmarkBar.textContent = bookmarksVisible ? \'隐藏书签栏\' : \'显示书签栏\'',
1103
+ ' // 顶栏高了 34px,页面下移量要跟上(缩放时还要除以倍数)。',
1104
+ ' const factor = Number(window.__dshZoom) || 1',
1105
+ ' applyPageInset(chromeInset() / factor)',
1106
+ ' }',
1107
+ " const renderBookmarks = () => {",
1108
+ " const list = loadBookmarks()",
1109
+ " const empty = bookmarkList.querySelector('.empty')",
1110
+ " if (list.length === 0) {",
1111
+ " if (empty === null) { const placeholder = document.createElement('div'); placeholder.className = 'empty'; placeholder.textContent = '暂无收藏'; bookmarkList.append(placeholder) }",
1112
+ " return",
1113
+ " }",
1114
+ " if (empty !== null) empty.remove()",
1115
+ " const byUrl = new Map(list.map(item => [item.url, item]))",
1116
+ " reconcileList(bookmarkList, list.map(item => item.url), () => {",
1117
+ " const row = document.createElement('div')",
1118
+ " row.className = 'item'",
1119
+ " const t = document.createElement('span'); t.className = 't'",
1120
+ " const u = document.createElement('span'); u.className = 'u'",
1121
+ " const x = document.createElement('button'); x.className = 'x'; x.type = 'button'; x.textContent = '×'; x.title = '删除收藏'",
1122
+ " // 两个监听器都读**当前**的 key(reuse 之后闭包里的旧 item 会过期)。",
1123
+ " x.addEventListener('click', event => {",
1124
+ " event.stopPropagation()",
1125
+ " const url = row.dataset.dshKey",
1126
+ " // 先本地摘掉(立即反馈),宿主随后会把权威列表推回来。",
1127
+ " window.__dshBookmarks = loadBookmarks().filter(b => b.url !== url)",
1128
+ " renderBookmarks()",
1129
+ " updateBookmarkStar()",
1130
+ " emitBookmarkRemove(url)",
1131
+ " })",
1132
+ " row.addEventListener('click', () => { const url = row.dataset.dshKey; if (isPanelOpen(panel)) beginSurfaceClose(panel); if (!emitOpenTab(url)) location.assign(url) })",
1133
+ " row.append(t, u, x)",
1134
+ " return row",
1135
+ " }, (row, url) => {",
1136
+ " const item = byUrl.get(url)",
1137
+ " row.querySelector('.t').textContent = item.title || url",
1138
+ " row.querySelector('.u').textContent = url",
1139
+ " }, 'bookmark')",
1140
+ " }",
1141
+ " const starSvg = bookmarks.querySelector('svg')",
1142
+ " // 前进/后退能不能点由宿主决定(tab.canGoBack/canGoForward)—— 两个表面必须一致,",
1143
+ " // 而 frame 那份读不到页面的历史、页面那份的 history.length 又不可靠。",
1144
+ " const applyNavState = () => {",
1145
+ " const tab = activeChromeTab()",
1146
+ " back.disabled = !(tab && tab.canGoBack === true)",
1147
+ " forward.disabled = !(tab && tab.canGoForward === true)",
1148
+ " }",
1149
+ " const updateBookmarkStar = () => {",
1150
+ " if (!starSvg) return",
1151
+ " // frame 表面不能读自己的 location(那是宿主的一张 data: 页),要读可见标签的地址 ——",
1152
+ " // 否则工具栏那颗星永远不会点亮(它一直是「未收藏」)。和安全指示同一个口径。",
1153
+ " // frame 表面拿不到完整地址:宿主故意只下发 origin(那份 chrome 跑在页面里,完整 URL",
1154
+ " // 会把 query/token 交给页面)。所以「这一页收藏了没」由宿主算好放在 tab.starred 里;",
1155
+ " // 页面那份读自己的 location 就行。",
1156
+ " const tab = CHROME_SURFACE === 'frame' ? activeChromeTab() : undefined",
1157
+ " const trimSlash = value => String(value || '').replace(/[/]$/, '')",
1158
+ " const starred = tab !== undefined",
1159
+ " ? tab.starred === true",
1160
+ " : location.href !== '' && loadBookmarks().some(b => trimSlash(b.url) === trimSlash(location.href))",
1161
+ " const previous = starSvg.dataset.starred",
1162
+ " starSvg.setAttribute('fill', starred ? 'currentColor' : 'none')",
1163
+ " starSvg.dataset.starred = starred ? '1' : '0'",
1164
+ " // 真的翻面时才弹一下:每次渲染都播就成噪声了。",
1165
+ " if (previous !== undefined && previous !== starSvg.dataset.starred) popIcon(starSvg, 'star')",
1166
+ " }",
1167
+ " const spinReload = () => { reload.classList.add('spinning'); window.setTimeout(() => reload.classList.remove('spinning'), 500) }",
1168
+ " const fmtTime = (at) => { const d = new Date(Number(at) || Date.now()); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0') }",
1169
+ " const fmtDate = (at) => { const d = new Date(Number(at) || Date.now()); return (d.getMonth() + 1) + '/' + d.getDate() }",
1170
+ " const describe = (entry) => {",
1171
+ " const p = entry.params || {}",
1172
+ " switch (entry.action) {",
1173
+ " case 'navigate': return '导航到 ' + String(p.url || '').slice(0, 80)",
1174
+ " case 'click': return '点击 (' + p.x + ', ' + p.y + ')'",
1175
+ " case 'fill': return '填写表单 ' + (Array.isArray(p.fields) ? p.fields.length : p.fields ?? 1) + ' 项'",
1176
+ " case 'type': return '输入文字' + (p.chars ? '(' + p.chars + ' 字符)' : '')",
1177
+ " case 'execute': return '执行页面脚本'",
1178
+ " case 'screenshot': return '截图' + (p.fullPage ? '(整页)' : '')",
1179
+ " case 'download': return '下载 ' + String(p.savePath || p.url || '').slice(0, 60)",
1180
+ " case 'content': return '读取页面内容' + (p.selector ? '(' + p.selector + ')' : '')",
1181
+ " case 'snapshot': return '页面快照'",
1182
+ " default: return String(entry.action || '')",
1183
+ " }",
1184
+ " }",
1185
+ " // 「刚变了」的一下短反馈(轨迹列表新增行 / 缩放百分比)。只对**真的变化**播,",
1186
+ " // 整表重画不播 —— 否则一开面板满屏都在动。",
1187
+ " // 工具栏按钮上的字(接管 / 交还 Agent)和 toast 的正文都是**整段换掉**的 ——",
1188
+ " // 值真的变了才播一下,避免每次重渲染都闪。",
1189
+ " const swapText = (element, next, id) => {",
1190
+ " const value = String(next == null ? '' : next)",
1191
+ " if (element.textContent === value) return",
1192
+ " element.textContent = value",
1193
+ " if (typeof element.animate !== 'function') return",
1194
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
1195
+ " motionLog.push({ id, phase: 'start', name: 'dshTextIn' })",
1196
+ " element.animate([{ opacity: .35 }, { opacity: 1 }], { duration: 140, easing: 'ease-out' })",
1197
+ " }",
1198
+ " // 切任务/切标签时,新露出来的页面从表面色淡进来(宿主只在「可见视图真的换了」时发这个 patch)。",
1199
+ " const playReveal = () => {",
1200
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
1201
+ " if (typeof reveal.animate !== 'function') return",
1202
+ " motionLog.push({ id: 'reveal', phase: 'start', name: 'dshReveal' })",
1203
+ " reveal.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 140, easing: 'ease-out' })",
1204
+ " }",
1205
+ " const flashChange = (element, id, name, frames) => {",
1206
+ " motionLog.push({ id, phase: 'start', name })",
1207
+ " if (typeof Element.prototype.animate !== 'function') return",
1208
+ " if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return",
1209
+ " element.animate(frames, { duration: 150, easing: 'cubic-bezier(.2,.8,.2,1)' })",
1210
+ " }",
1211
+ " const renderTrail = () => {",
1212
+ " const list = Array.isArray(window.__dshTrail) ? window.__dshTrail : []",
1213
+ " trailList.textContent = ''",
1214
+ " if (list.length === 0) { const empty = document.createElement('div'); empty.className = 'empty'; empty.textContent = '暂无操作记录'; trailList.append(empty); return }",
1215
+ " const items = [...list].reverse()",
1216
+ " let lastDay = ''",
1217
+ " for (const entry of items) {",
1218
+ " const day = fmtDate(entry.at)",
1219
+ " if (day !== lastDay) {",
1220
+ " const head = document.createElement('div'); head.className = 'activity-day'; head.dataset.dshTrailDay = day; head.textContent = '— ' + day + ' —'; trailList.append(head); lastDay = day",
1221
+ " }",
1222
+ " const row = document.createElement('div')",
1223
+ " row.className = 'activity-item'",
1224
+ " const time = document.createElement('span'); time.className = 't-time'; time.textContent = fmtTime(entry.at)",
1225
+ " const rail = document.createElement('div'); rail.className = 'timeline-rail'",
1226
+ " const flag = document.createElement('span'); flag.className = 't-flag ' + (entry.ok === false ? 'err' : 'ok'); flag.textContent = entry.ok === false ? '✗' : '✓'",
1227
+ " rail.append(flag)",
1228
+ " const text = document.createElement('span'); text.className = 't-text'; text.textContent = describe(entry)",
1229
+ " row.append(time, rail, text)",
1230
+ " trailList.append(row)",
1231
+ " }",
1232
+ " }",
1233
+ " window.__dshTrailRender = renderTrail",
1234
+ " const appendTrailEntry = entry => {",
1235
+ " if (!trailPanel.classList.contains('open')) return",
1236
+ " if (!entry || typeof entry !== 'object') return",
1237
+ " if (trailList.childElementCount === 0 || trailList.querySelector('.empty')) { renderTrail(); return }",
1238
+ " const atTop = trailList.scrollTop <= 4",
1239
+ " const previousHeight = trailList.scrollHeight",
1240
+ " const day = fmtDate(entry.at)",
1241
+ " const row = document.createElement('div')",
1242
+ " row.className = 'activity-item'",
1243
+ " const time = document.createElement('span'); time.className = 't-time'; time.textContent = fmtTime(entry.at)",
1244
+ " const rail = document.createElement('div'); rail.className = 'timeline-rail'",
1245
+ " const flag = document.createElement('span'); flag.className = 't-flag ' + (entry.ok === false ? 'err' : 'ok'); flag.textContent = entry.ok === false ? '✗' : '✓'",
1246
+ " rail.append(flag)",
1247
+ " const text = document.createElement('span'); text.className = 't-text'; text.textContent = describe(entry)",
1248
+ " row.append(time, rail, text)",
1249
+ " const first = trailList.firstElementChild",
1250
+ " if (first && first instanceof HTMLElement && first.dataset.dshTrailDay === day) first.after(row)",
1251
+ " else { const head = document.createElement('div'); head.className = 'activity-day'; head.dataset.dshTrailDay = day; head.textContent = '— ' + day + ' —'; trailList.prepend(row); trailList.prepend(head) }",
1252
+ " flashChange(row, 'trail-row', 'dshRowIn', [{ opacity: 0, transform: 'translateY(-4px)' }, { opacity: 1, transform: 'none' }])",
1253
+ " if (!atTop) trailList.scrollTop += trailList.scrollHeight - previousHeight",
1254
+ " }",
1255
+ " const emitTaskAction = taskKey => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'switch-task', taskKey, token: bindingToken })) } catch { /* host unavailable */ } }",
1256
+ " const emitTaskControl = (taskKey, control) => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'set-control-owner', taskKey, control, token: bindingToken })) } catch { /* host unavailable */ } }",
1257
+ " const emitWorkspacePanels = (tasks, trail) => { if (bindingToken === '') return; const payload = { type: 'set-workspace-panels', tasks, trail }; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ ...payload, token: bindingToken })) } catch { /* host unavailable */ } }",
1258
+ " const chromeTabList = () => Array.isArray(window.__dshTabs) ? window.__dshTabs : []",
1259
+ " const activeChromeTab = () => chromeTabList().find(tab => tab && tab.active === true)",
1260
+ " const activeTabUrl = () => { const tab = activeChromeTab(); return tab && typeof tab.url === 'string' ? tab.url : '' }",
1261
+ " const stepChromeTab = delta => {",
1262
+ " const list = chromeTabList()",
1263
+ " if (list.length < 2 || typeof chromeSelectedTaskKey !== 'string') return",
1264
+ " const at = list.findIndex(tab => tab && tab.active === true)",
1265
+ " const next = list[((at < 0 ? 0 : at) + delta + list.length) % list.length]",
1266
+ " if (next && typeof next.id === 'string') emitTabSwitch(chromeSelectedTaskKey, next.id)",
1267
+ " }",
1268
+ " const emitTabSwitch = (taskKey, tabId) => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'switch-tab', taskKey, tabId, token: bindingToken })) } catch { /* host unavailable */ } }",
1269
+ " // 新建/关闭标签:宿主没有权限自己造或销毁标签(标签的账在 provider 手里),",
1270
+ " // 所以这里只发请求,由宿主转给 provider,provider 建/销毁视图后再回推标签列表。",
1271
+ " const emitTabCreate = taskKey => { if (bindingToken === '' || typeof taskKey !== 'string') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'new-tab', taskKey, token: bindingToken })) } catch { /* host unavailable */ } }",
1272
+ " const emitTabMove = (taskKey, tabId, toIndex) => { if (bindingToken === '' || typeof taskKey !== 'string' || typeof tabId !== 'string' || !Number.isInteger(toIndex) || toIndex < 0) return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'move-tab', taskKey, tabId, toIndex, token: bindingToken })) } catch { /* host unavailable */ } }",
1273
+ " const emitTabClose = (taskKey, tabId) => { if (bindingToken === '' || typeof taskKey !== 'string' || typeof tabId !== 'string') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'close-tab', taskKey, tabId, token: bindingToken })) } catch { /* host unavailable */ } }",
1274
+ " const emitImportCookies = list => { if (bindingToken === '' || !Array.isArray(list)) return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'import-cookies', token: bindingToken, cookies: list })) } catch { /* host unavailable */ } }",
1275
+ " let toastTimer = 0",
1276
+ " let toastHideTimer = 0",
1277
+ " const finishToastHide = () => { toast.classList.remove('closing'); toast.classList.remove('open') }",
1278
+ " const hideToast = () => {",
1279
+ " if (!toast.classList.contains('open')) return",
1280
+ " // 收起时先淡出,再由计时器摘掉 open 真正 display:none。**不用 animationend 单独驱动** ——",
1281
+ " // 系统开了「减少动态效果」时动画不跑,那个事件就永远不来,toast 会挂在那儿不走。",
1282
+ " toast.classList.add('closing')",
1283
+ " if (toastHideTimer !== 0) window.clearTimeout(toastHideTimer)",
1284
+ " toastHideTimer = window.setTimeout(finishToastTimer, 220)",
1285
+ " }",
1286
+ " const finishToastTimer = () => { toastHideTimer = 0; finishToastHide() }",
1287
+ " const showToast = (text, level) => {",
1288
+ " swapText(toast, text, 'toast')",
1289
+ " toast.classList.toggle('warn', level === 'warn')",
1290
+ " // 正在淡出时又来了一条:取消退场,重新落下来。",
1291
+ " if (toastHideTimer !== 0) { window.clearTimeout(toastHideTimer); toastHideTimer = 0 }",
1292
+ " toast.classList.remove('closing')",
1293
+ " toast.classList.add('open')",
1294
+ " if (toastTimer !== 0) window.clearTimeout(toastTimer)",
1295
+ " toastTimer = window.setTimeout(hideToast, 5000)",
1296
+ " }",
1297
+ " // 动效测试缝:toast 在闭影子根里,外面既看不见也点不到,所以由 chrome 自己报状态、",
1298
+ " // 记录动画事件(__dshChromeMotion),冒烟据此断言「落下来 / 淡出去」都真的跑了。",
1299
+ " // 测试用:直接给第 N 个任务的缩略图喂一帧(真实的帧由宿主每隔几秒抓屏推来)。",
1300
+ " window.__dshChromeTaskThumb = (source, index) => {",
1301
+ " const thumbs = taskPanel.querySelectorAll('.task-thumb')",
1302
+ " const thumb = thumbs[typeof index === 'number' ? index : 0]",
1303
+ " if (!(thumb instanceof HTMLElement)) return 0",
1304
+ " const payload = String(source || '')",
1305
+ " taskThumbCache.set('probe', payload)",
1306
+ " paintTaskThumbnail(thumb, { thumbnail: payload, thumbnailVersion: Date.now() }, 'probe')",
1307
+ " return 1",
1308
+ " }",
1309
+ " window.__dshChromeToast = (text, level) => { showToast(text, level); return 'shown' }",
1310
+ " window.__dshChromeToastState = () => JSON.stringify({ open: toast.classList.contains('open'), closing: toast.classList.contains('closing'), text: toast.textContent })",
1311
+ " toast.addEventListener('animationstart', event => { motionLog.push({ id: 'toast', phase: 'start', name: String(event.animationName || '') }) })",
1312
+ " toast.addEventListener('animationend', event => { motionLog.push({ id: 'toast', phase: 'end', name: String(event.animationName || '') }) })",
1313
+ " findBar.addEventListener('animationstart', event => { motionLog.push({ id: 'find', phase: 'start', name: String(event.animationName || '') }) })",
1314
+ " findBar.addEventListener('animationend', event => { motionLog.push({ id: 'find', phase: 'end', name: String(event.animationName || '') }) })",
1315
+ " const emitBookmarkBar = visible => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'bookmark-bar', taskKey: chromeSelectedTaskKey, token: bindingToken, visible: !!visible })) } catch { /* host unavailable */ } }",
1316
+ " const emitZoom = factor => { if (bindingToken === '' || typeof factor !== 'number') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'set-zoom', taskKey: chromeSelectedTaskKey, factor, token: bindingToken })) } catch { /* host unavailable */ } }",
1317
+ " const latestText = latest => { if (!latest || typeof latest !== 'object') return ''; const action = String(latest.action || ''); return action === '' ? '' : action + (latest.at ? ' · ' + fmtTime(latest.at) : '') }",
1318
+ " const statusText = status => status === 'running' ? '执行中' : status === 'waiting-user' ? '等待用户' : status === 'failed' ? '失败' : '空闲'",
1319
+ ' let activeTask = null',
1320
+ ' let userControlRequested = false',
1321
+ " // Thumbnails arrive only through the 'task.thumbnail' patch, never in the",
1322
+ " // bulk task summaries (those are readable by the page being displayed).",
1323
+ ' const taskThumbCache = new Map()',
1324
+ ' const renderTaskControl = tasks => {',
1325
+ " activeTask = tasks.find(task => task && task.active === true) || null",
1326
+ " if (!activeTask || typeof activeTask.key !== 'string') { userControlRequested = false; taskControl.disabled = true; taskControl.textContent = '接管'; taskControl.removeAttribute('data-control'); return }",
1327
+ " const human = activeTask.control === 'human'",
1328
+ " if (!human) userControlRequested = false",
1329
+ " taskControl.disabled = false; taskControl.dataset.control = human ? 'human' : 'agent'; swapText(taskControl, human ? '交还 Agent' : '接管', 'task-control'); taskControl.setAttribute('aria-label', human ? '交还当前任务给 Agent' : '接管当前任务')",
1330
+ ' }',
1331
+ " // 缩略图每隔几秒就来一帧 —— 硬换会闪,所以新的**淡入**盖住旧的那帧,旧帧等动画结束再摘。",
1332
+ " // 两层必须落在同一格(grid-area:1/1),否则 grid 会把它们并排放。",
1333
+ " const paintThumbCanvas = (thumb, canvas) => {",
1334
+ " const previous = thumb.querySelector('canvas.task-thumb-canvas')",
1335
+ " if (previous === null) thumb.textContent = ''",
1336
+ " thumb.append(canvas)",
1337
+ " motionLog.push({ id: 'task-thumb', phase: 'start', name: 'dshThumbIn' })",
1338
+ " const reduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches",
1339
+ " if (previous === null || typeof canvas.animate !== 'function' || reduced) { if (previous !== null) previous.remove(); return }",
1340
+ " const drop = () => previous.remove()",
1341
+ " const animation = canvas.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, easing: 'ease-out' })",
1342
+ " // 收尾交给 finished,兜底 400ms(动画被取消时 finished 会 reject,也要收)。",
1343
+ " if (animation.finished && typeof animation.finished.then === 'function') animation.finished.then(drop, drop)",
1344
+ " else window.setTimeout(drop, 400)",
1345
+ " }",
1346
+ " const paintTaskThumbnail = (thumb, task, taskKey) => {",
1347
+ " const cached = taskThumbCache.get(taskKey)",
1348
+ " const source = typeof cached === 'string' ? cached : ''",
1349
+ " const version = String(Number.isFinite(task.thumbnailVersion) ? task.thumbnailVersion : 0)",
1350
+ " const token = taskKey + ':' + version + ':' + source.length",
1351
+ " if (thumb.dataset.dshThumbnailToken === token) return",
1352
+ " thumb.dataset.dshThumbnailToken = token",
1353
+ " const origin = typeof task.url === 'string' ? task.url.replace(/^https?:\\/\\//, '') : ''",
1354
+ " thumb.textContent = origin === '' ? 'DSH' : origin.toUpperCase().slice(0, 2)",
1355
+ " if (!source.startsWith('data:image/jpeg;base64,') || typeof createImageBitmap !== 'function') return",
1356
+ " try {",
1357
+ " const payload = source.slice('data:image/jpeg;base64,'.length)",
1358
+ " const bytes = Uint8Array.from(atob(payload), ch => ch.charCodeAt(0))",
1359
+ " const blob = new Blob([bytes], { type: 'image/jpeg' })",
1360
+ " createImageBitmap(blob).then(bitmap => {",
1361
+ " const canvas = document.createElement('canvas')",
1362
+ " canvas.className = 'task-thumb-canvas'",
1363
+ " canvas.width = 188",
1364
+ " canvas.height = 128",
1365
+ " const ctx = canvas.getContext('2d')",
1366
+ " if (!ctx) { if (typeof bitmap.close === 'function') bitmap.close(); return }",
1367
+ " const scale = Math.max(canvas.width / bitmap.width, canvas.height / bitmap.height)",
1368
+ " const drawnWidth = bitmap.width * scale",
1369
+ " const drawnHeight = bitmap.height * scale",
1370
+ " ctx.drawImage(bitmap, (canvas.width - drawnWidth) / 2, (canvas.height - drawnHeight) / 2, drawnWidth, drawnHeight)",
1371
+ " if (typeof bitmap.close === 'function') bitmap.close()",
1372
+ " if (thumb.isConnected && thumb.dataset.dshThumbnailToken === token) paintThumbCanvas(thumb, canvas)",
1373
+ " }).catch(() => { /* decode failure keeps fallback */ })",
1374
+ " } catch { /* malformed payload keeps fallback */ }",
1375
+ " }",
1376
+ " // 任务行原来是整列重建的(清空容器再 append)—— 任务多起来/结束时整列瞬移,",
1377
+ " // 正在跑的那个任务的缩略图也会被重画。改成和书签同一套:按 key 复用节点 + FLIP。",
1378
+ " const renderTasks = () => {",
1379
+ " const tasks = (Array.isArray(window.__dshTasks) ? window.__dshTasks : []).filter(task => task && typeof task === 'object')",
1380
+ " renderTaskControl(tasks)",
1381
+ " // key 不合法的任务照样列出来,但是禁用(第 8 轮就有的行为,别丢)。",
1382
+ " const keys = tasks.map((task, index) => (typeof task.key === 'string' && task.key.trim() !== '' ? task.key : 'malformed-' + index))",
1383
+ " const byKey = new Map(tasks.map((task, index) => [keys[index], task]))",
1384
+ " for (const child of Array.from(taskList.children)) {",
1385
+ " if (child instanceof HTMLElement && child.classList.contains('empty')) child.remove()",
1386
+ " }",
1387
+ " if (tasks.length === 0) {",
1388
+ " reconcileList(taskList, [], () => document.createElement('div'), () => {}, 'task-panel')",
1389
+ " const empty = document.createElement('div')",
1390
+ " empty.className = 'empty'",
1391
+ " empty.textContent = '暂无浏览器任务'",
1392
+ " taskList.append(empty)",
1393
+ " return",
1394
+ " }",
1395
+ " reconcileList(taskList, keys, () => {",
1396
+ " const row = document.createElement('button')",
1397
+ " row.type = 'button'",
1398
+ " const thumb = document.createElement('div'); thumb.className = 'task-thumb'; thumb.dataset.dshTaskThumb = '1'",
1399
+ " const title = document.createElement('span'); title.className = 'task-title'",
1400
+ " const state = document.createElement('span'); state.className = 'task-state'; state.dataset.dshTaskState = '1'",
1401
+ " const label = document.createElement('span'); label.dataset.dshTaskLabel = '1'",
1402
+ " title.append(state, label)",
1403
+ " const meta = document.createElement('span'); meta.className = 'task-meta'; meta.dataset.dshTaskMeta = '1'",
1404
+ " const recent = document.createElement('span'); recent.className = 'task-latest'; recent.dataset.dshTaskLatest = '1'",
1405
+ " const failure = document.createElement('span'); failure.className = 'task-error'; failure.dataset.dshTaskError = '1'",
1406
+ " row.append(thumb, title, meta, recent, failure)",
1407
+ " // 复用之后闭包里的 key 会过期 —— 点的时候从 dataset 现读(第 46 轮的教训)。",
1408
+ " row.addEventListener('click', () => {",
1409
+ " const key = row.dataset.dshKey",
1410
+ " if (typeof key === 'string' && key !== '') emitTaskAction(key)",
1411
+ " })",
1412
+ " return row",
1413
+ " }, (row, key) => {",
1414
+ " const task = byKey.get(key)",
1415
+ " if (task === undefined || !(row instanceof HTMLButtonElement)) return",
1416
+ " const status = ['idle', 'running', 'waiting-user', 'failed'].includes(task.status) ? task.status : 'idle'",
1417
+ " const control = task.control === 'human' ? 'human' : 'agent'",
1418
+ " row.className = 'task-row status-' + status + (task.active ? ' active' : '')",
1419
+ " row.disabled = task.active === true || !(typeof task.key === 'string' && task.key.trim() !== '')",
1420
+ " const state = row.querySelector('[data-dsh-task-state]')",
1421
+ " if (state instanceof HTMLElement) state.setAttribute('aria-label', statusText(status))",
1422
+ " const label = row.querySelector('[data-dsh-task-label]')",
1423
+ " if (label !== null) label.textContent = (typeof task.label === 'string' && task.label !== '' ? task.label : key)",
1424
+ " const meta = row.querySelector('[data-dsh-task-meta]')",
1425
+ " if (meta instanceof HTMLElement) swapText(meta, statusText(status) + ' · ' + (control === 'human' ? '人工控制' : 'Agent 控制'), 'task-meta')",
1426
+ " const recent = row.querySelector('[data-dsh-task-latest]')",
1427
+ " if (recent !== null) recent.textContent = latestText(task.latest)",
1428
+ " const failure = row.querySelector('[data-dsh-task-error]')",
1429
+ " if (failure !== null) failure.textContent = typeof task.error === 'string' ? task.error : ''",
1430
+ " const thumb = row.querySelector('.task-thumb')",
1431
+ " if (thumb instanceof HTMLElement) paintTaskThumbnail(thumb, task, key)",
1432
+ " }, 'task-panel')",
1433
+ " }",
1434
+ " window.__dshTaskRender = renderTasks",
1435
+ " const patchTaskRow = task => {",
1436
+ " if (!task || typeof task !== 'object' || typeof task.key !== 'string') return false",
1437
+ " const row = [...taskList.children].find(candidate => candidate instanceof HTMLElement && candidate.dataset.dshKey === task.key)",
1438
+ " if (!(row instanceof HTMLButtonElement)) return false",
1439
+ " const version = String(Number.isFinite(task.thumbnailVersion) ? task.thumbnailVersion : 0)",
1440
+ " if (row.dataset.dshThumbnailVersion !== version) return false",
1441
+ " const status = ['idle', 'running', 'waiting-user', 'failed'].includes(task.status) ? task.status : 'idle'",
1442
+ " const control = task.control === 'human' ? 'human' : 'agent'",
1443
+ " row.className = 'task-row status-' + status + (task.active ? ' active' : '')",
1444
+ " // 与整表渲染同一条规则:当前任务的行点不动(点了也只是切到自己)。",
1445
+ " row.disabled = task.active === true",
1446
+ " const state = row.querySelector('[data-dsh-task-state]')",
1447
+ " if (state instanceof HTMLElement) state.setAttribute('aria-label', statusText(status) + (task.active ? ',当前任务' : ',后台任务'))",
1448
+ " const label = row.querySelector('[data-dsh-task-label]')",
1449
+ " if (label instanceof HTMLElement) label.textContent = typeof task.label === 'string' && task.label !== '' ? task.label : task.key.slice(0, 12)",
1450
+ " const meta = row.querySelector('[data-dsh-task-meta]')",
1451
+ " if (meta instanceof HTMLElement) meta.textContent = statusText(status) + ' · ' + (control === 'human' ? '用户接管' : 'Agent 控制') + ' · ' + (Number.isFinite(task.tabs) ? task.tabs : 0) + ' 个标签页' + (typeof task.url === 'string' && task.url !== '' ? ' · ' + task.url : '')",
1452
+ " const recent = row.querySelector('[data-dsh-task-latest]')",
1453
+ " if (recent instanceof HTMLElement) recent.textContent = latestText(task.latest)",
1454
+ " const failure = row.querySelector('[data-dsh-task-error]')",
1455
+ " if (failure instanceof HTMLElement) failure.textContent = typeof task.error === 'string' ? task.error : ''",
1456
+ " return true",
1457
+ " }",
1458
+ " // 菜单的定位函数在下面才定义(它要用到工具栏按钮的矩形),但 applyWorkspacePanels",
1459
+ " // 在 bootstrap 到达时就会被调用——那时 const 还在 TDZ 里。用这个可替换的空实现先兜住。",
1460
+ " let anchorOpenMenus = () => {}",
1461
+ " const applyWorkspacePanels = () => {",
1462
+ " const state = window.__dshWorkspacePanels && typeof window.__dshWorkspacePanels === 'object' ? window.__dshWorkspacePanels : {}",
1463
+ " const tasksOpen = state.tasks === true",
1464
+ " const trailOpen = state.trail === true",
1465
+ " if (tasksOpen) showSurface(taskPanel); else if (isPanelOpen(taskPanel)) beginSurfaceClose(taskPanel)",
1466
+ " if (trailOpen) showSurface(trailPanel); else if (isPanelOpen(trailPanel)) beginSurfaceClose(trailPanel)",
1467
+ " anchorOpenMenus()",
1468
+ " if (tasksOpen || trailOpen) { bar.classList.add('open'); tabstrip.classList.add('open'); toolbarRevealZone.classList.remove('armed') }",
1469
+ " }",
1470
+ " const syncWorkspacePanels = () => { const tasksOpen = isPanelOpen(taskPanel); const trailOpen = isPanelOpen(trailPanel); emitWorkspacePanels(tasksOpen, trailOpen) }",
1471
+ " const armToolbarReveal = () => { if (!bar.classList.contains('open')) toolbarRevealZone.classList.add('armed') }",
1472
+ " const disarmToolbarReveal = () => { if (!bar.classList.contains('open') && root.activeElement !== toolbarReveal) toolbarRevealZone.classList.remove('armed') }",
1473
+ " const isToolbarTriggerPosition = event => event.clientY <= 56 && Math.abs(event.clientX - window.innerWidth / 2) <= 140",
1474
+ " const trackToolbarReveal = event => { if (!event.isTrusted || agentInputSuppressed() || bar.classList.contains('open')) return; if (isToolbarTriggerPosition(event)) armToolbarReveal(); else disarmToolbarReveal() }",
1475
+ " const triggerToolbarFromPage = event => {",
1476
+ " // Agent input must reach the page: swallowing it here made clicks in the",
1477
+ " // top-centre band silently vanish while the tool still reported success.",
1478
+ " if (!event.isTrusted || agentInputSuppressed() || bar.classList.contains('open') || event.button !== 0 || !isToolbarTriggerPosition(event)) return",
1479
+ " event.preventDefault()",
1480
+ " event.stopImmediatePropagation()",
1481
+ " openToolbar()",
1482
+ " }",
1483
+ " const openToolbar = () => { bar.classList.add('open'); tabstrip.classList.add('open'); toolbarRevealZone.classList.remove('armed'); toolbarRevealZone.style.display = 'none' }",
1484
+ " const closeToolbar = () => {",
1485
+ " bar.classList.remove('open')",
1486
+ " tabstrip.classList.remove('open')",
1487
+ " if (isPanelOpen(panel)) beginSurfaceClose(panel)",
1488
+ " if (isPanelOpen(trailPanel)) beginSurfaceClose(trailPanel)",
1489
+ " if (isPanelOpen(taskPanel)) beginSurfaceClose(taskPanel)",
1490
+ " syncWorkspacePanels()",
1491
+ " toolbarRevealZone.style.display = ''",
1492
+ " toolbarRevealZone.classList.add('armed')",
1493
+ " toolbarReveal.focus()",
1494
+ " }",
1495
+ " window.__dshWorkspaceRender = applyWorkspacePanels",
1496
+ " let chromeEpoch = -1",
1497
+ " let chromeRevision = -1",
1498
+ " let chromeSelectedTaskKey = undefined",
1499
+ " const requestChromeBootstrap = () => { if (bindingToken === '') return; try { const binding = window.__dshBrowserTaskAction; if (typeof binding === 'function') binding(JSON.stringify({ type: 'request-chrome-bootstrap', token: bindingToken })) } catch { /* host unavailable */ } }",
1500
+ " // 标签图标:宿主抓来的 favicon 会以 data: URL 出现在 tabs 里。",
1501
+ " // 这里**绝不建 img 元素、也不写它的 src 属性** —— 页面的 CSP 会拦掉图片加载",
1502
+ " // 一律走 atob → Blob → createImageBitmap → canvas。",
1503
+ " // 只有白名单里的栅格类型才解码;其余保持站点首字母回退。",
1504
+ " const tabIconPattern = /^data:image[/](?:png|jpeg|webp|gif|bmp|x-icon|vnd[.]microsoft[.]icon);base64,/",
1505
+ " const paintTabFavicon = (slot, tab) => {",
1506
+ " // 加载中就是 Chrome 那个转圈:占掉图标位,字母/图标都先不画。",
1507
+ " slot.classList.toggle('loading', tab.loading === true)",
1508
+ " if (tab.loading === true) { slot.textContent = ''; return }",
1509
+ " // 没图标时退化成站点首字母;www. 前缀要去掉,否则 iana.org 会显示成 W。",
1510
+ " const origin = typeof tab.url === 'string' ? tab.url.replace(/^https?:[/][/]/, '').replace(/^www[.]/i, '') : ''",
1511
+ " slot.textContent = origin === '' ? '' : origin.slice(0, 1).toUpperCase()",
1512
+ " const source = typeof tab.favicon === 'string' ? tab.favicon : ''",
1513
+ " if (!tabIconPattern.test(source) || typeof createImageBitmap !== 'function') return",
1514
+ " try {",
1515
+ " const payload = source.slice(source.indexOf(',') + 1)",
1516
+ " const bytes = Uint8Array.from(atob(payload), ch => ch.charCodeAt(0))",
1517
+ " const blob = new Blob([bytes], { type: source.slice(5, source.indexOf(';')) })",
1518
+ " createImageBitmap(blob).then(bitmap => {",
1519
+ " const canvas = document.createElement('canvas')",
1520
+ " canvas.className = 'tab-icon-canvas'",
1521
+ " canvas.width = 32",
1522
+ " canvas.height = 32",
1523
+ " const ctx = canvas.getContext('2d')",
1524
+ " if (!ctx) { if (typeof bitmap.close === 'function') bitmap.close(); return }",
1525
+ " const scale = Math.min(canvas.width / bitmap.width, canvas.height / bitmap.height)",
1526
+ " const drawnWidth = bitmap.width * scale",
1527
+ " const drawnHeight = bitmap.height * scale",
1528
+ " ctx.drawImage(bitmap, (canvas.width - drawnWidth) / 2, (canvas.height - drawnHeight) / 2, drawnWidth, drawnHeight)",
1529
+ " if (typeof bitmap.close === 'function') bitmap.close()",
1530
+ " if (slot.isConnected) { slot.textContent = ''; slot.append(canvas) }",
1531
+ " }).catch(() => { /* decode failure keeps the letter fallback */ })",
1532
+ " } catch { /* malformed payload keeps the letter fallback */ }",
1533
+ " }",
1534
+ " // 标签栏:数据来自 bootstrap 的 tabs 或 patch 的 tabs.set,只含当前选中任务的标签页。",
1535
+ " // --- 拖动排序用的小工具 ---",
1536
+ " let tabDrag = null",
1537
+ " const tabElements = () => Array.from(tabstrip.querySelectorAll('[data-dsh-tab-id]'))",
1538
+ " const clearTabShift = () => { for (const element of tabElements()) element.style.transform = '' }",
1539
+ " const tabDropIndex = (clientX, draggedId) => {",
1540
+ " const listed = (Array.isArray(window.__dshTabs) ? window.__dshTabs : []).filter(candidate => candidate && candidate.id !== draggedId)",
1541
+ " let index = 0",
1542
+ " for (const candidate of listed) {",
1543
+ " const element = tabstrip.querySelector('[data-dsh-tab-id=' + JSON.stringify(String(candidate.id)) + ']')",
1544
+ " if (element === null) continue",
1545
+ " const rect = element.getBoundingClientRect()",
1546
+ " if (clientX > rect.left + rect.width / 2) index += 1",
1547
+ " }",
1548
+ " return index",
1549
+ " }",
1550
+ " const shiftTabsForDrag = drag => {",
1551
+ " const listed = Array.isArray(window.__dshTabs) ? window.__dshTabs : []",
1552
+ " let position = 0",
1553
+ " for (const candidate of listed) {",
1554
+ " if (!candidate || typeof candidate.id !== 'string' || candidate.id === drag.id) continue",
1555
+ " const element = tabstrip.querySelector('[data-dsh-tab-id=' + JSON.stringify(String(candidate.id)) + ']')",
1556
+ " const before = position < drag.from",
1557
+ " position += 1",
1558
+ " if (element === null) continue",
1559
+ " // 让开:往右拖时中间那几格左移,往左拖时右移,视觉上给被拖的标签腾位置。",
1560
+ " if (!before && position - 1 >= drag.index) element.style.transform = ''",
1561
+ " else if (before && position - 1 >= drag.index) element.style.transform = 'translateX(' + Math.round(drag.width) + 'px)'",
1562
+ " else if (!before && position - 1 < drag.index) element.style.transform = 'translateX(-' + Math.round(drag.width) + 'px)'",
1563
+ " else element.style.transform = ''",
1564
+ " }",
1565
+ " }",
1566
+ " const onTabDragMove = event => {",
1567
+ " if (tabDrag === null) return",
1568
+ " const dx = event.clientX - tabDrag.startX",
1569
+ " if (tabDrag.moved !== true && Math.abs(dx) < 4) return",
1570
+ " tabDrag.moved = true",
1571
+ " tabDrag.button.classList.add('dragging')",
1572
+ " tabDrag.button.style.transform = 'translateX(' + Math.round(dx) + 'px)' ",
1573
+ " tabDrag.index = tabDropIndex(event.clientX, tabDrag.id)",
1574
+ " shiftTabsForDrag(tabDrag)",
1575
+ " }",
1576
+ " const endTabDrag = () => {",
1577
+ " if (tabDrag === null) return",
1578
+ " const drag = tabDrag",
1579
+ " tabDrag = null",
1580
+ " drag.button.classList.remove('dragging')",
1581
+ " drag.button.style.transform = ''",
1582
+ " clearTabShift()",
1583
+ " if (drag.moved === true && drag.index >= 0 && typeof chromeSelectedTaskKey === 'string') emitTabMove(chromeSelectedTaskKey, drag.id, drag.index)",
1584
+ " }",
1585
+ " // 入场动画跑完就摘掉 enter:免得动画的 transform 和拖动时的行内 transform 打架。",
1586
+ " tabstrip.addEventListener('animationend', event => {",
1587
+ " const target = event.target",
1588
+ " if (target !== null && typeof target.classList === 'object' && target.classList.contains('enter')) target.classList.remove('enter')",
1589
+ " })",
1590
+ " window.addEventListener('pointermove', onTabDragMove, true)",
1591
+ " window.addEventListener('pointerup', endTabDrag, true)",
1592
+ " window.addEventListener('pointercancel', endTabDrag, true)",
1593
+ " // 动效测试缝:弹层/标签都在闭影子根里,外面既点不到也算不到它们的 opacity,所以",
1594
+ " // 入场动画只给「本文档渲染过一次之后新增」的标签:chrome 每次导航都会重新注入,",
1595
+ " // 不加这个判断的话整条标签栏会在每个页面加载时重新弹一次。",
1596
+ " let tabsRendered = false",
1597
+ " let lastTabIds = []",
1598
+ " // 标签节点按 id 复用,而不是每次重画整条标签栏。两个理由:",
1599
+ " // ① 重建会让 active/hover 的过渡永远播不出来(新节点没有「上一个状态」);",
1600
+ " // ② 关掉一个标签后,剩下的标签要能滑到新位置(FLIP),重建就没有「前一刻的位置」了。",
1601
+ " const tabNodes = new Map()",
1602
+ " const idOfButton = button => typeof button.dataset.dshTabId === 'string' ? button.dataset.dshTabId : ''",
1603
+ " const createTabNode = tab => {",
1604
+ " const button = document.createElement('button')",
1605
+ " button.type = 'button'",
1606
+ " button.className = 'tab'",
1607
+ " button.setAttribute('role', 'tab')",
1608
+ " const icon = document.createElement('span')",
1609
+ " icon.className = 'fav'",
1610
+ " const text = document.createElement('span')",
1611
+ " text.className = 't'",
1612
+ " const close = document.createElement('span')",
1613
+ " close.className = 'close'",
1614
+ " close.setAttribute('role', 'button')",
1615
+ " close.setAttribute('aria-label', '关闭标签页')",
1616
+ " close.title = '关闭标签页'",
1617
+ " close.textContent = '✕'",
1618
+ " // 监听器只装一次,标签 id 从 dataset 现读 —— 节点复用之后捕获的旧对象就作废了。",
1619
+ " close.addEventListener('click', event => {",
1620
+ " event.stopPropagation()",
1621
+ " if (typeof chromeSelectedTaskKey === 'string') emitTabClose(chromeSelectedTaskKey, idOfButton(button))",
1622
+ " })",
1623
+ " button.addEventListener('click', () => { if (typeof chromeSelectedTaskKey === 'string') emitTabSwitch(chromeSelectedTaskKey, idOfButton(button)) })",
1624
+ " // Chrome 的行为:中键点标签页就关掉它(不用先切过去)。",
1625
+ " button.addEventListener('auxclick', event => {",
1626
+ " if (event.button !== 1) return",
1627
+ " event.preventDefault()",
1628
+ " event.stopPropagation()",
1629
+ " if (typeof chromeSelectedTaskKey === 'string') emitTabClose(chromeSelectedTaskKey, idOfButton(button))",
1630
+ " })",
1631
+ " // --- 拖动排序(Chrome 的行为)---",
1632
+ " // 拖动跟手,落点用「其它标签的中点」判定:toIndex 是**移除被拖标签之后**",
1633
+ " // 的插入位置 —— 宿主和 provider 都按这个口径 splice,两端口径一致。",
1634
+ " button.addEventListener('pointerdown', event => {",
1635
+ " if (event.button !== 0 || typeof chromeSelectedTaskKey !== 'string') return",
1636
+ " // 点 ✕ 不是拖:捕获指针会把 click 重定向到标签本身,✕ 就再也点不到了(实测)。",
1637
+ " const pressedOn = event.target",
1638
+ " if (pressedOn !== null && typeof pressedOn.closest === 'function' && pressedOn.closest('.close') !== null) return",
1639
+ " const id = idOfButton(button)",
1640
+ " const listed = Array.isArray(window.__dshTabs) ? window.__dshTabs : []",
1641
+ " const rect = button.getBoundingClientRect()",
1642
+ " tabDrag = { id, button, startX: event.clientX, width: rect.width, from: listed.findIndex(candidate => candidate && candidate.id === id), index: -1, moved: false }",
1643
+ " // 不用 setPointerCapture:窗口级的 pointermove/pointerup 已经够了,而捕获会把",
1644
+ " // 后续的 click 重定向到标签本身(✕ 按钮就点不到了,实测)。",
1645
+ " })",
1646
+ " button.append(icon, text, close)",
1647
+ " return { button, icon, text }",
1648
+ " }",
1649
+ " const updateTabNode = (node, tab) => {",
1650
+ " const button = node.button",
1651
+ " button.dataset.dshTabId = tab.id",
1652
+ " // 用 classList 而不是 className:enter / dragging 是这一层的状态,不能被覆盖掉。",
1653
+ " button.classList.toggle('active', tab.active === true)",
1654
+ " button.setAttribute('aria-selected', tab.active === true ? 'true' : 'false')",
1655
+ " button.title = typeof tab.url === 'string' && tab.url !== '' ? tab.url : (typeof tab.title === 'string' ? tab.title : '')",
1656
+ " node.text.textContent = typeof tab.title === 'string' && tab.title !== '' ? tab.title : '新标签页'",
1657
+ " // 节点现在是复用的,所以每次 patch 都会走到这里 —— 但 favicon 的重画是「先摆字母、",
1658
+ " // 再异步解码换 canvas」,无条件重画会让图标每来一次 patch 就闪一下。只在真变了才画。",
1659
+ " const url = typeof tab.url === 'string' ? tab.url : ''",
1660
+ " const favicon = typeof tab.favicon === 'string' ? tab.favicon : ''",
1661
+ " const loading = tab.loading === true",
1662
+ " if (node.paintedUrl !== url || node.paintedFavicon !== favicon || node.paintedLoading !== loading) {",
1663
+ " node.paintedUrl = url",
1664
+ " node.paintedFavicon = favicon",
1665
+ " node.paintedLoading = loading",
1666
+ " paintTabFavicon(node.icon, tab)",
1667
+ " }",
1668
+ " }",
1669
+ " // Chrome 的 + 紧跟最后一个标签;即使一个标签都没有也要留着,",
1670
+ " // 否则任务会卡在一个标签上,再也开不出新的。",
1671
+ " const newTab = document.createElement('button')",
1672
+ " newTab.type = 'button'",
1673
+ " newTab.className = 'newtab'",
1674
+ " newTab.setAttribute('aria-label', '新建标签页')",
1675
+ " newTab.title = '新建标签页'",
1676
+ " const plusIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')",
1677
+ " plusIcon.setAttribute('width', '14')",
1678
+ " plusIcon.setAttribute('height', '14')",
1679
+ " plusIcon.setAttribute('viewBox', '0 0 24 24')",
1680
+ " plusIcon.setAttribute('fill', 'none')",
1681
+ " plusIcon.setAttribute('stroke', 'currentColor')",
1682
+ " plusIcon.setAttribute('stroke-width', '2')",
1683
+ " plusIcon.setAttribute('stroke-linecap', 'round')",
1684
+ " const plusPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')",
1685
+ " plusPath.setAttribute('d', 'M12 5v14M5 12h14')",
1686
+ " plusIcon.append(plusPath)",
1687
+ " newTab.append(plusIcon)",
1688
+ " newTab.addEventListener('click', () => { if (typeof chromeSelectedTaskKey === 'string') emitTabCreate(chromeSelectedTaskKey) })",
1689
+ " tabstrip.append(newTab)",
1690
+ " const renderTabs = () => {",
1691
+ " const tabs = Array.isArray(window.__dshTabs) ? window.__dshTabs : []",
1692
+ " // 工具栏的刷新/停止跟着**当前标签**的加载状态走,而不是整个任务。",
1693
+ " const activeTab = tabs.find(candidate => candidate && candidate.active === true)",
1694
+ " bar.classList.toggle('loading', activeTab !== undefined && activeTab.loading === true)",
1695
+ " // FLIP 的「前一刻」必须在动 DOM 之前量;拖动中的那个标签不算(它有行内 transform)。",
1696
+ " const beforeX = new Map()",
1697
+ " for (const [id, node] of tabNodes) {",
1698
+ " if (tabDrag !== null && tabDrag.id === id) continue",
1699
+ " if (node.button.isConnected === true) beforeX.set(id, node.button.getBoundingClientRect().left)",
1700
+ " }",
1701
+ " const ids = []",
1702
+ " const seen = new Set()",
1703
+ " let anchor = null",
1704
+ " for (const tab of tabs) {",
1705
+ " if (!tab || typeof tab !== 'object' || typeof tab.id !== 'string') continue",
1706
+ " seen.add(tab.id)",
1707
+ " ids.push(tab.id)",
1708
+ " let node = tabNodes.get(tab.id)",
1709
+ " if (node === undefined) {",
1710
+ " node = createTabNode(tab)",
1711
+ " tabNodes.set(tab.id, node)",
1712
+ " if (tabsRendered && !lastTabIds.includes(tab.id)) {",
1713
+ " node.button.classList.add('enter')",
1714
+ " // 记在 JS 里而不是只靠动画事件:可见的标签栏在 frame 视图里,而冒烟只有页面",
1715
+ " // 的求值通道。CSS 那条(.enter → dshTabIn)由单元测试钉住,这里钉住「该不该播」。",
1716
+ " motionLog.push({ id: 'tab', phase: 'start', name: 'dshTabIn' })",
1717
+ " }",
1718
+ " }",
1719
+ " updateTabNode(node, tab)",
1720
+ " // 只在位置不对的时候搬动:无谓的重新插入会打断正在播的动画。",
1721
+ " if (anchor === null) { if (tabstrip.firstElementChild !== node.button) tabstrip.insertBefore(node.button, tabstrip.firstChild) }",
1722
+ " else if (anchor.nextElementSibling !== node.button) anchor.after(node.button)",
1723
+ " anchor = node.button",
1724
+ " }",
1725
+ " for (const id of [...tabNodes.keys()]) {",
1726
+ " if (seen.has(id)) continue",
1727
+ " const node = tabNodes.get(id)",
1728
+ " tabNodes.delete(id)",
1729
+ " if (node === undefined) continue",
1730
+ " // 关掉的那个标签自己也要收起来:挪出文档流(这样幸存者的 FLIP 才量得准),原地淡出",
1731
+ " // 并把宽度收到 0 —— 和 Chrome 一样。记在 JS 里(不靠动画事件):可见的标签栏在",
1732
+ " // frame 视图里,冒烟只有页面的求值通道;page 表面那份 rect 全是 0,动画跑不了。",
1733
+ " const button = node.button",
1734
+ " motionLog.push({ id: 'tab', phase: 'close', name: 'dshTabOut' })",
1735
+ " const rect = button.getBoundingClientRect()",
1736
+ " const stripRect = tabstrip.getBoundingClientRect()",
1737
+ " const canAnimate = typeof Element.prototype.animate === 'function' && rect.width > 1 &&",
1738
+ " !window.matchMedia('(prefers-reduced-motion: reduce)').matches",
1739
+ " if (!canAnimate) { button.remove(); continue }",
1740
+ " const width = Math.round(rect.width)",
1741
+ " button.style.position = 'absolute'",
1742
+ " button.style.left = Math.round(rect.left - stripRect.left) + 'px'",
1743
+ " button.style.top = Math.round(rect.top - stripRect.top) + 'px'",
1744
+ " button.style.width = width + 'px'",
1745
+ " button.style.pointerEvents = 'none'",
1746
+ " const closing = button.animate(",
1747
+ " [{ opacity: 1, width: width + 'px' }, { opacity: 0, width: '0px' }],",
1748
+ " { duration: 160, easing: 'cubic-bezier(.2,.8,.2,1)' },",
1749
+ " )",
1750
+ " if (closing !== null && typeof closing.addEventListener === 'function') closing.addEventListener('finish', () => button.remove())",
1751
+ " // 兜底:动画被取消或没跑也要收掉,否则会留下一个绝对定位的幽灵标签。",
1752
+ " window.setTimeout(() => button.remove(), 400)",
1753
+ " }",
1754
+ " if (tabstrip.lastElementChild !== newTab) tabstrip.append(newTab)",
1755
+ " // FLIP:位置变了的标签从旧位置滑过去。用列表顺序判断而不是 rect —— page 表面那份",
1756
+ " // 标签栏是 display:none,rect 全是 0;真正量 rect 的动画跑在可见的 frame 那份里。",
1757
+ " const moved = ids.filter(id => lastTabIds.includes(id) && lastTabIds.indexOf(id) !== ids.indexOf(id))",
1758
+ " if (moved.length > 0) {",
1759
+ " motionLog.push({ id: 'tab', phase: 'slide', name: String(moved.length) })",
1760
+ " if (typeof Element.prototype.animate === 'function') {",
1761
+ " for (const id of moved) {",
1762
+ " const node = tabNodes.get(id)",
1763
+ " const was = beforeX.get(id)",
1764
+ " if (node === undefined || was === undefined) continue",
1765
+ " const dx = was - node.button.getBoundingClientRect().left",
1766
+ " if (Math.abs(dx) < 1) continue",
1767
+ " node.button.animate([{ transform: 'translateX(' + dx + 'px)' }, { transform: 'none' }], { duration: 160, easing: 'cubic-bezier(.2,.8,.2,1)' })",
1768
+ " }",
1769
+ " }",
1770
+ " }",
1771
+ " lastTabIds = ids",
1772
+ " tabsRendered = true",
1773
+ " }",
1774
+ " window.__dshTabsRender = renderTabs",
1775
+ " // The one piece of this state machine worth testing on its own: a dropped or",
1776
+ " // reordered patch must trigger a resync, never a partial apply.",
1777
+ " const decideChromeMessage = (kind, epoch, revision, currentEpoch, currentRevision) => kind === 'bootstrap' ? 'bootstrap' : kind !== 'patch' ? 'ignore' : (epoch !== currentEpoch || revision !== currentRevision + 1) ? 'resync' : 'apply'",
1778
+ " let bookmarksChanged = false",
1779
+ " const applyChromeMessage = message => {",
1780
+ " // bootstrap 也带收藏(宿主每次注入都会给),所以这里统一收口。",
1781
+ " if (Array.isArray(message.bookmarks)) { window.__dshBookmarks = message.bookmarks; bookmarksChanged = true }",
1782
+ " if (message.bookmarkBar === true || message.bookmarkBar === false) applyBookmarksVisible(message.bookmarkBar)",
1783
+ " if (!message || typeof message !== 'object') return",
1784
+ " if (message.kind === 'bootstrap') {",
1785
+ " chromeEpoch = Number(message.epoch)",
1786
+ " chromeRevision = Number(message.revision)",
1787
+ " chromeSelectedTaskKey = typeof message.selectedTaskKey === 'string' ? message.selectedTaskKey : undefined",
1788
+ " window.__dshTasks = Array.isArray(message.tasks) ? message.tasks : []",
1789
+ " window.__dshTabs = Array.isArray(message.tabs) ? message.tabs : []",
1790
+ " const liveKeys = new Set(window.__dshTasks.map(task => task && task.key).filter(key => typeof key === 'string'))",
1791
+ " for (const key of [...taskThumbCache.keys()]) if (!liveKeys.has(key)) taskThumbCache.delete(key)",
1792
+ " window.__dshTrail = Array.isArray(message.trail) ? message.trail : []",
1793
+ " window.__dshWorkspacePanels = message.panels && typeof message.panels === 'object' ? message.panels : { tasks: false, trail: false }",
1794
+ " const saved = message.orbPosition",
1795
+ " orbPos = saved && typeof saved.x === 'number' && typeof saved.y === 'number' ? { x: saved.x, y: saved.y } : null",
1796
+ " applyOrbPos()",
1797
+ " renderTasks()",
1798
+ " renderOrb()",
1799
+ " renderTabs()",
1800
+ " renderTrail()",
1801
+ " applyWorkspacePanels()",
1802
+ " return",
1803
+ " }",
1804
+ " const decision = decideChromeMessage(message.kind, Number(message.epoch), Number(message.revision), chromeEpoch, chromeRevision)",
1805
+ " if (decision === 'ignore') return",
1806
+ " if (decision === 'resync') { requestChromeBootstrap(); return }",
1807
+ " chromeRevision = Number(message.revision)",
1808
+ " const tasks = Array.isArray(window.__dshTasks) ? [...window.__dshTasks] : []",
1809
+ " let fullTaskRender = false",
1810
+ " const taskPatches = []",
1811
+ " let trailReplaced = false",
1812
+ " const trailAppends = []",
1813
+ " let tabsChanged = false",
1814
+ " let panelsChanged = false",
1815
+ " for (const operation of Array.isArray(message.operations) ? message.operations : []) {",
1816
+ " if (!operation || typeof operation !== 'object') continue",
1817
+ " if (operation.op === 'task.upsert' && operation.task && typeof operation.task.key === 'string') {",
1818
+ " const index = tasks.findIndex(task => task && task.key === operation.task.key)",
1819
+ " if (index >= 0) { tasks[index] = operation.task; taskPatches.push(operation.task) }",
1820
+ " else { tasks.push(operation.task); fullTaskRender = true }",
1821
+ " } else if (operation.op === 'task.remove' && typeof operation.key === 'string') {",
1822
+ " const index = tasks.findIndex(task => task && task.key === operation.key)",
1823
+ " if (index >= 0) { tasks.splice(index, 1); fullTaskRender = true }",
1824
+ " } else if (operation.op === 'task.active') {",
1825
+ " chromeSelectedTaskKey = typeof operation.key === 'string' ? operation.key : undefined",
1826
+ " for (const task of tasks) if (task && typeof task === 'object') task.active = task.key === chromeSelectedTaskKey",
1827
+ " fullTaskRender = true",
1828
+ " } else if (operation.op === 'task.thumbnail' && typeof operation.key === 'string') {",
1829
+ " if (typeof operation.dataUrl === 'string') taskThumbCache.set(operation.key, operation.dataUrl)",
1830
+ " const task = tasks.find(candidate => candidate && candidate.key === operation.key)",
1831
+ " if (task) { task.thumbnailVersion = operation.version; fullTaskRender = true }",
1832
+ " } else if (operation.op === 'task.todos' && typeof operation.key === 'string') {",
1833
+ " // Agent 的任务清单:定向补丁(不进摘要),悬浮球读它。",
1834
+ " taskTodosCache.set(operation.key, Array.isArray(operation.todos) ? operation.todos : [])",
1835
+ " renderOrb()",
1836
+ " } else if (operation.op === 'trail.append' && operation.taskKey === chromeSelectedTaskKey && operation.entry) {",
1837
+ " const trail = Array.isArray(window.__dshTrail) ? window.__dshTrail : []",
1838
+ " window.__dshTrail = [...trail, operation.entry]",
1839
+ " trailAppends.push(operation.entry)",
1840
+ " } else if (operation.op === 'trail.replace' && (operation.taskKey === chromeSelectedTaskKey || operation.taskKey === undefined)) {",
1841
+ " window.__dshTrail = Array.isArray(operation.entries) ? operation.entries : []",
1842
+ " trailReplaced = true",
1843
+ " } else if (operation.op === 'reveal') {",
1844
+ " playReveal()",
1845
+ " } else if (operation.op === 'tabs.set') {",
1846
+ " window.__dshTabs = Array.isArray(operation.tabs) ? operation.tabs : []",
1847
+ " tabsChanged = true",
1848
+ " } else if (operation.op === 'panels.set' && operation.panels && typeof operation.panels === 'object') {",
1849
+ " window.__dshWorkspacePanels = operation.panels",
1850
+ " panelsChanged = true",
1851
+ " } else if (operation.op === 'notice' && typeof operation.text === 'string') {",
1852
+ " // 导入结果这类一次性反馈:宿主说了算,chrome 只负责显示。",
1853
+ " showToast(operation.text, operation.level === 'warn' ? 'warn' : undefined)",
1854
+ " } else if (operation.op === 'panel.state' && typeof operation.id === 'string') {",
1855
+ " // Relayed from the frame chrome: its buttons are real, the popups live here.",
1856
+ " const relayed = menus.find(candidate => candidate.id === operation.id)",
1857
+ " if (relayed !== undefined) {",
1858
+ " if (operation.open === true) {",
1859
+ " for (const other of menus) if (other.popup !== relayed.popup) closeMenu(other.popup)",
1860
+ " anchorPopupAt(relayed.popup, operation.left, operation.width !== undefined ? operation.width : relayed.width)",
1861
+ " showSurface(relayed.popup)",
1862
+ " relayed.trigger.setAttribute('aria-expanded', 'true')",
1863
+ " relayed.pinned = operation.pinned === true",
1864
+ " if (typeof relayed.onOpen === 'function') relayed.onOpen()",
1865
+ " } else {",
1866
+ " closeMenu(relayed.popup)",
1867
+ " }",
1868
+ " syncWorkspacePanels()",
1869
+ " }",
1870
+ " } else if (operation.op === 'bookmarkbar.set') {",
1871
+ " window.__dshBookmarkBar = operation.visible === true",
1872
+ " applyBookmarksVisible(operation.visible === true)",
1873
+ " } else if (operation.op === 'bookmarks.set') {",
1874
+ " window.__dshBookmarks = Array.isArray(operation.bookmarks) ? operation.bookmarks : []",
1875
+ " bookmarksChanged = true",
1876
+ " }",
1877
+ " }",
1878
+ " window.__dshTasks = tasks",
1879
+ " if (fullTaskRender) renderTasks()",
1880
+ " else if (taskPatches.length > 0) { renderTaskControl(tasks); for (const task of taskPatches) { if (!patchTaskRow(task)) { renderTasks(); break } } }",
1881
+ " renderOrb()",
1882
+ " if (trailReplaced) renderTrail()",
1883
+ " else for (const entry of trailAppends) appendTrailEntry(entry)",
1884
+ " // 换页也要重算星标:宿主给的标签 url 是**规范化过**的(没有结尾斜杠),而收藏里",
1885
+ " // 存的是 location.href(带斜杠)—— 直接比会永远不相等,所以按去掉结尾斜杠比。",
1886
+ " if (tabsChanged) { renderTabs(); updateBookmarkStar(); applyNavState() }",
1887
+ " if (panelsChanged) applyWorkspacePanels()",
1888
+ " if (bookmarksChanged) { renderBookmarks(); updateBookmarkStar(); renderBookmarkBar(); mmBookmarkBar.textContent = bookmarksVisible ? '隐藏书签栏' : '显示书签栏' }",
1889
+ " }",
1890
+ " window.__dshChromeApply = applyChromeMessage",
1891
+ " // 宿主可能在 chrome 挂载**完成之前**就把 bootstrap 推下来了(它只等导航提交,不等我们):",
1892
+ " // 那一刻 __dshChromeApply 还不存在,状态只落在 window.__dshChromeBootstrap 上,闭包里的",
1893
+ " // chromeSelectedTaskKey 一直是 undefined —— 于是 Ctrl+T / Ctrl+W / 切标签**静默失效**",
1894
+ " // (实测:工具栏照常渲染、Ctrl+D 这类不依赖它的还正常)。这里补apply一次。",
1895
+ " try { if (window.__dshChromeBootstrap) applyChromeMessage(window.__dshChromeBootstrap) } catch { /* 已经坏了也不影响挂载 */ }",
1896
+ " if (window.__dshChromeBootstrap) applyChromeMessage(window.__dshChromeBootstrap)",
1897
+ " // 二级菜单(收藏 / 操作轨迹 / 任务):Chrome 的弹层贴在触发按钮上、从按钮下方展开,",
1898
+ " // 所以位置不能写死在 CSS 里(按钮在工具栏右侧,窗口一改宽位置就变),每次打开按矩形算。",
1899
+ " // ⋮ 菜单里的「全屏」在进入/退出时要改文案,所以每次打开都重算一次。",
1900
+ " const renderMainMenu = () => {",
1901
+ " const label = mmFullscreen.querySelector('span')",
1902
+ " if (label) label.textContent = document.fullscreenElement ? '退出全屏' : '全屏'",
1903
+ " }",
1904
+ " const menus = [",
1905
+ " { id: 'bookmarks', popup: panel, trigger: bookmarks, width: 340, onOpen: () => { renderBookmarks(); updateBookmarkStar() } },",
1906
+ " { id: 'trail', popup: trailPanel, trigger: trail, width: 360, onOpen: renderTrail },",
1907
+ " { id: 'tasks', popup: taskPanel, trigger: tasksBtn, width: 380, onOpen: renderTasks },",
1908
+ " { id: 'main', popup: mainMenu, trigger: mainMenuBtn, width: 264, onOpen: renderMainMenu },",
1909
+ " ]",
1910
+ " // 给 Ctrl+H 用(Chrome 的历史快捷键 -> 我们的操作轨迹面板)。",
1911
+ " const trailMenu = menus[1]",
1912
+ " // host 被套了 zoom = 1/页面缩放(见 applyZoomFactor),所以 host 内部 1px 只有",
1913
+ " // 1/缩放 个页面 px。触发按钮的 rect、中继来的坐标都是**页面 px**,直接写进 style",
1914
+ " // 会被当成 host px —— 缩放 ≠ 100% 时弹层就跑偏(实测 160% 下偏 330px)。",
1915
+ " // frame 是另一个视图、不缩放,它报出去的坐标本来就是页面 px,所以不换算。",
1916
+ " const chromeScale = () => (CHROME_SURFACE === 'frame' ? 1 : zoomFactor)",
1917
+ " const popupGeometry = (trigger, width) => {",
1918
+ " const z = chromeScale()",
1919
+ " const rect = trigger.getBoundingClientRect()",
1920
+ " const viewportWidth = document.documentElement.clientWidth || window.innerWidth || 0",
1921
+ " const viewportHeight = document.documentElement.clientHeight || window.innerHeight || 0",
1922
+ " const usable = Math.max(200, Math.min(width, viewportWidth - 16))",
1923
+ " // A trigger with no box is hidden (the page surface keeps the popups but not",
1924
+ " // the toolbar), so fall back to right-aligning — same place the button is.",
1925
+ " const hidden = rect.width === 0 && rect.height === 0",
1926
+ " const anchorLeft = hidden ? viewportWidth - usable - 8 : rect.left",
1927
+ " const left = Math.max(8, Math.min(anchorLeft, Math.max(8, viewportWidth - usable - 8)))",
1928
+ " const top = Math.round(hidden ? 8 : rect.bottom + 8)",
1929
+ " // 以上全是页面 px,写进 style 前统一换成 host px。",
1930
+ " return { left: left * z, top: top * z, usable: usable * z, viewportHeight: viewportHeight * z }",
1931
+ " }",
1932
+ " const anchorPopupAt = (popup, left, width) => {",
1933
+ " const viewportWidth = document.documentElement.clientWidth || window.innerWidth || 0",
1934
+ " const viewportHeight = document.documentElement.clientHeight || window.innerHeight || 0",
1935
+ " const usable = Math.max(200, Math.min(typeof width === 'number' ? width : 320, viewportWidth - 16))",
1936
+ " const clamped = Math.max(8, Math.min(typeof left === 'number' ? left : viewportWidth - usable - 8, Math.max(8, viewportWidth - usable - 8)))",
1937
+ " const z = chromeScale()",
1938
+ " popup.style.width = usable * z + 'px'",
1939
+ " popup.style.left = clamped * z + 'px'",
1940
+ " popup.style.top = 8 * z + 'px'",
1941
+ " popup.style.maxHeight = Math.max(180, viewportHeight - 20) * z + 'px'",
1942
+ " }",
1943
+ " const anchorPopup = (popup, trigger, width) => {",
1944
+ " const g = popupGeometry(trigger, width)",
1945
+ " popup.style.width = g.usable + 'px'",
1946
+ " popup.style.left = g.left + 'px'",
1947
+ " popup.style.top = g.top + 'px'",
1948
+ " popup.style.maxHeight = Math.max(180, g.viewportHeight - g.top - 12) + 'px'",
1949
+ " return g",
1950
+ " }",
1951
+ " // frame 上的按钮是真的,弹层却必须画在页面里(frame 只有 84px 高,菜单会被裁)。",
1952
+ " // 所以 frame 把「哪个菜单、按钮在哪、多宽」告诉宿主,宿主中继给页面。",
1953
+ " const emitPanelState = (id, open, left, width, pinned) => {",
1954
+ " if (!onFrameSurface || typeof id !== 'string' || id === '' || bindingToken === '') return",
1955
+ " try {",
1956
+ " const binding = window.__dshBrowserTaskAction",
1957
+ " if (typeof binding === 'function') binding(JSON.stringify({ type: 'panel-state', token: bindingToken, id, open: !!open, pinned: pinned === true, ...(typeof left === 'number' ? { left } : {}), ...(typeof width === 'number' ? { width } : {}) }))",
1958
+ " } catch { /* host unavailable */ }",
1959
+ " }",
1960
+ " const closeMenu = (popup, reason) => {",
1961
+ " // 已经关了、或者正在退场,都不用再来一次(isPanelOpen 把「淡出中」也算没开)。",
1962
+ " if (!isPanelOpen(popup)) return",
1963
+ " beginSurfaceClose(popup)",
1964
+ " const finish = () => { cancelPanelClose(popup); popup.classList.remove('open') }",
1965
+ " // 由**动画结束**来收尾,不是死等一个固定时长:机器忙的时候动画可能还没跑完就被摘掉,",
1966
+ " // 那样菜单会直接消失(看起来就是没动画)。400ms 兜底保证它不会卡住。",
1967
+ " const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches",
1968
+ " const onEnd = event => { if (String(event.animationName || '') === 'dshPopOut') finish() }",
1969
+ " if (!reduced) popup.addEventListener('animationend', onEnd)",
1970
+ " const timer = window.setTimeout(finish, reduced ? 0 : 400)",
1971
+ " closingState.set(popup, { timer, onEnd })",
1972
+ " for (const entry of menus) if (entry.popup === popup) { entry.trigger.setAttribute('aria-expanded', 'false'); entry.pinned = false; if (reason !== 'hover') emitPanelState(entry.id, false) }",
1973
+ " syncWorkspacePanels()",
1974
+ " }",
1975
+ " const closeAllMenus = () => { for (const entry of menus) closeMenu(entry.popup) }",
1976
+ " // 指针只是**划过**工具栏(去别的地方)不该弹菜单 —— 用户实测报的。所以悬停要停留",
1977
+ " // 一下才算「想开」;指针离开按钮就撤销这次还没到点的打开。这一条不会误伤「从按钮",
1978
+ " // 走向菜单」:能走到菜单说明指针已经在按钮上停够久了,计时早就到点。",
1979
+ " const HOVER_OPEN_DELAY_MS = 240",
1980
+ " const hoverOpenTimers = new Map()",
1981
+ " const cancelHoverOpen = entry => {",
1982
+ " const timer = hoverOpenTimers.get(entry)",
1983
+ " if (timer !== undefined) { window.clearTimeout(timer); hoverOpenTimers.delete(entry) }",
1984
+ " }",
1985
+ " // 悬停是「预览」,点击是「钉住」:悬停打开后鼠标离开就收,点一下把它留在原地,",
1986
+ " // 再点一下才关。不这么做的话,鼠标一移到按钮上菜单先开、紧接着的点击又把它关掉。",
1987
+ " const openMenu = (entry, byHover) => {",
1988
+ " // 真正打开(点击 / 宿主推来的状态)就作废掉还没到点的悬停计时,否则它稍后",
1989
+ " // 会以「悬停」的身份再开一次,把刚点住(pinned)的菜单降级成悬停预览。",
1990
+ " cancelHoverOpen(entry)",
1991
+ " for (const other of menus) if (other.popup !== entry.popup) closeMenu(other.popup)",
1992
+ " const geometry = anchorPopup(entry.popup, entry.trigger, entry.width)",
1993
+ " emitPanelState(entry.id, true, geometry.left, geometry.usable, byHover !== true)",
1994
+ " const wasOpen = isPanelOpen(entry.popup)",
1995
+ " showSurface(entry.popup)",
1996
+ " entry.trigger.setAttribute('aria-expanded', 'true')",
1997
+ " entry.pinned = byHover !== true",
1998
+ " if (!wasOpen && typeof entry.onOpen === 'function') entry.onOpen()",
1999
+ " syncWorkspacePanels()",
2000
+ " }",
2001
+ " const toggleMenu = entry => {",
2002
+ " if (!entry.popup.classList.contains('open')) { openMenu(entry); return }",
2003
+ " if (entry.pinned !== true) { entry.pinned = true; return }",
2004
+ " closeMenu(entry.popup)",
2005
+ " }",
2006
+ " // 宿主推来的面板状态(set-workspace-panels)也会直接开面板,这时同样要贴按钮。",
2007
+ " anchorOpenMenus = () => { for (const entry of menus) if (entry.popup.classList.contains('open')) anchorPopup(entry.popup, entry.trigger, entry.width) }",
2008
+ " const toggleTrail = () => toggleMenu(menus[1])",
2009
+ " const closeTrail = () => closeMenu(trailPanel)",
2010
+ " const toggleTasks = () => toggleMenu(menus[2])",
2011
+ " const closeTasks = () => closeMenu(taskPanel)",
2012
+ " const togglePanel = () => toggleMenu(menus[0])",
2013
+ " applyWorkspacePanels()",
2014
+ " // Chrome 的排布是「导航在左、地址栏居中、功能图标在右」。原始标记里这三个",
2015
+ " // 图标在地址栏之前,直接重排 DOM 比改写那几行超长 SVG 字符串安全得多。",
2016
+ " const rightGroup = root.querySelector('.right-group')",
2017
+ " if (rightGroup !== null) {",
2018
+ " for (const node of [bookmarks, trail, tasksBtn]) rightGroup.insertBefore(node, taskControl)",
2019
+ " }",
2020
+ " // Chrome 式常驻顶栏:默认展开,用户点「收起」后才回到悬停模式。",
2021
+ " openToolbar()",
2022
+ '',
2023
+ " toolbarRevealZone.addEventListener('pointerenter', armToolbarReveal)",
2024
+ " toolbarRevealZone.addEventListener('pointerleave', disarmToolbarReveal)",
2025
+ " toolbarRevealZone.addEventListener('mouseenter', armToolbarReveal)",
2026
+ " toolbarRevealZone.addEventListener('mouseleave', disarmToolbarReveal)",
2027
+ " window.addEventListener('pointermove', trackToolbarReveal, true)",
2028
+ " window.addEventListener('mousemove', trackToolbarReveal, true)",
2029
+ " window.addEventListener('pointerdown', triggerToolbarFromPage, true)",
2030
+ " window.addEventListener('mousedown', triggerToolbarFromPage, true)",
2031
+ " toolbarReveal.addEventListener('focus', armToolbarReveal)",
2032
+ " toolbarReveal.addEventListener('blur', () => window.setTimeout(disarmToolbarReveal, 0))",
2033
+ " toolbarReveal.addEventListener('click', openToolbar)",
2034
+ " toolbarHide.addEventListener('click', closeToolbar)",
2035
+ " back.addEventListener('click', () => { if (onFrameSurface) emitPageAction('back'); else history.back() })",
2036
+ " forward.addEventListener('click', () => { if (onFrameSurface) emitPageAction('forward'); else history.forward() })",
2037
+ " reload.addEventListener('click', () => { spinReload(); if (onFrameSurface) emitPageAction('reload'); else location.reload() })",
2038
+ " stop.addEventListener('click', () => { if (onFrameSurface) emitPageAction('stop'); else window.stop() })",
2039
+ " // 主页 = 新标签页。两种表面都交给宿主:页面不能自己导航到 data:(Chromium 拦渲染器发起的 data: 导航)。",
2040
+ " home.addEventListener('click', () => emitPageAction('home'))",
2041
+ " bookmarks.addEventListener('click', togglePanel)",
2042
+ " trail.addEventListener('click', toggleTrail)",
2043
+ " trailClose.addEventListener('click', closeTrail)",
2044
+ " tasksBtn.addEventListener('click', toggleTasks)",
2045
+ " taskClose.addEventListener('click', closeTasks)",
2046
+ " // 用户要求:二级菜单改成「悬停在按钮上」就展开(点击仍然保留,键盘/触屏要用)。",
2047
+ " // 鼠标从按钮滑到菜单要跨过 8px 空隙,所以收起给一个宽限期,进入菜单就取消。",
2048
+ " const HOVER_CLOSE_DELAY_MS = 220",
2049
+ " const hoverCloseTimers = new Map()",
2050
+ " const cancelHoverClose = popup => { const timer = hoverCloseTimers.get(popup); if (timer !== undefined) { window.clearTimeout(timer); hoverCloseTimers.delete(popup) } }",
2051
+ " const scheduleHoverClose = popup => {",
2052
+ " cancelHoverClose(popup)",
2053
+ " const entry = menus.find(candidate => candidate.popup === popup)",
2054
+ " // 被点住(pinned)的菜单不因为鼠标移开而消失。",
2055
+ " if (entry !== undefined && entry.pinned === true) return",
2056
+ " hoverCloseTimers.set(popup, window.setTimeout(() => { hoverCloseTimers.delete(popup); closeMenu(popup, 'hover') }, HOVER_CLOSE_DELAY_MS))",
2057
+ " }",
2058
+ " for (const entry of menus) {",
2059
+ " entry.pinned = false",
2060
+ " entry.popup.addEventListener('animationstart', event => { motionLog.push({ id: entry.id, phase: 'start', name: String(event.animationName || '') }) })",
2061
+ " entry.popup.addEventListener('animationend', event => { motionLog.push({ id: entry.id, phase: 'end', name: String(event.animationName || '') }) })",
2062
+ " entry.trigger.setAttribute('aria-haspopup', 'true')",
2063
+ " entry.trigger.setAttribute('aria-expanded', 'false')",
2064
+ " entry.trigger.addEventListener('pointerenter', () => {",
2065
+ " cancelHoverClose(entry.popup)",
2066
+ " cancelHoverOpen(entry)",
2067
+ " hoverOpenTimers.set(entry, window.setTimeout(() => { hoverOpenTimers.delete(entry); openMenu(entry, true) }, HOVER_OPEN_DELAY_MS))",
2068
+ " })",
2069
+ " // 离开按钮不安排关闭:鼠标一进入页面视图,frame 就再也看不到指针了,",
2070
+ " // 分不清「正走向菜单」和「走开了」——那个判断归页面(它的弹层有指针事件)。",
2071
+ " // 但「还没到点的悬停」必须撤销,那才是「只是划过去」。",
2072
+ " entry.trigger.addEventListener('pointerleave', () => {",
2073
+ " cancelHoverOpen(entry)",
2074
+ " if (!onFrameSurface) scheduleHoverClose(entry.popup)",
2075
+ " })",
2076
+ " entry.popup.addEventListener('pointerenter', () => cancelHoverClose(entry.popup))",
2077
+ " entry.popup.addEventListener('pointerleave', () => scheduleHoverClose(entry.popup))",
2078
+ " }",
2079
+ " // ⋮ 菜单的每一项都接到插件已有的能力上:新标签页走同一个 emitTabCreate,",
2080
+ " // 收藏复用 saveCurrentPage(宿主侧存储),三个面板只是打开各自那张菜单。",
2081
+ " const toggleFullscreen = () => {",
2082
+ " try {",
2083
+ " if (document.fullscreenElement) { void document.exitFullscreen?.() }",
2084
+ " else { void document.documentElement.requestFullscreen?.() }",
2085
+ " } catch { /* fullscreen unavailable */ }",
2086
+ " }",
2087
+ " zoomOut.addEventListener('click', () => { setZoom(zoomFactor - 0.1) })",
2088
+ " zoomIn.addEventListener('click', () => { setZoom(zoomFactor + 0.1) })",
2089
+ " zoomLevel.addEventListener('click', () => { setZoom(1) })",
2090
+ " mmNewTab.addEventListener('click', () => { closeMenu(mainMenu); if (typeof chromeSelectedTaskKey === 'string') emitTabCreate(chromeSelectedTaskKey) })",
2091
+ " mmFind.addEventListener('click', () => { closeMenu(mainMenu); openFind() })",
2092
+ " mmBookmark.addEventListener('click', () => { closeMenu(mainMenu); saveCurrentPage() })",
2093
+ " // 人手点的这一下才是「真的开」——只有这条路径播入场动画。",
2094
+ " const toggleBookmarkBar = () => { applyBookmarksVisible(!bookmarksVisible, true); emitBookmarkBar(bookmarksVisible) }",
2095
+ " mmBookmarkBar.addEventListener('click', () => { closeMenu(mainMenu); toggleBookmarkBar() })",
2096
+ " // 选文件交给 <input type=file>:chrome 因此不需要任何磁盘读取权限,浏览器自己给 File。",
2097
+ " const importInput = document.createElement('input')",
2098
+ " importInput.type = 'file'",
2099
+ " importInput.accept = '.json,application/json'",
2100
+ " // 必须藏起来:它在 DOM 里是为了 .click() 可靠,但原生文件控件会直接画出来(实测)。",
2101
+ " importInput.style.display = 'none'",
2102
+ " importInput.addEventListener('change', () => {",
2103
+ " const file = importInput.files && importInput.files.length > 0 ? importInput.files[0] : null",
2104
+ " importInput.value = ''",
2105
+ " if (file === null) return",
2106
+ " if (file.size > 2 * 1024 * 1024) { showToast('文件太大(上限 2 MB)', 'warn'); return }",
2107
+ " file.text().then(text => {",
2108
+ " let parsed",
2109
+ " try { parsed = JSON.parse(text) } catch { showToast('不是合法的 JSON 文件', 'warn'); return }",
2110
+ " const list = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' && Array.isArray(parsed.cookies) ? parsed.cookies : null)",
2111
+ " if (list === null) { showToast('文件里没有 cookies 数组', 'warn'); return }",
2112
+ " if (list.length === 0) { showToast('文件里没有 cookie', 'warn'); return }",
2113
+ " emitImportCookies(list)",
2114
+ " showToast('正在导入 ' + list.length + ' 个 cookie…')",
2115
+ " }).catch(() => showToast('读取文件失败', 'warn'))",
2116
+ " })",
2117
+ " root.append(importInput)",
2118
+ " mmImportCookies.addEventListener('click', () => { closeMenu(mainMenu); importInput.click() })",
2119
+ " // 和 __dshChromeFind / __dshChromeApply 一类:给集成测试一个不用点闭影子根就能走完整条链的入口。",
2120
+ " window.__dshChromeBookmarkBar = () => toggleBookmarkBar()",
2121
+ " mmBookmarks.addEventListener('click', () => { openMenu(menus[0]) })",
2122
+ " mmTrail.addEventListener('click', () => { openMenu(menus[1]) })",
2123
+ " mmTasks.addEventListener('click', () => { openMenu(menus[2]) })",
2124
+ " mmFullscreen.addEventListener('click', () => { closeMenu(mainMenu); toggleFullscreen() })",
2125
+ " // 点到别处 / 按 Esc 收起钉住的菜单(Chrome 的菜单也是这么消失的)。",
2126
+ " window.addEventListener('pointerdown', event => { if (!event.composedPath().includes(host)) closeAllMenus() }, true)",
2127
+ " window.addEventListener('keydown', event => { if (event.key === 'Escape') closeAllMenus() }, true)",
2128
+ " const toggleTaskControl = () => { if (activeTask && typeof activeTask.key === 'string') emitTaskControl(activeTask.key, activeTask.control === 'human' ? 'agent' : 'human') }",
2129
+ " taskControl.addEventListener('pointerdown', event => { if (event.button === 0) toggleTaskControl() })",
2130
+ " taskControl.addEventListener('click', event => { if (event.detail === 0) toggleTaskControl() })",
2131
+ " const saveCurrentPage = () => {",
2132
+ " // frame 里的 location 是宿主那张 data: 页,收藏它毫无意义 —— 取标签摘要。",
2133
+ " const url = onFrameSurface ? activeTabUrl() : location.href",
2134
+ " const tab = activeChromeTab()",
2135
+ " const title = onFrameSurface",
2136
+ " ? (tab && typeof tab.title === 'string' && tab.title !== '' ? tab.title : url)",
2137
+ " : (document.title || url)",
2138
+ " if (url === '') return",
2139
+ " // 本地先加,立即有反馈;宿主是权威(会写盘并回推)。",
2140
+ " window.__dshBookmarks = [{ url, title }, ...loadBookmarks().filter(b => b.url !== url)]",
2141
+ " renderBookmarks()",
2142
+ " updateBookmarkStar()",
2143
+ " emitBookmarkAdd()",
2144
+ ' }',
2145
+ " saveBookmark.addEventListener('click', saveCurrentPage)",
2146
+ " // Chrome 的行为:在**未聚焦**的地址栏上按下鼠标 → 全选,所以接着输入就是整条替换;",
2147
+ " // 已经聚焦时再点,走默认行为落到光标定位——和 Chrome 一致。",
2148
+ " // 关键:**不要在 focus 事件里 select()**。实测那样会让合成输入(CDP Input.insertText)",
2149
+ " // 再也落不进这个 shadow DOM 地址栏。这里改在 pointerdown 里 preventDefault 掉默认的",
2150
+ " // 落点行为,再手动聚焦 + 设选区,不碰 focus 事件。冒烟里有「点地址栏→输入→回车」",
2151
+ " // 的端到端检查,万一 insertText 又落不进来,那条会红。",
2152
+ " address.addEventListener('pointerdown', event => {",
2153
+ " if (root.activeElement === address) return",
2154
+ " event.preventDefault()",
2155
+ " address.focus()",
2156
+ " address.setSelectionRange(0, address.value.length)",
2157
+ " })",
2158
+ " address.addEventListener('keydown', event => {",
2159
+ " // 回车后要失焦:地址栏有焦点时 refreshAddress 会跳过(不然会把用户正在输入的地址",
2160
+ " // 冲掉),于是导航到新页面后它还停在上一页的地址上。Chrome 也是回车即收键盘。",
2161
+ " if (event.key === 'Enter') { event.preventDefault(); const typed = address.value; address.blur(); navigate(typed) }",
2162
+ " else if (event.key === 'Escape') address.blur()",
2163
+ ' })',
2164
+ " window.addEventListener('keydown', event => {",
2165
+ " // 和缩放一样同时看 key 和 code:合成按键(CDP / 自动化)给的 key 可能是",
2166
+ " // 'Unidentified',而 code 始终是物理位置 —— 只比 key 的 Ctrl+L / Ctrl+F 在",
2167
+ " // 自动化下永远不触发(实测)。",
2168
+ " if (event.ctrlKey || event.metaKey) {",
2169
+ " const chord = event.key.toLowerCase()",
2170
+ " const chordCode = event.code",
2171
+ " if (chord === 'l' || chordCode === 'KeyL') { event.preventDefault(); focusAddressBar() }",
2172
+ " else if (chord === 'f' || chordCode === 'KeyF') { event.preventDefault(); openFind() }",
2173
+ " // Chrome 的标签页快捷键。和上面一样同时看 key 和 code。",
2174
+ " else if (chord === 't' || chordCode === 'KeyT') { event.preventDefault(); if (typeof chromeSelectedTaskKey === 'string') emitTabCreate(chromeSelectedTaskKey) }",
2175
+ " else if (chord === 'w' || chordCode === 'KeyW') {",
2176
+ " event.preventDefault()",
2177
+ " const tab = activeChromeTab()",
2178
+ " if (tab && typeof tab.id === 'string' && typeof chromeSelectedTaskKey === 'string') emitTabClose(chromeSelectedTaskKey, tab.id)",
2179
+ " }",
2180
+ " else if (chord === 'tab' || chordCode === 'Tab') { event.preventDefault(); stepChromeTab(event.shiftKey ? -1 : 1) }",
2181
+ " // Chrome 的另外几个:收藏(Ctrl+D)、书签栏(Ctrl+Shift+B)、刷新(Ctrl+R)、历史(Ctrl+H)。",
2182
+ " else if (chord === 'd' || chordCode === 'KeyD') { event.preventDefault(); saveCurrentPage() }",
2183
+ " else if ((chord === 'b' || chordCode === 'KeyB') && event.shiftKey) { event.preventDefault(); applyBookmarksVisible(!bookmarksVisible); emitBookmarkBar(bookmarksVisible) }",
2184
+ " else if (chord === 'r' || chordCode === 'KeyR') { event.preventDefault(); spinReload(); if (onFrameSurface) emitPageAction('reload'); else location.reload() }",
2185
+ " else if (chord === 'h' || chordCode === 'KeyH') { event.preventDefault(); openMenu(trailMenu) }",
2186
+ " }",
2187
+ " // 不带 Ctrl 的几个:F5 刷新、Alt+左右前进后退、Esc 停止加载(Chrome 的行为)。",
2188
+ " if (event.key === 'F5') { event.preventDefault(); spinReload(); if (onFrameSurface) emitPageAction('reload'); else location.reload() }",
2189
+ " else if (event.altKey && (event.key === 'ArrowLeft' || event.code === 'ArrowLeft')) { event.preventDefault(); if (onFrameSurface) emitPageAction('back'); else history.back() }",
2190
+ " else if (event.altKey && (event.key === 'ArrowRight' || event.code === 'ArrowRight')) { event.preventDefault(); if (onFrameSurface) emitPageAction('forward'); else history.forward() }",
2191
+ " else if (event.key === 'Escape' && stop !== null && getComputedStyle(stop).display !== 'none') { if (onFrameSurface) emitPageAction('stop'); else window.stop() }",
2192
+ " // Chrome 的缩放快捷键:Ctrl + / Ctrl - / Ctrl 0。",
2193
+ " // 同时看 key 和 code —— 合成按键(CDP / 自动化)给的 key 可能是",
2194
+ " // 'Unidentified',而 code 始终是物理位置。",
2195
+ " if (event.ctrlKey || event.metaKey) {",
2196
+ " const key = event.key",
2197
+ " const code = event.code",
2198
+ " const isZero = key === '0' || code === 'Digit0' || code === 'Numpad0'",
2199
+ " const isPlus = key === '+' || key === '=' || code === 'Equal' || code === 'NumpadAdd'",
2200
+ " const isMinus = key === '-' || code === 'Minus' || code === 'NumpadSubtract'",
2201
+ " if (isZero) { event.preventDefault(); setZoom(1) }",
2202
+ " else if (isPlus) { event.preventDefault(); setZoom(zoomFactor + 0.1) }",
2203
+ " else if (isMinus) { event.preventDefault(); setZoom(zoomFactor - 0.1) }",
2204
+ " }",
2205
+ ' }, true)',
2206
+ " window.addEventListener('popstate', refreshAddress)",
2207
+ " window.addEventListener('hashchange', refreshAddress)",
2208
+ '',
2209
+ " // User-control signal: mark the host so model-facing snapshots can tell",
2210
+ " // that a human is driving this page right now (ego's handoff semantics).",
2211
+ " let lastUserActivity = 0",
2212
+ " let chromePageActive = window.__dshChromeActive !== false",
2213
+ " let userActivityTimer = 0",
2214
+ " let addressTimer = 0",
2215
+ " const stopChromeTimers = () => { if (userActivityTimer !== 0) { window.clearInterval(userActivityTimer); userActivityTimer = 0 } if (addressTimer !== 0) { window.clearInterval(addressTimer); addressTimer = 0 } }",
2216
+ " const startChromeTimers = () => {",
2217
+ " if (!chromePageActive) return",
2218
+ " refreshAddress()",
2219
+ " if (userActivityTimer === 0) userActivityTimer = window.setInterval(() => { if (lastUserActivity !== 0 && Date.now() - lastUserActivity > 60000) host.removeAttribute('data-dsh-user-active') }, 15000)",
2220
+ " if (addressTimer === 0) addressTimer = window.setInterval(refreshAddress, 750)",
2221
+ " }",
2222
+ " const setChromeActive = active => { chromePageActive = active === true; stopChromeTimers(); if (chromePageActive) startChromeTimers() }",
2223
+ " window.__dshChromeSetActive = setChromeActive",
2224
+ " const markUserActive = () => { lastUserActivity = Date.now(); host.setAttribute('data-dsh-user-active', '1') }",
2225
+ " const agentInputSuppressed = () => Number(host.getAttribute('data-dsh-agent-input-until') || '0') > Date.now()",
2226
+ " const isChromeEvent = event => event.target === host || event.composedPath().includes(host)",
2227
+ " const isEditableTarget = target => target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || (target instanceof HTMLElement && target.isContentEditable)",
2228
+ " const isScrollKey = event => ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key) && !isEditableTarget(event.target)",
2229
+ " const isScrollbarPointer = event => event.clientX >= document.documentElement.clientWidth || event.clientY >= document.documentElement.clientHeight",
2230
+ " const canTrackUserEvent = event => event.isTrusted && !isChromeEvent(event) && !agentInputSuppressed()",
2231
+ " const requestUserControl = () => { if (userControlRequested || !activeTask || typeof activeTask.key !== 'string' || activeTask.control === 'human') return; userControlRequested = true; markUserActive(); emitTaskControl(activeTask.key, 'human') }",
2232
+ " let pointerCandidate = null",
2233
+ " const beginPointerCandidate = (id, event) => { if (!canTrackUserEvent(event) || isScrollbarPointer(event)) return; pointerCandidate = { id, x: event.clientX, y: event.clientY, moved: false } }",
2234
+ " const movePointerCandidate = (id, event) => { if (!pointerCandidate || pointerCandidate.id !== id) return; if (Math.hypot(event.clientX - pointerCandidate.x, event.clientY - pointerCandidate.y) > 8) pointerCandidate.moved = true }",
2235
+ " const finishPointerCandidate = (id, event) => { const candidate = pointerCandidate; pointerCandidate = null; if (!candidate || candidate.id !== id || candidate.moved || !canTrackUserEvent(event)) return; requestUserControl() }",
2236
+ " window.addEventListener('pointerdown', event => beginPointerCandidate(event.pointerId, event), true)",
2237
+ " window.addEventListener('pointermove', event => movePointerCandidate(event.pointerId, event), true)",
2238
+ " window.addEventListener('pointerup', event => finishPointerCandidate(event.pointerId, event), true)",
2239
+ " window.addEventListener('pointercancel', () => { pointerCandidate = null }, true)",
2240
+ " // Electron CDP emits Mouse Events for some input paths without Pointer Events.",
2241
+ " window.addEventListener('mousedown', event => { if (pointerCandidate === null) beginPointerCandidate('mouse', event) }, true)",
2242
+ " window.addEventListener('mousemove', event => movePointerCandidate('mouse', event), true)",
2243
+ " window.addEventListener('mouseup', event => finishPointerCandidate('mouse', event), true)",
2244
+ " window.addEventListener('keydown', event => { if (!canTrackUserEvent(event) || isScrollKey(event)) return; requestUserControl() }, true)",
2245
+ " // No wheel listener is intentional: scrolling never changes ownership.",
2246
+ '',
2247
+ ' setChromeActive(chromePageActive)',
2248
+ " host.setAttribute('data-dsh-browser-ready', '1')",
2249
+ ' }',
2250
+ " // 整个 mount 包一层:这脚本是一条直线,任何一处抛异常都会**静默**把它后面的",
2251
+ " // 全部砍掉(工具栏照常出现,但菜单/快捷键/面板悄悄没了 —— 实测 mmFind 少一行",
2252
+ " // getElementById 就让 Ctrl+F 彻底失效,且没有任何报错)。异常写到 <html> 的",
2253
+ " // data 属性上,页面读得到,冒烟也会断言它是空的。",
2254
+ " const mountSafely = () => {",
2255
+ " try { mount() } catch (error) {",
2256
+ " try { document.documentElement.dataset.dshChromeError = String((error && error.message) || error) } catch { /* ignore */ }",
2257
+ " }",
2258
+ " }",
2259
+ '',
2260
+ " // 尽快挂载:只要 <html> 存在就挂,不等 DOMContentLoaded。",
2261
+ " // 一个 body 传得很慢的页面,readyState 会在整个传输期间都是 'loading',",
2262
+ " // 等它就意味着**整个加载过程中工具栏和标签栏都不见了**(实测 6 秒的 body",
2263
+ " // 期间截图为空)。mount() 自己是幂等的,所以这里再挂一次 DOMContentLoaded",
2264
+ " // 兜底,已经挂过就直接返回。",
2265
+ " if (document.documentElement) mountSafely()",
2266
+ " document.addEventListener('DOMContentLoaded', mountSafely, { once: true })",
2267
+ '})()',
2268
+ ].join('\n');
2269
+ }