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/CHANGELOG.md +50 -0
- package/README.md +12 -0
- package/SECURITY.md +4 -0
- package/lib/browser-lifecycle.js +9 -2
- package/lib/cache-clean.d.ts +90 -0
- package/lib/cache-clean.js +386 -0
- package/lib/challenge.d.ts +8 -0
- package/lib/challenge.js +16 -0
- package/lib/client.js +250 -91
- package/lib/playwright.js +2 -1
- package/lib/provider.d.ts +14 -0
- package/lib/provider.js +38 -5
- package/lib/runtime-state.d.ts +20 -0
- package/lib/runtime-state.js +53 -0
- package/lib/settings-card.js +118 -0
- package/lib/tool-render.js +3 -2
- package/package.json +1 -1
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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: '
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
'
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
'
|
|
452
|
-
+ '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
+ '
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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);
|