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/config.d.ts CHANGED
@@ -41,6 +41,32 @@ export interface PlaywrightConfig {
41
41
  /** Incremental ref-diff mode; false = always full snapshots (zero behavioral change). */
42
42
  diff: boolean;
43
43
  };
44
+ /**
45
+ * 进场特效:把一个页面抹白,再用一群光标把它重新画出来。
46
+ *
47
+ * 可选:老配置对象里没有这一节时,由 Schemastery 的 Config 补齐默认值
48
+ * (见下面的 `swarm: z.object(...).default(...)`)。做成可选是为了让手写的
49
+ * 测试配置、以及旧部署的 patch 层都能直接通过类型检查。
50
+ */
51
+ swarm?: {
52
+ /** 出厂就开着:这是装完就能看见的那一下「哇」。设置卡上的开关可以随时关掉。 */
53
+ enabled: boolean;
54
+ /** 只在某个网站第一次打开时播;false = 每次导航都播(不建议)。 */
55
+ oncePerSite: boolean;
56
+ /** 播放速度档位 1–3(3 最快)。 */
57
+ speed: number;
58
+ /** 最多播多久(毫秒)。0 = 不限制,完整播完。 */
59
+ maxMs: number;
60
+ /** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
61
+ playOnVeiled: boolean;
62
+ /** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
63
+ persistPlayed: boolean;
64
+ /**
65
+ * 播放范围:`viewport` = 只画当前这一屏(默认,1–10 秒结束);
66
+ * `page` = 由插件按节奏往下滚,一屏一屏把整页画完(会接管滚动,时间长得多)。
67
+ */
68
+ range: 'viewport' | 'page';
69
+ };
44
70
  }
45
71
  /** Bounded page data the extraction consumer feeds to a model. */
46
72
  export interface PageData {
package/lib/config.js CHANGED
@@ -45,6 +45,26 @@ export const Config = z.object({
45
45
  diff: z.boolean().default(false),
46
46
  })
47
47
  .default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
48
+ swarm: z
49
+ .object({
50
+ // 出厂关:动效资产不再随 npm 包分发(0.8.0 起),默认开会让每个新用户
51
+ // 在"文件根本不存在"的状态下开箱报错。想要的人把资产放回 assets/ 再打开。
52
+ enabled: z.boolean().default(false),
53
+ // 只在每个网站第一次打开时播,避免每次导航都把页面盖住。
54
+ oncePerSite: z.boolean().default(true),
55
+ // 1–3 档,脚本里对应点几次速度按钮。
56
+ speed: z.number().default(3),
57
+ // 硬上限:到点无条件退回真实页面。原版脚本在长页面上要约 12 秒,留 15 秒。
58
+ maxMs: z.number().default(15000),
59
+ // 默认关:遮罩后面的内容没解锁就不该被画出来。自己的面板想看可以打开。
60
+ playOnVeiled: z.boolean().default(false),
61
+ // 默认关:整个浏览器窗口一关就忘掉这趟播过哪些站,下次重开全部重播。
62
+ persistPlayed: z.boolean().default(false),
63
+ // 只画首屏:长页面不动,短页面也不会卡在"下半页永远等不到"。想看整页的人
64
+ // 到设置卡选「整页」——那会把滚动接过去几秒,所以不做默认。
65
+ range: z.union([z.const('viewport'), z.const('page')]).default('viewport'),
66
+ })
67
+ .default({ enabled: false, oncePerSite: true, speed: 3, maxMs: 15000, playOnVeiled: false, persistPlayed: false }),
48
68
  });
49
69
  /** Channels probed in order when none is configured. */
50
70
  export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
@@ -9,6 +9,8 @@ import { assertAllowedUrl } from "./url-policy.js";
9
9
  import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
10
10
  import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
11
11
  import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
12
+ import { maybePlaySwarm, stopSwarm, willPlaySwarm } from "./swarm-intro.js";
13
+ import { armCurtain, clearCurtainToken } from "./swarm-curtain.js";
12
14
  /** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
13
15
  export async function withAbort(promise, signal) {
14
16
  // Attach handlers FIRST: the caller's promise may still be draining (a
@@ -99,6 +101,10 @@ export async function runAction(session, page, action) {
99
101
  const already = session.provider.pendingDialogFor(page);
100
102
  if (already !== undefined)
101
103
  return { blocked: true, pending: already };
104
+ // 进场特效运行时页面最上层盖着覆盖层。它不接管鼠标(pointer-events:none),
105
+ // 拦不住点击,但 AI 动手时它该让开。放在对话框检查之后:页面被原生对话框
106
+ // 卡住时 evaluate 会一直挂着,那会把整个动作拖死,所以还加了个 300ms 上限。
107
+ await Promise.race([stopSwarm(page), new Promise((resolve) => setTimeout(resolve, 300))]);
102
108
  const wait = session.provider.armDialogWait(page);
103
109
  const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
104
110
  const winner = await Promise.race([outcome, wait.promise]);
@@ -113,12 +119,20 @@ export async function navigate(session, url, waitUntil, signal) {
113
119
  return session.run(async () => {
114
120
  const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
115
121
  const page = await session.ensurePage();
122
+ // 提前启动要在导航**之前**打标记:addInitScript 读到它,就在页面脚本之前把
123
+ // 原版引擎拉起来(白底 + 18 个光标从第一帧就在),原页面不会闪出来。
124
+ if (willPlaySwarm(target.toString(), session.provider.config, session.provider.desiredHeadless())) {
125
+ await armCurtain(page);
126
+ }
116
127
  try {
117
128
  const racedNav = await session.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal));
118
129
  if (racedNav.blocked)
119
130
  return session.blockedSnapshot(page, racedNav.pending);
120
131
  }
121
132
  catch (error) {
133
+ // 导航没走成:标记还留在 window.name 里,而它跨导航保留。不清掉的话,
134
+ // 下一次普通跳转会意外提前启动引擎(2026-11-09 复核 find B)。
135
+ await clearCurtainToken(page);
122
136
  if (isAbortError(error) || signal?.aborted === true)
123
137
  throw error;
124
138
  // A redirect chain can leave the allow-list: the guard refuses the hop and
@@ -126,7 +140,24 @@ export async function navigate(session, url, waitUntil, signal) {
126
140
  session.throwIfNavigationBlocked();
127
141
  throw asTimeoutError('navigation', error);
128
142
  }
129
- return session.settleAndSnapshot(page, true);
143
+ // **先放特效、再拍快照**:原版开场本身就是"页面被抹白 + 光标从两边飞进来打转",
144
+ // 让它立刻登场,用户就不会先看见网页、过一会儿才播动画(2026-11-09 用户的判断:
145
+ // 与其自己做一套过场,不如用原版那一套)。资产里"扫描页面"那一步被改成等放行信号,
146
+ // 所以拍快照期间页面不会被它滚走;快照拍完再放行。
147
+ maybePlaySwarm(page, session.provider.config, session.provider.desiredHeadless());
148
+ // 放行必须放 finally:快照阶段抛错、被原生弹窗拦住、导航被拒,都不能把页面
149
+ // 一直扣在白幕下等满 5 秒兜底(2026-11-09 复核建议 1)。
150
+ try {
151
+ return await session.settleAndSnapshot(page, true);
152
+ }
153
+ finally {
154
+ await page
155
+ .evaluate(() => {
156
+ ;
157
+ window.__dshSwarmGo = true;
158
+ })
159
+ .catch(() => { });
160
+ }
130
161
  });
131
162
  }
132
163
  /**
package/lib/provider.js CHANGED
@@ -13,6 +13,8 @@ import { PlaywrightSession } from "./session.js";
13
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
+ import { swarmBootScript } from "./swarm-curtain.js";
17
+ import { forgetOnBrowserClose } from "./swarm-intro.js";
16
18
  import { isWindowMinimized } from "./window-focus.js";
17
19
  import { installHostGuard } from "./navigation-guard.js";
18
20
  export class PlaywrightProvider {
@@ -267,11 +269,17 @@ export class PlaywrightProvider {
267
269
  // Allow-list on the wire, not just at the entry points (navigation-guard.ts).
268
270
  await this.installHostGuard(context);
269
271
  await context.addInitScript(SNAPSHOT_SCRIPT);
272
+ // 提前启动「同一个引擎」:只有导航前打了标记的那次才在页面脚本之前启动原版特效
273
+ // (白底 + 18 个光标从第一帧就在),没标记的普通导航不会白闪。见 swarm-curtain.ts。
274
+ await context.addInitScript({ content: swarmBootScript() });
270
275
  const session = new PlaywrightSession(this, owner, context);
271
276
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
272
277
  this.entries.set(owner, entry);
273
278
  this.armIdle(entry);
274
279
  void context.on('close', () => {
280
+ // 这个浏览器上下文关掉:默认只忘掉它自己那趟播过哪些站。
281
+ // 别的窗口还开着时不清它们的记录(2026-11-09 复核 find B)。
282
+ forgetOnBrowserClose(context);
275
283
  if (this.entries.get(owner) === entry) {
276
284
  if (entry.timer !== undefined)
277
285
  clearTimeout(entry.timer);
@@ -10,8 +10,12 @@ import { isAbortError } from './page-diagnostics.ts';
10
10
  * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
11
11
  * @param label - 超时消息里说明卡在哪一步
12
12
  * @param signal - 可选的取消信号
13
+ * @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
14
+ * @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
15
+ * 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
16
+ * 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
13
17
  */
14
- export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined'): Promise<T | undefined>;
18
+ export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined', consequence?: string): Promise<T | undefined>;
15
19
  /** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
16
20
  export declare function isReadTimeout(error: unknown): boolean;
17
21
  /** 取消错误保持原样传递(不多包一层)。 */
@@ -26,8 +26,12 @@ import { isAbortError } from "./page-diagnostics.js";
26
26
  * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
27
27
  * @param label - 超时消息里说明卡在哪一步
28
28
  * @param signal - 可选的取消信号
29
+ * @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
30
+ * @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
31
+ * 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
32
+ * 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
29
33
  */
30
- export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject') {
34
+ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject', consequence = 'The call was abandoned; the page may still be running its script.') {
31
35
  const guarded = Promise.resolve(promise);
32
36
  guarded.catch(() => { }); // 超时后原调用仍在跑:别让它的 rejection 冒到宿主
33
37
  return new Promise((resolve, reject) => {
@@ -50,9 +54,12 @@ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout
50
54
  label +
51
55
  ' did not finish within ' +
52
56
  String(timeoutMs) +
53
- 'ms; the page may be stuck running script. The next browser call reopens the session.')));
57
+ 'ms; the page may be stuck running script. ' +
58
+ consequence)));
54
59
  }, timeoutMs);
55
- timer.unref?.();
60
+ // 不要 unref:期限是「到点必须断」的承诺,unref 之后若这是进程里最后一个句柄,
61
+ // 事件循环会判定无事可做直接退出,调用方永远等不到这条 ACTION_TIMEOUT
62
+ // (tests/read-deadline.test.ts 就是这么被打成 cancelledByParent 的)。
56
63
  if (signal !== undefined && typeof signal.addEventListener === 'function') {
57
64
  if (signal.aborted)
58
65
  onAbort();
@@ -27,6 +27,20 @@
27
27
  import type { LoginSites } from './login-sites.ts';
28
28
  /** How a page is read into a snapshot tree. */
29
29
  export type SnapshotEngine = 'legacy' | 'aria';
30
+ /** 进场特效的播放参数(速度/时长/是否只播一次),只存用户改过的项。 */
31
+ export interface SwarmTuning {
32
+ speed?: number;
33
+ maxMs?: number;
34
+ oncePerSite?: boolean;
35
+ /** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
36
+ playOnVeiled?: boolean;
37
+ /** AI 要动手时怎么办:'auto-close' = 立刻关掉动画(默认);'keep-alive' = 动画继续但不挡点击。 */
38
+ whileAiWorks?: 'auto-close' | 'keep-alive';
39
+ /** 播放范围:只画首屏 / 整页(由插件接管滚动,一屏一屏画完)。 */
40
+ range?: 'viewport' | 'page';
41
+ /** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
42
+ persistPlayed?: boolean;
43
+ }
30
44
  /** Current enable state (loads from disk on first call). */
31
45
  export declare function getEnabled(): boolean;
32
46
  /** Set the enable state. Returns true if the value actually changed. */
@@ -40,6 +54,18 @@ export declare function subscribe(fn: (enabled: boolean) => void): () => void;
40
54
  export declare function getHeadless(): boolean | undefined;
41
55
  /** Persist the window-mode choice. Returns true when the value changed. */
42
56
  export declare function setHeadless(next: boolean): boolean;
57
+ /**
58
+ * 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
59
+ */
60
+ export declare function getSwarm(): boolean | undefined;
61
+ /** 记下用户对进场特效的选择。Returns true when the value changed. */
62
+ export declare function setSwarm(next: boolean): boolean;
63
+ /** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
64
+ export declare function isSwarmUserChosen(): boolean;
65
+ /** 播放参数:只含用户改过的项,其余跟配置默认值。 */
66
+ export declare function getSwarmTuning(): SwarmTuning;
67
+ /** 改播放参数(只传要改的项)。Returns true when something changed. */
68
+ export declare function setSwarmTuning(patch: SwarmTuning): boolean;
43
69
  /** Publish the configured default (launch.headless) for display. */
44
70
  export declare function publishConfiguredHeadless(value: boolean): void;
45
71
  /** The configured default, when the provider has published it. */
@@ -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
@@ -327,19 +327,42 @@ export class PlaywrightSession {
327
327
  return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
328
328
  };
329
329
  try {
330
- const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal);
330
+ const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject',
331
+ // 报错文案必须说人话、说真话:这一页真的被关掉了,页面里没提交的东西跟着没了。
332
+ // 关页不弹 beforeunload(弹了可能又卡死),所以用户只有从这条消息里才知道。
333
+ '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
334
  return snap;
332
335
  }
333
336
  catch (error) {
334
337
  // 超时只能让我们这一侧返回,页面里那个 evaluate 还在跑。作废会话句柄,
335
- // 下一次调用会重新拿到干净窗口,而不是继承这个卡住的页面。
338
+ // 再把那张卡死的页面真正关掉——否则持久化模式下 ensurePage() 会把同一个
339
+ // 页面交还给下一次调用,所谓「自愈」只是换了个句柄继续超时(issue #5)。
336
340
  if (isReadTimeout(error)) {
337
341
  this.closed = true;
338
342
  this.provider.invalidateOwner(this.owner);
343
+ await this.discardStuckPage(page);
339
344
  }
340
345
  throw error;
341
346
  }
342
347
  }
348
+ /**
349
+ * Close one page whose script has stopped answering, so the next acquire starts
350
+ * on a live tab instead of inheriting it. Bounded and best-effort: main thread
351
+ * wedged badly enough to stall an evaluate may never answer close either, and
352
+ * this runs on the error path — it must never turn a timeout into a second hang.
353
+ *
354
+ * 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
355
+ * 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
356
+ * 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
357
+ */
358
+ async discardStuckPage(page) {
359
+ try {
360
+ await withDeadline(page.close({ runBeforeUnload: false }), 2_000, 'closing the stuck page', undefined, 'resolve-undefined');
361
+ }
362
+ catch {
363
+ /* already gone, or too wedged to close: nothing better we can do */
364
+ }
365
+ }
343
366
  /**
344
367
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
345
368
  * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
@@ -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,