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.
@@ -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>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 「同一个引擎」的提前启动:从打开网页的第一帧起,画面就是**原版特效自己**的
3
+ * 白底 + 18 个光标在转,全程只有这一套,不再有「自造白幕 → 切到原版」的交接断层。
4
+ *
5
+ * 做法:把随包的 `assets/swarm-rebuild.js`(原版引擎)整段塞进 `addInitScript`,
6
+ * 它在页面自己的脚本之前、<html> 一出现就启动(脚本里的"延后到 <html>"补丁见
7
+ * scripts/swarm-patch.mjs)。所以它比"打开页面→加载完→再注入"早得多,原页面
8
+ * 来不及露脸(用户 2026-11-09 最在意的那"一瞬间原画面"就此压住)。
9
+ *
10
+ * 只在真的要播的那次启动:导航前用 `window.name` 打标记(实测**跨域导航会保留**),
11
+ * addInitScript 读到标记才启动、并立刻清掉。三重保险防"残留标记误启动":
12
+ * · 只在顶层文档启动(`window.top === window`),iframe 不另起一套;
13
+ * · 标记带时间戳,超过 30 秒的旧标记不认(导航失败后残留的那枚会自己过期);
14
+ * · 任何"不播"路径都调 dropCurtain,把 name 和引擎一起清干净。
15
+ * 万一标记没落到最终文档(少见的重定向),playSwarm 会检测到"引擎不在"并回退到
16
+ * 旧的"加载后注入",功能仍可用,只是那一页会先露一下。
17
+ *
18
+ * 为什么不再自画 canvas 假光标:那是第二套引擎,和原版的光标参数再像也是两套,
19
+ * 交接必然"断一下、突然重新开始"(用户反复指出的缝)。用原版自己从第一帧登场,
20
+ * 缝就没了 —— 这正是用户 16:38 选过的「删掉自造的、用原版开场」,这次真正落地。
21
+ */
22
+ import fs from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ /** 导航前写进 window.name 的标记:新文档第一帧读到它才启动引擎。 */
25
+ const TOKEN = '__dsh_swarm__:';
26
+ /**
27
+ * 生成注册进 `addInitScript` 的脚本全文。
28
+ *
29
+ * 必须用**字符串拼接**把资产塞进去,不能用模板字符串:资产里有大量反引号和
30
+ * `${...}`,放进外层模板字符串会被当成插值直接炸掉。
31
+ * 每次现读资产:和 playSwarm 里 `readScript()` 一样,改完磁盘立即用新的,不缓存。
32
+ */
33
+ export function swarmBootScript() {
34
+ let asset;
35
+ try {
36
+ asset = fs.readFileSync(fileURLToPath(new URL('../assets/swarm-rebuild.js', import.meta.url)), 'utf8');
37
+ }
38
+ catch {
39
+ // 资产不在(0.8.0 起 assets/ 不再随 npm 包分发)。这里**绝不能往外抛**:
40
+ // 两个调用点都是 `await ctx.addInitScript({ content: swarmBootScript() })`,
41
+ // 抛出去就是上下文创建失败、浏览器直接打不开 —— 把"动效没了"放大成"插件挂了"。
42
+ // 返回一段空注释脚本,等于这次启动不注入任何东西,行为与"没开这个功能"一致。
43
+ return '/* dsh: swarm asset not shipped (0.8.0+) — nothing injected */\n';
44
+ }
45
+ return ('if (window.top === window && typeof window.name === "string" &&\n' +
46
+ ' window.name.indexOf("' +
47
+ TOKEN +
48
+ '") === 0 &&\n' +
49
+ ' Date.now() - Number(window.name.slice(' +
50
+ TOKEN.length +
51
+ ')) < 30000) {\n' +
52
+ ' window.name = "";\n' +
53
+ ' window.__dshSwarmEarly = true;\n' +
54
+ asset +
55
+ '\n}\n');
56
+ }
57
+ /** 导航前打标记:下一次文档创建时,addInitScript 会把原版引擎提前启动。 */
58
+ export async function armCurtain(page) {
59
+ try {
60
+ await page.evaluate((token) => {
61
+ window.name = token;
62
+ }, TOKEN + Date.now());
63
+ }
64
+ catch {
65
+ /* 页面还没就绪(about:blank 之前),下一次导航照样会带标记 */
66
+ }
67
+ }
68
+ /** 清掉导航标记(导航失败时别让它留到下一次普通跳转)。 */
69
+ export async function clearCurtainToken(page) {
70
+ try {
71
+ await page.evaluate(() => {
72
+ window.name = '';
73
+ });
74
+ }
75
+ catch {
76
+ /* 页面已经走了 */
77
+ }
78
+ }
79
+ /**
80
+ * 决定"这一页不播"时:把可能已经提前启动的引擎彻底关掉。
81
+ *
82
+ * 这层原是"撤掉自造白幕",现在没有白幕了 —— 引擎是真的、会一直等放行,所以必须
83
+ * 调它自己的 stop(false)、把宿主立刻摘掉、把滚动还原,否则那一页会永远盖着白。
84
+ * 触发场景:遮罩页不播、CSP 把影子树样式表挡成半成品、oncePerSite 判定跳过、
85
+ * 用户关掉特效等。顺手把标记和几个开关清干净,免得同文档内 toggle/replay 被旧值影响。
86
+ */
87
+ export async function dropCurtain(page, _waitForSwarm = false) {
88
+ try {
89
+ await page.evaluate(() => {
90
+ const w = window;
91
+ const api = w.__swarmRebuild;
92
+ if (api !== undefined && typeof api.stop === 'function')
93
+ api.stop(false);
94
+ // 还原滚动(提前启动的引擎在等放行、还没扫描,通常没滚过;保险起见照做)。
95
+ const start = w.__dshSwarmStart;
96
+ if (start !== undefined) {
97
+ const env = api?.app?.env;
98
+ if (env !== undefined && typeof env.scrollTo === 'function')
99
+ env.scrollTo(start.y, start.x);
100
+ else
101
+ window.scrollTo(start.x, start.y);
102
+ delete w.__dshSwarmStart;
103
+ }
104
+ // stop(false) 是延时移除,这里立刻摘,免得 AI 紧接着的点击命中测试被盖住。
105
+ for (const host of document.querySelectorAll('swarm-rebuild'))
106
+ host.remove();
107
+ w.name = '';
108
+ delete w.__dshSwarmEarly;
109
+ delete w.__dshSwarmGo;
110
+ });
111
+ }
112
+ catch {
113
+ /* 页面已经走了 */
114
+ }
115
+ }
@@ -0,0 +1,66 @@
1
+ import type { Page } from 'playwright-core';
2
+ /** 这个域名播过没有。跨所有活着的窗口判断。 */
3
+ export declare function hasPlayed(host: string): boolean;
4
+ /** 记下这个域名已经播过;ctx 传所属浏览器上下文,不传则用兜底。 */
5
+ export declare function markPlayed(host: string, ctx?: object): void;
6
+ /** 清空播放记录(设置卡上的「清空记录」用):内存和磁盘一起清。 */
7
+ export declare function resetPlayed(): void;
8
+ /**
9
+ * 「一直记住」开关切换时调用:打开就把这趟已有记录立刻落盘(别等关窗,
10
+ * 免得宿主崩溃丢记录);关掉就删掉磁盘记录,防止以后切回来时幽灵复活
11
+ * (2026-11-09 复核 find A)。这个文件是插件自己写的,删它不动用户数据。
12
+ */
13
+ export declare function onPersistPlayedChanged(enabled: boolean): void;
14
+ /**
15
+ * 某个浏览器上下文关掉时调用:只忘掉**它自己**那趟播过哪些站。
16
+ * 还有别的窗口活着时,别的窗口的记录一律保留(复核 find B)。
17
+ * persist 打开时把这趟记录落盘,下次接着记。
18
+ * 关单个标签不会走到这里 —— 那是 closeTab,不是 context 关闭。
19
+ */
20
+ export declare function forgetOnBrowserClose(ctx?: object): void;
21
+ /** 这个页面正在播吗。 */
22
+ export declare function isPlaying(page: Page): boolean;
23
+ /**
24
+ * 关掉正在播的特效。任何工具动作开始前都调用它 —— 覆盖层本身不接管鼠标
25
+ * (pointer-events: none),所以拦不住点击;但 AI 动手时它该让开,画面才
26
+ * 不会被人当成页面坏了。
27
+ *
28
+ * 失败一律吞掉:页面可能已经关掉、正在跳转,或者根本没播过。
29
+ */
30
+ export declare function stopSwarm(page: Page): Promise<void>;
31
+ /**
32
+ * 启动页。原来浏览器刚打开时停在 about:blank —— 一张纯白页,除了占位没有
33
+ * 任何用处,用户看见只会问「这是干嘛的」。这里换成一张说明卡:告诉人它已经
34
+ * 就绪、地址栏为什么是 about:blank、以及怎么用。没有外部资源,纯内联样式,
35
+ * 断网也是秒开。
36
+ */
37
+ export declare const WELCOME_HTML = "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\n<title>\u6D4F\u89C8\u5668\u5DF2\u5C31\u7EEA</title>\n<style>\n :root { color-scheme: light; }\n * { box-sizing: border-box; }\n body {\n margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;\n background: #f5f6f8; color: #1d1d1f;\n font: 15px/1.7 system-ui, -apple-system, \"Segoe UI\", \"Microsoft YaHei\", sans-serif;\n }\n .card {\n width: min(560px, 86vw); padding: 38px 40px 34px; background: #fff; border-radius: 18px;\n box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 12px 40px rgba(0,0,0,.08);\n }\n .dot { width: 9px; height: 9px; border-radius: 50%; background: #34c759; display: inline-block; margin-right: 9px; vertical-align: middle; }\n h1 { font-size: 23px; margin: 0 0 6px; letter-spacing: -.01em; }\n .sub { color: #6b6b70; margin: 0 0 26px; }\n .row { display: flex; gap: 12px; padding: 13px 0; border-top: 1px solid #ececf0; }\n .k { color: #8a8a8f; min-width: 96px; font-size: 13px; }\n .v { flex: 1; font-size: 14px; }\n code { font-family: ui-monospace, Consolas, monospace; background: #f2f2f5; padding: 1px 6px; border-radius: 5px; font-size: 13px; }\n .foot { margin: 24px 0 0; color: #9a9aa0; font-size: 12.5px; }\n</style></head><body>\n<div class=\"card\">\n <h1><span class=\"dot\"></span>\u6D4F\u89C8\u5668\u5DF2\u5C31\u7EEA</h1>\n <p class=\"sub\">\u60F3\u8BA9\u6211\u6253\u5F00\u54EA\u4E2A\u7F51\u7AD9\uFF0C\u8BF4\u4E00\u53E5\u5C31\u884C\u3002</p>\n <div class=\"row\"><div class=\"k\">\u5730\u5740\u680F</div><div class=\"v\"><code>about:blank</code> \u2014\u2014 \u6D4F\u89C8\u5668\u5185\u7F6E\u7684\u7A7A\u767D\u9875\uFF0C\u8868\u793A\u8FD8\u6CA1\u53BB\u4EFB\u4F55\u7F51\u7AD9</div></div>\n <div class=\"row\"><div class=\"k\">\u8FD9\u4E00\u9875</div><div class=\"v\">\u662F\u5149\u6807\u4E00\u7B14\u4E00\u7B14\u753B\u51FA\u6765\u7684\uFF0C\u6309 <code>Esc</code> \u53EF\u4EE5\u7ACB\u5373\u8DF3\u8FC7</div></div>\n <div class=\"row\"><div class=\"k\">\u767B\u5F55\u72B6\u6001</div><div class=\"v\">\u4FDD\u5B58\u5728\u8FD9\u4E2A\u6D4F\u89C8\u5668\u91CC\uFF0C\u5173\u6389\u7A97\u53E3\u4E5F\u4E0D\u4F1A\u4E22</div></div>\n <p class=\"foot\">\u7B2C\u4E00\u6B21\u6253\u5F00\u67D0\u4E2A\u7F51\u7AD9\u65F6\uFF0C\u90A3\u9875\u4E5F\u4F1A\u8FD9\u6837\u81EA\u5DF1\u957F\u51FA\u6765\u4E00\u6B21\u3002</p>\n</div></body></html>";
38
+ /**
39
+ * 把空白页换成启动卡片,并在上面放一次特效。特效不等它播完(启动不能被
40
+ * 拖住 9 秒),放出去就返回;真有工具动作要执行时 stopSwarm 会先把它关掉。
41
+ */
42
+ export declare function showWelcome(page: Page, config: {
43
+ swarm?: {
44
+ enabled?: boolean;
45
+ speed?: number;
46
+ maxMs?: number;
47
+ };
48
+ } | undefined): Promise<void>;
49
+ /**
50
+ * 这次导航要不要播(只看不费时的条件,用来决定"开场白幕"要不要提前挂)。
51
+ *
52
+ * 注意传的是**目标地址**:导航前的 `page.url()` 还是旧页面,拿它判断会永远不播。
53
+ * 遮罩页那种需要读页面的判断留到注入之后做,那时会顺手把白幕撤掉。
54
+ */
55
+ export declare function willPlaySwarm(targetUrl: string, config: Parameters<typeof maybePlaySwarm>[1], headless?: boolean): boolean;
56
+ /** 导航之后调用:够条件就播一次,不等它播完(导航照常立刻返回快照)。 */
57
+ export declare function maybePlaySwarm(page: Page, config: {
58
+ swarm?: {
59
+ enabled?: boolean;
60
+ oncePerSite?: boolean;
61
+ speed?: number;
62
+ maxMs?: number;
63
+ playOnVeiled?: boolean;
64
+ range?: 'viewport' | 'page';
65
+ };
66
+ } | undefined, headless?: boolean): void;