dsh-browser-playwright-codex 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -16,13 +16,13 @@
16
16
  * 不再自带 font-family、不再写死颜色 / 字号(浅色深色自动跟随)。
17
17
  */
18
18
  window.__ModuleLoader__.load({
19
- id: 'dsh-browser-playwright-codex',
20
- factory: (require) => {
21
- const module = { exports: {} };
22
- const exports = module.exports;
23
- const React = require('react');
24
- const h = React.createElement;
25
- const { useEffect, useState, useRef } = React;
19
+ id: 'dsh-browser-playwright-codex',
20
+ factory: (require) => {
21
+ const module = { exports: {} }
22
+ const exports = module.exports
23
+ const React = require('react')
24
+ const h = React.createElement
25
+ const { useEffect, useState, useRef } = React
26
26
 
27
27
  // ---- 官方排版套件:与设置页其它面板同一套令牌 / 原子件 ----
28
28
  // 1) 不再覆盖 font-family:继承外壳的 --dsw-font-family
@@ -31,7 +31,11 @@ window.__ModuleLoader__.load({
31
31
  // 3) 按钮 / 圆点 / 开关 / 标签直接用官方 @deepseek-ai/dsh-client-ui-primitives
32
32
  // (静态基座模块,third-party client bundle 可直接 require;取不到时退回同令牌样式)
33
33
  const UI = (() => {
34
- try { return require('@deepseek-ai/dsh-client-ui-primitives') || {} } catch (e) { return {} }
34
+ try {
35
+ return require('@deepseek-ai/dsh-client-ui-primitives') || {}
36
+ } catch (e) {
37
+ return {}
38
+ }
35
39
  })()
36
40
 
37
41
  const T = {
@@ -51,7 +55,14 @@ window.__ModuleLoader__.load({
51
55
  title: { margin: '0 0 6px', fontSize: 16, fontWeight: 500, lineHeight: '24px', color: T.primary },
52
56
  intro: { margin: '0 0 16px', fontSize: 13, lineHeight: '20px', color: T.tertiary },
53
57
  list: { display: 'flex', flexDirection: 'column', gap: 8 },
54
- row: { display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', border: '0.5px solid ' + T.border, borderRadius: 12 },
58
+ row: {
59
+ display: 'flex',
60
+ alignItems: 'center',
61
+ gap: 12,
62
+ padding: '12px 14px',
63
+ border: '0.5px solid ' + T.border,
64
+ borderRadius: 12,
65
+ },
55
66
  rowTitle: { fontSize: 14, fontWeight: 500, lineHeight: '22px', color: T.primary },
56
67
  rowDesc: { fontSize: 12, lineHeight: '18px', color: T.secondary },
57
68
  meta: { fontSize: 11, lineHeight: '16px', color: T.tertiary, marginTop: 2 },
@@ -69,11 +80,19 @@ window.__ModuleLoader__.load({
69
80
  // ghost 在这页会显得轻一档,跟旁边的官方按钮不是一路。
70
81
  if (UI.Button) return h(UI.Button, { variant: 'outline', size: 'sm', ...props, style })
71
82
  return h('button', {
72
- type: 'button', ...props,
83
+ type: 'button',
84
+ ...props,
73
85
  style: {
74
- height: 28, padding: '0 10px', border: '0.5px solid ' + T.borderL3, borderRadius: 14,
75
- background: 'transparent', font: 'inherit', fontSize: 12, lineHeight: '18px',
76
- color: danger ? T.error : T.primary, cursor: props.disabled ? 'default' : 'pointer',
86
+ height: 28,
87
+ padding: '0 10px',
88
+ border: '0.5px solid ' + T.borderL3,
89
+ borderRadius: 14,
90
+ background: 'transparent',
91
+ font: 'inherit',
92
+ fontSize: 12,
93
+ lineHeight: '18px',
94
+ color: danger ? T.error : T.primary,
95
+ cursor: props.disabled ? 'default' : 'pointer',
77
96
  opacity: props.disabled ? 0.4 : 1,
78
97
  },
79
98
  })
@@ -87,30 +106,66 @@ window.__ModuleLoader__.load({
87
106
 
88
107
  function Toggle({ checked, onChange, label, disabled }) {
89
108
  if (UI.Switch) return h(UI.Switch, { checked, onChange, label, disabled })
90
- return h('button', {
91
- type: 'button', role: 'switch', 'aria-checked': checked, 'aria-label': label, disabled,
92
- onClick: () => onChange(!checked),
93
- style: { width: 36, height: 20, flex: 'none', padding: 2, border: 'none', borderRadius: 10, background: checked ? T.primary : T.borderL3, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1 },
94
- }, h('span', { style: { display: 'block', width: 16, height: 16, borderRadius: '50%', background: 'var(--dsw-alias-label-primary-foreground)', transform: checked ? 'translateX(16px)' : 'none', transition: 'transform 120ms ease' } }))
109
+ return h(
110
+ 'button',
111
+ {
112
+ type: 'button',
113
+ role: 'switch',
114
+ 'aria-checked': checked,
115
+ 'aria-label': label,
116
+ disabled,
117
+ onClick: () => onChange(!checked),
118
+ style: {
119
+ width: 36,
120
+ height: 20,
121
+ flex: 'none',
122
+ padding: 2,
123
+ border: 'none',
124
+ borderRadius: 10,
125
+ background: checked ? T.primary : T.borderL3,
126
+ cursor: disabled ? 'default' : 'pointer',
127
+ opacity: disabled ? 0.5 : 1,
128
+ },
129
+ },
130
+ h('span', {
131
+ style: {
132
+ display: 'block',
133
+ width: 16,
134
+ height: 16,
135
+ borderRadius: '50%',
136
+ background: 'var(--dsw-alias-label-primary-foreground)',
137
+ transform: checked ? 'translateX(16px)' : 'none',
138
+ transition: 'transform 120ms ease',
139
+ },
140
+ }),
141
+ )
95
142
  }
96
143
 
97
144
  /** 官方 Tag:像「已启用 / 已禁用」这类状态小标记(以前是行内一段灰字)。 */
98
145
  function Tag(props) {
99
146
  if (UI.Tag) return h(UI.Tag, { tone: props.tone || 'outline' }, props.children)
100
- return h('span', { style: { fontSize: 11, lineHeight: '16px', color: T.tertiary, marginRight: 6 } }, props.children)
147
+ return h(
148
+ 'span',
149
+ { style: { fontSize: 11, lineHeight: '16px', color: T.tertiary, marginRight: 6 } },
150
+ props.children,
151
+ )
101
152
  }
102
153
 
103
154
  /** 官方 DisclosureRow:长说明默认折叠(首屏只留结论)。 */
104
155
  function Notes(props) {
105
156
  const [open, setOpen] = useState(false)
106
157
  if (UI.DisclosureRow) {
107
- return h(UI.DisclosureRow, {
108
- title: props.title,
109
- open: open,
110
- expandable: true,
111
- expandOnRowClick: true,
112
- onToggle: () => setOpen(!open),
113
- }, open ? props.children : null)
158
+ return h(
159
+ UI.DisclosureRow,
160
+ {
161
+ title: props.title,
162
+ open: open,
163
+ expandable: true,
164
+ expandOnRowClick: true,
165
+ onToggle: () => setOpen(!open),
166
+ },
167
+ open ? props.children : null,
168
+ )
114
169
  }
115
170
  return h('div', null, props.children)
116
171
  }
@@ -118,646 +173,1163 @@ window.__ModuleLoader__.load({
118
173
  // 单选行:与 dsh-search-switch 的 RadioRow 同一套(role=radio / 选中描边加粗 /
119
174
  // 圆点标记 / 键盘可操作),所以两张卡片放在一起看不出是两个人写的。
120
175
  function RadioRow({ p, selected, disabled, onPick }) {
121
- return h('div', {
122
- role: 'radio',
123
- 'aria-checked': selected,
124
- 'aria-label': p.label,
125
- tabIndex: disabled ? -1 : 0,
126
- onClick: () => { if (!disabled) onPick(p.id) },
127
- onKeyDown: (e) => {
128
- if (disabled) return
129
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(p.id) }
130
- },
131
- style: {
132
- display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px',
133
- border: (selected ? '1px' : '0.5px') + ' solid ' + (selected ? T.primary : T.border),
134
- borderRadius: 12,
135
- cursor: disabled ? 'default' : 'pointer',
136
- opacity: disabled && !selected ? 0.6 : 1,
137
- },
138
- }, [
139
- h('span', {
140
- key: 'mark',
176
+ return h(
177
+ 'div',
178
+ {
179
+ role: 'radio',
180
+ 'aria-checked': selected,
181
+ 'aria-label': p.label,
182
+ tabIndex: disabled ? -1 : 0,
183
+ onClick: () => {
184
+ if (!disabled) onPick(p.id)
185
+ },
186
+ onKeyDown: (e) => {
187
+ if (disabled) return
188
+ if (e.key === 'Enter' || e.key === ' ') {
189
+ e.preventDefault()
190
+ onPick(p.id)
191
+ }
192
+ },
141
193
  style: {
142
- width: 16, height: 16, flex: 'none', marginTop: 2, borderRadius: '50%',
143
- border: '1.5px solid ' + (selected ? T.primary : T.borderL3),
144
- display: 'flex', alignItems: 'center', justifyContent: 'center',
194
+ display: 'flex',
195
+ alignItems: 'flex-start',
196
+ gap: 12,
197
+ padding: '12px 14px',
198
+ border: (selected ? '1px' : '0.5px') + ' solid ' + (selected ? T.primary : T.border),
199
+ borderRadius: 12,
200
+ cursor: disabled ? 'default' : 'pointer',
201
+ opacity: disabled && !selected ? 0.6 : 1,
145
202
  },
146
- }, selected ? h('span', { style: { width: 8, height: 8, borderRadius: '50%', background: T.primary } }) : null),
203
+ },
204
+ [
205
+ h(
206
+ 'span',
207
+ {
208
+ key: 'mark',
209
+ style: {
210
+ width: 16,
211
+ height: 16,
212
+ flex: 'none',
213
+ marginTop: 2,
214
+ borderRadius: '50%',
215
+ border: '1.5px solid ' + (selected ? T.primary : T.borderL3),
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ justifyContent: 'center',
219
+ },
220
+ },
221
+ selected ? h('span', { style: { width: 8, height: 8, borderRadius: '50%', background: T.primary } }) : null,
222
+ ),
223
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
224
+ h('div', { key: 'label', style: S.rowTitle }, p.label),
225
+ p.hint ? h('div', { key: 'hint', style: S.rowDesc }, p.hint) : null,
226
+ ]),
227
+ selected ? h(Dot, { key: 'dot', state: 'done' }) : null,
228
+ ],
229
+ )
230
+ }
231
+
232
+ /**
233
+ * 一行小胶囊:3–4 个互斥选项时比竖排 RadioRow 省一半高度。
234
+ * 语义仍然是 radiogroup,键盘行为(Tab / Enter / 空格)与 RadioRow 一致。
235
+ */
236
+ function ChipRow({ label, options, value, disabled, onPick, hint }) {
237
+ return h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 10, flexWrap: 'wrap' } }, [
238
+ h(
239
+ 'span',
240
+ {
241
+ key: 'label',
242
+ style: { flex: 'none', width: 56, paddingTop: 4, fontSize: 12, lineHeight: '18px', color: T.tertiary },
243
+ },
244
+ label,
245
+ ),
246
+ h('div', { key: 'chips', style: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 } }, [
247
+ h(
248
+ 'div',
249
+ {
250
+ key: 'row',
251
+ role: 'radiogroup',
252
+ 'aria-label': label,
253
+ style: { display: 'flex', gap: 6, flexWrap: 'wrap' },
254
+ },
255
+ options.map((option) =>
256
+ h(
257
+ 'button',
258
+ {
259
+ key: String(option.value),
260
+ type: 'button',
261
+ role: 'radio',
262
+ 'aria-checked': option.value === value,
263
+ 'aria-label': option.label,
264
+ tabIndex: disabled ? -1 : 0,
265
+ onClick: () => {
266
+ if (!disabled) onPick(option.value)
267
+ },
268
+ onKeyDown: (e) => {
269
+ if (disabled) return
270
+ if (e.key === 'Enter' || e.key === ' ') {
271
+ e.preventDefault()
272
+ onPick(option.value)
273
+ }
274
+ },
275
+ style: {
276
+ height: 26,
277
+ padding: '0 11px',
278
+ font: 'inherit',
279
+ fontSize: 12,
280
+ lineHeight: '24px',
281
+ borderRadius: 13,
282
+ cursor: disabled ? 'default' : 'pointer',
283
+ opacity: disabled ? 0.5 : 1,
284
+ border: '0.5px solid ' + (option.value === value ? T.primary : T.borderL3),
285
+ background: option.value === value ? T.primary : 'transparent',
286
+ color: option.value === value ? 'var(--dsw-alias-label-primary-foreground)' : T.secondary,
287
+ transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
288
+ },
289
+ },
290
+ option.label,
291
+ ),
292
+ ),
293
+ ),
294
+ hint ? h('div', { key: 'hint', style: S.meta }, hint) : null,
295
+ ]),
296
+ ])
297
+ }
298
+
299
+ /**
300
+ * 子区里的一行开关:左边标题 + 灰色副说明,右边小开关。
301
+ * 和父行同构,只是没有状态点、字号小一档。
302
+ */
303
+ function ToggleRow({ title, hint, checked, onChange, disabled, label, warning }) {
304
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, paddingTop: 2 } }, [
147
305
  h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
148
- h('div', { key: 'label', style: S.rowTitle }, p.label),
149
- p.hint ? h('div', { key: 'hint', style: S.rowDesc }, p.hint) : null,
306
+ h('div', { key: 'label', style: { ...S.rowDesc, color: T.primary } }, title),
307
+ // warning:打开后有代价的开关,副说明转成警示色,扫一眼就能注意到。
308
+ h('div', { key: 'hint', style: warning ? { ...S.meta, color: T.warn } : S.meta }, hint),
150
309
  ]),
151
- selected ? h(Dot, { key: 'dot', state: 'done' }) : null,
310
+ h(Toggle, { key: 'toggle', checked, onChange, disabled, label: label ?? title }),
152
311
  ])
153
312
  }
154
313
 
155
- const API = '/api/browser-playwright';
156
- const HEADER = 'browser-toggle';
314
+ const API = '/api/browser-playwright'
315
+ const HEADER = 'browser-toggle'
157
316
 
158
- // 两种"看网页"的方式。写成用户能懂的话,不出现 legacy / aria / 引擎 这些词。
159
- const ENGINE_OPTIONS = [
160
- {
161
- id: 'legacy',
162
- label: '兼容模式',
163
- hint: '插件自己去找页面上的按钮和输入框。用得最久、测得最多,遇到奇怪的网站最不容易出问题。',
164
- },
165
- {
166
- id: 'aria',
167
- label: '智能模式',
168
- hint: '改用浏览器官方的"页面说明书"(和读屏软件看的是同一份)。菜单、弹窗、动态加载多的网站通常更准;万一读不出来,它会自动退回兼容模式。',
169
- },
170
- ]
317
+ // 两种"看网页"的方式。写成用户能懂的话,不出现 legacy / aria / 引擎 这些词。
318
+ const ENGINE_OPTIONS = [
319
+ {
320
+ id: 'legacy',
321
+ label: '兼容模式',
322
+ hint: '插件自己去找页面上的按钮和输入框。用得最久、测得最多,遇到奇怪的网站最不容易出问题。',
323
+ },
324
+ {
325
+ id: 'aria',
326
+ label: '智能模式',
327
+ hint: '改用浏览器官方的"页面说明书"(和读屏软件看的是同一份)。菜单、弹窗、动态加载多的网站通常更准;万一读不出来,它会自动退回兼容模式。',
328
+ },
329
+ ]
171
330
 
172
- // 两个"强力工具":默认关闭,用户想要才打开。写的都是人话,不出现 evaluate/cdp。
173
- const GATE_ROWS = [
174
- {
175
- key: 'allowEvaluate',
176
- label: '允许在页面里执行任意 JavaScript',
177
- hint: '打开后 AI 能在任意网页里运行脚本:能读走页面上的任何内容,也能用你的登录状态去点、去填、去提交。真正需要时才打开。',
178
- },
179
- {
180
- key: 'diagnostics',
181
- label: '诊断工具:控制台消息与网络请求',
182
- hint: '给排障用:能读出页面控制台的报错和最近的网络请求(URL、状态、失败原因)。平时用不到;遇到"点了没反应"或"背后的请求失败了"再打开。',
183
- },
184
- {
185
- key: 'allowCdp',
186
- label: '允许原始 CDP 调试协议',
187
- hint: '给排查疑难页面用:只放行"查看类"和"模拟输入类"命令,读取 Cookie/存储、改网络、动安全设置这类命令一律被拦住。',
188
- },
189
- ]
190
-
191
- async function api(path, method, body) {
192
- // The header goes on reads too: the host now requires it for GET as well, so a GET
193
- // without it is a 403 (2026-10-16 the CSRF check moved from POST-only to every route).
194
- const opts = { method, headers: { 'x-dsh-plugin': HEADER } };
195
- if (method === 'POST') {
196
- opts.headers['Content-Type'] = 'application/json';
197
- if (body !== undefined)
198
- opts.body = JSON.stringify(body);
199
- }
200
- else {
201
- opts.headers['Cache-Control'] = 'no-store';
202
- }
203
- const res = await fetch(API + path, opts);
204
- return res.json();
331
+ // 两个"强力工具":默认关闭,用户想要才打开。写的都是人话,不出现 evaluate/cdp。
332
+ const GATE_ROWS = [
333
+ {
334
+ key: 'allowEvaluate',
335
+ label: '允许在页面里执行任意 JavaScript',
336
+ hint: '打开后 AI 能在任意网页里运行脚本:能读走页面上的任何内容,也能用你的登录状态去点、去填、去提交。真正需要时才打开。',
337
+ },
338
+ {
339
+ key: 'diagnostics',
340
+ label: '诊断工具:控制台消息与网络请求',
341
+ hint: '给排障用:能读出页面控制台的报错和最近的网络请求(URL、状态、失败原因)。平时用不到;遇到"点了没反应"或"背后的请求失败了"再打开。',
342
+ },
343
+ {
344
+ key: 'allowCdp',
345
+ label: '允许原始 CDP 调试协议',
346
+ hint: '给排查疑难页面用:只放行"查看类"和"模拟输入类"命令,读取 Cookie/存储、改网络、动安全设置这类命令一律被拦住。',
347
+ },
348
+ ]
349
+
350
+ async function api(path, method, body) {
351
+ // The header goes on reads too: the host now requires it for GET as well, so a GET
352
+ // without it is a 403 (2026-10-16 the CSRF check moved from POST-only to every route).
353
+ const opts = { method, headers: { 'x-dsh-plugin': HEADER } }
354
+ if (method === 'POST') {
355
+ opts.headers['Content-Type'] = 'application/json'
356
+ if (body !== undefined) opts.body = JSON.stringify(body)
357
+ } else {
358
+ opts.headers['Cache-Control'] = 'no-store'
359
+ }
360
+ const res = await fetch(API + path, opts)
361
+ return res.json()
362
+ }
363
+
364
+ // Which domains the user chose not to see. A view preference only: it lives in this
365
+ // browser's localStorage, never in the deployment's files and never on the host.
366
+ const HIDDEN_KEY = 'dsh-browser-toggle.hidden-logins'
367
+ function readHiddenDomains() {
368
+ try {
369
+ const raw = window.localStorage.getItem(HIDDEN_KEY)
370
+ const parsed = raw === null ? [] : JSON.parse(raw)
371
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []
372
+ } catch {
373
+ return []
374
+ }
375
+ }
376
+ function writeHiddenDomains(list) {
377
+ try {
378
+ window.localStorage.setItem(HIDDEN_KEY, JSON.stringify(list))
379
+ } catch {
380
+ // private mode / quota: hiding a row is cosmetic, losing the preference is fine
381
+ }
382
+ }
383
+ function BrowserPanel() {
384
+ const [enabled, setEnabled] = useState(null)
385
+ const [engine, setEngine] = useState(null)
386
+ const [engineUserChosen, setEngineUserChosen] = useState(false)
387
+ const [swarm, setSwarm] = useState(null)
388
+ const [swarmSpeed, setSwarmSpeed] = useState(null)
389
+ const [swarmMaxMs, setSwarmMaxMs] = useState(null)
390
+ const [swarmOnce, setSwarmOnce] = useState(null)
391
+ // null = 用户没表态:遮罩默认不播(假),AI 动手时默认收动画。
392
+ const [swarmVeiled, setSwarmVeiled] = useState(null)
393
+ const [swarmAi, setSwarmAi] = useState(null)
394
+ // null = 用户没表态:默认只画首屏(整页模式会接管滚动,所以不做默认)。
395
+ const [swarmRange, setSwarmRange] = useState(null)
396
+ // null = 用户没表态:默认关窗就重播(播放记录只在这趟浏览器里有效)。
397
+ const [swarmPersist, setSwarmPersist] = useState(null)
398
+ // 参数太多:默认收起来,点「展开参数」才看到那一堆选项。
399
+ const [swarmParamsOpen, setSwarmParamsOpen] = useState(false)
400
+ const [gates, setGates] = useState({ allowEvaluate: null, allowCdp: null, diagnostics: null })
401
+ const [busy, setBusy] = useState(false)
402
+ const [msg, setMsg] = useState('')
403
+ const [logins, setLogins] = useState(null)
404
+ const [headless, setHeadless] = useState(null)
405
+ const [headlessUserChosen, setHeadlessUserChosen] = useState(false)
406
+ const [showAll, setShowAll] = useState(false)
407
+ const [openGroups, setOpenGroups] = useState([])
408
+ const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains())
409
+ const [loginsOpen, setLoginsOpen] = useState(false)
410
+ const [disk, setDisk] = useState(null)
411
+ const [diskPicked, setDiskPicked] = useState({
412
+ 'browser-cache': true,
413
+ 'temp-residue': true,
414
+ 'updater-residue': true,
415
+ 'attachment-cache': false,
416
+ })
417
+ const [diskPreview, setDiskPreview] = useState(null)
418
+ const [diskResult, setDiskResult] = useState(null)
419
+ const mountedRef = useRef(true)
420
+
421
+ const refresh = async () => {
422
+ try {
423
+ const s = await api('/state', 'GET')
424
+ if (!mountedRef.current) return
425
+ if (typeof s.enabled === 'boolean') setEnabled(s.enabled)
426
+ if (typeof s.engine === 'string') setEngine(s.engine)
427
+ setEngineUserChosen(s.engineUserChosen === true)
428
+ if (typeof s.swarm === 'boolean') setSwarm(s.swarm)
429
+ if (typeof s.swarmSpeed === 'number') setSwarmSpeed(s.swarmSpeed)
430
+ if (typeof s.swarmMaxMs === 'number') setSwarmMaxMs(s.swarmMaxMs)
431
+ if (typeof s.swarmOncePerSite === 'boolean') setSwarmOnce(s.swarmOncePerSite)
432
+ if (typeof s.swarmPlayOnVeiled === 'boolean') setSwarmVeiled(s.swarmPlayOnVeiled)
433
+ if (s.swarmWhileAiWorks === 'auto-close' || s.swarmWhileAiWorks === 'keep-alive')
434
+ setSwarmAi(s.swarmWhileAiWorks)
435
+ if (s.swarmRange === 'viewport' || s.swarmRange === 'page') setSwarmRange(s.swarmRange)
436
+ if (typeof s.swarmPersistPlayed === 'boolean') setSwarmPersist(s.swarmPersistPlayed)
437
+ if (typeof s.headless === 'boolean') setHeadless(s.headless)
438
+ setHeadlessUserChosen(s.headlessUserChosen === true)
439
+ setGates({
440
+ allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
441
+ allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
442
+ diagnostics: typeof s.diagnostics === 'boolean' ? s.diagnostics : null,
443
+ })
444
+ } catch (error) {
445
+ if (mountedRef.current) setMsg('状态获取失败:' + (error?.message ?? String(error)))
205
446
  }
447
+ }
206
448
 
207
- // Which domains the user chose not to see. A view preference only: it lives in this
208
- // browser's localStorage, never in the deployment's files and never on the host.
209
- const HIDDEN_KEY = 'dsh-browser-toggle.hidden-logins';
210
- function readHiddenDomains() {
211
- try {
212
- const raw = window.localStorage.getItem(HIDDEN_KEY);
213
- const parsed = raw === null ? [] : JSON.parse(raw);
214
- return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : [];
215
- }
216
- catch {
217
- return [];
218
- }
449
+ const refreshLogins = async () => {
450
+ try {
451
+ const r = await api('/logins', 'GET')
452
+ if (mountedRef.current) setLogins(r && typeof r === 'object' ? (r.logins ?? null) : null)
453
+ } catch {
454
+ if (mountedRef.current) setLogins(null)
219
455
  }
220
- function writeHiddenDomains(list) {
221
- try {
222
- window.localStorage.setItem(HIDDEN_KEY, JSON.stringify(list));
223
- }
224
- catch {
225
- // private mode / quota: hiding a row is cosmetic, losing the preference is fine
226
- }
456
+ }
457
+
458
+ useEffect(() => {
459
+ mountedRef.current = true
460
+ refresh()
461
+ refreshLogins()
462
+ refreshDisk()
463
+ const timer = setInterval(refresh, 5000)
464
+ // Cookies change far more slowly than switches; polling this less often keeps the
465
+ // settings page from doing pointless work.
466
+ const loginsTimer = setInterval(refreshLogins, 15000)
467
+ return () => {
468
+ mountedRef.current = false
469
+ clearInterval(timer)
470
+ clearInterval(loginsTimer)
227
471
  }
228
- function BrowserPanel() {
229
- const [enabled, setEnabled] = useState(null);
230
- const [engine, setEngine] = useState(null);
231
- const [engineUserChosen, setEngineUserChosen] = useState(false);
232
- const [gates, setGates] = useState({ allowEvaluate: null, allowCdp: null, diagnostics: null });
233
- const [busy, setBusy] = useState(false);
234
- const [msg, setMsg] = useState('');
235
- const [logins, setLogins] = useState(null);
236
- const [headless, setHeadless] = useState(null);
237
- const [headlessUserChosen, setHeadlessUserChosen] = useState(false);
238
- const [showAll, setShowAll] = useState(false);
239
- const [openGroups, setOpenGroups] = useState([]);
240
- const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains());
241
- const [loginsOpen, setLoginsOpen] = useState(false);
242
- const [disk, setDisk] = useState(null);
243
- const [diskPicked, setDiskPicked] = useState({ 'browser-cache': true, 'temp-residue': true, 'updater-residue': true, 'attachment-cache': false });
244
- const [diskPreview, setDiskPreview] = useState(null);
245
- const [diskResult, setDiskResult] = useState(null);
246
- const mountedRef = useRef(true);
247
-
248
- const refresh = async () => {
249
- try {
250
- const s = await api('/state', 'GET');
251
- if (!mountedRef.current) return;
252
- if (typeof s.enabled === 'boolean')
253
- setEnabled(s.enabled);
254
- if (typeof s.engine === 'string')
255
- setEngine(s.engine);
256
- setEngineUserChosen(s.engineUserChosen === true);
257
- if (typeof s.headless === 'boolean') setHeadless(s.headless);
258
- setHeadlessUserChosen(s.headlessUserChosen === true);
259
- setGates({
260
- allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
261
- allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
262
- diagnostics: typeof s.diagnostics === 'boolean' ? s.diagnostics : null,
263
- });
264
- }
265
- catch (error) {
266
- if (mountedRef.current)
267
- setMsg('状态获取失败:' + (error?.message ?? String(error)));
268
- }
269
- };
472
+ }, [])
270
473
 
271
- const refreshLogins = async () => {
272
- try {
273
- const r = await api('/logins', 'GET');
274
- if (mountedRef.current)
275
- setLogins(r && typeof r === 'object' ? (r.logins ?? null) : null);
276
- }
277
- catch {
278
- if (mountedRef.current)
279
- setLogins(null);
280
- }
281
- };
282
-
283
- useEffect(() => {
284
- mountedRef.current = true;
285
- refresh();
286
- refreshLogins();
287
- refreshDisk();
288
- const timer = setInterval(refresh, 5000);
289
- // Cookies change far more slowly than switches; polling this less often keeps the
290
- // settings page from doing pointless work.
291
- const loginsTimer = setInterval(refreshLogins, 15000);
292
- return () => {
293
- mountedRef.current = false;
294
- clearInterval(timer);
295
- clearInterval(loginsTimer);
296
- };
297
- }, []);
298
-
299
- const applyEnabled = async (next) => {
300
- if (enabled === null || busy || next === enabled)
301
- return;
302
- setBusy(true);
303
- setMsg('');
304
- try {
305
- const r = await api('/toggle', 'POST', { enabled: next });
306
- if (typeof r.enabled === 'boolean')
307
- setEnabled(r.enabled);
308
- setMsg(r.changed
309
- ? (next ? '已启用:AI 可以替你操作网页。' : '已禁用:AI 不再操作网页,已打开的窗口会自动关闭(登录状态保留)。')
310
- : '状态未变化');
311
- }
312
- catch (error) {
313
- setMsg('切换失败:' + (error?.message ?? String(error)));
314
- }
315
- finally {
316
- setBusy(false);
317
- }
318
- };
319
-
320
- const chooseEngine = async (next) => {
321
- if (engine === null || busy || next === engine)
322
- return;
323
- setBusy(true);
324
- setMsg('');
325
- try {
326
- const r = await api('/engine', 'POST', { engine: next });
327
- if (typeof r.engine === 'string')
328
- setEngine(r.engine);
329
- const picked = ENGINE_OPTIONS.find((o) => o.id === r.engine) || {};
330
- setEngineUserChosen(true);
331
- setMsg(r.changed
332
- ? '已切到「' + (picked.label || r.engine) + '」,下一步操作就生效,不用重启。'
333
- : '状态未变化');
334
- }
335
- catch (error) {
336
- setMsg('切换失败:' + (error?.message ?? String(error)));
337
- }
338
- finally {
339
- setBusy(false);
340
- }
341
- };
342
-
343
- const chooseWindow = async (nextHeadless) => {
344
- if (headless === null || busy || nextHeadless === headless) return;
345
- setBusy(true);
346
- setMsg('');
347
- try {
348
- const r = await api('/window', 'POST', { headless: nextHeadless });
349
- if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown');
350
- if (typeof r.headless === 'boolean') setHeadless(r.headless);
351
- setHeadlessUserChosen(true);
352
- setMsg(r.changed
353
- ? (nextHeadless
354
- ? '已切到后台运行(无头)。下次操作时重开浏览器;标签页会关闭,登录状态保留。'
355
- : '已切到显示窗口。下次操作时重开浏览器;标签页会关闭,登录状态保留。')
356
- : '状态未变化');
357
- } catch (error) {
358
- setMsg('切换失败:' + (error?.message ?? String(error)));
359
- } finally {
360
- setBusy(false);
361
- }
362
- };
363
-
364
- const applyGates = async (key, next) => {
365
- if (gates[key] === null || busy || next === gates[key])
366
- return;
367
- setBusy(true);
368
- setMsg('');
369
- // Send both: the host writes one managed block for the pair.
370
- const payload = {
371
- allowEvaluate: gates.allowEvaluate === true,
372
- allowCdp: gates.allowCdp === true,
373
- diagnostics: gates.diagnostics === true,
374
- };
375
- payload[key] = next;
376
- try {
377
- const r = await api('/gates', 'POST', payload);
378
- if (r && r.error)
379
- throw new Error(r.error.message || r.error.code || 'unknown');
380
- setGates({
381
- allowEvaluate: r.allowEvaluate === true,
382
- allowCdp: r.allowCdp === true,
383
- diagnostics: r.diagnostics === true,
384
- });
385
- setMsg(r.changed
386
- ? (next
387
- ? '已打开:' + ((GATE_ROWS.find((row) => row.key === key) || {}).label || key) + '。工具会在热重载后出现(约 1-2 秒);若一直没出现,重启一次 DSH。'
388
- : '已关闭。工具会在热重载后消失;若还在,重启一次 DSH。')
389
- : '状态未变化');
390
- }
391
- catch (error) {
392
- setMsg('切换失败:' + (error?.message ?? String(error)));
393
- }
394
- finally {
395
- setBusy(false);
396
- }
397
- };
398
-
399
- const fmt = (bytes) => {
400
- if (typeof bytes !== 'number') return '—';
401
- if (bytes < 1024) return bytes + ' B';
402
- const units = ['KB', 'MB', 'GB'];
403
- let value = bytes / 1024;
404
- let unit = 0;
405
- while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
406
- return value.toFixed(value >= 100 ? 0 : 1) + ' ' + units[unit];
407
- };
408
-
409
- const refreshDisk = async () => {
410
- try {
411
- const d = await api('/disk/stat', 'GET');
412
- if (mountedRef.current && d && !d.error) setDisk(d);
413
- } catch (error) {
414
- if (mountedRef.current) setMsg('磁盘统计失败:' + (error?.message ?? String(error)));
415
- }
416
- };
417
-
418
- const runDisk = async (dryRun) => {
419
- if (busy) return;
420
- const targets = Object.keys(diskPicked).filter((id) => diskPicked[id]);
421
- if (targets.length === 0) { setMsg('先勾选要清理的项目'); return; }
422
- setBusy(true);
423
- setMsg('');
424
- try {
425
- const r = await api('/disk/clean', 'POST', { targets, dryRun });
426
- if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown');
427
- if (dryRun) {
428
- setDiskPreview(r);
429
- setDiskResult(null);
430
- } else {
431
- setDiskPreview(null);
432
- setDiskResult(r);
433
- setMsg('已释放 ' + fmt(r.freedBytes) + '(跳过 ' + (r.perTarget || []).reduce((n, t) => n + ((t.skipped && t.skipped.length) || 0), 0) + ' 项)');
434
- refreshDisk();
435
- }
436
- } catch (error) {
437
- setMsg('清理失败:' + (error?.message ?? String(error)));
438
- } finally {
439
- setBusy(false);
440
- }
441
- };
442
-
443
- const skipSummary = (report) => {
444
- if (!report || !Array.isArray(report.perTarget)) return '';
445
- const reasons = {};
446
- for (const t of report.perTarget) for (const s of (t.skipped || [])) reasons[s.reason] = (reasons[s.reason] || 0) + 1;
447
- const parts = Object.keys(reasons).map((k) => k + ' ' + reasons[k]);
448
- return parts.length > 0 ? ' 跳过:' + parts.join('、') : '';
449
- };
450
-
451
- const state = enabled === null ? 'ongoing' : (enabled ? 'done' : 'idle');
452
- const gateState = (key) => (gates[key] === null ? 'ongoing' : (gates[key] ? 'done' : 'idle'));
453
- const gatesOn = GATE_ROWS.filter((row) => gates[row.key] === true).length;
454
- const statusLabel = enabled === null ? '读取中…' : (enabled ? '已启用' : '已禁用');
455
- const current = ENGINE_OPTIONS.find((o) => o.id === engine) || null;
456
-
457
- const GATE_SHORT = {
458
- allowEvaluate: '页面里的任意 JavaScript',
459
- allowCdp: '原始 CDP 调试协议',
460
- diagnostics: '控制台消息 / 网络请求',
461
- };
462
- const ENGINE_SHORT = { legacy: '最稳,奇怪网站也不容易出错', aria: '更准,复杂页面优先' };
463
-
464
- return h('div', { style: S.page }, [
465
- h('h2', { key: 'title', style: S.title }, '浏览器'),
466
- h('p', { key: 'intro', style: S.intro },
467
- '让 AI 用你的登录状态替你上网办事。这里的改动立刻生效,不需要重启。'),
468
-
469
- // ---- 主开关 ----
470
- h('div', { key: 'row', style: S.row }, [
471
- h(Dot, { key: 'dot', state }),
472
- h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
473
- h('div', { key: 'label', style: S.rowTitle }, '允许 AI 操作网页'),
474
- h('div', { key: 'desc', style: S.rowDesc },
475
- h(Tag, { key: 'status' }, statusLabel),
476
- '关闭后窗口会关掉,登录状态保留。'),
477
- ]),
478
- h(Toggle, {
479
- key: 'toggle',
480
- checked: enabled === true,
481
- onChange: applyEnabled,
482
- disabled: enabled === null || busy,
483
- label: '允许 AI 操作网页',
474
+ const applyEnabled = async (next) => {
475
+ if (enabled === null || busy || next === enabled) return
476
+ setBusy(true)
477
+ setMsg('')
478
+ try {
479
+ const r = await api('/toggle', 'POST', { enabled: next })
480
+ if (typeof r.enabled === 'boolean') setEnabled(r.enabled)
481
+ setMsg(
482
+ r.changed
483
+ ? next
484
+ ? '已启用:AI 可以替你操作网页。'
485
+ : '已禁用:AI 不再操作网页,已打开的窗口会自动关闭(登录状态保留)。'
486
+ : '状态未变化',
487
+ )
488
+ } catch (error) {
489
+ setMsg('切换失败:' + (error?.message ?? String(error)))
490
+ } finally {
491
+ setBusy(false)
492
+ }
493
+ }
494
+
495
+ const applySwarm = async (next) => {
496
+ // null = 还没选过,等同于默认的「开」;只有真的变化才发请求
497
+ if (busy || next === (swarm !== false)) return
498
+ setBusy(true)
499
+ setMsg('')
500
+ try {
501
+ const r = await api('/swarm', 'POST', { enabled: next })
502
+ if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown')
503
+ if (typeof r.swarm === 'boolean') setSwarm(r.swarm)
504
+ setMsg(
505
+ next
506
+ ? '进场特效已打开:每个网站第一次打开时,页面会被抹白、再用一群光标画回来,播完自动退回。'
507
+ : '进场特效已关闭:以后打开网页就是普通网页。',
508
+ )
509
+ } catch (error) {
510
+ setMsg('切换失败:' + (error?.message ?? String(error)))
511
+ } finally {
512
+ setBusy(false)
513
+ }
514
+ }
515
+
516
+ const setSwarmOpt = async (patch) => {
517
+ if (busy) return
518
+ setBusy(true)
519
+ setMsg('')
520
+ try {
521
+ const r = await api('/swarm', 'POST', patch)
522
+ if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown')
523
+ if (typeof r.swarmSpeed === 'number') setSwarmSpeed(r.swarmSpeed)
524
+ else if (patch.speed !== undefined) setSwarmSpeed(patch.speed)
525
+ if (typeof r.swarmMaxMs === 'number') setSwarmMaxMs(r.swarmMaxMs)
526
+ else if (patch.maxMs !== undefined) setSwarmMaxMs(patch.maxMs)
527
+ if (typeof r.swarmOncePerSite === 'boolean') setSwarmOnce(r.swarmOncePerSite)
528
+ else if (patch.oncePerSite !== undefined) setSwarmOnce(patch.oncePerSite)
529
+ if (typeof r.swarmPlayOnVeiled === 'boolean') setSwarmVeiled(r.swarmPlayOnVeiled)
530
+ else if (patch.playOnVeiled !== undefined) setSwarmVeiled(patch.playOnVeiled)
531
+ if (r.swarmWhileAiWorks === 'auto-close' || r.swarmWhileAiWorks === 'keep-alive')
532
+ setSwarmAi(r.swarmWhileAiWorks)
533
+ else if (patch.whileAiWorks !== undefined) setSwarmAi(patch.whileAiWorks)
534
+ if (r.swarmRange === 'viewport' || r.swarmRange === 'page') setSwarmRange(r.swarmRange)
535
+ else if (patch.range !== undefined) setSwarmRange(patch.range)
536
+ if (typeof r.swarmPersistPlayed === 'boolean') setSwarmPersist(r.swarmPersistPlayed)
537
+ else if (patch.persistPlayed !== undefined) setSwarmPersist(patch.persistPlayed)
538
+ setMsg('已保存,下一次打开网页起生效。')
539
+ } catch (error) {
540
+ setMsg('保存失败:' + (error?.message ?? String(error)))
541
+ } finally {
542
+ setBusy(false)
543
+ }
544
+ }
545
+
546
+ // 副标题里的摘要:不展开也能看出当前是几倍速、多长时间、播多大范围、是否每站只播一次。
547
+ const swarmSummary = () => {
548
+ const speed = (swarmSpeed ?? 3) + '×'
549
+ const limit = swarmMaxMs ?? 15000
550
+ const duration = limit === 0 ? '一直播完' : limit / 1000 + ' 秒'
551
+ const range = swarmRange === 'page' ? '整页' : '只画首屏'
552
+ const once = swarmOnce !== false ? '每站只播一次' : '每次都播'
553
+ const memory = swarmPersist === true ? '一直记住' : '关窗重播'
554
+ return [speed, duration, range, once, memory].join(' · ')
555
+ }
556
+
557
+ const chooseEngine = async (next) => {
558
+ if (engine === null || busy || next === engine) return
559
+ setBusy(true)
560
+ setMsg('')
561
+ try {
562
+ const r = await api('/engine', 'POST', { engine: next })
563
+ if (typeof r.engine === 'string') setEngine(r.engine)
564
+ const picked = ENGINE_OPTIONS.find((o) => o.id === r.engine) || {}
565
+ setEngineUserChosen(true)
566
+ setMsg(
567
+ r.changed ? '已切到「' + (picked.label || r.engine) + '」,下一步操作就生效,不用重启。' : '状态未变化',
568
+ )
569
+ } catch (error) {
570
+ setMsg('切换失败:' + (error?.message ?? String(error)))
571
+ } finally {
572
+ setBusy(false)
573
+ }
574
+ }
575
+
576
+ const chooseWindow = async (nextHeadless) => {
577
+ if (headless === null || busy || nextHeadless === headless) return
578
+ setBusy(true)
579
+ setMsg('')
580
+ try {
581
+ const r = await api('/window', 'POST', { headless: nextHeadless })
582
+ if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown')
583
+ if (typeof r.headless === 'boolean') setHeadless(r.headless)
584
+ setHeadlessUserChosen(true)
585
+ setMsg(
586
+ r.changed
587
+ ? nextHeadless
588
+ ? '已切到后台运行(无头)。下次操作时重开浏览器;标签页会关闭,登录状态保留。'
589
+ : '已切到显示窗口。下次操作时重开浏览器;标签页会关闭,登录状态保留。'
590
+ : '状态未变化',
591
+ )
592
+ } catch (error) {
593
+ setMsg('切换失败:' + (error?.message ?? String(error)))
594
+ } finally {
595
+ setBusy(false)
596
+ }
597
+ }
598
+
599
+ const applyGates = async (key, next) => {
600
+ if (gates[key] === null || busy || next === gates[key]) return
601
+ setBusy(true)
602
+ setMsg('')
603
+ // Send both: the host writes one managed block for the pair.
604
+ const payload = {
605
+ allowEvaluate: gates.allowEvaluate === true,
606
+ allowCdp: gates.allowCdp === true,
607
+ diagnostics: gates.diagnostics === true,
608
+ }
609
+ payload[key] = next
610
+ try {
611
+ const r = await api('/gates', 'POST', payload)
612
+ if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown')
613
+ setGates({
614
+ allowEvaluate: r.allowEvaluate === true,
615
+ allowCdp: r.allowCdp === true,
616
+ diagnostics: r.diagnostics === true,
617
+ })
618
+ setMsg(
619
+ r.changed
620
+ ? next
621
+ ? '已打开:' +
622
+ ((GATE_ROWS.find((row) => row.key === key) || {}).label || key) +
623
+ '。工具会在热重载后出现(约 1-2 秒);若一直没出现,重启一次 DSH。'
624
+ : '已关闭。工具会在热重载后消失;若还在,重启一次 DSH。'
625
+ : '状态未变化',
626
+ )
627
+ } catch (error) {
628
+ setMsg('切换失败:' + (error?.message ?? String(error)))
629
+ } finally {
630
+ setBusy(false)
631
+ }
632
+ }
633
+
634
+ const fmt = (bytes) => {
635
+ if (typeof bytes !== 'number') return '—'
636
+ if (bytes < 1024) return bytes + ' B'
637
+ const units = ['KB', 'MB', 'GB']
638
+ let value = bytes / 1024
639
+ let unit = 0
640
+ while (value >= 1024 && unit < units.length - 1) {
641
+ value /= 1024
642
+ unit += 1
643
+ }
644
+ return value.toFixed(value >= 100 ? 0 : 1) + ' ' + units[unit]
645
+ }
646
+
647
+ const refreshDisk = async () => {
648
+ try {
649
+ const d = await api('/disk/stat', 'GET')
650
+ if (mountedRef.current && d && !d.error) setDisk(d)
651
+ } catch (error) {
652
+ if (mountedRef.current) setMsg('磁盘统计失败:' + (error?.message ?? String(error)))
653
+ }
654
+ }
655
+
656
+ const runDisk = async (dryRun) => {
657
+ if (busy) return
658
+ const targets = Object.keys(diskPicked).filter((id) => diskPicked[id])
659
+ if (targets.length === 0) {
660
+ setMsg('先勾选要清理的项目')
661
+ return
662
+ }
663
+ setBusy(true)
664
+ setMsg('')
665
+ try {
666
+ const r = await api('/disk/clean', 'POST', { targets, dryRun })
667
+ if (r && r.error) throw new Error(r.error.message || r.error.code || 'unknown')
668
+ if (dryRun) {
669
+ setDiskPreview(r)
670
+ setDiskResult(null)
671
+ } else {
672
+ setDiskPreview(null)
673
+ setDiskResult(r)
674
+ setMsg(
675
+ '已释放 ' +
676
+ fmt(r.freedBytes) +
677
+ '(跳过 ' +
678
+ (r.perTarget || []).reduce((n, t) => n + ((t.skipped && t.skipped.length) || 0), 0) +
679
+ ' 项)',
680
+ )
681
+ refreshDisk()
682
+ }
683
+ } catch (error) {
684
+ setMsg('清理失败:' + (error?.message ?? String(error)))
685
+ } finally {
686
+ setBusy(false)
687
+ }
688
+ }
689
+
690
+ const skipSummary = (report) => {
691
+ if (!report || !Array.isArray(report.perTarget)) return ''
692
+ const reasons = {}
693
+ for (const t of report.perTarget)
694
+ for (const s of t.skipped || []) reasons[s.reason] = (reasons[s.reason] || 0) + 1
695
+ const parts = Object.keys(reasons).map((k) => k + ' ' + reasons[k])
696
+ return parts.length > 0 ? ' 跳过:' + parts.join('、') : ''
697
+ }
698
+
699
+ const state = enabled === null ? 'ongoing' : enabled ? 'done' : 'idle'
700
+ const gateState = (key) => (gates[key] === null ? 'ongoing' : gates[key] ? 'done' : 'idle')
701
+ const gatesOn = GATE_ROWS.filter((row) => gates[row.key] === true).length
702
+ const statusLabel = enabled === null ? '读取中…' : enabled ? '已启用' : '已禁用'
703
+ const current = ENGINE_OPTIONS.find((o) => o.id === engine) || null
704
+
705
+ const GATE_SHORT = {
706
+ allowEvaluate: '页面里的任意 JavaScript',
707
+ allowCdp: '原始 CDP 调试协议',
708
+ diagnostics: '控制台消息 / 网络请求',
709
+ }
710
+ const ENGINE_SHORT = { legacy: '最稳,奇怪网站也不容易出错', aria: '更准,复杂页面优先' }
711
+
712
+ return h('div', { style: S.page }, [
713
+ h('h2', { key: 'title', style: S.title }, '浏览器'),
714
+ h('p', { key: 'intro', style: S.intro }, '让 AI 用你的登录状态替你上网办事。这里的改动立刻生效,不需要重启。'),
715
+
716
+ // ---- 主开关 ----
717
+ h('div', { key: 'row', style: S.row }, [
718
+ h(Dot, { key: 'dot', state }),
719
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
720
+ h('div', { key: 'label', style: S.rowTitle }, '允许 AI 操作网页'),
721
+ h(
722
+ 'div',
723
+ { key: 'desc', style: S.rowDesc },
724
+ h(Tag, { key: 'status' }, statusLabel),
725
+ '关闭后窗口会关掉,登录状态保留。',
726
+ ),
727
+ ]),
728
+ h(Toggle, {
729
+ key: 'toggle',
730
+ checked: enabled === true,
731
+ onChange: applyEnabled,
732
+ disabled: enabled === null || busy,
733
+ label: '允许 AI 操作网页',
734
+ }),
735
+ ]),
736
+
737
+ // ---- 进场特效:一张卡片,父行在上,子项用发丝线分隔 ----
738
+ h(
739
+ 'div',
740
+ {
741
+ key: 'swarmCard',
742
+ style: {
743
+ ...S.row,
744
+ flexDirection: 'column',
745
+ alignItems: 'stretch',
746
+ gap: 0,
747
+ padding: 0,
748
+ overflow: 'hidden',
749
+ },
750
+ },
751
+ [
752
+ h('div', { key: 'head', style: { display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' } }, [
753
+ h(Dot, { key: 'dot', state: swarm === false ? 'idle' : 'done' }),
754
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
755
+ h('div', { key: 'label', style: S.rowTitle }, '进场特效:让页面自己长回来'),
756
+ h(
757
+ 'div',
758
+ { key: 'desc', style: S.rowDesc },
759
+ '第一次打开某个网站时,页面会被抹白,再由一群光标把方框、文字、图片一笔一笔画回来;播完自动退回真实页面。',
760
+ ),
761
+ h('div', { key: 'meta', style: S.meta }, swarmSummary()),
762
+ ]),
763
+ h(Toggle, {
764
+ key: 'toggle',
765
+ // null = 用户没表态:配置默认是开,不能显示成关着的灰开关。
766
+ checked: swarm !== false,
767
+ onChange: applySwarm,
768
+ disabled: busy,
769
+ label: '进场特效:让页面自己长回来',
770
+ }),
771
+ ]),
772
+ // 参数太多:默认收起来,一眼扫过只看上面那行摘要;点一下才展开
773
+ // (用户 2026-11-09:选项越来越多,铺开看着累)。
774
+ swarm === false
775
+ ? null
776
+ : h(
777
+ 'div',
778
+ {
779
+ key: 'paramsToggle',
780
+ style: {
781
+ borderTop: '0.5px solid ' + T.border,
782
+ padding: '10px 14px',
783
+ display: 'flex',
784
+ alignItems: 'center',
785
+ justifyContent: 'space-between',
786
+ cursor: 'pointer',
787
+ userSelect: 'none',
788
+ fontSize: 12,
789
+ color: T.tertiary,
790
+ },
791
+ onClick: () => setSwarmParamsOpen((open) => !open),
792
+ },
793
+ [
794
+ h('span', { key: 'label' }, '详细参数'),
795
+ h('span', { key: 'hint', style: S.groupCount }, swarmParamsOpen ? '点击收起 ▲' : '点击展开 ▼'),
796
+ ],
797
+ ),
798
+ // 展开后才显示那一堆设置项。
799
+ swarm === false || !swarmParamsOpen
800
+ ? null
801
+ : h(
802
+ 'div',
803
+ {
804
+ key: 'params',
805
+ style: {
806
+ borderTop: '0.5px solid ' + T.border,
807
+ padding: '12px 14px 13px',
808
+ display: 'flex',
809
+ flexDirection: 'column',
810
+ gap: 10,
811
+ },
812
+ },
813
+ [
814
+ h(ChipRow, {
815
+ key: 'speed',
816
+ label: '播放速度',
817
+ value: swarmSpeed ?? 3,
818
+ disabled: busy,
819
+ onPick: (v) => setSwarmOpt({ speed: v }),
820
+ options: [
821
+ { value: 1, label: '1×' },
822
+ { value: 2, label: '2×' },
823
+ { value: 3, label: '3×' },
824
+ ],
484
825
  }),
485
- ]),
486
-
487
- // ---- 页面读取方式 ----
488
- h('div', { key: 'engineGroup', style: S.group }, [
489
- '页面读取方式',
490
- h('span', { key: 'cur', style: S.groupCount },
491
- current ? ' 当前:' + current.label : ' 读取中…'),
492
- ]),
493
- h('div', { key: 'engines', style: { ...S.list, marginTop: 8 } },
494
- ENGINE_OPTIONS.map((o) => h(RadioRow, {
495
- key: o.id,
496
- p: { id: o.id, label: o.label, hint: ENGINE_SHORT[o.id] || '' },
497
- selected: engine === o.id,
498
- disabled: engine === null || busy,
499
- onPick: chooseEngine,
500
- }))),
501
- h(Notes, {
502
- key: 'engineNotes',
503
- title: '两种模式到底差在哪',
504
- children: h('div', null, ENGINE_OPTIONS.map((o) => h('p', { key: o.id, style: S.note },
505
- o.label + ':' + o.hint))),
506
- }),
507
-
508
- // ---- 浏览器窗口(有头 / 无头) ----
509
- h('div', { key: 'windowGroup', style: S.group }, [
510
- '浏览器窗口',
511
- h('span', { key: 'cur', style: S.groupCount },
512
- headless === null ? ' 读取中…' : (headless ? ' 当前:后台运行(无头)' : ' 当前:显示窗口(有头)')
513
- + (headlessUserChosen ? '(你选的)' : '')),
514
- ]),
515
- h('div', { key: 'windows', style: { ...S.list, marginTop: 8 } }, [
516
- h(RadioRow, {
517
- key: 'headful',
518
- p: { id: 'headful', label: '显示浏览器窗口', hint: '能看见、能随时接手;登录和验证码可以自己处理' },
519
- selected: headless === false,
520
- disabled: headless === null || busy,
521
- onPick: () => chooseWindow(false),
826
+ h(ChipRow, {
827
+ key: 'duration',
828
+ label: '播放时长',
829
+ value: swarmMaxMs ?? 15000,
830
+ disabled: busy,
831
+ onPick: (v) => setSwarmOpt({ maxMs: v }),
832
+ options: [
833
+ { value: 10000, label: '10 秒' },
834
+ { value: 15000, label: '15 秒' },
835
+ { value: 25000, label: '25 秒' },
836
+ { value: 0, label: '一直播完' },
837
+ ],
522
838
  }),
523
- h(RadioRow, {
524
- key: 'headless',
525
- p: { id: 'headless', label: '后台运行(无头)', hint: '更快、不弹窗;但你看不见它,登录/验证码只能远程处理' },
526
- selected: headless === true,
527
- disabled: headless === null || busy,
528
- onPick: () => chooseWindow(true),
839
+ h(ChipRow, {
840
+ key: 'range',
841
+ label: '播放范围',
842
+ value: swarmRange ?? 'viewport',
843
+ disabled: busy,
844
+ onPick: (v) => setSwarmOpt({ range: v }),
845
+ hint:
846
+ swarmRange === 'page'
847
+ ? '一屏一屏往下滚着画完,播完滚回顶部。期间别动滚轮,时间长得多。'
848
+ : '只画当前这一屏,几秒就结束,长页面看不到下半部分。',
849
+ options: [
850
+ { value: 'viewport', label: '只画首屏' },
851
+ { value: 'page', label: '整页' },
852
+ ],
529
853
  }),
530
- ]),
531
- h(Notes, {
532
- key: 'windowNotes',
533
- title: '切换窗口模式会发生什么',
534
- children: h('div', null, [
535
- h('p', { key: 'reopen', style: S.note },
536
- '有头/无头是启动参数,不能原地切换:下次操作时会关掉旧窗口、按新模式重开。标签页和未提交的表单会丢,登录状态会先导出、不会丢。'),
537
- h('p', { key: 'risk', style: S.note },
538
- '无头模式下插件"看得见、能接管"的前提不成立——验证码/二次验证只有提示,没人能在窗口里替你点。只在无人值守、且你接受这个代价时用。'),
539
- ]),
540
- }),
541
-
542
- // ---- 工具权限 ----
543
- h('div', { key: 'gateGroup', style: S.group }, [
544
- '工具权限',
545
- h('span', { key: 'sum', style: S.groupCount },
546
- gatesOn === 0 ? ' 全部关闭' : ' ' + gatesOn + ' / ' + GATE_ROWS.length + ' 已开启'),
547
- ]),
548
- h('div', { key: 'gates', style: { ...S.list, marginTop: 8 } },
549
- GATE_ROWS.map((row) => h('div', { key: row.key, style: S.row }, [
550
- h(Dot, { key: 'dot', state: gateState(row.key) }),
551
- h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
552
- h('div', { key: 'label', style: S.rowTitle }, row.label),
553
- h('div', { key: 'desc', style: S.rowDesc }, GATE_SHORT[row.key] || row.hint),
854
+ h(ToggleRow, {
855
+ key: 'once',
856
+ title: '每个网站只播一次',
857
+ hint: '关掉就每次打开都播一遍。',
858
+ checked: swarmOnce !== false,
859
+ onChange: (next) => setSwarmOpt({ oncePerSite: next }),
860
+ disabled: busy,
861
+ }),
862
+ h(ToggleRow, {
863
+ key: 'veiled',
864
+ title: '遮罩页面也播',
865
+ // 代价必须写在开关旁边:这层会把整屏盖住,遮罩后面的内容也一样被画出来。
866
+ hint: '登录墙、验证页这类被整屏盖住的页面也播。代价:遮罩底下没解锁的内容也会被一笔笔画出来,公共场合别开。',
867
+ checked: swarmVeiled === true,
868
+ onChange: (next) => setSwarmOpt({ playOnVeiled: next }),
869
+ disabled: busy,
870
+ warning: swarmVeiled === true,
871
+ }),
872
+ h(ChipRow, {
873
+ key: 'aiWork',
874
+ label: 'AI 工作时',
875
+ value: swarmAi ?? 'auto-close',
876
+ disabled: busy,
877
+ onPick: (v) => setSwarmOpt({ whileAiWorks: v }),
878
+ hint:
879
+ swarmAi === 'keep-alive'
880
+ ? '动画照播到底;那一层不吃鼠标,AI 的点照常落在真实页面上。'
881
+ : 'AI 一动手,动画立刻收掉(最多等 300 毫秒)。',
882
+ options: [
883
+ { value: 'auto-close', label: '自动关闭动画' },
884
+ { value: 'keep-alive', label: '动画继续跑' },
885
+ ],
886
+ }),
887
+ h(ToggleRow, {
888
+ key: 'persist',
889
+ title: '一直记住,不重播',
890
+ hint: '默认关:整个浏览器窗口关掉后,下次再打开这些网页重新播一遍。打开后,播过的网站一直记着。关单个标签不影响。',
891
+ checked: swarmPersist === true,
892
+ onChange: (next) => setSwarmOpt({ persistPlayed: next }),
893
+ disabled: busy,
894
+ }),
895
+ ],
896
+ ),
897
+ // 署名:原创作者 Joel Califa 的唯一条件(2026-11-08 书面授权)。放在卡片里,但不随开关隐藏——
898
+ // 关掉特效不等于这份代码不在这台机器上。
899
+ h(
900
+ 'div',
901
+ {
902
+ key: 'credit',
903
+ style: {
904
+ borderTop: '0.5px solid ' + T.border,
905
+ padding: '9px 14px 10px',
906
+ fontSize: 11,
907
+ lineHeight: '16px',
908
+ color: T.tertiary,
909
+ },
910
+ },
911
+ '动效:Joel Califa(Swarm)· 代码:Dhvanil Patel(swarm-cursors)',
912
+ ),
913
+ ],
914
+ ),
915
+
916
+ // ---- 页面读取方式 ----
917
+ h('div', { key: 'engineGroup', style: S.group }, [
918
+ '页面读取方式',
919
+ h('span', { key: 'cur', style: S.groupCount }, current ? ' 当前:' + current.label : ' 读取中…'),
920
+ ]),
921
+ h(
922
+ 'div',
923
+ { key: 'engines', style: { ...S.list, marginTop: 8 } },
924
+ ENGINE_OPTIONS.map((o) =>
925
+ h(RadioRow, {
926
+ key: o.id,
927
+ p: { id: o.id, label: o.label, hint: ENGINE_SHORT[o.id] || '' },
928
+ selected: engine === o.id,
929
+ disabled: engine === null || busy,
930
+ onPick: chooseEngine,
931
+ }),
932
+ ),
933
+ ),
934
+ h(Notes, {
935
+ key: 'engineNotes',
936
+ title: '两种模式到底差在哪',
937
+ children: h(
938
+ 'div',
939
+ null,
940
+ ENGINE_OPTIONS.map((o) => h('p', { key: o.id, style: S.note }, o.label + ':' + o.hint)),
941
+ ),
942
+ }),
943
+
944
+ // ---- 浏览器窗口(有头 / 无头) ----
945
+ h('div', { key: 'windowGroup', style: S.group }, [
946
+ '浏览器窗口',
947
+ h(
948
+ 'span',
949
+ { key: 'cur', style: S.groupCount },
950
+ headless === null
951
+ ? ' 读取中…'
952
+ : (headless ? ' 当前:后台运行(无头)' : ' 当前:显示窗口(有头)') +
953
+ (headlessUserChosen ? '(你选的)' : ''),
954
+ ),
955
+ ]),
956
+ h('div', { key: 'windows', style: { ...S.list, marginTop: 8 } }, [
957
+ h(RadioRow, {
958
+ key: 'headful',
959
+ p: { id: 'headful', label: '显示浏览器窗口', hint: '能看见、能随时接手;登录和验证码可以自己处理' },
960
+ selected: headless === false,
961
+ disabled: headless === null || busy,
962
+ onPick: () => chooseWindow(false),
963
+ }),
964
+ h(RadioRow, {
965
+ key: 'headless',
966
+ p: {
967
+ id: 'headless',
968
+ label: '后台运行(无头)',
969
+ hint: '更快、不弹窗;但你看不见它,登录/验证码只能远程处理',
970
+ },
971
+ selected: headless === true,
972
+ disabled: headless === null || busy,
973
+ onPick: () => chooseWindow(true),
974
+ }),
975
+ ]),
976
+ h(Notes, {
977
+ key: 'windowNotes',
978
+ title: '切换窗口模式会发生什么',
979
+ children: h('div', null, [
980
+ h(
981
+ 'p',
982
+ { key: 'reopen', style: S.note },
983
+ '有头/无头是启动参数,不能原地切换:下次操作时会关掉旧窗口、按新模式重开。标签页和未提交的表单会丢,登录状态会先导出、不会丢。',
984
+ ),
985
+ h(
986
+ 'p',
987
+ { key: 'risk', style: S.note },
988
+ '无头模式下插件"看得见、能接管"的前提不成立——验证码/二次验证只有提示,没人能在窗口里替你点。只在无人值守、且你接受这个代价时用。',
989
+ ),
990
+ ]),
991
+ }),
992
+
993
+ // ---- 工具权限 ----
994
+ h('div', { key: 'gateGroup', style: S.group }, [
995
+ '工具权限',
996
+ h(
997
+ 'span',
998
+ { key: 'sum', style: S.groupCount },
999
+ gatesOn === 0 ? ' 全部关闭' : ' ' + gatesOn + ' / ' + GATE_ROWS.length + ' 已开启',
1000
+ ),
1001
+ ]),
1002
+ h(
1003
+ 'div',
1004
+ { key: 'gates', style: { ...S.list, marginTop: 8 } },
1005
+ GATE_ROWS.map((row) =>
1006
+ h('div', { key: row.key, style: S.row }, [
1007
+ h(Dot, { key: 'dot', state: gateState(row.key) }),
1008
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
1009
+ h('div', { key: 'label', style: S.rowTitle }, row.label),
1010
+ h('div', { key: 'desc', style: S.rowDesc }, GATE_SHORT[row.key] || row.hint),
1011
+ ]),
1012
+ h(Toggle, {
1013
+ key: 'toggle',
1014
+ checked: gates[row.key] === true,
1015
+ onChange: (next) => applyGates(row.key, next),
1016
+ disabled: gates[row.key] === null || busy,
1017
+ label: row.label,
1018
+ }),
1019
+ ]),
1020
+ ),
1021
+ ),
1022
+ h(Notes, {
1023
+ key: 'gateNotes',
1024
+ title: '这些开关具体放行什么',
1025
+ children: h('div', null, [
1026
+ h(
1027
+ 'p',
1028
+ { key: 'evaluate', style: S.note },
1029
+ '页面里执行任意 JavaScript:能读走页面内容,也能用你的登录状态去点、去填、去提交。真正需要时才打开。',
1030
+ ),
1031
+ h(
1032
+ 'p',
1033
+ { key: 'cdp', style: S.note },
1034
+ '原始 CDP 协议:只放行查看类和模拟输入类命令;读 Cookie/存储、改网络、动安全设置一律被拦。排查疑难页面用。',
1035
+ ),
1036
+ h(
1037
+ 'p',
1038
+ { key: 'diagnostics', style: S.note },
1039
+ '控制台消息 + 网络请求:能读出页面报错和最近的请求(URL、状态、失败原因)。平时用不到,遇到"点了没反应"再打开。',
1040
+ ),
1041
+ h(
1042
+ 'p',
1043
+ { key: 'effect', style: S.note },
1044
+ '工具在热重载后出现或消失(约 1-2 秒);若一直没变,重启一次 DSH。',
1045
+ ),
1046
+ ]),
1047
+ }),
1048
+
1049
+ h('div', { key: 'diskGroup', style: S.group }, [
1050
+ '缓存与磁盘',
1051
+ h(
1052
+ 'span',
1053
+ { key: 'sum', style: S.groupCount },
1054
+ disk === null ? ' 统计中…' : ' 可清理 ' + fmt(disk.totalBytes),
1055
+ ),
1056
+ ]),
1057
+ h(
1058
+ 'div',
1059
+ { key: 'diskList', style: { ...S.list, marginTop: 8 } },
1060
+ (disk === null ? [] : disk.items).map((item) =>
1061
+ h('div', { key: item.id, style: S.row }, [
1062
+ h(
1063
+ 'div',
1064
+ {
1065
+ key: 'check',
1066
+ role: 'checkbox',
1067
+ 'aria-checked': diskPicked[item.id] === true,
1068
+ 'aria-label': item.label,
1069
+ tabIndex: 0,
1070
+ onClick: () => setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] }),
1071
+ onKeyDown: (e) => {
1072
+ if (e.key === 'Enter' || e.key === ' ') {
1073
+ e.preventDefault()
1074
+ setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] })
1075
+ }
1076
+ },
1077
+ style: {
1078
+ width: 16,
1079
+ height: 16,
1080
+ flex: 'none',
1081
+ borderRadius: 4,
1082
+ cursor: 'pointer',
1083
+ border: '1.5px solid ' + (diskPicked[item.id] ? T.primary : T.borderL3),
1084
+ display: 'flex',
1085
+ alignItems: 'center',
1086
+ justifyContent: 'center',
1087
+ },
1088
+ },
1089
+ diskPicked[item.id]
1090
+ ? h('span', { style: { width: 8, height: 8, borderRadius: 2, background: T.primary } })
1091
+ : null,
1092
+ ),
1093
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
1094
+ h('div', { key: 'label', style: S.rowTitle }, item.label),
1095
+ h(
1096
+ 'div',
1097
+ { key: 'desc', style: S.rowDesc },
1098
+ fmt(item.bytes) + ' ' + item.files + ' 个文件' + (item.note ? ' ' + item.note : ''),
1099
+ ),
1100
+ ]),
1101
+ ]),
1102
+ ),
1103
+ ),
1104
+ h('div', { key: 'diskActions', style: S.footer }, [
1105
+ h(Btn, { key: 'rescan', onClick: refreshDisk, disabled: busy }, '重新统计'),
1106
+ h(Btn, { key: 'preview', onClick: () => runDisk(true), disabled: busy || disk === null }, '先预演'),
1107
+ diskPreview
1108
+ ? h(
1109
+ Btn,
1110
+ { key: 'clean', onClick: () => runDisk(false), disabled: busy },
1111
+ '确认清理 ' + fmt(diskPreview.freedBytes),
1112
+ )
1113
+ : null,
1114
+ ]),
1115
+ diskPreview
1116
+ ? h(
1117
+ 'div',
1118
+ { key: 'diskPreview', style: S.tip },
1119
+ '预演:将释放 ' + fmt(diskPreview.freedBytes) + skipSummary(diskPreview) + '(点了确认才会真删)',
1120
+ )
1121
+ : null,
1122
+ diskResult
1123
+ ? h(
1124
+ 'div',
1125
+ { key: 'diskResult', style: S.tip },
1126
+ '已释放 ' + fmt(diskResult.freedBytes) + skipSummary(diskResult),
1127
+ )
1128
+ : null,
1129
+
1130
+ // ---- 已登录站点(默认收起) ----
1131
+ h(
1132
+ 'div',
1133
+ {
1134
+ key: 'loginsGroup',
1135
+ style: { ...S.group, cursor: 'pointer', userSelect: 'none' },
1136
+ onClick: () => setLoginsOpen((open) => !open),
1137
+ },
1138
+ [
1139
+ '已登录的站点',
1140
+ h(
1141
+ 'span',
1142
+ { key: 'count', style: S.groupCount },
1143
+ logins === null || logins.sites === undefined
1144
+ ? ' 读取中…'
1145
+ : ' ' +
1146
+ logins.sites.length +
1147
+ ' 个域 · ' +
1148
+ logins.sites.reduce((acc, site) => acc + site.cookies, 0) +
1149
+ ' 个 cookie',
1150
+ ),
1151
+ h('span', { key: 'hint', style: { ...S.groupCount, marginLeft: 8 } }, loginsOpen ? '点击收起' : '点击展开'),
1152
+ ],
1153
+ ),
1154
+ loginsOpen
1155
+ ? h(
1156
+ Notes,
1157
+ { key: 'loginsNotes', title: '这份名单怎么来的' },
1158
+ h(
1159
+ 'p',
1160
+ { style: S.note },
1161
+ '只列这台浏览器真的访问过的域;第三方统计/广告域已排除。判据是 OWASP 的会话特征,' +
1162
+ '名字不作为依据;不显示 cookie 的名字和值,名单只在本机内存,AI 看不到。',
1163
+ ),
1164
+ )
1165
+ : null,
1166
+ loginsOpen === false
1167
+ ? null
1168
+ : logins === null
1169
+ ? h(
1170
+ 'div',
1171
+ { key: 'loginsUnknown', style: S.tip },
1172
+ '读取不到:这个 profile 里的插件版本还没上报,或还没打开过浏览器。',
1173
+ )
1174
+ : (() => {
1175
+ const pool = showAll ? logins.sites : logins.sites.filter((s) => s.likely)
1176
+ const visible = pool.filter((s) => hiddenDomains.indexOf(s.domain) < 0)
1177
+ const hiddenCount = pool.length - visible.length
1178
+ const byGroup = new Map()
1179
+ const items = []
1180
+ for (const site of visible) {
1181
+ const key = site.group ? site.group.key : null
1182
+ if (key === null) {
1183
+ items.push({ kind: 'single', site })
1184
+ continue
1185
+ }
1186
+ let group = byGroup.get(key)
1187
+ if (group === undefined) {
1188
+ group = { kind: 'group', key, label: site.group.label, sites: [] }
1189
+ byGroup.set(key, group)
1190
+ items.push(group)
1191
+ }
1192
+ group.sites.push(site)
1193
+ }
1194
+ for (let i = items.length - 1; i >= 0; i -= 1) {
1195
+ if (items[i].kind === 'group' && items[i].sites.length === 1)
1196
+ items[i] = { kind: 'single', site: items[i].sites[0] }
1197
+ }
1198
+ const sum = (list, pick) => list.reduce((acc, s) => acc + pick(s), 0)
1199
+ const rowFor = (site, indented) =>
1200
+ h(
1201
+ 'div',
1202
+ {
1203
+ key: site.domain,
1204
+ style: indented ? { ...S.row, paddingLeft: 18 } : S.row,
1205
+ },
1206
+ [
1207
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
1208
+ h('div', { key: 'label', style: S.rowTitle }, [
1209
+ site.likely ? h(Tag, { key: 'tag' }, '推测登录') : null,
1210
+ site.alias || site.title || site.domain,
554
1211
  ]),
555
- h(Toggle, {
556
- key: 'toggle',
557
- checked: gates[row.key] === true,
558
- onChange: (next) => applyGates(row.key, next),
559
- disabled: gates[row.key] === null || busy,
560
- label: row.label,
561
- }),
562
- ]))),
563
- h(Notes, {
564
- key: 'gateNotes',
565
- title: '这些开关具体放行什么',
566
- children: h('div', null, [
567
- h('p', { key: 'evaluate', style: S.note },
568
- '页面里执行任意 JavaScript:能读走页面内容,也能用你的登录状态去点、去填、去提交。真正需要时才打开。'),
569
- h('p', { key: 'cdp', style: S.note },
570
- '原始 CDP 协议:只放行查看类和模拟输入类命令;读 Cookie/存储、改网络、动安全设置一律被拦。排查疑难页面用。'),
571
- h('p', { key: 'diagnostics', style: S.note },
572
- '控制台消息 + 网络请求:能读出页面报错和最近的请求(URL、状态、失败原因)。平时用不到,遇到"点了没反应"再打开。'),
573
- h('p', { key: 'effect', style: S.note },
574
- '工具在热重载后出现或消失(约 1-2 秒);若一直没变,重启一次 DSH。'),
1212
+ h(
1213
+ 'div',
1214
+ { key: 'desc', style: S.rowDesc },
1215
+ (site.alias ? site.domain + ' ' : '') +
1216
+ (site.title && site.title !== site.alias ? '「' + site.title + '」 ' : '') +
1217
+ site.cookies +
1218
+ ' 个 cookie',
1219
+ ),
1220
+ ]),
1221
+ h(
1222
+ Btn,
1223
+ {
1224
+ key: 'hide',
1225
+ onClick: () => {
1226
+ const next = hiddenDomains.concat([site.domain])
1227
+ setHiddenDomains(next)
1228
+ writeHiddenDomains(next)
1229
+ },
1230
+ },
1231
+ '隐藏',
1232
+ ),
1233
+ ],
1234
+ )
1235
+ const renderItem = (item) => {
1236
+ if (item.kind === 'single') return rowFor(item.site, false)
1237
+ const open = openGroups.indexOf(item.key) >= 0
1238
+ return h('div', { key: 'group-' + item.key }, [
1239
+ h('div', { key: 'head', style: S.row }, [
1240
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
1241
+ h(
1242
+ 'div',
1243
+ { key: 'label', style: S.rowTitle },
1244
+ item.label + '(' + item.sites.length + ' 个域)',
1245
+ ),
1246
+ h('div', { key: 'desc', style: S.rowDesc }, sum(item.sites, (s) => s.cookies) + ' 个 cookie'),
1247
+ ]),
1248
+ h(
1249
+ Btn,
1250
+ {
1251
+ key: 'toggle',
1252
+ onClick: () =>
1253
+ setOpenGroups(
1254
+ open ? openGroups.filter((k) => k !== item.key) : openGroups.concat([item.key]),
1255
+ ),
1256
+ },
1257
+ open ? '收起' : '展开',
1258
+ ),
575
1259
  ]),
576
- }),
577
-
578
- h('div', { key: 'diskGroup', style: S.group }, [
579
- '缓存与磁盘',
580
- h('span', { key: 'sum', style: S.groupCount },
581
- disk === null ? ' 统计中…' : ' 可清理 ' + fmt(disk.totalBytes)),
582
- ]),
583
- h('div', { key: 'diskList', style: { ...S.list, marginTop: 8 } },
584
- (disk === null ? [] : disk.items).map((item) => h('div', { key: item.id, style: S.row }, [
585
- h('div', {
586
- key: 'check',
587
- role: 'checkbox',
588
- 'aria-checked': diskPicked[item.id] === true,
589
- 'aria-label': item.label,
590
- tabIndex: 0,
591
- onClick: () => setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] }),
592
- onKeyDown: (e) => {
593
- if (e.key === 'Enter' || e.key === ' ') {
594
- e.preventDefault();
595
- setDiskPicked({ ...diskPicked, [item.id]: !diskPicked[item.id] });
596
- }
597
- },
598
- style: {
599
- width: 16, height: 16, flex: 'none', borderRadius: 4, cursor: 'pointer',
600
- border: '1.5px solid ' + (diskPicked[item.id] ? T.primary : T.borderL3),
601
- display: 'flex', alignItems: 'center', justifyContent: 'center',
1260
+ open
1261
+ ? h(
1262
+ 'div',
1263
+ { key: 'members' },
1264
+ item.sites.map((s) => rowFor(s, true)),
1265
+ )
1266
+ : null,
1267
+ ])
1268
+ }
1269
+ return h('div', { key: 'loginsBody' }, [
1270
+ h(
1271
+ 'div',
1272
+ { key: 'list', style: { ...S.list, marginTop: 8 } },
1273
+ visible.length === 0
1274
+ ? h(
1275
+ 'div',
1276
+ { key: 'none', style: S.tip },
1277
+ hiddenCount > 0 ? '这一页都被你隐藏了。' : '还没发现登录过的站点。',
1278
+ )
1279
+ : items.map(renderItem),
1280
+ ),
1281
+ h('div', { key: 'loginsFooter', style: S.footer }, [
1282
+ h(
1283
+ 'span',
1284
+ { key: 'src', style: S.tip },
1285
+ (logins.source === 'live' ? '实时' : '上次快照') +
1286
+ (logins.attribution === 'unfiltered' ? '(未做第一方过滤)' : ''),
1287
+ ),
1288
+ h(
1289
+ Btn,
1290
+ {
1291
+ key: 'all',
1292
+ onClick: () => setShowAll(!showAll),
1293
+ },
1294
+ showAll ? '只看像登录态的' : '显示其余 ' + (logins.total - logins.likelyTotal) + ' 个',
1295
+ ),
1296
+ hiddenCount > 0
1297
+ ? h(
1298
+ Btn,
1299
+ {
1300
+ key: 'reset',
1301
+ onClick: () => {
1302
+ setHiddenDomains([])
1303
+ writeHiddenDomains([])
602
1304
  },
603
- }, diskPicked[item.id] ? h('span', { style: { width: 8, height: 8, borderRadius: 2, background: T.primary } }) : null),
604
- h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
605
- h('div', { key: 'label', style: S.rowTitle }, item.label),
606
- h('div', { key: 'desc', style: S.rowDesc },
607
- fmt(item.bytes) + ' ' + item.files + ' 个文件'
608
- + (item.note ? ' ' + item.note : '')),
609
- ]),
610
- ]))),
611
- h('div', { key: 'diskActions', style: S.footer }, [
612
- h(Btn, { key: 'rescan', onClick: refreshDisk, disabled: busy }, '重新统计'),
613
- h(Btn, { key: 'preview', onClick: () => runDisk(true), disabled: busy || disk === null }, '先预演'),
614
- diskPreview
615
- ? h(Btn, { key: 'clean', onClick: () => runDisk(false), disabled: busy }, '确认清理 ' + fmt(diskPreview.freedBytes))
616
- : null,
617
- ]),
618
- diskPreview
619
- ? h('div', { key: 'diskPreview', style: S.tip },
620
- '预演:将释放 ' + fmt(diskPreview.freedBytes) + skipSummary(diskPreview) + '(点了确认才会真删)')
621
- : null,
622
- diskResult
623
- ? h('div', { key: 'diskResult', style: S.tip },
624
- '已释放 ' + fmt(diskResult.freedBytes) + skipSummary(diskResult))
625
- : null,
626
-
627
- // ---- 已登录站点(默认收起) ----
628
- h('div', {
629
- key: 'loginsGroup',
630
- style: { ...S.group, cursor: 'pointer', userSelect: 'none' },
631
- onClick: () => setLoginsOpen((open) => !open),
632
- }, [
633
- '已登录的站点',
634
- h('span', { key: 'count', style: S.groupCount },
635
- logins === null || logins.sites === undefined
636
- ? ' 读取中…'
637
- : ' ' + logins.sites.length + ' 个域 · '
638
- + logins.sites.reduce((acc, site) => acc + site.cookies, 0) + ' 个 cookie'),
639
- h('span', { key: 'hint', style: { ...S.groupCount, marginLeft: 8 } },
640
- loginsOpen ? '点击收起' : '点击展开'),
641
- ]),
642
- loginsOpen ? h(Notes, { key: 'loginsNotes', title: '这份名单怎么来的' },
643
- h('p', { style: S.note },
644
- '只列这台浏览器真的访问过的域;第三方统计/广告域已排除。判据是 OWASP 的会话特征,'
645
- + '名字不作为依据;不显示 cookie 的名字和值,名单只在本机内存,AI 看不到。')) : null,
646
- loginsOpen === false
647
- ? null
648
- : logins === null
649
- ? h('div', { key: 'loginsUnknown', style: S.tip },
650
- '读取不到:这个 profile 里的插件版本还没上报,或还没打开过浏览器。')
651
- : (() => {
652
- const pool = showAll ? logins.sites : logins.sites.filter((s) => s.likely);
653
- const visible = pool.filter((s) => hiddenDomains.indexOf(s.domain) < 0);
654
- const hiddenCount = pool.length - visible.length;
655
- const byGroup = new Map();
656
- const items = [];
657
- for (const site of visible) {
658
- const key = site.group ? site.group.key : null;
659
- if (key === null) { items.push({ kind: 'single', site }); continue; }
660
- let group = byGroup.get(key);
661
- if (group === undefined) {
662
- group = { kind: 'group', key, label: site.group.label, sites: [] };
663
- byGroup.set(key, group);
664
- items.push(group);
665
- }
666
- group.sites.push(site);
667
- }
668
- for (let i = items.length - 1; i >= 0; i -= 1) {
669
- if (items[i].kind === 'group' && items[i].sites.length === 1)
670
- items[i] = { kind: 'single', site: items[i].sites[0] };
671
- }
672
- const sum = (list, pick) => list.reduce((acc, s) => acc + pick(s), 0);
673
- const rowFor = (site, indented) => h('div', {
674
- key: site.domain,
675
- style: indented ? { ...S.row, paddingLeft: 18 } : S.row,
676
- }, [
677
- h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
678
- h('div', { key: 'label', style: S.rowTitle }, [
679
- site.likely ? h(Tag, { key: 'tag' }, '推测登录') : null,
680
- site.alias || site.title || site.domain,
681
- ]),
682
- h('div', { key: 'desc', style: S.rowDesc },
683
- (site.alias ? site.domain + ' ' : '')
684
- + (site.title && site.title !== site.alias ? '「' + site.title + '」 ' : '')
685
- + site.cookies + ' 个 cookie'),
686
- ]),
687
- h(Btn, {
688
- key: 'hide',
689
- onClick: () => {
690
- const next = hiddenDomains.concat([site.domain]);
691
- setHiddenDomains(next);
692
- writeHiddenDomains(next);
693
- },
694
- }, '隐藏'),
695
- ]);
696
- const renderItem = (item) => {
697
- if (item.kind === 'single') return rowFor(item.site, false);
698
- const open = openGroups.indexOf(item.key) >= 0;
699
- return h('div', { key: 'group-' + item.key }, [
700
- h('div', { key: 'head', style: S.row }, [
701
- h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
702
- h('div', { key: 'label', style: S.rowTitle },
703
- item.label + '(' + item.sites.length + ' 个域)'),
704
- h('div', { key: 'desc', style: S.rowDesc },
705
- sum(item.sites, (s) => s.cookies) + ' 个 cookie'),
706
- ]),
707
- h(Btn, {
708
- key: 'toggle',
709
- onClick: () => setOpenGroups(open
710
- ? openGroups.filter((k) => k !== item.key)
711
- : openGroups.concat([item.key])),
712
- }, open ? '收起' : '展开'),
713
- ]),
714
- open ? h('div', { key: 'members' }, item.sites.map((s) => rowFor(s, true))) : null,
715
- ]);
716
- };
717
- return h('div', { key: 'loginsBody' }, [
718
- h('div', { key: 'list', style: { ...S.list, marginTop: 8 } },
719
- visible.length === 0
720
- ? h('div', { key: 'none', style: S.tip },
721
- hiddenCount > 0 ? '这一页都被你隐藏了。' : '还没发现登录过的站点。')
722
- : items.map(renderItem)),
723
- h('div', { key: 'loginsFooter', style: S.footer }, [
724
- h('span', { key: 'src', style: S.tip },
725
- (logins.source === 'live' ? '实时' : '上次快照')
726
- + (logins.attribution === 'unfiltered' ? '(未做第一方过滤)' : '')),
727
- h(Btn, {
728
- key: 'all',
729
- onClick: () => setShowAll(!showAll),
730
- }, showAll ? '只看像登录态的' : '显示其余 ' + (logins.total - logins.likelyTotal) + ' 个'),
731
- hiddenCount > 0
732
- ? h(Btn, {
733
- key: 'reset',
734
- onClick: () => { setHiddenDomains([]); writeHiddenDomains([]); },
735
- }, '恢复 ' + hiddenCount + ' 个被隐藏的')
736
- : null,
737
- ]),
738
- ]);
739
- })(),
740
-
741
- msg ? h('div', { key: 'msg', style: S.msg }, msg) : null,
742
- h('div', { key: 'foot', style: S.footer }, [
743
- h(Btn, { key: 'refresh', onClick: refresh, disabled: busy }, '刷新状态'),
744
- ]),
745
- ]);
746
- }
1305
+ },
1306
+ '恢复 ' + hiddenCount + ' 个被隐藏的',
1307
+ )
1308
+ : null,
1309
+ ]),
1310
+ ])
1311
+ })(),
747
1312
 
748
- function apply(ctx) {
749
- ctx.effect(() =>
750
- ctx.slots.inject('settings.section', () =>
751
- ctx.slots.register(
752
- { name: 'settings.section', id: 'browser-toggle', order: 106, label: () => '浏览器' },
753
- BrowserPanel,
754
- ),
755
- ),
756
- );
757
- }
1313
+ msg ? h('div', { key: 'msg', style: S.msg }, msg) : null,
1314
+ h('div', { key: 'foot', style: S.footer }, [
1315
+ h(Btn, { key: 'refresh', onClick: refresh, disabled: busy }, '刷新状态'),
1316
+ ]),
1317
+ ])
1318
+ }
1319
+
1320
+ function apply(ctx) {
1321
+ ctx.effect(() =>
1322
+ ctx.slots.inject('settings.section', () =>
1323
+ ctx.slots.register(
1324
+ { name: 'settings.section', id: 'browser-toggle', order: 106, label: () => '浏览器' },
1325
+ BrowserPanel,
1326
+ ),
1327
+ ),
1328
+ )
1329
+ }
758
1330
 
759
- exports.apply = apply;
760
- exports.inject = ['slots'];
761
- return module.exports;
762
- },
763
- });
1331
+ exports.apply = apply
1332
+ exports.inject = ['slots']
1333
+ return module.exports
1334
+ },
1335
+ })