dsh-browser-playwright-codex 0.7.7 → 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.
@@ -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,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * swarm-contract — 这段第三方资产的**接口契约**:插件到底捏着上游脚本的哪几个把手。
3
+ *
4
+ * 为什么要有这个文件(2026-11-10):
5
+ *
6
+ * 起因是"上游改版,我这边会不会突然失效"。查完事实是:**不会**。上游那份脚本是
7
+ * vendored 进来的(`assets/swarm-rebuild.upstream.js`),SHA-256 在 `scripts/swarm-patch.mjs`
8
+ * 里锁死,上游在 GitHub 上改成什么样,本地这份一个字节都不会动。真正会失效的是另一件事 ——
9
+ * 插件对上游内部结构的依赖**散落在三个文件的字符串里**(`swarm-intro.ts` 里 20 余处
10
+ * `page.evaluate` 的字面量、`swarm-curtain.ts` 的桥、`swarm-patch.mjs` 的匹配片段),
11
+ * 没有任何一处声明"这是我们要的接口"。于是:
12
+ *
13
+ * - 加一处依赖,没人知道又多了个耦合点;
14
+ * - 补丁改一处,插件侧不知道自己的前提已经变了;
15
+ * - `docs/SWARM-CODE.md` 写着"改动 4 处",实际已经是 7 处 —— 文档对不上代码,
16
+ * 而这份文档正是"上游改版时该查哪里"的唯一依据。
17
+ *
18
+ * 所以这里把三个层次钉死,并交给 `tests/swarm-contract.test.ts` 机械校验:
19
+ *
20
+ * Tier A(硬接口) 上游自己挂在 window 上的句柄树。补丁一处都没动,插件主路径全走这里。
21
+ * 上游改了 → 插件立刻坏 → 这是**必须盯住**的部分。
22
+ * Tier B(兜底口) `.hud` / `[data-a=*]`。上游原生,但**已被我们的补丁删掉**。
23
+ * 插件只在"万一有人塞回未打补丁的原版"时才走这些分支。
24
+ * Tier C(自造桥) `__dshSwarm*`。我们自己注入的,上游永远不会提供,也不该指望。
25
+ *
26
+ * 契约的用法:**改任何一处依赖,先改这个文件**,再改实现,再看测试红绿。测试会去
27
+ * `assets/swarm-rebuild.upstream.js` 里逐条核对下面的每个名字是否真的存在 —— 上游换人、
28
+ * 改名、删节点,这里第一个报错,而不是等某天浏览器里莫名其妙不播了。
29
+ *
30
+ * 法律面(别绕过它):上游仓库根目录**没有 LICENSE**(默认保留所有权利),
31
+ * issue #12 至今零回复。所以这段资产**不能 fork 成"我们自己的版本"** —— fork 不改变授权,
32
+ * 只多出一份照样没有许可的副本。它只能留在本机自用、默认关闭、不随包分发。
33
+ */
34
+ /** 上游出处与入仓指纹。这三项之外的任何东西,都不该成为插件的依赖。 */
35
+ export declare const SWARM_UPSTREAM: {
36
+ /** 上游仓库。注意:根目录没有 LICENSE 文件。 */
37
+ readonly repo: "DhvanilPatel/swarm-cursors";
38
+ /** 入仓的那份原始文件(**未经补丁**)。diff 的基准。 */
39
+ readonly vendoredFile: "assets/swarm-rebuild.upstream.js";
40
+ /**
41
+ * 入仓时那份上游文件的 SHA-256。
42
+ * `scripts/swarm-patch.mjs` 的 `UPSTREAM_SHA256` 必须与它逐字符相同(测试会比对),
43
+ * 否则"补丁按哪一版写的"就说不清了。
44
+ */
45
+ readonly sha256: "6a30f19daaa182d52673035feaeae775c398fa426655cc71a87a451fe00c7bfe";
46
+ /** 补丁后的产出(**随包分发的那份**)。 */
47
+ readonly patchedFile: "assets/swarm-rebuild.js";
48
+ /** 授权状态:`none` = 无许可证,默认保留所有权利。不是"宽松许可"。 */
49
+ readonly license: "none";
50
+ };
51
+ /**
52
+ * Tier A —— 硬接口。上游官方句柄树,补丁完全没碰,插件主路径全走这里。
53
+ *
54
+ * 事实依据(2026-11-10 在 `assets/swarm-rebuild.upstream.js` 里核对过):
55
+ * 上游用 `const KEY = "__swarmRebuild"; window[KEY] = { toggle() {...}, stop(...) }` 暴露自己,
56
+ * 并把 `app`(engine / env / view / tour)挂在同一个对象上。这一段补丁一字未改。
57
+ */
58
+ export declare const SWARM_API: {
59
+ /** 上游暴露自己的全局名。脚本第二次注入时会自己 `toggle()`。 */
60
+ readonly global: "__swarmRebuild";
61
+ /** 句柄上的方法。 */
62
+ readonly methods: {
63
+ readonly toggle: "toggle";
64
+ /**
65
+ * 停止。补丁后的脚本里 `stop(false)` = 立刻 destroy(取消 RAF、移除宿主),
66
+ * `stop(true)` = 淡出回真页面(原来 "Go live" 按钮走的路)。
67
+ * **插件一律用 `false`**:要的是立刻让路,不是让用户看淡出。
68
+ */
69
+ readonly stop: "stop";
70
+ };
71
+ /** `stop(reveal)` 的两种语义。 */
72
+ readonly stopArgs: {
73
+ /** 立即销毁,不留残余覆盖层。插件用这个。 */
74
+ readonly immediate: false;
75
+ /** 淡出到真页面。 */
76
+ readonly reveal: true;
77
+ };
78
+ /** `window.__swarmRebuild.app` 上插件用到的字段。 */
79
+ readonly app: {
80
+ /** 引擎:速度/启动都在这里。 */
81
+ readonly engine: {
82
+ readonly path: "engine";
83
+ /** 速度,上游初值 `1`;上游 UI 的 `speeds = [1, 2, 3]`,按钮点 N 次即 1+N 倍。**可直接写。** */
84
+ readonly speed: "speed";
85
+ /** 启动引擎(`app.tour = true` 之后调它才真正开始往下滚)。 */
86
+ readonly start: "start";
87
+ /** 是否已启动。已是 `true` 就别再 `start()` 一遍。 */
88
+ readonly started: "started";
89
+ };
90
+ /** 环境探针:整页滚动 / 局部滚动(`overflow:auto` 容器)都靠它。 */
91
+ readonly env: {
92
+ readonly path: "env";
93
+ /** `() => { ox, oy, sx, sy, vw, vh }`。局部滚动页里 `window.scrollY` 恒为 0,只有它知道真实位置。 */
94
+ readonly geom: "geom";
95
+ /** `(y, x) => void`,`behavior: 'instant'`。还原滚动位置用。 */
96
+ readonly scrollTo: "scrollTo";
97
+ };
98
+ /** 视图:影子树的入口。 */
99
+ readonly view: {
100
+ readonly path: "view";
101
+ /** 影子树(上游以 `mode: 'open'` 创建)。 */
102
+ readonly root: "root";
103
+ };
104
+ /** 整页模式开关。上游 Tour 按钮只做两件事之一就是这个;补丁删了按钮,所以直接写字段。 */
105
+ readonly tour: "tour";
106
+ };
107
+ /** 句柄自身的影子树兜底(`app.view.root` 拿不到时用它)。 */
108
+ readonly rootFallback: "root";
109
+ /** 覆盖层的宿主元素:自定义元素标签,`document.querySelector` 可直接选到。 */
110
+ readonly hostTag: "swarm-rebuild";
111
+ };
112
+ /**
113
+ * Tier B —— 兜底口。上游原生,但**已被补丁删除**,插件只在"塞回了未打补丁的原版"时才用到。
114
+ *
115
+ * 2026-11-10 核对 `assets/swarm-rebuild.js`:`data-a=` 从上游的 11 处降到 2 处 ——
116
+ * 控制条整个被删(`scripts/swarm-patch.mjs` 第 1 处改动),剩下两处是
117
+ * `[data-a="sound"]`(有 `if (!b || !this.sfx)` 防护)和一处 `name` 拼接的通用选择器。
118
+ *
119
+ * **所以这一层不是主路径。** 主路径全在 Tier A。这里留着是为了:有人把未打补丁的原版
120
+ * 塞回 `assets/`,插件仍然能用(点按钮),而不是当场白屏。
121
+ */
122
+ export declare const SWARM_DOM: {
123
+ /** 控制条容器。CSS 里定义:`.hud` / `.hud.on` / `.hud .dots` / `.hud .txt` / `.hud .status`。 */
124
+ readonly hud: ".hud";
125
+ /** 头像组(控制条上的小圆点)。 */
126
+ readonly dots: ".hud .dots";
127
+ /** 状态文字。 */
128
+ readonly status: ".hud .status";
129
+ /** 进度条:`<div class="bar"><i></i></div>`。 */
130
+ readonly barFill: ".hud .bar i";
131
+ /** 插件往影子树里插的隐藏规则所带的标记(重试时据此判断"已经插过了")。 */
132
+ readonly hideStyleAttr: "data-dsh-swarm-hide";
133
+ /** 隐藏规则本体。用 CSSOM/内联 + 这条规则双层压住,重建的节点也一并压。 */
134
+ readonly hideStyleText: ".hud{display:none!important}";
135
+ /**
136
+ * 控制条按钮。`selector` 是插件实际会用 `querySelector` 去点的那个字符串。
137
+ *
138
+ * `upstreamQuery` 标的是**另一件事**:上游自己的代码里有没有出现过这个选择器。
139
+ * 两者不等价,`close` 就是反例 —— 上游 HTML 里确实渲染了 `<button data-a="close" class="x">✕</button>`,
140
+ * 但它的代码从不 `querySelector('[data-a="close"]')`,点击走事件委托。
141
+ * 也就是说这一条是**我们自己声称有效**的选择器,靠的是 DOM 属性确实存在,而不是上游验证过。
142
+ * 上游哪天把 `data-a` 换成别的属性名、HTML 模板改了而代码没改(反之亦然),差别就在这里。
143
+ */
144
+ readonly buttons: {
145
+ /** 整页自动滚动。插件不点它(改 `app.tour = true`),留着仅为完整性。上游有查询。 */
146
+ readonly tour: {
147
+ readonly action: "tour";
148
+ readonly selector: "[data-a=\"tour\"]";
149
+ readonly upstreamQuery: true;
150
+ };
151
+ /** 帧速切换(1× → 2× → 3×)。插件直接写 `engine.speed`,不点它。上游有查询。 */
152
+ readonly speed: {
153
+ readonly action: "speed";
154
+ readonly selector: "[data-a=\"speed\"]";
155
+ readonly upstreamQuery: true;
156
+ };
157
+ /** 声音开关(快捷键 M)。插件从不碰。上游有查询(带 `if (!b || !this.sfx)` 空值防护)。 */
158
+ readonly sound: {
159
+ readonly action: "sound";
160
+ readonly selector: "[data-a=\"sound\"]";
161
+ readonly upstreamQuery: true;
162
+ };
163
+ /** Go live = `stop(true)`。兜底退出路径之一。上游有查询。 */
164
+ readonly live: {
165
+ readonly action: "live";
166
+ readonly selector: "[data-a=\"live\"]";
167
+ readonly upstreamQuery: true;
168
+ };
169
+ /** 重播。插件从不碰。上游有查询。 */
170
+ readonly replay: {
171
+ readonly action: "replay";
172
+ readonly selector: "[data-a=\"replay\"]";
173
+ readonly upstreamQuery: true;
174
+ };
175
+ /** ✕ = `stop(false)`。兜底退出路径之二。**上游不查询它**(事件委托),仅 HTML 属性存在。 */
176
+ readonly close: {
177
+ readonly action: "close";
178
+ readonly selector: "[data-a=\"close\"]";
179
+ readonly upstreamQuery: false;
180
+ };
181
+ };
182
+ /** 退出时的兜底顺序:先 live 再 close(对应 `stop(true)` / `stop(false)`)。 */
183
+ readonly exitFallbackOrder: readonly ["[data-a=\"live\"]", "[data-a=\"close\"]"];
184
+ };
185
+ /**
186
+ * Tier C —— 自造桥。插件注入页面、上游永远不会提供的那些全局名。
187
+ *
188
+ * 上游改版跟这一层**无关**(它根本不认识这些名字)。这一层坏的原因通常是插件自己改了
189
+ * 注入时机、或者有人重构时漏了某个赋值 —— 所以它更需要测试,而不是文档。
190
+ */
191
+ export declare const SWARM_BRIDGE: {
192
+ /** `() => void`,插件注入的"立刻退出"入口。**Tier A 的 `stop(false)` 走不通时才用。** */
193
+ readonly stop: "__dshSwarmStop";
194
+ /** `{ x, y }`,播放前的滚动位置。局部滚动页由 `env.geom()` 覆盖掉 window 那份。 */
195
+ readonly start: "__dshSwarmStart";
196
+ /** `true` = 引擎已在 `addInitScript` 阶段提前起来,**绝不能再注入第二遍**(否则白底和光标全部重来)。 */
197
+ readonly early: "__dshSwarmEarly";
198
+ /** `true` = AI 已经在用这个页面,整页滚动循环看到它就停。 */
199
+ readonly aiWorking: "__dshSwarmAiWorking";
200
+ /** `true` = 快照已经拿到手,可以放行扫描。`swarm-patch.mjs` 第 5 处改动等它。 */
201
+ readonly go: "__dshSwarmGo";
202
+ };
203
+ /**
204
+ * 上游自带的诊断钩子。插件**不依赖**它们,列在这里是为了两件事:
205
+ * ① 调试时知道这些名字是谁的,别误当成我们的桥;
206
+ * ② 上游改版核对时,这些名字若消失,说明改动范围比预期大,值得警觉。
207
+ */
208
+ export declare const SWARM_UPSTREAM_DEBUG_HOOKS: readonly ["__swarmShot", "__swarmSfxLog", "__swarmHidden", "__swarmUnhide"];
209
+ /**
210
+ * 上游改版应急流程。**这是"有据可依"的落点** —— 不是祈祷上游别改,而是改了好核对。
211
+ *
212
+ * 触发条件:把新的上游文件换进 `assets/swarm-rebuild.upstream.js`(这一步必须是人主动做的)。
213
+ * 本地日常开发**不需要**做任何事:vendored 文件不会自己变。
214
+ *
215
+ * 核对顺序(严格按此序,否则会追错方向):
216
+ * 1. `npm run swarm:patch` —— SHA 不符会当场拒绝,并报出新旧两个哈希。**先看这一条。**
217
+ * 2. 按新哈希更新 `SWARM_UPSTREAM.sha256` 和 `scripts/swarm-patch.mjs` 的 `UPSTREAM_SHA256`
218
+ * (两处必须一致,测试会比对)。
219
+ * 3. `node --test tests/swarm-contract.test.ts` —— 它会逐条到新文件里找 Tier A/B 的每个名字。
220
+ * 红的每一条,就是上游这次真动过的地方,一条一条处理。
221
+ * 4. 只有 Tier A 变红才是**必须改代码**的。Tier B 变红通常只需更新选择器(它只是兜底)。
222
+ * Tier C 变红说明插件自己的桥出了问题,与上游无关。
223
+ * 5. 改完实现后,跑 `npm test` 全量 —— `swarm-intro.test.ts` 会用假 page 校验调用序列。
224
+ * 6. 最后更新 `docs/SWARM-CODE.md`(改了几处、为什么)和 `docs/SWARM-CONTRACT.md` 的指纹段。
225
+ */
226
+ export declare const SWARM_UPGRADE_CHECKLIST: readonly ["npm run swarm:patch", "sync SWARM_UPSTREAM.sha256 <-> scripts/swarm-patch.mjs UPSTREAM_SHA256", "node --test tests/swarm-contract.test.ts", "triage red: Tier A = code change, Tier B = selector update, Tier C = our own bug", "npm test", "update docs/SWARM-CODE.md + docs/SWARM-CONTRACT.md fingerprints"];
@@ -0,0 +1,209 @@
1
+ /**
2
+ * swarm-contract — 这段第三方资产的**接口契约**:插件到底捏着上游脚本的哪几个把手。
3
+ *
4
+ * 为什么要有这个文件(2026-11-10):
5
+ *
6
+ * 起因是"上游改版,我这边会不会突然失效"。查完事实是:**不会**。上游那份脚本是
7
+ * vendored 进来的(`assets/swarm-rebuild.upstream.js`),SHA-256 在 `scripts/swarm-patch.mjs`
8
+ * 里锁死,上游在 GitHub 上改成什么样,本地这份一个字节都不会动。真正会失效的是另一件事 ——
9
+ * 插件对上游内部结构的依赖**散落在三个文件的字符串里**(`swarm-intro.ts` 里 20 余处
10
+ * `page.evaluate` 的字面量、`swarm-curtain.ts` 的桥、`swarm-patch.mjs` 的匹配片段),
11
+ * 没有任何一处声明"这是我们要的接口"。于是:
12
+ *
13
+ * - 加一处依赖,没人知道又多了个耦合点;
14
+ * - 补丁改一处,插件侧不知道自己的前提已经变了;
15
+ * - `docs/SWARM-CODE.md` 写着"改动 4 处",实际已经是 7 处 —— 文档对不上代码,
16
+ * 而这份文档正是"上游改版时该查哪里"的唯一依据。
17
+ *
18
+ * 所以这里把三个层次钉死,并交给 `tests/swarm-contract.test.ts` 机械校验:
19
+ *
20
+ * Tier A(硬接口) 上游自己挂在 window 上的句柄树。补丁一处都没动,插件主路径全走这里。
21
+ * 上游改了 → 插件立刻坏 → 这是**必须盯住**的部分。
22
+ * Tier B(兜底口) `.hud` / `[data-a=*]`。上游原生,但**已被我们的补丁删掉**。
23
+ * 插件只在"万一有人塞回未打补丁的原版"时才走这些分支。
24
+ * Tier C(自造桥) `__dshSwarm*`。我们自己注入的,上游永远不会提供,也不该指望。
25
+ *
26
+ * 契约的用法:**改任何一处依赖,先改这个文件**,再改实现,再看测试红绿。测试会去
27
+ * `assets/swarm-rebuild.upstream.js` 里逐条核对下面的每个名字是否真的存在 —— 上游换人、
28
+ * 改名、删节点,这里第一个报错,而不是等某天浏览器里莫名其妙不播了。
29
+ *
30
+ * 法律面(别绕过它):上游仓库根目录**没有 LICENSE**(默认保留所有权利),
31
+ * issue #12 至今零回复。所以这段资产**不能 fork 成"我们自己的版本"** —— fork 不改变授权,
32
+ * 只多出一份照样没有许可的副本。它只能留在本机自用、默认关闭、不随包分发。
33
+ */
34
+ /** 上游出处与入仓指纹。这三项之外的任何东西,都不该成为插件的依赖。 */
35
+ export const SWARM_UPSTREAM = {
36
+ /** 上游仓库。注意:根目录没有 LICENSE 文件。 */
37
+ repo: 'DhvanilPatel/swarm-cursors',
38
+ /** 入仓的那份原始文件(**未经补丁**)。diff 的基准。 */
39
+ vendoredFile: 'assets/swarm-rebuild.upstream.js',
40
+ /**
41
+ * 入仓时那份上游文件的 SHA-256。
42
+ * `scripts/swarm-patch.mjs` 的 `UPSTREAM_SHA256` 必须与它逐字符相同(测试会比对),
43
+ * 否则"补丁按哪一版写的"就说不清了。
44
+ */
45
+ sha256: '6a30f19daaa182d52673035feaeae775c398fa426655cc71a87a451fe00c7bfe',
46
+ /** 补丁后的产出(**随包分发的那份**)。 */
47
+ patchedFile: 'assets/swarm-rebuild.js',
48
+ /** 授权状态:`none` = 无许可证,默认保留所有权利。不是"宽松许可"。 */
49
+ license: 'none',
50
+ };
51
+ /**
52
+ * Tier A —— 硬接口。上游官方句柄树,补丁完全没碰,插件主路径全走这里。
53
+ *
54
+ * 事实依据(2026-11-10 在 `assets/swarm-rebuild.upstream.js` 里核对过):
55
+ * 上游用 `const KEY = "__swarmRebuild"; window[KEY] = { toggle() {...}, stop(...) }` 暴露自己,
56
+ * 并把 `app`(engine / env / view / tour)挂在同一个对象上。这一段补丁一字未改。
57
+ */
58
+ export const SWARM_API = {
59
+ /** 上游暴露自己的全局名。脚本第二次注入时会自己 `toggle()`。 */
60
+ global: '__swarmRebuild',
61
+ /** 句柄上的方法。 */
62
+ methods: {
63
+ toggle: 'toggle',
64
+ /**
65
+ * 停止。补丁后的脚本里 `stop(false)` = 立刻 destroy(取消 RAF、移除宿主),
66
+ * `stop(true)` = 淡出回真页面(原来 "Go live" 按钮走的路)。
67
+ * **插件一律用 `false`**:要的是立刻让路,不是让用户看淡出。
68
+ */
69
+ stop: 'stop',
70
+ },
71
+ /** `stop(reveal)` 的两种语义。 */
72
+ stopArgs: {
73
+ /** 立即销毁,不留残余覆盖层。插件用这个。 */
74
+ immediate: false,
75
+ /** 淡出到真页面。 */
76
+ reveal: true,
77
+ },
78
+ /** `window.__swarmRebuild.app` 上插件用到的字段。 */
79
+ app: {
80
+ /** 引擎:速度/启动都在这里。 */
81
+ engine: {
82
+ path: 'engine',
83
+ /** 速度,上游初值 `1`;上游 UI 的 `speeds = [1, 2, 3]`,按钮点 N 次即 1+N 倍。**可直接写。** */
84
+ speed: 'speed',
85
+ /** 启动引擎(`app.tour = true` 之后调它才真正开始往下滚)。 */
86
+ start: 'start',
87
+ /** 是否已启动。已是 `true` 就别再 `start()` 一遍。 */
88
+ started: 'started',
89
+ },
90
+ /** 环境探针:整页滚动 / 局部滚动(`overflow:auto` 容器)都靠它。 */
91
+ env: {
92
+ path: 'env',
93
+ /** `() => { ox, oy, sx, sy, vw, vh }`。局部滚动页里 `window.scrollY` 恒为 0,只有它知道真实位置。 */
94
+ geom: 'geom',
95
+ /** `(y, x) => void`,`behavior: 'instant'`。还原滚动位置用。 */
96
+ scrollTo: 'scrollTo',
97
+ },
98
+ /** 视图:影子树的入口。 */
99
+ view: {
100
+ path: 'view',
101
+ /** 影子树(上游以 `mode: 'open'` 创建)。 */
102
+ root: 'root',
103
+ },
104
+ /** 整页模式开关。上游 Tour 按钮只做两件事之一就是这个;补丁删了按钮,所以直接写字段。 */
105
+ tour: 'tour',
106
+ },
107
+ /** 句柄自身的影子树兜底(`app.view.root` 拿不到时用它)。 */
108
+ rootFallback: 'root',
109
+ /** 覆盖层的宿主元素:自定义元素标签,`document.querySelector` 可直接选到。 */
110
+ hostTag: 'swarm-rebuild',
111
+ };
112
+ /**
113
+ * Tier B —— 兜底口。上游原生,但**已被补丁删除**,插件只在"塞回了未打补丁的原版"时才用到。
114
+ *
115
+ * 2026-11-10 核对 `assets/swarm-rebuild.js`:`data-a=` 从上游的 11 处降到 2 处 ——
116
+ * 控制条整个被删(`scripts/swarm-patch.mjs` 第 1 处改动),剩下两处是
117
+ * `[data-a="sound"]`(有 `if (!b || !this.sfx)` 防护)和一处 `name` 拼接的通用选择器。
118
+ *
119
+ * **所以这一层不是主路径。** 主路径全在 Tier A。这里留着是为了:有人把未打补丁的原版
120
+ * 塞回 `assets/`,插件仍然能用(点按钮),而不是当场白屏。
121
+ */
122
+ export const SWARM_DOM = {
123
+ /** 控制条容器。CSS 里定义:`.hud` / `.hud.on` / `.hud .dots` / `.hud .txt` / `.hud .status`。 */
124
+ hud: '.hud',
125
+ /** 头像组(控制条上的小圆点)。 */
126
+ dots: '.hud .dots',
127
+ /** 状态文字。 */
128
+ status: '.hud .status',
129
+ /** 进度条:`<div class="bar"><i></i></div>`。 */
130
+ barFill: '.hud .bar i',
131
+ /** 插件往影子树里插的隐藏规则所带的标记(重试时据此判断"已经插过了")。 */
132
+ hideStyleAttr: 'data-dsh-swarm-hide',
133
+ /** 隐藏规则本体。用 CSSOM/内联 + 这条规则双层压住,重建的节点也一并压。 */
134
+ hideStyleText: '.hud{display:none!important}',
135
+ /**
136
+ * 控制条按钮。`selector` 是插件实际会用 `querySelector` 去点的那个字符串。
137
+ *
138
+ * `upstreamQuery` 标的是**另一件事**:上游自己的代码里有没有出现过这个选择器。
139
+ * 两者不等价,`close` 就是反例 —— 上游 HTML 里确实渲染了 `<button data-a="close" class="x">✕</button>`,
140
+ * 但它的代码从不 `querySelector('[data-a="close"]')`,点击走事件委托。
141
+ * 也就是说这一条是**我们自己声称有效**的选择器,靠的是 DOM 属性确实存在,而不是上游验证过。
142
+ * 上游哪天把 `data-a` 换成别的属性名、HTML 模板改了而代码没改(反之亦然),差别就在这里。
143
+ */
144
+ buttons: {
145
+ /** 整页自动滚动。插件不点它(改 `app.tour = true`),留着仅为完整性。上游有查询。 */
146
+ tour: { action: 'tour', selector: '[data-a="tour"]', upstreamQuery: true },
147
+ /** 帧速切换(1× → 2× → 3×)。插件直接写 `engine.speed`,不点它。上游有查询。 */
148
+ speed: { action: 'speed', selector: '[data-a="speed"]', upstreamQuery: true },
149
+ /** 声音开关(快捷键 M)。插件从不碰。上游有查询(带 `if (!b || !this.sfx)` 空值防护)。 */
150
+ sound: { action: 'sound', selector: '[data-a="sound"]', upstreamQuery: true },
151
+ /** Go live = `stop(true)`。兜底退出路径之一。上游有查询。 */
152
+ live: { action: 'live', selector: '[data-a="live"]', upstreamQuery: true },
153
+ /** 重播。插件从不碰。上游有查询。 */
154
+ replay: { action: 'replay', selector: '[data-a="replay"]', upstreamQuery: true },
155
+ /** ✕ = `stop(false)`。兜底退出路径之二。**上游不查询它**(事件委托),仅 HTML 属性存在。 */
156
+ close: { action: 'close', selector: '[data-a="close"]', upstreamQuery: false },
157
+ },
158
+ /** 退出时的兜底顺序:先 live 再 close(对应 `stop(true)` / `stop(false)`)。 */
159
+ exitFallbackOrder: ['[data-a="live"]', '[data-a="close"]'],
160
+ };
161
+ /**
162
+ * Tier C —— 自造桥。插件注入页面、上游永远不会提供的那些全局名。
163
+ *
164
+ * 上游改版跟这一层**无关**(它根本不认识这些名字)。这一层坏的原因通常是插件自己改了
165
+ * 注入时机、或者有人重构时漏了某个赋值 —— 所以它更需要测试,而不是文档。
166
+ */
167
+ export const SWARM_BRIDGE = {
168
+ /** `() => void`,插件注入的"立刻退出"入口。**Tier A 的 `stop(false)` 走不通时才用。** */
169
+ stop: '__dshSwarmStop',
170
+ /** `{ x, y }`,播放前的滚动位置。局部滚动页由 `env.geom()` 覆盖掉 window 那份。 */
171
+ start: '__dshSwarmStart',
172
+ /** `true` = 引擎已在 `addInitScript` 阶段提前起来,**绝不能再注入第二遍**(否则白底和光标全部重来)。 */
173
+ early: '__dshSwarmEarly',
174
+ /** `true` = AI 已经在用这个页面,整页滚动循环看到它就停。 */
175
+ aiWorking: '__dshSwarmAiWorking',
176
+ /** `true` = 快照已经拿到手,可以放行扫描。`swarm-patch.mjs` 第 5 处改动等它。 */
177
+ go: '__dshSwarmGo',
178
+ };
179
+ /**
180
+ * 上游自带的诊断钩子。插件**不依赖**它们,列在这里是为了两件事:
181
+ * ① 调试时知道这些名字是谁的,别误当成我们的桥;
182
+ * ② 上游改版核对时,这些名字若消失,说明改动范围比预期大,值得警觉。
183
+ */
184
+ export const SWARM_UPSTREAM_DEBUG_HOOKS = ['__swarmShot', '__swarmSfxLog', '__swarmHidden', '__swarmUnhide'];
185
+ /**
186
+ * 上游改版应急流程。**这是"有据可依"的落点** —— 不是祈祷上游别改,而是改了好核对。
187
+ *
188
+ * 触发条件:把新的上游文件换进 `assets/swarm-rebuild.upstream.js`(这一步必须是人主动做的)。
189
+ * 本地日常开发**不需要**做任何事:vendored 文件不会自己变。
190
+ *
191
+ * 核对顺序(严格按此序,否则会追错方向):
192
+ * 1. `npm run swarm:patch` —— SHA 不符会当场拒绝,并报出新旧两个哈希。**先看这一条。**
193
+ * 2. 按新哈希更新 `SWARM_UPSTREAM.sha256` 和 `scripts/swarm-patch.mjs` 的 `UPSTREAM_SHA256`
194
+ * (两处必须一致,测试会比对)。
195
+ * 3. `node --test tests/swarm-contract.test.ts` —— 它会逐条到新文件里找 Tier A/B 的每个名字。
196
+ * 红的每一条,就是上游这次真动过的地方,一条一条处理。
197
+ * 4. 只有 Tier A 变红才是**必须改代码**的。Tier B 变红通常只需更新选择器(它只是兜底)。
198
+ * Tier C 变红说明插件自己的桥出了问题,与上游无关。
199
+ * 5. 改完实现后,跑 `npm test` 全量 —— `swarm-intro.test.ts` 会用假 page 校验调用序列。
200
+ * 6. 最后更新 `docs/SWARM-CODE.md`(改了几处、为什么)和 `docs/SWARM-CONTRACT.md` 的指纹段。
201
+ */
202
+ export const SWARM_UPGRADE_CHECKLIST = [
203
+ 'npm run swarm:patch',
204
+ 'sync SWARM_UPSTREAM.sha256 <-> scripts/swarm-patch.mjs UPSTREAM_SHA256',
205
+ 'node --test tests/swarm-contract.test.ts',
206
+ 'triage red: Tier A = code change, Tier B = selector update, Tier C = our own bug',
207
+ 'npm test',
208
+ 'update docs/SWARM-CODE.md + docs/SWARM-CONTRACT.md fingerprints',
209
+ ];
@@ -0,0 +1,22 @@
1
+ import type { Page } from 'playwright-core';
2
+ /**
3
+ * 生成注册进 `addInitScript` 的脚本全文。
4
+ *
5
+ * 必须用**字符串拼接**把资产塞进去,不能用模板字符串:资产里有大量反引号和
6
+ * `${...}`,放进外层模板字符串会被当成插值直接炸掉。
7
+ * 每次现读资产:和 playSwarm 里 `readScript()` 一样,改完磁盘立即用新的,不缓存。
8
+ */
9
+ export declare function swarmBootScript(): string;
10
+ /** 导航前打标记:下一次文档创建时,addInitScript 会把原版引擎提前启动。 */
11
+ export declare function armCurtain(page: Page): Promise<void>;
12
+ /** 清掉导航标记(导航失败时别让它留到下一次普通跳转)。 */
13
+ export declare function clearCurtainToken(page: Page): Promise<void>;
14
+ /**
15
+ * 决定"这一页不播"时:把可能已经提前启动的引擎彻底关掉。
16
+ *
17
+ * 这层原是"撤掉自造白幕",现在没有白幕了 —— 引擎是真的、会一直等放行,所以必须
18
+ * 调它自己的 stop(false)、把宿主立刻摘掉、把滚动还原,否则那一页会永远盖着白。
19
+ * 触发场景:遮罩页不播、CSP 把影子树样式表挡成半成品、oncePerSite 判定跳过、
20
+ * 用户关掉特效等。顺手把标记和几个开关清干净,免得同文档内 toggle/replay 被旧值影响。
21
+ */
22
+ export declare function dropCurtain(page: Page, _waitForSwarm?: boolean): Promise<void>;