dsh-browser-playwright-codex 0.7.1 → 0.7.4

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
@@ -233,10 +233,16 @@ window.__ModuleLoader__.load({
233
233
  const [busy, setBusy] = useState(false);
234
234
  const [msg, setMsg] = useState('');
235
235
  const [logins, setLogins] = useState(null);
236
+ const [headless, setHeadless] = useState(null);
237
+ const [headlessUserChosen, setHeadlessUserChosen] = useState(false);
236
238
  const [showAll, setShowAll] = useState(false);
237
239
  const [openGroups, setOpenGroups] = useState([]);
238
240
  const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains());
239
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);
240
246
  const mountedRef = useRef(true);
241
247
 
242
248
  const refresh = async () => {
@@ -248,6 +254,8 @@ window.__ModuleLoader__.load({
248
254
  if (typeof s.engine === 'string')
249
255
  setEngine(s.engine);
250
256
  setEngineUserChosen(s.engineUserChosen === true);
257
+ if (typeof s.headless === 'boolean') setHeadless(s.headless);
258
+ setHeadlessUserChosen(s.headlessUserChosen === true);
251
259
  setGates({
252
260
  allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
253
261
  allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
@@ -276,6 +284,7 @@ window.__ModuleLoader__.load({
276
284
  mountedRef.current = true;
277
285
  refresh();
278
286
  refreshLogins();
287
+ refreshDisk();
279
288
  const timer = setInterval(refresh, 5000);
280
289
  // Cookies change far more slowly than switches; polling this less often keeps the
281
290
  // settings page from doing pointless work.
@@ -331,6 +340,27 @@ window.__ModuleLoader__.load({
331
340
  }
332
341
  };
333
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
+
334
364
  const applyGates = async (key, next) => {
335
365
  if (gates[key] === null || busy || next === gates[key])
336
366
  return;
@@ -366,24 +396,84 @@ window.__ModuleLoader__.load({
366
396
  }
367
397
  };
368
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
+
369
451
  const state = enabled === null ? 'ongoing' : (enabled ? 'done' : 'idle');
370
452
  const gateState = (key) => (gates[key] === null ? 'ongoing' : (gates[key] ? 'done' : 'idle'));
371
453
  const gatesOn = GATE_ROWS.filter((row) => gates[row.key] === true).length;
372
454
  const statusLabel = enabled === null ? '读取中…' : (enabled ? '已启用' : '已禁用');
373
455
  const current = ENGINE_OPTIONS.find((o) => o.id === engine) || null;
374
456
 
457
+ const GATE_SHORT = {
458
+ allowEvaluate: '页面里的任意 JavaScript',
459
+ allowCdp: '原始 CDP 调试协议',
460
+ diagnostics: '控制台消息 / 网络请求',
461
+ };
462
+ const ENGINE_SHORT = { legacy: '最稳,奇怪网站也不容易出错', aria: '更准,复杂页面优先' };
463
+
375
464
  return h('div', { style: S.page }, [
376
465
  h('h2', { key: 'title', style: S.title }, '浏览器'),
377
466
  h('p', { key: 'intro', style: S.intro },
378
- '让 AI 用你的登录状态替你上网办事(查后台、填表、抓列表)。这里的改动立刻生效,不需要重启。'),
467
+ '让 AI 用你的登录状态替你上网办事。这里的改动立刻生效,不需要重启。'),
468
+
469
+ // ---- 主开关 ----
379
470
  h('div', { key: 'row', style: S.row }, [
380
471
  h(Dot, { key: 'dot', state }),
381
472
  h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
382
473
  h('div', { key: 'label', style: S.rowTitle }, '允许 AI 操作网页'),
383
- h('div', { key: 'desc', style: S.rowDesc }, [
474
+ h('div', { key: 'desc', style: S.rowDesc },
384
475
  h(Tag, { key: 'status' }, statusLabel),
385
- ' 关闭后已打开的窗口会自动关闭,登录状态会保留。',
386
- ]),
476
+ '关闭后窗口会关掉,登录状态保留。'),
387
477
  ]),
388
478
  h(Toggle, {
389
479
  key: 'toggle',
@@ -393,37 +483,74 @@ window.__ModuleLoader__.load({
393
483
  label: '允许 AI 操作网页',
394
484
  }),
395
485
  ]),
396
- h('div', { key: 'group', style: S.group }, '页面识别方式'),
397
- h(Notes, { key: 'groupNotes', title: '说明' },
398
- h('p', { style: S.note },
399
- '决定它怎么"看"一个网页:页面上有哪些能点的、能填的。两种都能正常用,选哪个看你常去的网站。')),
400
- h('div', { key: 'engines', style: { ...S.list, marginTop: 8 } }, ENGINE_OPTIONS.map((o) =>
401
- h(RadioRow, {
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, {
402
495
  key: o.id,
403
- p: o,
496
+ p: { id: o.id, label: o.label, hint: ENGINE_SHORT[o.id] || '' },
404
497
  selected: engine === o.id,
405
498
  disabled: engine === null || busy,
406
499
  onPick: chooseEngine,
407
500
  }))),
408
- h('div', { key: 'footer', style: S.footer }, [
409
- h(Btn, { key: 'refresh', onClick: refresh, disabled: busy }, '刷新状态'),
410
- current
411
- ? h('span', { key: 'cur', style: S.tip },
412
- '当前:' + current.label + (engineUserChosen ? '(你选的)' : '(默认)'))
413
- : null,
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 ? '(你选的)' : '')),
414
514
  ]),
415
- h('div', { key: 'gatesGroup', style: S.group }, '强力工具(默认关闭)'),
416
- h(Notes, { key: 'gatesNotes', title: '说明' },
417
- h('p', { style: S.note },
418
- '这两个开关控制的是"能力",不是效果微调:打开"任意 JavaScript"就等于把页面里的东西都交给 AI。'
419
- + '插件包里默认关闭 —— 因为包是发给每个安装者的,默认打开等于替所有人做了这个决定;'
420
- + '这里打开的是你自己机器上的选择,记在你自己机器的配置层里。')),
421
- h('div', { key: 'gates', style: { ...S.list, marginTop: 8 } }, GATE_ROWS.map((row) =>
422
- h('div', { key: row.key, style: S.row }, [
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),
522
+ }),
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),
529
+ }),
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 }, [
423
550
  h(Dot, { key: 'dot', state: gateState(row.key) }),
424
551
  h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
425
552
  h('div', { key: 'label', style: S.rowTitle }, row.label),
426
- h('div', { key: 'desc', style: S.rowDesc }, row.hint),
553
+ h('div', { key: 'desc', style: S.rowDesc }, GATE_SHORT[row.key] || row.hint),
427
554
  ]),
428
555
  h(Toggle, {
429
556
  key: 'toggle',
@@ -433,26 +560,89 @@ window.__ModuleLoader__.load({
433
560
  label: row.label,
434
561
  }),
435
562
  ]))),
436
- h('div', { key: 'gatesFooter', style: S.footer },
437
- gates.allowEvaluate === null && gates.allowCdp === null
438
- ? h('span', { key: 'unknown', style: S.tip }, '当前:读取不到(这个 profile 里的插件版本还没上报闸门状态)')
439
- : h('span', { key: 'sum', style: S.tip }, '当前:' + (gatesOn === 0 ? '都关着' : gatesOn + ' 个开着'))),
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。'),
575
+ ]),
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',
602
+ },
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
+ // ---- 已登录站点(默认收起) ----
440
628
  h('div', {
441
629
  key: 'loginsGroup',
442
630
  style: { ...S.group, cursor: 'pointer', userSelect: 'none' },
443
631
  onClick: () => setLoginsOpen((open) => !open),
444
- }, '已登录的站点(只给你自己看)'
445
- + (logins === null || logins.sites === undefined
446
- ? ''
447
- : ' · ' + logins.sites.length + ' 个域 · ' + logins.sites.reduce((acc, site) => acc + site.cookies, 0) + ' 个 cookie')
448
- + (loginsOpen ? ' ▾ 点击收起' : ' ▸ 点击展开')),
449
- loginsOpen ? h(Notes, { key: 'loginsNotes', title: '说明' },
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: '这份名单怎么来的' },
450
643
  h('p', { style: S.note },
451
- '只列**这台浏览器真的访问过**的域(你在 DSH 里打开过的页面),第三方统计/广告域已排除 ——'
452
- + '别人的 cookie 属于你当时浏览的那个站,不代表你登录了它。判据是 OWASP 的会话特征:'
453
- + 'httpOnly 且(会话级 或 值像令牌),名字不作为依据。'
454
- + '每一行的说明来自网页自己的标题,或一份离线对照表(认不出的就直接显示域名,不编)。'
455
- + '这里不显示 cookie 的名字和值;名单只存在于本机内存、不写磁盘;也没有任何工具能让 AI 看到它。')) : null,
644
+ '只列这台浏览器真的访问过的域;第三方统计/广告域已排除。判据是 OWASP 的会话特征,'
645
+ + '名字不作为依据;不显示 cookie 的名字和值,名单只在本机内存,AI 看不到。')) : null,
456
646
  loginsOpen === false
457
647
  ? null
458
648
  : logins === null
@@ -462,15 +652,11 @@ window.__ModuleLoader__.load({
462
652
  const pool = showAll ? logins.sites : logins.sites.filter((s) => s.likely);
463
653
  const visible = pool.filter((s) => hiddenDomains.indexOf(s.domain) < 0);
464
654
  const hiddenCount = pool.length - visible.length;
465
- // 同一家公司的域折叠成一行:真实名单里京东系二十来个域各自占一行,小半屏都是它。
466
655
  const byGroup = new Map();
467
656
  const items = [];
468
657
  for (const site of visible) {
469
658
  const key = site.group ? site.group.key : null;
470
- if (key === null) {
471
- items.push({ kind: 'single', site });
472
- continue;
473
- }
659
+ if (key === null) { items.push({ kind: 'single', site }); continue; }
474
660
  let group = byGroup.get(key);
475
661
  if (group === undefined) {
476
662
  group = { kind: 'group', key, label: site.group.label, sites: [] };
@@ -479,7 +665,6 @@ window.__ModuleLoader__.load({
479
665
  }
480
666
  group.sites.push(site);
481
667
  }
482
- // 组里只剩一个域就没有折叠的意义,退回单行。
483
668
  for (let i = items.length - 1; i >= 0; i -= 1) {
484
669
  if (items[i].kind === 'group' && items[i].sites.length === 1)
485
670
  items[i] = { kind: 'single', site: items[i].sites[0] };
@@ -492,16 +677,12 @@ window.__ModuleLoader__.load({
492
677
  h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
493
678
  h('div', { key: 'label', style: S.rowTitle }, [
494
679
  site.likely ? h(Tag, { key: 'tag' }, '推测登录') : null,
495
- // 说明优先:光看域名认不出来是这个功能最初的痛点。
496
680
  site.alias || site.title || site.domain,
497
681
  ]),
498
682
  h('div', { key: 'desc', style: S.rowDesc },
499
683
  (site.alias ? site.domain + ' ' : '')
500
684
  + (site.title && site.title !== site.alias ? '「' + site.title + '」 ' : '')
501
- + site.cookies + ' 个 cookie(httpOnly ' + site.httpOnly
502
- + ' / 会话 ' + site.sessionLike
503
- + ' / 值像令牌 ' + site.tokenLike + ')'
504
- + (site.signals.length > 0 ? ' ' + site.signals.join(' · ') : '')),
685
+ + site.cookies + ' 个 cookie'),
505
686
  ]),
506
687
  h(Btn, {
507
688
  key: 'hide',
@@ -515,74 +696,52 @@ window.__ModuleLoader__.load({
515
696
  const renderItem = (item) => {
516
697
  if (item.kind === 'single') return rowFor(item.site, false);
517
698
  const open = openGroups.indexOf(item.key) >= 0;
518
- const anyLikely = item.sites.some((s) => s.likely);
519
699
  return h('div', { key: 'group-' + item.key }, [
520
700
  h('div', { key: 'head', style: S.row }, [
521
701
  h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
522
- h('div', { key: 'label', style: S.rowTitle }, [
523
- anyLikely ? h(Tag, { key: 'tag' }, '推测登录') : null,
524
- item.label + '(' + item.sites.length + ' 个域)',
525
- ]),
702
+ h('div', { key: 'label', style: S.rowTitle },
703
+ item.label + '(' + item.sites.length + ' 个域)'),
526
704
  h('div', { key: 'desc', style: S.rowDesc },
527
- sum(item.sites, (s) => s.cookies) + ' 个 cookie httpOnly '
528
- + sum(item.sites, (s) => s.httpOnly) + ' 会话 '
529
- + sum(item.sites, (s) => s.sessionLike) + ' 值像令牌 '
530
- + sum(item.sites, (s) => s.tokenLike)),
705
+ sum(item.sites, (s) => s.cookies) + ' 个 cookie'),
531
706
  ]),
532
707
  h(Btn, {
533
708
  key: 'toggle',
534
- onClick: () => {
535
- setOpenGroups(open
536
- ? openGroups.filter((k) => k !== item.key)
537
- : openGroups.concat([item.key]));
538
- },
709
+ onClick: () => setOpenGroups(open
710
+ ? openGroups.filter((k) => k !== item.key)
711
+ : openGroups.concat([item.key])),
539
712
  }, open ? '收起' : '展开'),
540
713
  ]),
541
- open
542
- ? h('div', { key: 'members' }, item.sites.map((s) => rowFor(s, true)))
543
- : null,
714
+ open ? h('div', { key: 'members' }, item.sites.map((s) => rowFor(s, true))) : null,
544
715
  ]);
545
716
  };
546
717
  return h('div', { key: 'loginsBody' }, [
547
718
  h('div', { key: 'list', style: { ...S.list, marginTop: 8 } },
548
719
  visible.length === 0
549
720
  ? h('div', { key: 'none', style: S.tip },
550
- hiddenCount > 0
551
- ? '这一页都被你隐藏了。'
552
- : '还没发现登录过的站点(用这个浏览器登录过网站后,这里会出现)。')
721
+ hiddenCount > 0 ? '这一页都被你隐藏了。' : '还没发现登录过的站点。')
553
722
  : items.map(renderItem)),
554
723
  h('div', { key: 'loginsFooter', style: S.footer }, [
555
724
  h('span', { key: 'src', style: S.tip },
556
- (logins.source === 'live' ? '实时(浏览器开着)' : '上次快照(浏览器当前没开)')
557
- + ' ' + new Date(logins.capturedAt).toLocaleTimeString()
558
- + ' 你在 DSH 里访问过 ' + logins.total + ' 个域,其中 '
559
- + logins.likelyTotal + ' 个像登录态'
560
- + (logins.thirdPartyTotal > 0
561
- ? ';另有 ' + logins.thirdPartyTotal + ' 个第三方域(统计/广告)已排除'
562
- : '')
563
- + (logins.attribution === 'unfiltered'
564
- ? '(这次没拿到浏览器打开的页面列表,未做第一方过滤:下面可能有第三方域)'
565
- : '')),
725
+ (logins.source === 'live' ? '实时' : '上次快照')
726
+ + (logins.attribution === 'unfiltered' ? '(未做第一方过滤)' : '')),
566
727
  h(Btn, {
567
728
  key: 'all',
568
729
  onClick: () => setShowAll(!showAll),
569
- }, showAll
570
- ? '只看像登录态的 ' + logins.likelyTotal + ' 个'
571
- : '显示其余 ' + (logins.total - logins.likelyTotal) + ' 个'),
572
-
730
+ }, showAll ? '只看像登录态的' : '显示其余 ' + (logins.total - logins.likelyTotal) + ' 个'),
573
731
  hiddenCount > 0
574
732
  ? h(Btn, {
575
733
  key: 'reset',
576
- onClick: () => {
577
- setHiddenDomains([]);
578
- writeHiddenDomains([]);
579
- },
580
- }, '恢复显示 ' + hiddenCount + ' 个被隐藏的')
734
+ onClick: () => { setHiddenDomains([]); writeHiddenDomains([]); },
735
+ }, '恢复 ' + hiddenCount + ' 个被隐藏的')
581
736
  : null,
582
737
  ]),
583
738
  ]);
584
739
  })(),
740
+
585
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
+ ]),
586
745
  ]);
587
746
  }
588
747
 
package/lib/playwright.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { PlaywrightProvider } from "./provider.js";
18
18
  import {} from "./config.js";
19
- import { subscribe as subscribeEnabled } from "./runtime-state.js";
19
+ import { publishConfiguredHeadless, subscribe as subscribeEnabled } from "./runtime-state.js";
20
20
  /** Cordis plugin name used by loader diagnostics. */
21
21
  export const name = 'browser-playwright';
22
22
  /** The browser runtime this provider registers into. */
@@ -37,6 +37,7 @@ export { assertAllowedUrl } from "./url-policy.js";
37
37
  export { resolveSnapshotEngine } from "./page-snapshot.js";
38
38
  export function apply(ctx, config) {
39
39
  const provider = new PlaywrightProvider(config);
40
+ publishConfiguredHeadless(config.launch.headless === true);
40
41
  ctx.browser.registerProvider(provider);
41
42
  // Runtime on/off switch: when disabled the UI (or a future hot reload)
42
43
  // flips the flag and the provider tears down every active window so the
package/lib/provider.d.ts CHANGED
@@ -32,6 +32,8 @@ export declare class PlaywrightProvider {
32
32
  id: string;
33
33
  /** Legacy (persistent:false) shared browser instance. */
34
34
  browser: Browser | undefined;
35
+ /** Window mode the live process was launched with (undefined = nothing running). */
36
+ launchedHeadless: boolean | undefined;
35
37
  /** Persistent-mode shared profile-backed context (the personal window). */
36
38
  context: BrowserContext | undefined;
37
39
  entries: Map<string, OwnerEntry>;
@@ -83,6 +85,18 @@ export declare class PlaywrightProvider {
83
85
  };
84
86
  /** One line describing a parked dialog for the tool result. */
85
87
  dialogNoteFor(pending: PendingDialogRecord): string;
88
+ /**
89
+ * Window mode to launch with: the user's switch wins over the configured
90
+ * default. Read on every launch, so a change lands on the next operation
91
+ * without touching a patch file.
92
+ */
93
+ desiredHeadless(): boolean;
94
+ /**
95
+ * Close a running process whose mode no longer matches. The next acquire
96
+ * relaunches with the new mode; login state is exported first, so only the
97
+ * tabs and unsaved page state are lost — never the login.
98
+ */
99
+ private relaunchIfModeChanged;
86
100
  acquire(owner: string): Promise<PlaywrightSession>;
87
101
  disposeOwner(owner: string): Promise<void>;
88
102
  dispose(): Promise<void>;
package/lib/provider.js CHANGED
@@ -10,7 +10,7 @@ import {} from 'playwright-core';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { PlaywrightSession } from "./session.js";
13
- import { getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
13
+ import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites, } from "./runtime-state.js";
14
14
  import { BrowserError } from "./errors.js";
15
15
  import { SNAPSHOT_SCRIPT } from "./injected.js";
16
16
  import { isWindowMinimized } from "./window-focus.js";
@@ -19,6 +19,8 @@ export class PlaywrightProvider {
19
19
  id = 'playwright';
20
20
  /** Legacy (persistent:false) shared browser instance. */
21
21
  browser;
22
+ /** Window mode the live process was launched with (undefined = nothing running). */
23
+ launchedHeadless = undefined;
22
24
  /** Persistent-mode shared profile-backed context (the personal window). */
23
25
  context;
24
26
  entries = new Map();
@@ -170,17 +172,45 @@ export class PlaywrightProvider {
170
172
  ' Answer it with browser_dialog({ accept }) — accept only if the user authorized exactly this action,' +
171
173
  ' otherwise dismiss; no other tool can act until it is answered.');
172
174
  }
175
+ /**
176
+ * Window mode to launch with: the user's switch wins over the configured
177
+ * default. Read on every launch, so a change lands on the next operation
178
+ * without touching a patch file.
179
+ */
180
+ desiredHeadless() {
181
+ return getEffectiveHeadless(this.config.launch.headless === true);
182
+ }
183
+ /**
184
+ * Close a running process whose mode no longer matches. The next acquire
185
+ * relaunches with the new mode; login state is exported first, so only the
186
+ * tabs and unsaved page state are lost — never the login.
187
+ */
188
+ async relaunchIfModeChanged() {
189
+ const desired = this.desiredHeadless();
190
+ if (this.launchedHeadless === undefined || this.launchedHeadless === desired)
191
+ return;
192
+ await this.dispose();
193
+ }
173
194
  async acquire(owner) {
174
195
  if (!getEnabled()) {
175
196
  throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
176
197
  }
177
198
  const existing = this.entries.get(owner);
178
199
  if (existing) {
179
- existing.lastUsed = Date.now();
180
- this.armIdle(existing);
181
- return existing.session;
200
+ // A window-mode switch made while this owner is active must not hand back
201
+ // a session in the old mode: closing and relaunching here keeps the change
202
+ // one call away instead of "next restart".
203
+ if (this.launchedHeadless !== undefined && this.launchedHeadless !== this.desiredHeadless()) {
204
+ await this.dispose();
205
+ }
206
+ else {
207
+ existing.lastUsed = Date.now();
208
+ this.armIdle(existing);
209
+ return existing.session;
210
+ }
182
211
  }
183
212
  if (this.config.launch.persistent) {
213
+ await this.relaunchIfModeChanged();
184
214
  // Personal-browser mode: all owners drive one shared profile-backed
185
215
  // window (Codex-style). Entries are lightweight session handles on
186
216
  // the shared context; the last owner to leave closes the window.
@@ -191,6 +221,7 @@ export class PlaywrightProvider {
191
221
  this.armIdle(entry);
192
222
  return session;
193
223
  }
224
+ await this.relaunchIfModeChanged();
194
225
  const browser = await this.ensureBrowser();
195
226
  while (this.entries.size >= this.config.maxSessions) {
196
227
  const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
@@ -203,7 +234,7 @@ export class PlaywrightProvider {
203
234
  // (system DPI respected) exactly like a hand-opened Chrome, instead
204
235
  // of Playwright forcing a fixed CSS viewport that overflows the
205
236
  // window on scaled displays. Headless keeps the fixed viewport.
206
- ...(this.config.launch.headless ? { viewport: this.config.launch.viewport } : { viewport: null }),
237
+ ...(this.desiredHeadless() ? { viewport: this.config.launch.viewport } : { viewport: null }),
207
238
  ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
208
239
  });
209
240
  context.on('page', (page) => this.armDialogGuard(page));
@@ -234,6 +265,7 @@ export class PlaywrightProvider {
234
265
  if (this.entries.size === 0 && this.context !== undefined) {
235
266
  const ctx = this.context;
236
267
  this.context = undefined;
268
+ this.launchedHeadless = undefined;
237
269
  await this.persistState(ctx);
238
270
  await ctx.close().catch(() => { });
239
271
  }
@@ -242,6 +274,7 @@ export class PlaywrightProvider {
242
274
  await entry.context.close().catch(() => { });
243
275
  }
244
276
  async dispose() {
277
+ this.launchedHeadless = undefined;
245
278
  const owners = [...this.entries.keys()];
246
279
  for (const owner of owners)
247
280
  await this.disposeOwner(owner);