dsh-browser-playwright-codex 0.7.8 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/NOTICE.md +26 -5
- package/README.md +33 -10
- package/assets/swarm-rebuild.js +3864 -0
- package/lib/browser-lifecycle.js +27 -1
- package/lib/challenge.d.ts +7 -4
- package/lib/challenge.js +28 -6
- package/lib/client.js +1245 -647
- package/lib/config.d.ts +26 -0
- package/lib/config.js +19 -0
- package/lib/errors.d.ts +17 -0
- package/lib/errors.js +28 -0
- package/lib/gates-patch.d.ts +41 -1
- package/lib/gates-patch.js +103 -5
- package/lib/injected.js +45 -0
- package/lib/page-actions.d.ts +24 -0
- package/lib/page-actions.js +73 -11
- package/lib/provider.js +8 -0
- package/lib/read-deadline.d.ts +5 -1
- package/lib/read-deadline.js +10 -3
- package/lib/runtime-state.d.ts +26 -0
- package/lib/runtime-state.js +100 -0
- package/lib/session.d.ts +11 -0
- package/lib/session.js +80 -11
- package/lib/settings-card.js +123 -0
- package/lib/snapshot-render.js +52 -7
- package/lib/swarm-contract.d.ts +232 -0
- package/lib/swarm-contract.js +215 -0
- package/lib/swarm-curtain.d.ts +22 -0
- package/lib/swarm-curtain.js +115 -0
- package/lib/swarm-intro.d.ts +66 -0
- package/lib/swarm-intro.js +829 -0
- package/lib/tool-types.d.ts +10 -0
- package/lib/tool.js +20 -6
- package/lib/types.d.ts +6 -0
- package/package.json +18 -13
package/lib/runtime-state.js
CHANGED
|
@@ -14,6 +14,9 @@ let _enabled = true;
|
|
|
14
14
|
let _engine = undefined;
|
|
15
15
|
/** User's window-mode choice: true = headless, undefined = follow the config. */
|
|
16
16
|
let _headless = undefined;
|
|
17
|
+
/** 进场特效的开关:true/false = 用户自己选的,undefined = 跟配置的默认值。 */
|
|
18
|
+
let _swarm = undefined;
|
|
19
|
+
let _swarmTuning = undefined;
|
|
17
20
|
/** Config default pushed in by the provider (display only). */
|
|
18
21
|
let _configuredHeadless = undefined;
|
|
19
22
|
/** Default pushed in by the provider that is actually loaded (display only). */
|
|
@@ -40,6 +43,31 @@ function load() {
|
|
|
40
43
|
// Same contract as engine: absent/unknown means "follow the configured
|
|
41
44
|
// default", so files written before this setting existed stay valid.
|
|
42
45
|
_headless = typeof obj.headless === 'boolean' ? obj.headless : undefined;
|
|
46
|
+
// 同一个约定:没写过或值不认识 = 跟配置默认值,老状态文件照样有效。
|
|
47
|
+
_swarm = typeof obj.swarm === 'boolean' ? obj.swarm : undefined;
|
|
48
|
+
const tuning = obj.swarmTuning;
|
|
49
|
+
if (tuning !== undefined && tuning !== null && typeof tuning === 'object') {
|
|
50
|
+
// 只收认识的项;缺的项不写,读的时候自然落回配置默认值。
|
|
51
|
+
const next = {};
|
|
52
|
+
if (typeof tuning.speed === 'number')
|
|
53
|
+
next.speed = tuning.speed;
|
|
54
|
+
if (typeof tuning.maxMs === 'number')
|
|
55
|
+
next.maxMs = tuning.maxMs;
|
|
56
|
+
if (typeof tuning.oncePerSite === 'boolean')
|
|
57
|
+
next.oncePerSite = tuning.oncePerSite;
|
|
58
|
+
if (typeof tuning.playOnVeiled === 'boolean')
|
|
59
|
+
next.playOnVeiled = tuning.playOnVeiled;
|
|
60
|
+
if (tuning.whileAiWorks === 'auto-close' || tuning.whileAiWorks === 'keep-alive')
|
|
61
|
+
next.whileAiWorks = tuning.whileAiWorks;
|
|
62
|
+
if (tuning.range === 'viewport' || tuning.range === 'page')
|
|
63
|
+
next.range = tuning.range;
|
|
64
|
+
if (typeof tuning.persistPlayed === 'boolean')
|
|
65
|
+
next.persistPlayed = tuning.persistPlayed;
|
|
66
|
+
_swarmTuning = next;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
_swarmTuning = undefined;
|
|
70
|
+
}
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
73
|
catch {
|
|
@@ -69,6 +97,10 @@ function persist() {
|
|
|
69
97
|
const payload = _engine === undefined ? { enabled: _enabled } : { enabled: _enabled, engine: _engine };
|
|
70
98
|
if (_headless !== undefined)
|
|
71
99
|
payload.headless = _headless;
|
|
100
|
+
if (_swarm !== undefined)
|
|
101
|
+
payload.swarm = _swarm;
|
|
102
|
+
if (_swarmTuning !== undefined)
|
|
103
|
+
payload.swarmTuning = _swarmTuning;
|
|
72
104
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
73
105
|
fs.renameSync(tmp, target);
|
|
74
106
|
}
|
|
@@ -127,6 +159,74 @@ export function setHeadless(next) {
|
|
|
127
159
|
persist();
|
|
128
160
|
return true;
|
|
129
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
|
|
164
|
+
*/
|
|
165
|
+
export function getSwarm() {
|
|
166
|
+
load();
|
|
167
|
+
return _swarm;
|
|
168
|
+
}
|
|
169
|
+
/** 记下用户对进场特效的选择。Returns true when the value changed. */
|
|
170
|
+
export function setSwarm(next) {
|
|
171
|
+
load();
|
|
172
|
+
if (typeof next !== 'boolean')
|
|
173
|
+
return false;
|
|
174
|
+
if (_swarm === next)
|
|
175
|
+
return false;
|
|
176
|
+
_swarm = next;
|
|
177
|
+
persist();
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
|
|
181
|
+
export function isSwarmUserChosen() {
|
|
182
|
+
load();
|
|
183
|
+
return _swarm !== undefined;
|
|
184
|
+
}
|
|
185
|
+
/** 播放参数:只含用户改过的项,其余跟配置默认值。 */
|
|
186
|
+
export function getSwarmTuning() {
|
|
187
|
+
load();
|
|
188
|
+
return _swarmTuning ?? {};
|
|
189
|
+
}
|
|
190
|
+
/** 改播放参数(只传要改的项)。Returns true when something changed. */
|
|
191
|
+
export function setSwarmTuning(patch) {
|
|
192
|
+
load();
|
|
193
|
+
const next = { ...(_swarmTuning ?? {}) };
|
|
194
|
+
let changed = false;
|
|
195
|
+
if (typeof patch.speed === 'number' && next.speed !== patch.speed) {
|
|
196
|
+
next.speed = patch.speed;
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
if (typeof patch.maxMs === 'number' && next.maxMs !== patch.maxMs) {
|
|
200
|
+
next.maxMs = patch.maxMs;
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if (typeof patch.oncePerSite === 'boolean' && next.oncePerSite !== patch.oncePerSite) {
|
|
204
|
+
next.oncePerSite = patch.oncePerSite;
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
if (typeof patch.playOnVeiled === 'boolean' && next.playOnVeiled !== patch.playOnVeiled) {
|
|
208
|
+
next.playOnVeiled = patch.playOnVeiled;
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if ((patch.whileAiWorks === 'auto-close' || patch.whileAiWorks === 'keep-alive') &&
|
|
212
|
+
next.whileAiWorks !== patch.whileAiWorks) {
|
|
213
|
+
next.whileAiWorks = patch.whileAiWorks;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
if ((patch.range === 'viewport' || patch.range === 'page') && next.range !== patch.range) {
|
|
217
|
+
next.range = patch.range;
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
if (typeof patch.persistPlayed === 'boolean' && next.persistPlayed !== patch.persistPlayed) {
|
|
221
|
+
next.persistPlayed = patch.persistPlayed;
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
224
|
+
if (!changed)
|
|
225
|
+
return false;
|
|
226
|
+
_swarmTuning = next;
|
|
227
|
+
persist();
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
130
230
|
/** Publish the configured default (launch.headless) for display. */
|
|
131
231
|
export function publishConfiguredHeadless(value) {
|
|
132
232
|
_configuredHeadless = value === true;
|
package/lib/session.d.ts
CHANGED
|
@@ -149,6 +149,17 @@ export declare class PlaywrightSession {
|
|
|
149
149
|
capture(page: Page, opts: {
|
|
150
150
|
interactiveOnly?: boolean;
|
|
151
151
|
} | undefined, afterAction?: boolean): Promise<BrowserSnapshot>;
|
|
152
|
+
/**
|
|
153
|
+
* Close one page whose script has stopped answering, so the next acquire starts
|
|
154
|
+
* on a live tab instead of inheriting it. Bounded and best-effort: main thread
|
|
155
|
+
* wedged badly enough to stall an evaluate may never answer close either, and
|
|
156
|
+
* this runs on the error path — it must never turn a timeout into a second hang.
|
|
157
|
+
*
|
|
158
|
+
* 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
|
|
159
|
+
* 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
|
|
160
|
+
* 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
|
|
161
|
+
*/
|
|
162
|
+
private discardStuckPage;
|
|
152
163
|
/**
|
|
153
164
|
* Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
|
|
154
165
|
* An unavailable or unreadable aria tree (`missing`) degrades to the legacy
|
package/lib/session.js
CHANGED
|
@@ -240,7 +240,12 @@ export class PlaywrightSession {
|
|
|
240
240
|
}
|
|
241
241
|
/** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
|
|
242
242
|
async waitForElementStable(locator) {
|
|
243
|
-
|
|
243
|
+
// locator.evaluate has no timeout of its own either: the probe runs page
|
|
244
|
+
// script, so a wedged main thread makes it hang before the action it is
|
|
245
|
+
// supposed to protect. Degrade to "not stable" and let the action's own
|
|
246
|
+
// Playwright timeout report the real problem (same contract as before, just
|
|
247
|
+
// no longer unbounded).
|
|
248
|
+
const ok = await withDeadline(locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false), Math.max(3000, this.timeoutMs()), 'waiting for the element to hold still', this.currentSignal, 'resolve-undefined').catch(() => false);
|
|
244
249
|
return ok === true;
|
|
245
250
|
}
|
|
246
251
|
/** Post-action settle: wait out a real navigation, then render-stabilize before snapshotting. */
|
|
@@ -327,19 +332,42 @@ export class PlaywrightSession {
|
|
|
327
332
|
return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
|
|
328
333
|
};
|
|
329
334
|
try {
|
|
330
|
-
const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal
|
|
335
|
+
const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject',
|
|
336
|
+
// 报错文案必须说人话、说真话:这一页真的被关掉了,页面里没提交的东西跟着没了。
|
|
337
|
+
// 关页不弹 beforeunload(弹了可能又卡死),所以用户只有从这条消息里才知道。
|
|
338
|
+
'That page has been closed; anything typed into it but not submitted is lost, and if it was the last tab the session now starts on a blank page.');
|
|
331
339
|
return snap;
|
|
332
340
|
}
|
|
333
341
|
catch (error) {
|
|
334
342
|
// 超时只能让我们这一侧返回,页面里那个 evaluate 还在跑。作废会话句柄,
|
|
335
|
-
//
|
|
343
|
+
// 再把那张卡死的页面真正关掉——否则持久化模式下 ensurePage() 会把同一个
|
|
344
|
+
// 页面交还给下一次调用,所谓「自愈」只是换了个句柄继续超时(issue #5)。
|
|
336
345
|
if (isReadTimeout(error)) {
|
|
337
346
|
this.closed = true;
|
|
338
347
|
this.provider.invalidateOwner(this.owner);
|
|
348
|
+
await this.discardStuckPage(page);
|
|
339
349
|
}
|
|
340
350
|
throw error;
|
|
341
351
|
}
|
|
342
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Close one page whose script has stopped answering, so the next acquire starts
|
|
355
|
+
* on a live tab instead of inheriting it. Bounded and best-effort: main thread
|
|
356
|
+
* wedged badly enough to stall an evaluate may never answer close either, and
|
|
357
|
+
* this runs on the error path — it must never turn a timeout into a second hang.
|
|
358
|
+
*
|
|
359
|
+
* 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
|
|
360
|
+
* 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
|
|
361
|
+
* 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
|
|
362
|
+
*/
|
|
363
|
+
async discardStuckPage(page) {
|
|
364
|
+
try {
|
|
365
|
+
await withDeadline(page.close({ runBeforeUnload: false }), 2_000, 'closing the stuck page', undefined, 'resolve-undefined');
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
/* already gone, or too wedged to close: nothing better we can do */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
343
371
|
/**
|
|
344
372
|
* Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
|
|
345
373
|
* An unavailable or unreadable aria tree (`missing`) degrades to the legacy
|
|
@@ -556,7 +584,11 @@ export class PlaywrightSession {
|
|
|
556
584
|
if (page === undefined)
|
|
557
585
|
continue;
|
|
558
586
|
this.guardBlockedDialog(page);
|
|
559
|
-
|
|
587
|
+
// page.title() is a page round trip with no timeout: one tab running a
|
|
588
|
+
// stuck script used to hang the whole tab list, and every tab after it.
|
|
589
|
+
// An unreadable title degrades to "" — the URL still identifies the tab.
|
|
590
|
+
const title = await withDeadline(page.title().catch(() => ''), Math.max(2000, Math.min(this.timeoutMs(), 5000)), 'reading the title of tab ' + String(index), this.currentSignal, 'resolve-undefined').catch(() => undefined);
|
|
591
|
+
out.push({ index, url: page.url(), title: title ?? '', active: index === activeIndex });
|
|
560
592
|
}
|
|
561
593
|
return out;
|
|
562
594
|
});
|
|
@@ -600,7 +632,29 @@ export class PlaywrightSession {
|
|
|
600
632
|
});
|
|
601
633
|
}
|
|
602
634
|
async pageData() {
|
|
603
|
-
|
|
635
|
+
// page.evaluate again: the extract walker is page script, so a stuck tab used
|
|
636
|
+
// to hang browser_extract exactly like a snapshot read. Same deadline, and on
|
|
637
|
+
// timeout the same cleanup — drop the handle and the page so the next call
|
|
638
|
+
// does not inherit a page that will never answer.
|
|
639
|
+
try {
|
|
640
|
+
return await withDeadline(pageData(this), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject', 'That page has been closed; anything typed into it but not submitted is lost.');
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
if (isReadTimeout(error)) {
|
|
644
|
+
this.closed = true;
|
|
645
|
+
this.provider.invalidateOwner(this.owner);
|
|
646
|
+
let page;
|
|
647
|
+
try {
|
|
648
|
+
page = this.currentPageSync();
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
page = undefined;
|
|
652
|
+
}
|
|
653
|
+
if (page !== undefined)
|
|
654
|
+
await this.discardStuckPage(page);
|
|
655
|
+
}
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
604
658
|
}
|
|
605
659
|
async evaluate(expression, signal) {
|
|
606
660
|
// The switch is enforced HERE, not only in the tool layer: browser_evaluate
|
|
@@ -616,14 +670,26 @@ export class PlaywrightSession {
|
|
|
616
670
|
this.provider.touch(this.owner);
|
|
617
671
|
const page = await this.ensurePage();
|
|
618
672
|
const fn = new Function('return (' + expression + ')');
|
|
673
|
+
// page.evaluate takes no timeout: an expression with an infinite loop (a
|
|
674
|
+
// shader compile, a spin on a mutex) never returns and the tool call hangs
|
|
675
|
+
// forever. Bound it with the same budget the snapshot read uses, and on
|
|
676
|
+
// timeout drop this handle + the stuck page so the next call starts clean
|
|
677
|
+
// instead of inheriting a page that will never answer.
|
|
678
|
+
const once = async () => (await withDeadline(withAbort(page.evaluate(fn), signal), this.timeoutMs(), 'evaluating page JavaScript', undefined, 'reject', 'That page has been closed; anything typed into it but not submitted is lost, and the expression may have left the page unresponsive.'));
|
|
619
679
|
try {
|
|
620
|
-
return await
|
|
680
|
+
return await once();
|
|
621
681
|
}
|
|
622
682
|
catch (error) {
|
|
683
|
+
if (isReadTimeout(error)) {
|
|
684
|
+
this.closed = true;
|
|
685
|
+
this.provider.invalidateOwner(this.owner);
|
|
686
|
+
await this.discardStuckPage(page);
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
623
689
|
if (!isContextDestroyed(error) || signal?.aborted === true)
|
|
624
690
|
throw error;
|
|
625
691
|
await this.settleNavigation(page);
|
|
626
|
-
return
|
|
692
|
+
return once();
|
|
627
693
|
}
|
|
628
694
|
});
|
|
629
695
|
}
|
|
@@ -665,13 +731,16 @@ export class PlaywrightSession {
|
|
|
665
731
|
// inside the driver, and the failure gets reported as a protocol error.
|
|
666
732
|
const session = this.cdpSession;
|
|
667
733
|
const send = session.send.bind(session);
|
|
668
|
-
|
|
734
|
+
// A CDP command has no timeout of its own either, and some of them
|
|
735
|
+
// (screenshots, layout dumps) run page script internally. Same budget as
|
|
736
|
+
// a page read; the CDP session itself stays usable, only this call dies.
|
|
737
|
+
return await withDeadline(withAbort(send(method, params ?? {}), signal), this.timeoutMs(), 'running CDP command ' + method, undefined, 'reject', 'The command was abandoned; the page may still be working on it. Take a fresh snapshot to see where it landed.');
|
|
669
738
|
}
|
|
670
739
|
catch (error) {
|
|
671
|
-
|
|
740
|
+
// A deadline and a cancel both mean what they say: never relabel them as
|
|
741
|
+
// a protocol failure (the wrapping below only owns real CDP errors).
|
|
742
|
+
if (isAbortError(error) || isReadTimeout(error))
|
|
672
743
|
throw error;
|
|
673
|
-
// CDP protocol errors are already structured ("Protocol error
|
|
674
|
-
// (DOM.foo): ..."); wrap non-abort failures in a stable code.
|
|
675
744
|
const detail = error instanceof Error ? error.message : String(error);
|
|
676
745
|
if (/Protocol error/.test(detail)) {
|
|
677
746
|
const m = /\(([^)]+)\): ([\s\S]+)/.exec(detail);
|
package/lib/settings-card.js
CHANGED
|
@@ -63,6 +63,7 @@ import * as runtimeState from "./runtime-state.js";
|
|
|
63
63
|
// The patch writer lives in its own dependency-free module so it can be tested
|
|
64
64
|
// without a DSH host (a half-written patch layer would break every plugin load).
|
|
65
65
|
import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
|
|
66
|
+
import { onPersistPlayedChanged, resetPlayed } from "./swarm-intro.js";
|
|
66
67
|
import { readFileSync } from 'node:fs';
|
|
67
68
|
import { clean, isTargetId, resolvePaths, scan } from "./cache-clean.js";
|
|
68
69
|
const { getEnabled, setEnabled, getEffectiveEngine, setSnapshotEngine, isEngineUserChosen } = runtimeState;
|
|
@@ -238,9 +239,119 @@ function registerWindowRoute(ctx) {
|
|
|
238
239
|
},
|
|
239
240
|
}));
|
|
240
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* POST /swarm { enabled } — 进场特效的开关。
|
|
244
|
+
*
|
|
245
|
+
* 只是用户在设置卡上的选择,落盘后立刻生效:下一次导航就会按新值决定播不播,
|
|
246
|
+
* 不用重启,也不用断开已经开着的窗口。
|
|
247
|
+
*/
|
|
248
|
+
function registerSwarmRoute(ctx) {
|
|
249
|
+
ctx.effect(() => ctx.webServer.register({
|
|
250
|
+
kind: 'exact',
|
|
251
|
+
path: PREFIX + '/swarm',
|
|
252
|
+
handler: async (req, res) => {
|
|
253
|
+
if (req.method !== 'POST')
|
|
254
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
255
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
256
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
257
|
+
const body = await readBody(req);
|
|
258
|
+
if (!body.ok)
|
|
259
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
260
|
+
let payload;
|
|
261
|
+
try {
|
|
262
|
+
payload = JSON.parse(body.text || '{}');
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
266
|
+
}
|
|
267
|
+
if (payload.enabled !== undefined && typeof payload.enabled !== 'boolean')
|
|
268
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`enabled` must be a boolean' } });
|
|
269
|
+
// 清空播放记录:打开开关之后想让每个网站重新播一遍,就顺手清一下。
|
|
270
|
+
const reset = payload.resetPlayed === true;
|
|
271
|
+
if (reset) {
|
|
272
|
+
try {
|
|
273
|
+
resetPlayed();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* 文件不存在就是已经清过了 */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let changed = false;
|
|
280
|
+
if (typeof payload.enabled === 'boolean')
|
|
281
|
+
changed = runtimeState.setSwarm(payload.enabled) || changed;
|
|
282
|
+
// 播放参数:速度 1–3;时长 0(完整播完)或 3000–30000 毫秒;是否每站只播一次。
|
|
283
|
+
const patch = {};
|
|
284
|
+
if (payload.speed !== undefined) {
|
|
285
|
+
const speed = Number(payload.speed);
|
|
286
|
+
if (!Number.isFinite(speed) || speed < 1 || speed > 3)
|
|
287
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`speed` must be 1–3' } });
|
|
288
|
+
patch.speed = Math.round(speed);
|
|
289
|
+
}
|
|
290
|
+
if (payload.maxMs !== undefined) {
|
|
291
|
+
const maxMs = Number(payload.maxMs);
|
|
292
|
+
if (!Number.isFinite(maxMs) || (maxMs !== 0 && (maxMs < 3000 || maxMs > 30000)))
|
|
293
|
+
return sendJson(res, 400, {
|
|
294
|
+
error: { code: 'INVALID_BODY', message: '`maxMs` must be 0 or 3000–30000' },
|
|
295
|
+
});
|
|
296
|
+
patch.maxMs = Math.round(maxMs);
|
|
297
|
+
}
|
|
298
|
+
if (payload.oncePerSite !== undefined) {
|
|
299
|
+
if (typeof payload.oncePerSite !== 'boolean')
|
|
300
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`oncePerSite` must be a boolean' } });
|
|
301
|
+
patch.oncePerSite = payload.oncePerSite;
|
|
302
|
+
}
|
|
303
|
+
if (payload.playOnVeiled !== undefined) {
|
|
304
|
+
if (typeof payload.playOnVeiled !== 'boolean')
|
|
305
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`playOnVeiled` must be a boolean' } });
|
|
306
|
+
patch.playOnVeiled = payload.playOnVeiled;
|
|
307
|
+
}
|
|
308
|
+
if (payload.whileAiWorks !== undefined) {
|
|
309
|
+
if (payload.whileAiWorks !== 'auto-close' && payload.whileAiWorks !== 'keep-alive')
|
|
310
|
+
return sendJson(res, 400, {
|
|
311
|
+
error: { code: 'INVALID_BODY', message: '`whileAiWorks` must be auto-close or keep-alive' },
|
|
312
|
+
});
|
|
313
|
+
patch.whileAiWorks = payload.whileAiWorks;
|
|
314
|
+
}
|
|
315
|
+
if (payload.range !== undefined) {
|
|
316
|
+
if (payload.range !== 'viewport' && payload.range !== 'page')
|
|
317
|
+
return sendJson(res, 400, {
|
|
318
|
+
error: { code: 'INVALID_BODY', message: '`range` must be viewport or page' },
|
|
319
|
+
});
|
|
320
|
+
patch.range = payload.range;
|
|
321
|
+
}
|
|
322
|
+
if (payload.persistPlayed !== undefined) {
|
|
323
|
+
if (typeof payload.persistPlayed !== 'boolean')
|
|
324
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`persistPlayed` must be a boolean' } });
|
|
325
|
+
patch.persistPlayed = payload.persistPlayed;
|
|
326
|
+
}
|
|
327
|
+
const wasPersist = runtimeState.getSwarmTuning().persistPlayed === true;
|
|
328
|
+
if (Object.keys(patch).length > 0)
|
|
329
|
+
changed = runtimeState.setSwarmTuning(patch) || changed;
|
|
330
|
+
// 开关真的翻了才动记录:打开立刻把这趟落盘,关掉删盘(防幽灵复活)。
|
|
331
|
+
const nowPersist = runtimeState.getSwarmTuning().persistPlayed === true;
|
|
332
|
+
if (nowPersist !== wasPersist)
|
|
333
|
+
onPersistPlayedChanged(nowPersist);
|
|
334
|
+
const tuning = runtimeState.getSwarmTuning();
|
|
335
|
+
sendJson(res, 200, {
|
|
336
|
+
swarm: runtimeState.getSwarm(),
|
|
337
|
+
swarmSpeed: tuning.speed ?? null,
|
|
338
|
+
swarmMaxMs: tuning.maxMs ?? null,
|
|
339
|
+
swarmOncePerSite: tuning.oncePerSite ?? null,
|
|
340
|
+
swarmPlayOnVeiled: tuning.playOnVeiled ?? null,
|
|
341
|
+
swarmWhileAiWorks: tuning.whileAiWorks ?? null,
|
|
342
|
+
swarmRange: tuning.range ?? null,
|
|
343
|
+
swarmPersistPlayed: tuning.persistPlayed ?? null,
|
|
344
|
+
changed,
|
|
345
|
+
resetPlayed: reset,
|
|
346
|
+
appliedOn: 'next-navigation',
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
241
351
|
export function apply(ctx) {
|
|
242
352
|
registerDiskRoutes(ctx);
|
|
243
353
|
registerWindowRoute(ctx);
|
|
354
|
+
registerSwarmRoute(ctx);
|
|
244
355
|
ctx.effect(() => ctx.webServer.register({
|
|
245
356
|
kind: 'exact',
|
|
246
357
|
path: `${PREFIX}/state`,
|
|
@@ -265,6 +376,18 @@ export function apply(ctx) {
|
|
|
265
376
|
headless: runtimeState.getEffectiveHeadless(),
|
|
266
377
|
headlessUserChosen: runtimeState.isHeadlessUserChosen(),
|
|
267
378
|
headlessConfigured: runtimeState.getConfiguredHeadless(),
|
|
379
|
+
// 进场特效:null = 用户没表态,跟配置默认(出厂开)。
|
|
380
|
+
swarm: runtimeState.getSwarm() ?? null,
|
|
381
|
+
swarmUserChosen: runtimeState.isSwarmUserChosen(),
|
|
382
|
+
// 播放参数:undefined = 跟配置默认(3× / 15 秒 / 每站一次)。
|
|
383
|
+
swarmSpeed: runtimeState.getSwarmTuning().speed ?? null,
|
|
384
|
+
swarmMaxMs: runtimeState.getSwarmTuning().maxMs ?? null,
|
|
385
|
+
swarmOncePerSite: runtimeState.getSwarmTuning().oncePerSite ?? null,
|
|
386
|
+
// null = 跟配置默认(遮罩不播 / AI 动手时自动关闭动画)。
|
|
387
|
+
swarmPlayOnVeiled: runtimeState.getSwarmTuning().playOnVeiled ?? null,
|
|
388
|
+
swarmWhileAiWorks: runtimeState.getSwarmTuning().whileAiWorks ?? null,
|
|
389
|
+
swarmRange: runtimeState.getSwarmTuning().range ?? null,
|
|
390
|
+
swarmPersistPlayed: runtimeState.getSwarmTuning().persistPlayed ?? null,
|
|
268
391
|
// What the user's own layer says right now. Differs from the above
|
|
269
392
|
// between a write and the live reload that applies it.
|
|
270
393
|
patchGates: managedBlockGates() ?? null,
|
package/lib/snapshot-render.js
CHANGED
|
@@ -42,6 +42,21 @@ const TRACKING_PARAMS = new Set([
|
|
|
42
42
|
]);
|
|
43
43
|
/** Default cap for a rendered href, in characters. */
|
|
44
44
|
export const MAX_HREF_LENGTH = 160;
|
|
45
|
+
/**
|
|
46
|
+
* Path shapes that mark a redirect-wrapping URL — a link that does not point at
|
|
47
|
+
* its destination but at the site's own click-tracking endpoint, which forwards
|
|
48
|
+
* there. Search engines wrap every organic result this way (Bing `/ck/a?...`,
|
|
49
|
+
* Google `/url?q=...`, Facebook `l.php?u=...`). The real destination is encoded
|
|
50
|
+
* in a long query parameter, so the rendered href is 160 characters of base64
|
|
51
|
+
* that says nothing the link's own name does not — and it repeats for every
|
|
52
|
+
* result on the page.
|
|
53
|
+
*
|
|
54
|
+
* This is a generic category (a tracking endpoint on the same origin as the
|
|
55
|
+
* page), not a per-site special case. When one matches we render a short marker
|
|
56
|
+
* instead of the wrapped URL: the ref is what clicks, and the link's name plus
|
|
57
|
+
* any destination node already carry the meaning.
|
|
58
|
+
*/
|
|
59
|
+
const REDIRECT_WRAPPERS = [/\/ck\/a\?/i, /\/url\?q=/i, /l\.php\?u=/i];
|
|
45
60
|
/** Clip a string to a display budget, marking the cut. */
|
|
46
61
|
function clip(value, maxLength) {
|
|
47
62
|
return value.length <= maxLength ? value : value.slice(0, maxLength - 1) + '…';
|
|
@@ -56,6 +71,13 @@ function clip(value, maxLength) {
|
|
|
56
71
|
* @returns the shortened href
|
|
57
72
|
*/
|
|
58
73
|
export function trimUrlForDisplay(href, maxLength = MAX_HREF_LENGTH) {
|
|
74
|
+
// A redirect-wrapping href (search-engine click tracker) is 160 characters of
|
|
75
|
+
// encoded destination that says nothing the link's name does not. Render a
|
|
76
|
+
// short marker instead; the ref is what clicks, so this never changes where a
|
|
77
|
+
// click lands — only what the model reads.
|
|
78
|
+
if (REDIRECT_WRAPPERS.some((re) => re.test(href))) {
|
|
79
|
+
return '→ (redirect wrapper)';
|
|
80
|
+
}
|
|
59
81
|
const hashAt = href.indexOf('#');
|
|
60
82
|
const hash = hashAt >= 0 ? href.slice(hashAt) : '';
|
|
61
83
|
const head = hashAt >= 0 ? href.slice(0, hashAt) : href;
|
|
@@ -94,13 +116,14 @@ function isTransparentWrapper(node) {
|
|
|
94
116
|
node.level === undefined &&
|
|
95
117
|
node.checked === undefined &&
|
|
96
118
|
node.selected === undefined &&
|
|
97
|
-
node.disabled === undefined
|
|
119
|
+
node.disabled === undefined &&
|
|
120
|
+
node.value === undefined);
|
|
98
121
|
}
|
|
99
122
|
/** Render one node line into the accumulating line list. */
|
|
100
|
-
function renderNode(node, depth, lines) {
|
|
123
|
+
function renderNode(node, depth, lines, seenHrefs) {
|
|
101
124
|
if (isTransparentWrapper(node)) {
|
|
102
125
|
for (const child of node.children)
|
|
103
|
-
renderNode(child, depth, lines);
|
|
126
|
+
renderNode(child, depth, lines, seenHrefs);
|
|
104
127
|
return;
|
|
105
128
|
}
|
|
106
129
|
const indent = ' '.repeat(depth);
|
|
@@ -116,15 +139,33 @@ function renderNode(node, depth, lines) {
|
|
|
116
139
|
flags.push('selected');
|
|
117
140
|
if (node.disabled === true)
|
|
118
141
|
flags.push('disabled');
|
|
142
|
+
// The current value of a text entry field: without it the model cannot see
|
|
143
|
+
// what a fill actually left behind, which read as "no visible change".
|
|
144
|
+
if (node.value !== undefined && node.value !== '')
|
|
145
|
+
flags.push('value=' + node.value);
|
|
119
146
|
if (node.ref !== undefined)
|
|
120
147
|
flags.push('ref=' + node.ref);
|
|
121
148
|
if (flags.length > 0)
|
|
122
149
|
line += ' [' + flags.join(', ') + ']';
|
|
123
|
-
if (node.href !== undefined)
|
|
124
|
-
|
|
150
|
+
if (node.href !== undefined) {
|
|
151
|
+
// Same href, second and later occurrence within ONE snapshot: the URL is
|
|
152
|
+
// already printed at its first occurrence, so repeat only a pointer to it.
|
|
153
|
+
// Every ref and element stays — this trims bytes, never click targets. Only
|
|
154
|
+
// absolute URLs are deduped (a bare "/docs" can name different pages).
|
|
155
|
+
const abs = /^https?:\/\//i.test(node.href);
|
|
156
|
+
const firstRef = abs ? seenHrefs.get(node.href) : undefined;
|
|
157
|
+
if (firstRef !== undefined && firstRef !== node.ref) {
|
|
158
|
+
line += ' -> (same as ' + firstRef + ')';
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
line += ' -> ' + trimUrlForDisplay(node.href);
|
|
162
|
+
if (abs && node.ref !== undefined)
|
|
163
|
+
seenHrefs.set(node.href, node.ref);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
125
166
|
lines.push(line);
|
|
126
167
|
for (const child of node.children)
|
|
127
|
-
renderNode(child, depth + 1, lines);
|
|
168
|
+
renderNode(child, depth + 1, lines, seenHrefs);
|
|
128
169
|
}
|
|
129
170
|
/**
|
|
130
171
|
* Render a node tree as indented lines, one element per line.
|
|
@@ -133,8 +174,12 @@ function renderNode(node, depth, lines) {
|
|
|
133
174
|
*/
|
|
134
175
|
export function renderSnapshotTree(nodes) {
|
|
135
176
|
const lines = [];
|
|
177
|
+
// href -> ref of its first occurrence in THIS snapshot. Same href second and
|
|
178
|
+
// later renders as "(same as <ref>)" instead of repeating the URL. Scoped to
|
|
179
|
+
// one snapshot on purpose: a fresh snapshot after navigation re-prints URLs.
|
|
180
|
+
const seenHrefs = new Map();
|
|
136
181
|
for (const node of nodes)
|
|
137
|
-
renderNode(node, 0, lines);
|
|
182
|
+
renderNode(node, 0, lines, seenHrefs);
|
|
138
183
|
return lines.join('\n');
|
|
139
184
|
}
|
|
140
185
|
/**
|