dsh-mini-remote 0.1.9 → 0.2.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/lib/mirror.js ADDED
@@ -0,0 +1,1715 @@
1
+ /**
2
+ * 「进阶设置」:把电脑端界面(DSH Web GUI)经我们自己的端口转出去。
3
+ *
4
+ * ## 为什么要有这个东西
5
+ *
6
+ * 手机上那张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
7
+ * 这个模块给你一条退路:**要改那些设置时,直接把电脑端的界面整个搬过来看**——
8
+ * 不迁移任何页面,不逐个适配,**同一份界面**。
9
+ *
10
+ * ## 为什么必须经我们代理,不能直接连
11
+ *
12
+ * 实测:电脑端界面监听在 `127.0.0.1:19387`,**只绑回环**——手机根本够不着。
13
+ * 而我们的端口本来就绑了局域网和 Tailscale,**手机够得着**。所以由我们中转。
14
+ *
15
+ * ## 认证:走正门,不绕过
16
+ *
17
+ * 那个界面要一个**按「权威域名」绑定、用本进程密钥签名**的 cookie(实测裸连回 401)。
18
+ * 密钥是私有的,谁也拿不到——**我们也不去拿**。
19
+ *
20
+ * 走的是官方留的正门:宿主的 connection 服务有公开方法
21
+ * `authenticatedUrl(baseUrl)`——「把本次启动的令牌加到应用地址上」。拿到的地址形如
22
+ * `http://127.0.0.1:19387/?token=<令牌>`;**请求它一次,服务器就回 303 并铸出那个 cookie**
23
+ * (官方 BrowserAuth.authorizeIndex 的原文:「A valid root query token mints the cookie
24
+ * and redirects」)。
25
+ *
26
+ * 所以这个模块做的是:**服务端自己去走一次官方认证,把 cookie 收在自己手里**,
27
+ * 之后每个转发请求替手机带上它。**手机全程碰不到任何官方凭据。**
28
+ *
29
+ * ## 两个必须钉死的细节
30
+ *
31
+ * ① **Host 头固定成目标自己的地址。** 官方那个 cookie 是**按 Host 签的**
32
+ * (requestAuthority 只看 `host` 头):`payload.authority === 请求的 Host`。
33
+ * 所以转发时必须让目标看到的 Host 和铸 cookie 时那一份**完全一致**,
34
+ * 否则每个请求都 401。这里固定成上游自己的 `host:port`。
35
+ * ② **不缓冲响应。** 那个界面的实时更新走 SSE(客户端用 EventSource、服务端发
36
+ * text/event-stream,实测不是 WebSocket)。一旦缓冲,界面就再也不刷新了。
37
+ * 所以响应体一律 pipe,不攒。
38
+ */
39
+ import { request as httpRequest } from 'node:http'
40
+ import { connect as netConnect } from 'node:net'
41
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
42
+ import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'
43
+
44
+ /** 这些头是「逐跳」的,不该被转发(RFC 7230 §6.1)。 */
45
+ const HOP_BY_HOP = new Set([
46
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
47
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
48
+ ])
49
+
50
+ /**
51
+ * 这些响应头会把「不许被嵌进框架」写死,我们要去掉——
52
+ * 这个界面是要给手机看的(第一阶段开新标签,第二阶段可能就是内嵌)。
53
+ * 去掉不等于放宽安全:能拿到这些响应的前提是**已经通过了我们自己的令牌**。
54
+ */
55
+ const FRAME_BLOCKERS = new Set(['x-frame-options', 'content-security-policy'])
56
+
57
+ /**
58
+ * **默认拒绝被嵌,只有持票的那一次放行**(2026-10-15)。
59
+ *
60
+ * `FRAME_BLOCKERS` 上面那条说的是「上游写死的禁止头要摘掉」;摘掉之后**由我们自己
61
+ * 定这一页许不许被嵌**。今天的两个用法不一样:
62
+ *
63
+ * · **手机页那一层**(`lib/page.html` 的 `#mirrorFrame`)是**同源** iframe——
64
+ * 地址是相对的 `/mini/mirror/?token=…`,和手机页同一个 host:port;
65
+ * · **DSH 桌面端那一层**(「远程窗口」面板)是**跨源又跨站**的 iframe——
66
+ * 桌面端自己的页面跑在自定义协议 `dsh-app://app/` 上(2026-10-15 实查 main.js:
67
+ * `protocol.handle(SCHEME…)`、`assertMainApplication` 里那句 `dsh-app://app/`),
68
+ * 镜像则在 `http://127.0.0.1:<我们的端口>`。
69
+ *
70
+ * 所以默认这一条就给「只许同源」:手机页照常能嵌,别的站点一律嵌不进来。
71
+ * 跨源那一层要嵌,地址里必须带着**一次性票**(见下面 issueTicket/redeemTicket)——
72
+ * 票只有本机回环上的宿主那一半拿得到,所以「谁能嵌」由票决定,而不是由一份
73
+ * 源白名单决定(桌面端那一侧不是个 http 地址,白名单本来也无从写起)。
74
+ *
75
+ * 注意这两个头**不是**上游的头:上游那一份已经在 writeOut 里丢掉了。
76
+ */
77
+ const FRAME_SELF_ONLY = {
78
+ 'x-frame-options': 'SAMEORIGIN',
79
+ 'content-security-policy': "frame-ancestors 'self'",
80
+ }
81
+
82
+ /**
83
+ * 那条「受信任通道」的前缀。
84
+ *
85
+ * 装在这台机器上的 `dsh-remote-web-ui` 会在页面里装一条门控通道:它把手机上发的
86
+ * 每个请求**改写成 `/remote/<原路径>`**,再带上设备令牌,由宿主那边认过令牌之后
87
+ * 「以本机身份」重发一次。我们这边没有设备令牌,也不需要——
88
+ * **因为我们的代理本来就从回环发出、Host 也固定成上游自己,宿主已经把我们当本机了。**
89
+ *
90
+ * 所以这里把前缀**剥掉**:`/remote/api/xxx` → `/api/xxx`。
91
+ * 请求落到宿主时正是它想要的形状,那条通道于是「成功」,闸门自己就放下。
92
+ */
93
+ const REMOTE_CHANNEL_PREFIX = '/remote'
94
+
95
+ /**
96
+ * 这些路径可以让浏览器**长期留着**。
97
+ *
98
+ * 2026-10-06 量出来的:那 34 MB 里,**主程序包和插件包加起来 25 MB,上游一个缓存头都没给**
99
+ * (首页 HTML 也没有;只有宠物立绘有 `no-cache` + etag)。浏览器于是只能"猜着缓存",
100
+ * 每次打开都可能重新拉一遍——**蜂窝下就是这么卡死的**。
101
+ *
102
+ * **为什么敢长期留**:这两类的地址里都带**内容指纹**——
103
+ * `/assets/index-5SrrfWpU.js` 带哈希、`plugins/??a,b&rev=…` 带 rev。
104
+ * **内容一变,地址就变**,所以旧的留着不会造成「看到旧代码」。
105
+ *
106
+ * 只给这两类,别的一律不碰(HTML 尤其不能缓存:它是外壳,还要我们注入)。
107
+ */
108
+ const CACHEABLE = /^\/(assets|plugins)\//
109
+ const LONG_CACHE = 'public, max-age=604800' // 7 天;地址带指纹,不需要更激进
110
+
111
+ /**
112
+ * 告诉官方界面「你是这台主机的主人」。
113
+ *
114
+ * 官方界面把「配置面仅限本机」做成**客户端**判断(各客户端插件看
115
+ * `connection.isLoopback`),而这个值由 `__DSH_TRANSPORT__.ownsHost` 推导出来。
116
+ * 桌面外壳是在**任何启动项之前**把它设上的;这一页的服务端是我们,所以由我们授予。
117
+ *
118
+ * **必须尽早注入**(紧跟 `<head>`):启动项是内联脚本,跑在 head 里,晚一步就读不到了。
119
+ */
120
+ const HOST_HOOK = '<script>globalThis.__DSH_TRANSPORT__=globalThis.__DSH_TRANSPORT__||{};'
121
+ + 'globalThis.__DSH_TRANSPORT__.ownsHost=true;</script>'
122
+
123
+ /**
124
+ * **把别的插件的「移动端适配」挡在门外。**
125
+ *
126
+ * ## 为什么(2026-10-06 用户定的方向)
127
+ *
128
+ * 用户原话:「让我们的插件参考那个插件的方式来做『被搬过来的那个 DSH 界面』,
129
+ * **而不是直接搬那个插件适配的界面**。等于电脑端设置界面实际上也应该是我们自己的插件的产物。」
130
+ *
131
+ * 这一页里的适配,**必须是我们自己做的**。可机器上装着的 `@linxin666/dsh-remote-web-ui`
132
+ * 会**在运行时**往这一页里插一段适配样式、再往 `<body>` 上贴几个标记;它那些选择器
133
+ * **写宽了**(`[class$="_overlay"] [class$="_panel"] …` 这类按类名后缀匹配),
134
+ * 而官方界面的类名正好也是那几个,于是**官方面板被它一起改了**。
135
+ *
136
+ * **上一版我是在它的地基上打补丁**:它改了哪两处,我就把那两处掰回来。
137
+ * 那是被动挨打——它以后多改一处,我就得再补一处。**现在改成从根上挡掉。**
138
+ *
139
+ * ## 挡哪几样
140
+ *
141
+ * · `remote-web-ui` 插的那段 `<style data-plugin-css="…">`——**里面还有没带 body 前缀的规则**,
142
+ * 光摘标记挡不住它,必须连样式一起摘;
143
+ * · `dsh-remote-portrait`——它的「适配生效」标记,整套 `body.… ` 规则靠它;
144
+ * · `dsh-remote-compact-picker` / `dsh-remote-header-seated`——它另外两处改官方界面的标记。
145
+ *
146
+ * ## 后来又加了一个:`dsh-mobile-nav`(2026-10-06,用户真机报的)
147
+ *
148
+ * 用户报「设置页上方永远是一块只有图标的面板、占掉大半屏、名字要点一下才出来」。
149
+ * 把这一页里所有样式表过了一遍,**改这套设置面板的只有两个插件**,其中真正**改布局**的是
150
+ * `@dsh-external/dsh-mobile-nav/mobile.css`——这个 id 是 **`dsh-pocket`** 插件的客户端
151
+ * (源码 `client/mobile/mobile.css.ts`,它自己给自己贴的名字)。它把面板的导航列表改成:
152
+ *
153
+ * [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"]))
154
+ * > :first-child > :last-child { display: grid !important; grid-template-columns: repeat(3, 1fr) !important }
155
+ *
156
+ * ——「上方三列图标格子」就是这一条。它的特异性(0,6,1)**高于我们按 `[data-shortcut-modal]`
157
+ * 写的规则**(0,2,0),两边都带 `!important` 时**特异性高的赢**,所以上一版我们那条
158
+ * `flex-direction:row !important` 根本没生效(元素还是 `display:grid`,改方向没用)。
159
+ * **补丁打不过它,把它的样式摘掉才是正解。**
160
+ *
161
+ * **摘它这一整份是安全的**:`mobile.css.ts` 整份都写在 `@media (max-width:1023px)` 里
162
+ * (外加一段桌面端隐藏手机控件的守卫),**通篇都是手机适配,没有一条是界面正常运转必需**。
163
+ * 实测:在镜像页里把这一整段摘掉,主页面截图**看不出变化**(它那套「框架 / 抽屉 / 悬浮按钮」
164
+ * 规则靠 `[data-mobile-nav="frame"]` 这个标记,而这页里根本没有那个标记)。
165
+ *
166
+ * **故意不摘的**:`@linxin666/dsh-web-all/...../web-ui-settings.module.css`。它**不是纯适配**——
167
+ * 同一份里还装着那个插件自己设置小节的正常样式(`.HfjcPG_section` / `_heading` / `_lede`…),
168
+ * 整份摘掉会把那个小节的样式一起弄没。它对设置面板的唯一影响,是把第 5~8 个导航格的图标
169
+ * 换成它自己画的,**不动布局**,留着无害。
170
+ *
171
+ * ## 代价(已经跟用户说清并获同意)
172
+ *
173
+ * 它自己那个「远程访问」设置页,在这一页里也会退回桌面样子。**这个代价该付**:
174
+ * 镜像本来就是「电脑端界面」,本来就该是桌面样子;在别人的界面里塞手机样式,
175
+ * 才是我们一直在收拾的那个烂摊子。
176
+ *
177
+ * ## 为什么盯着
178
+ *
179
+ * 它们都是**在运行时**插的样式:`remote-web-ui` 在竖屏/尺寸变化时会重新贴一遍,
180
+ * `dsh-pocket` 也会在重新挂载时重插。所以不能只做一次。
181
+ * 观察范围**故意收得很窄**(只看 head 的子节点、只看 body 的 class):
182
+ * 盯着整棵树会在这种重页面上白白烧掉性能。
183
+ */
184
+ /**
185
+ * ## 两层:通用规则 + 点名名单(2026-10-06 用户拍板「做吧」)
186
+ *
187
+ * 用户问:「如果我们安装了其他的远程遥控相关的 DSH 插件,都可能对这个页面造成影响?」
188
+ * ——**会**。这一页搬的就是真的 DSH 界面,装了什么插件这里就有什么插件,
189
+ * 而「手机适配」那一类插件本来就是冲着改界面来的,选择器写得很宽(`[aria-modal="true"]`、
190
+ * `[class$="_navCell"]` 这种),**撞上官方的元素就改**。
191
+ *
192
+ * 只靠点名,等于**每装一个新插件就要再加一个名字**。所以补一层通用的:
193
+ *
194
+ * **通用层**:第三方(非 `@deepseek-ai/`)的样式表,**整份都写在窄屏条件里** → 摘掉。
195
+ * 这类「手机适配」几乎都是这个形状(`dsh-pocket` 那份就是整份包在 `@media (max-width:1023px)` 里)。
196
+ *
197
+ * 判断**不拆字符串,交给浏览器的 CSS 解析器**(`style.sheet.cssRules`):
198
+ * 顶层规则**必须全是媒体查询、且条件里是窄屏**,才算「整份只在窄屏生效」。
199
+ * 自己写正则去数大括号,遇到嵌套和注释就会判错。
200
+ *
201
+ * **点名层**:已知那两家**无论如何都摘**——它们有的规则并不在窄屏条件里(比如挂在 body 类名上的),
202
+ * 通用层抓不到。
203
+ *
204
+ * ## 第三层:**摘标记**(2026-10-06 在真面板上量出根因之后补的)
205
+ *
206
+ * 「设置页导航只有图标、点一下才出字」的真凶是 `meow-smooth` 那个插件:它在面板上贴一个
207
+ * `data-meow-smooth-settings="collapsed"`,一条 `opacity:0` 就把整排名字按成透明。
208
+ * 它既躲过了点名层(标签上写的是 `data-plugin`,不是 `data-plugin-css`),
209
+ * 也躲过了通用层(**它那几条选择器里一个类名都没有**,按类名搜根本搜不到)。
210
+ *
211
+ * 所以除了摘样式,**还要把面板上那个标记摘掉**:它的规则全部要求带那个标记,
212
+ * 它的点击监听也只在状态是 `collapsed/expanded` 时才动手——标记一摘,两边同时失效。
213
+ * 详见脚本里 `SETTINGS_MOBILE_ATTR` 那一段。
214
+ *
215
+ * **官方的包一律不动**(`@deepseek-ai/` 开头):那是界面本身,摘了就把界面弄坏了。
216
+ */
217
+ const STRIP_FOREIGN_ADAPT = `<script>(function(){
218
+ // 点名名单:data-plugin-css 里含这些字样的一律摘(兜底,通用层抓不到的靠它)
219
+ var STYLE_OWNERS = ["remote-web-ui", "dsh-mobile-nav"];
220
+ // 挂在 body 上的「适配生效」标记,一并摘掉
221
+ var BODY_CLASSES = ["dsh-remote-portrait", "dsh-remote-compact-picker", "dsh-remote-header-seated"];
222
+ // 窄屏的判定上限:条件里的 max-width 不超过这个数就算窄屏
223
+ var NARROW_MAX = 1280;
224
+ /*
225
+ * **第三条路:挂在面板上的「手机端设置页」标记**(2026-10-06 在真面板上量出来的)。
226
+ *
227
+ * meow-smooth 这个插件(源码 src/settings-mobile.ts)在窄屏把设置面板标成「收起态」:
228
+ * div[role="dialog"][data-meow-smooth-settings="collapsed"] > nav > div > button > span
229
+ * { flex:0; max-width:0; opacity:0 }
230
+ * ——导航里的**名字整片透明**,只剩图标;而它同时在 document 上挂了个 capture 点击监听,
231
+ * 点进导航时 preventDefault + stopPropagation、把标记改成 expanded,**字这才出来**
232
+ * (顺带把那一次点击吃掉,所以点第一下还切不了页)。用户报的「只有图标、点一下字才出」
233
+ * 一个字不差地就是它。
234
+ *
235
+ * **为什么前两轮都没抓到**:它那几条选择器里**一个类名都没有**(nav 底下直接到 button
236
+ * 再到 span),按 navLabel 搜选择器搜不到;而它那份样式的标签上写的是
237
+ * data-plugin="meow-smooth" 和 data-meow-settings-css,**压根没有 data-plugin-css**
238
+ * ——上面两层(点名层、通用层)连看都没看它一眼。
239
+ *
240
+ * 所以补这一条:**把面板上那个标记摘掉**。摘掉之后它那些规则(全部要求带这个标记)
241
+ * 和那个点击监听(状态不是 collapsed/expanded 就直接返回)**同时失效**——
242
+ * 实测:摘掉后名字 opacity 立刻变 1、字出来、点标签能正常切页。
243
+ *
244
+ * **必须一直盯着**:关掉面板再打开时它会重新贴上去(实测过),所以不能摘一次就算完。
245
+ */
246
+ var SETTINGS_MOBILE_ATTR = "data-meow-smooth-settings";
247
+ /*
248
+ * **第四样:挂在 html 元素上的「卷起」标记**(2026-10-07 无头 Edge 加真触摸模拟量出来的)。
249
+ *
250
+ * 用户报「**主页面整片空白**(聊天页和别的非设置页都一样),只剩左上角鲸鱼按钮和立绘;
251
+ * 设置页正常」,说这是回归。真凶是 meow-smooth 这个插件的另一条窄屏规则:
252
+ *
253
+ * @media (max-width:1023px)
254
+ * html[data-meow-smooth-furled] [data-slot="root"] > [data-sidebar-collapsed]
255
+ * { grid-template-columns: 0px minmax(0px,1fr) 0px !important }
256
+ *
257
+ * 它把主框架那个网格**从一列改成三列**(0 / 1fr / 0),而这一页的 DOM 顺序跟它设想的不一样:
258
+ * 官方的侧栏在 ≤768px 那段里是 position:absolute(脱流),于是**主栏成了第一个在流里的
259
+ * 子元素、被塞进第 1 列那 0 个像素**——主栏宽度直接算成 0。
260
+ * 活页面上连续量到的实况(390×844 视口、Emulation.setTouchEmulationEnabled 真触摸模拟):
261
+ * 主框架 grid-template-columns 算出来是 **0px 390px 0px**、
262
+ * 主栏([data-pane="conversation"])**宽 0px**、
263
+ * 欢迎语那个 span 被压成 **26×192 的竖排字**、输入框那一层 0 宽——
264
+ * 整片内容都在,只是**每一层都被压成零宽**,屏幕上就只剩背景色(截图里面板关掉后
265
+ * 只有鲸鱼按钮和立绘,跟用户那张截图一模一样)。
266
+ *
267
+ * **为什么不是我们摘错了东西**(用户的第一怀疑):把整段 STRIP_FOREIGN_ADAPT 从网页里
268
+ * 拿掉、原样重新加载,data-meow-smooth-furled 照样在、主栏照样是 **0px**。
269
+ * 那个标记是 meow-smooth 自己贴的,跟我们摘不摘无关。前两层也抓不到它这条规则:
270
+ * 它那份 style[data-plugin="meow-smooth"] **整份不是「只在窄屏」**(里面有没包媒体查询的
271
+ * 规则,通用层判不过),选择器里也一个点名名单里的字都没有。
272
+ *
273
+ * **为什么摘标记就能治**:它这一套「卷起态」的规则**全部以 html[data-meow-smooth-furled]
274
+ * 开头**(网格、隐藏侧栏那条竖线、会话头的 margin-left、它自己的悬浮球…)。
275
+ * 标记一摘,整套同时失效,页面回到「没卷起」的布局——也就是这一页本来就该有的桌面样子。
276
+ *
277
+ * **为什么要一直盯着**:它跟设置面板那个标记是同一个性质,是在运行时贴上去的
278
+ * (实测在应用启动后才出现),所以不能摘一次就算完。
279
+ */
280
+ var HTML_MOBILE_ATTR = "data-meow-smooth-furled";
281
+
282
+ /** 这份样式是不是「整份只在窄屏生效」。 */
283
+ function isNarrowOnly(tag) {
284
+ try {
285
+ var sheet = tag.sheet;
286
+ if (!sheet || !sheet.cssRules || !sheet.cssRules.length) return false;
287
+ for (var i = 0; i < sheet.cssRules.length; i += 1) {
288
+ var r = sheet.cssRules[i];
289
+ if (r.type !== 4) return false; // 4 = CSSMediaRule,不是媒体查询就直接否掉
290
+ var cond = String(r.conditionText || (r.media && r.media.mediaText) || "");
291
+ var m = /max-width\\s*:\\s*(\\d+)/i.exec(cond);
292
+ if (!m || parseInt(m[1], 10) > NARROW_MAX) return false;
293
+ }
294
+ return true;
295
+ } catch (e) {
296
+ // 读不到规则(跨源等)就当不是,宁可留着也不误摘
297
+ return false;
298
+ }
299
+ }
300
+
301
+ function strip() {
302
+ try {
303
+ var b = document.body;
304
+ if (b && b.classList) {
305
+ for (var i = 0; i < BODY_CLASSES.length; i += 1) b.classList.remove(BODY_CLASSES[i]);
306
+ }
307
+ // 面板上的「手机端设置页」标记(见上面那段说明)——它姓什么不重要,摘掉就是。
308
+ var panels = document.querySelectorAll("[role=dialog]");
309
+ for (var p = 0; p < panels.length; p += 1) {
310
+ if (panels[p].hasAttribute(SETTINGS_MOBILE_ATTR)) panels[p].removeAttribute(SETTINGS_MOBILE_ATTR);
311
+ }
312
+ // 主界面那个「卷起」标记挂在 html 元素上(见上面 HTML_MOBILE_ATTR 那段说明):
313
+ // 不摘掉它,主栏的网格列就是 0px,整片主页面被压成零宽。
314
+ var docEl = document.documentElement;
315
+ if (docEl && docEl.hasAttribute(HTML_MOBILE_ATTR)) docEl.removeAttribute(HTML_MOBILE_ATTR);
316
+ if (!document.head) return;
317
+ // **样式标签上写的 data-plugin 也要看**:有插件(meow-smooth 就是)不用
318
+ // data-plugin-css 那个约定,只写 data-plugin。只按前一个属性找,等于把这类整份漏掉。
319
+ var st = document.head.querySelectorAll("style[data-plugin-css],style[data-plugin]");
320
+ for (var j = 0; j < st.length; j += 1) {
321
+ var tag = st[j];
322
+ var v = String(tag.getAttribute("data-plugin-css") || tag.getAttribute("data-plugin") || "");
323
+ if (v.indexOf("@deepseek-ai/") >= 0) continue; // 官方的包不动
324
+ var named = false;
325
+ for (var k = 0; k < STYLE_OWNERS.length; k += 1) {
326
+ if (v.indexOf(STYLE_OWNERS[k]) >= 0) { named = true; break; }
327
+ }
328
+ if (named || isNarrowOnly(tag)) tag.remove();
329
+ }
330
+ } catch (e) {}
331
+ }
332
+
333
+ strip();
334
+ try {
335
+ // **两个观察者,各自的范围都钉得很死**(原来是「看 head 的子节点 + body 自己的 class」,
336
+ // 就是怕在这么重的页面上白烧性能,这条原则不变)。
337
+ var mo = new MutationObserver(strip);
338
+ if (document.head) mo.observe(document.head, { childList: true });
339
+ if (document.body) mo.observe(document.body, { attributes: true, attributeFilter: ["class"] });
340
+ // 第二条只管**那两个标记**:设置面板那个挂在 body 深处的节点上,所以必须 subtree;
341
+ // 但 attributeFilter 只有那两个属性名,别的属性怎么变都不会把我们叫醒。
342
+ // (不跟上面合成一条:同一节点再 observe 一次是**替换**掉上一次,
343
+ // 合成一条就得把 class 也放进 subtree 范围里——那才是真的会烧性能。)
344
+ var moSettings = new MutationObserver(strip);
345
+ if (document.body) {
346
+ moSettings.observe(document.body, {
347
+ attributes: true, subtree: true, attributeFilter: [SETTINGS_MOBILE_ATTR, HTML_MOBILE_ATTR],
348
+ });
349
+ }
350
+ // 第三条只管 html 元素上那个「卷起」标记。**html 不在 body 里面**,
351
+ // 上面那条 subtree 观察器够不着它,所以只能单独盯一个节点;
352
+ // 不带 subtree、attributeFilter 只有一个名字,代价可以忽略。
353
+ var moHtml = new MutationObserver(strip);
354
+ if (document.documentElement) {
355
+ moHtml.observe(document.documentElement, {
356
+ attributes: true, attributeFilter: [HTML_MOBILE_ATTR],
357
+ });
358
+ }
359
+ } catch (e) {}
360
+ })();</script>`
361
+
362
+
363
+ /**
364
+ * **我们自己**给这一页做的布局适配。
365
+ *
366
+ * 这是「我们自己的产物」那一半——见上面 STRIP_FOREIGN_ADAPT 的说明:
367
+ * 先把别的插件的适配挡在门外,**要改什么由这里说了算**。
368
+ *
369
+ * 现在只有两条,都是**实测出来的**:
370
+ *
371
+ * ① **把设置面板那层浮层抬到侧栏上面。** 官方界面里侧栏是 `position:absolute; z-index:1100`,
372
+ * 设置面板那层浮层是 `z-index:1000`——**侧栏比它高一百**。桌面端侧栏只占左半边,
373
+ * 看不出来;手机屏窄,侧栏一展开就整屏盖住,点「设置」什么都看不见。
374
+ *
375
+ * **第一版我把侧栏压到 20,那是反的,真机上立刻出问题**:面板的**遮罩**(同一层,
376
+ * 1000)反过来盖住了侧栏,用户看到侧栏发暗、**点击落在遮罩上**——遮罩一收侧栏就消失,
377
+ * 设置页也进不去。**所以要把面板抬上去,不能把侧栏压下去。**
378
+ *
379
+ * ② **底部让开手势条/圆角——这一条已经搬去外层页**(2026-10-07 搬的,理由在 `ADAPT_CSS`
380
+ * 那一行的注释里):`#mirrorFrame` 是外层页 lib/page.html 的元素,注进被镜像文档的规则
381
+ * 一个像素都管不着它。顶部那条一直就在我们自己手里(page.html 的 `.mv-bar` 的
382
+ * `padding-top`),现在底部那条也回去了。
383
+ *
384
+ * **删掉的一条**:上一版这里还有「把官方设置面板的导航掰回竖排、字始终显示」——
385
+ * 那是在**别的插件的地基上打补丁**(它改哪两处我就掰哪两处)。现在改成从根上挡掉
386
+ * (见 STRIP_FOREIGN_ADAPT),**那两条补丁不需要了**。
387
+ *
388
+ * ③ **把设置面板的导航压成一条「可横向滑动的标签条」,把屏幕让给内容。**
389
+ *
390
+ * 2026-10-06 用户报:「上方的图标部分,下方点了图标的具体页面,然后上方页面一直固定在那,
391
+ * 具体页面只有半个手机屏」。**读官方客户端源码确认过**:官方面板本来是「左边一列导航
392
+ * 188px + 右边内容」,而**它自己的 `wide` 只影响侧栏那个按钮、完全不影响面板内部布局**——
393
+ * 所以「横排在上方」是别人注入的样式干的(见 STRIP_FOREIGN_ADAPT)。
394
+ *
395
+ * **是谁干的已经查清了**(2026-10-06):是 `@dsh-external/dsh-mobile-nav`(`dsh-pocket`)
396
+ * 那一条三列网格规则,特异性比我们高,所以上一版我们那条 `flex-direction:row` 打不过它
397
+ * ——现在按名单把它的样式整个摘掉(见 STRIP_FOREIGN_ADAPT),**这里不再跟谁抢优先级**,
398
+ * 只按我们要的样子定下来:
399
+ * 面板改成竖排(导航在上、内容在下),导航压成**一行、不折行、可横向滑动、名字露出来**。
400
+ * 这样导航只占约 50px 高,**剩下的整屏都给内容**。
401
+ *
402
+ * **选择器用 `[data-shortcut-modal="settings"]`**:这是官方渲染在面板上的固定标记
403
+ * (`role="dialog"`、`aria-modal="true"` 那一块),**不受类名哈希变化影响**,比按后缀猜稳。
404
+ *
405
+ * **不改的**:官方界面自己已经把侧栏做成抽屉了(`position:absolute` + 网格父元素)。
406
+ * 我原以为要改它,注入试过才发现是白改——**动之前先试,别凭想象改**。
407
+ *
408
+ * ④ **把 `_content` 的高度链接上,让它能滚**(2026-10-07 真面板量出来的)。
409
+ *
410
+ * 用户报「点击图标出现的页面没有滚动条,也没法滚动往下拉」。**这次先怀疑的方向是错的**:
411
+ * 我照着截图上「插件市场」四个字竖着排,判断「面板又变回横排、内容区被挤扁」,
412
+ * 甚至打算再去追「哪个插件把面板改回横排」。
413
+ *
414
+ * **量了才知道不是**:在真面板上逐张样式表 `matches()` 一遍,管着面板的规则只有两条
415
+ * ——官方那条 `.xxx_panel{display:flex;…}` 和**我们自己**那条 `flex-direction:column`,
416
+ * nav 的计算方向是 `row`、`_content` 是 `column`,**竖排好着呢**;
417
+ * 我还在 390px 视口下把十几个导航格子逐个量了 rect:**全都在一条 55px 高的横条里**。
418
+ * 截图里那四个竖排的字,是那个小节自己的标题栏窄,不是面板被挤扁。
419
+ *
420
+ * **真正的病灶**:`_content` 缺 `min-height:0`,高度链断在这一层,
421
+ * 于是「滚不动」——详见 `ADAPT_CSS` 里那一行上面的说明(有前后实测数字)。
422
+ *
423
+ * 教训跟上一轮一样:**别在猜出来的根因上做修复**。上一轮猜「官方 flex 为竖列设计」,
424
+ * 真原因是别的插件的 `opacity:0`;这一轮猜「又变回横排了」,真原因是高度链断了。
425
+ * 两次都是**先在真面板上量、再动手**才对的。
426
+ *
427
+ * ⑤⑥⑦ **「插件市场」那一节(`dsh-market`)**(2026-10-07 在真面板上量出来的,见下面逐条注释):
428
+ * ⑤ 它的标题行是 `nowrap` 的 7 件东西挤 **286px**,中文按字断行 → 标题和按钮变竖排、
429
+ * 最后一个按钮被裁掉 → **给它折行**;
430
+ * ⑥ 它自带一条窄屏规则把面板的导航整条藏掉(进了这一节就没导航、回不去别的节)
431
+ * → **按更高的特异性要回来**;
432
+ * ⑦ 它那排页签总宽 427px 而只有 286px、自己不滚 → 右边三个被裁掉 → **给它横向滚动**。
433
+ * 三条都**锁在 `[data-dsh-market-root]` 里面**,只碰它这一节,别的插件/别的节一条都不动。
434
+ *
435
+ * ⑧–⑭ **同一节里「那块能滚的列表窗口」再做大**(2026-10-07 用户第二次报「窗口高度还是太矮」,
436
+ * 逐项在真面板上量的,账写在下面逐条注释里):面板撑高、官方那两处富余留白、市场自己的留白,
437
+ * 以及**标题行里重复的信息**(图标 / 仓库名 / 版本号)。合起来列表窗口 **356 → 498px**,
438
+ * 看卡片的地方 **204 → 378px**;**有用的东西一样没少**(三个按钮、搜索、分类、提示条、页签)。
439
+ * 同样全部锁在市场根里——`:has()` 只在真的停在这一节时才命中(切走之后市场根会被卸载,
440
+ * 活服务上实测过)。
441
+ * 我们**自己**那排导航也顺手压矮了(⑮,见 `nav` 那两条):它属于我们自己的适配,
442
+ * 每高 1px 都是每个节的内容区少 1px,实测市场那一节**列表窗口 +10px**。
443
+ *
444
+ * ⑯ **同一节里「社区介绍」那一整格藏掉**(2026-10-07 用户第二次点名:「导出日志也可以隐藏」):
445
+ * 那一格 56px 里四件东西——纯介绍文字(36px,两行)、「申请收录插件 ↗」(18px,给作者用的)、
446
+ * 一个空占位、「导出日志」(90×28,诊断按钮)。上一轮只藏了前两样、把导出日志留着
447
+ * (列表窗口 498 → 526px);这一轮用户点名把导出日志也藏掉,于是**整格都藏**:
448
+ * **526 → 560px**、看卡片的地方 **406 → 440px**(+34 = 那个按钮 28 + `_head` 那道 6px 间隔)。
449
+ * **整格藏比「只把按钮也加进隐藏名单」多赚 6px**:那一格留在 DOM 里哪怕高度是 0,
450
+ * `_head` 的 flex gap 照样占一道。
451
+ *
452
+ * ⑰ **共用那条「内容区头」里的动作区藏掉**(2026-10-07 用户点名:「打开配置文件也可以隐藏」)。
453
+ * **这一条故意不在市场根里,它作用在每一个设置节**——那条头是面板自己的
454
+ * (`_content > _header`),16 个节实测长得一模一样:`_actions`(94×28,里面**只有**
455
+ * 「打开配置文件」一个按钮,逐节点名数过)+ `wCInkW_close`(28×28)。
456
+ * 所以它和「只给插件市场腾高度」不是一回事:写着共用就写在共用层,不假装它只在市场里。
457
+ *
458
+ * **它一分高度都省不下**(真面板实测):市场那一节 40px、别的节 54px(官方写死的
459
+ * `height:54px`),这两条**本来就由 ✕ 那一行撑着**——藏掉左边那件之后,头高、内容区高、
460
+ * 能滚的像素**三个数一个都没变**。收益 0,只是少一个按钮;用户知情后仍要藏,所以照做,
461
+ * 但账留在这里,别让后人以为它腾出了高度。
462
+ *
463
+ * 同一条里补的 `justify-content:flex-end` 是**给这条规则自己的副作用收尾**:官方那条头是
464
+ * `space-between`,而动作区自己带 `margin-left:auto`(实测解出来 188px)、被它顶在最右边;
465
+ * 动作区一藏,只剩 ✕ 一个孩子,`space-between` 就把它放到最左边——✕ 从 x=324 跳到 x=34。
466
+ * ✕ 是用户唯一能退出面板的出口,不该因为它左边那件东西消失就换到另一头——
467
+ * 固定成右对齐之后实测 ✕ 回到 **x=324**(与改前一致),仍然 28×28、点得中、点了就关。
468
+ *
469
+ * ⑱ **侧栏那一列放开纵向滚动**(2026-10-07 在活页面上量出来的)。
470
+ *
471
+ * 用户报:「导航栏其实没显示全,设置按钮是看不到的,能否搞个滚动条」。
472
+ * 侧栏(不是设置面板)那一列 `[data-pane="sidebar"]` 写着 `overflow:hidden`,
473
+ * 而它的 **scrollHeight 696 / clientHeight 599**——多出来的 97px 被直接裁掉,
474
+ * 最底下的「设置」整条落在屏幕外。**不是高度链断在某一层,是根本没有滚动容器**;
475
+ * 逐层数字和为什么只动这一层,写在 `ADAPT_CSS` 那条规则上面的注释里。
476
+ *
477
+ * ⑲ **中间那块 `_regionArea` 不参与压缩**(2026-10-08 在活页面上量出来的)。
478
+ *
479
+ * 用户报:「电脑端界面的导航栏,现在看不到历史会话了」。它是那一列里唯一的
480
+ * `flex:1 1 0%`,⑱ 让列能滚之后**第一个被让掉的就是它**——实测 rect `[8,284,272,0]`、
481
+ * `scrollHeight 58 / clientHeight 0`;里面装的「工作区」标题(36px)和会话列表
482
+ * (`_list`,`scrollHeight 1722`,7 条会话)一样没少,只是整块高 0 + `overflow:hidden`
483
+ * 被裁干净,列表自己也还是「窗口 16px 对内容 1722px」的第二次裁。
484
+ *
485
+ * **用户点名要的是「整条排出来、往下滑由整条侧栏去滚」**:把这一层的 `flex` 改成
486
+ * `0 0 auto`(不参与压缩),高度按内容走。实测这一层 **1764px**、列表窗口 **1722**=
487
+ * 它自己的 `scrollHeight`(**能滚量 0,列表不再自己滚**)、一眼看得见 **6 条**会话;
488
+ * 代价是那一列变成 **2460px**,够着最底下的「设置」要滚 **1861px**。
489
+ * 逐层数字、为什么只动这一层、以及候选写法的对照,写在 `ADAPT_CSS` 那一条上面。
490
+ *
491
+ * 类名按**后缀**命中:它的类名是「哈希_名字」,哈希每次构建都变,只有后半段稳。
492
+ */
493
+ const SETTINGS_PANEL = '[data-shortcut-modal="settings"]'
494
+
495
+ const ADAPT_CSS = '<style id="mini-mirror-adapt">'
496
+ // 抬到 1200 的**只许是设置面板那一层**(2026-10-07 收窄的)。
497
+ //
498
+ // 原先是 `[class*="_overlay"]` 一把抓——「按后缀命中」这种写法在我们自己手里也会失手:
499
+ // 活服务上量到的实况(`document.querySelectorAll('[class*="_overlay"]')` 的每一个人):
500
+ // · `.BynINW_overlayLayer`(官方浮层容器,原生 `z-index:20`)被抬成 **1200**;
501
+ // · `.RlGAzG_overlayAnchor`(原生没有 z-index)被抬成 **1200**;
502
+ // · 官方账号那一层 `.TaJwIq_overlay`(原生 `z-index:1001`、整屏)也在名单里;
503
+ // · 还有 tldraw 的 `.tlui-dialog__overlay` / `.tl-error-boundary__overlay`
504
+ // ——活那几份样式表里名字带 `_overlay` 的一共七八个,我们只想动其中一个。
505
+ // 一个 `z-index:20` 的容器被抬到 1200,它的子树(`> *` 是 `pointer-events:auto`)就跟
506
+ // 设置面板平级、还能按 DOM 顺序压上去——这就是「一把抓的选择器顺手把别人也改了」,
507
+ // 和上一版「把侧栏压到 20」是同一类失手,只是方向反过来。
508
+ //
509
+ // 收窄成:**只有那个「直系子元素就是设置面板」的浮层**才抬。设置面板上的
510
+ // `data-shortcut-modal="settings"` 是官方固定标记(理由见下面 ③ 那段),不随类名哈希变;
511
+ // 活服务上全文档里这个标记只有一个,而一个元素只有一个父级,
512
+ // 所以这条选择器**有且只有一个命中**——正好是设置面板那一层。
513
+ // 官方结构(活服务上量的):`wCInkW_overlay > wCInkW_mask + wCInkW_panel[data-shortcut-modal=settings]`。
514
+ + '@media (max-width: 640px){[class*="_overlay"]:has(> [data-shortcut-modal="settings"]){z-index:1200 !important}}'
515
+ // 底部让位那一条(原 `#mirrorFrame{padding-bottom:env(safe-area-inset-bottom,0px)}`)**搬走了**:
516
+ // `#mirrorFrame` 在外层页 lib/page.html 里,这段样式却是注进**被镜像的那份文档**的
517
+ // ——两个文档,那条规则从写下的那天起就没生效过(活服务上量到:框的 padding-bottom = 0px,
518
+ // 而被镜像的那份 HTML 里躺着一份官方界面根本没有的 `#mirrorFrame` 规则)。
519
+ // 现在写在 page.html 的 `#mirrorFrame` 上,**这里不留同一条**(留着等于误导后人去改它)。
520
+ //
521
+ // **面板高度也不要在这里收**(2026-10-07 量过之后决定不写):
522
+ // 参考插件给它那份面板补了 `max-height:calc(100dvh - 32px)`。那条在**它那个架构**下成立
523
+ // (它用的是顶层文档,`dvh` 看得见地址栏),在我们这里不成立——镜像页跑在 iframe 里,
524
+ // **嵌套视口的 `dvh` 等于框自己的高**(无头 Edge 实测:400px 的框里 vh=dvh=svh=lvh=400,
525
+ // 1200px 的框里四个都是 1200;「地址栏差值」本来就是顶层帧才有的东西),
526
+ // 到我们这儿它退化成 `100vh - 32px`;
527
+ // 而官方面板的高度是 `min(800px, calc(100vh - 48px))`,**本来就比它矮 16px**,
528
+ // 于是这条 max-height 永远轮不到生效(活服务上 844 / 600 / 500 三个视口高度都量过:
529
+ // 面板高 796 / 552 / 452,而那条规则解出来是 812 / 568 / 468,挂上去面板高度一点没变),
530
+ // 加进来就是一条**死规则**——正是 ① 刚清掉的那种。
531
+ // 地址栏把面板顶出屏幕这件事,账在**外层页**(框的高度按可视区算),不在这里。
532
+ // ③ 设置面板:导航压成一条可横向滑动的标签条(见上面那段说明)
533
+ + '@media (max-width: 640px){'
534
+ + SETTINGS_PANEL + '{flex-direction:column !important}'
535
+ + SETTINGS_PANEL + ' [class*="_navTitle"]{display:none !important}'
536
+ // ④ **内容区的高度链要接上,不然「能看见」但「滚不动」**(2026-10-07 真面板量出来的)。
537
+ //
538
+ // 用户报:「点击图标出现的页面没有滚动条,也没法滚动往下拉」。第一眼看像滚动坏了,
539
+ // **其实是高度链断在 `_content` 这一层**——官方的规则里只写了
540
+ // `.xxx_content{flex:1 1 0%; min-width:0}`,**没有 `min-height:0`**。
541
+ // 桌面横排时它恰好等于面板高(1500px 视口实测 panel 800 / nav 800 / content 800),
542
+ // 所以官方自己看不出问题;**我们一改成竖排,导航在上面占掉 55px,这一层的
543
+ // 「自动最小高度」(`min-height:auto` = 内容高)就顶穿了面板**:
544
+ // 实测 panel 高 796(overflow:hidden)、content 高 **1425**、options 高 **1371**,
545
+ // 而 `_options` 的 scrollHeight 也是 1371 —— **它能滚多少 = 0**。
546
+ // 面板自己那点 `overflow:hidden` 只是把超出部分裁掉,裁掉的部分永远拉不回来。
547
+ //
548
+ // 补上 `min-height:0` 之后(同一次实测):content 1425 → **741**、
549
+ // options 1371 → **687**、**能滚 684px**,位置和 `[class*="_options"]` 原本
550
+ // 就写着的 `min-height:0` 对上了——**官方的滚动设计本来就是靠这一层收口,
551
+ // 只是它漏写了 `_content`**。
552
+ //
553
+ // **故意不加媒体查询**:面板高是 `min(800px, calc(100vh - …))`,横排桌面端
554
+ // 也只有 800px 高,一样需要这一层收口(只是它碰巧没露馅)。条件写得越少越不容易写错。
555
+ //
556
+ // **也不去想别的补法**(比如给 `_options` 写死高度、或者把面板改成 `overflow:auto`):
557
+ // 那是绕过官方的滚动结构另造一套;我们只把它缺的那一格补上,其余照旧。
558
+ + SETTINGS_PANEL + ' [class*="_content"]{min-height:0 !important}'
559
+ // **上下内边距 8px → 5px、格子高 34px → 30px**(2026-10-07 量的,也就是上面说的 ⑮)。
560
+ // 这排导航是我们自己的东西,它每高 1px,下面每一个设置节的内容区就少 1px;
561
+ // 在「插件市场」那一节这个换算最直接:实测**列表窗口 +10px**(356 → 366)。
562
+ // 只压高度、不动字号(13px 照旧,读起来一样)——缩的是上下留白和格子高度,
563
+ // 手机上 30px 的格子在这个「横向滑动的标签条」里仍然点得中。
564
+ + SETTINGS_PANEL + ' nav[class*="_nav"]{'
565
+ + 'flex-direction:row !important;flex-wrap:nowrap !important;width:auto !important;'
566
+ + 'gap:6px !important;padding:5px 10px !important;overflow-x:auto !important;overflow-y:hidden !important}'
567
+ + SETTINGS_PANEL + ' [class*="_navList"]{'
568
+ + 'flex-direction:row !important;flex-wrap:nowrap !important;gap:6px !important;'
569
+ + 'overflow-x:auto !important;overflow-y:hidden !important}'
570
+ + SETTINGS_PANEL + ' [class*="_navCell"]{'
571
+ + 'flex:none !important;height:30px !important;padding:0 12px !important;gap:6px !important}'
572
+ // 名字:**首帧没字的真根因已经量出来了**(2026-10-06,在真面板上)。
573
+ //
574
+ // 上一版顾问群判断「官方 flex 是为竖列设计的、被我们改成横排后首帧被压到零宽」——
575
+ // **真机验证证伪了**。后来在本地把真面板打开、逐张样式表 matches 一遍,真相是:
576
+ // `meow-smooth` 那个插件把面板标成「收起态」,一条
577
+ // `... > nav > div > button > span { flex:0; max-width:0; opacity:0 }`
578
+ // 让名字**有宽度、有文字,但整片透明**(实测 rect 52×22、textContent 正常、opacity 0,
579
+ // 而同一个格子里的 svg 图标 opacity 1——「图标在、字不在」)。
580
+ // **真凶已经按第三层摘掉了**(见 STRIP_FOREIGN_ADAPT 里那个属性清扫)。
581
+ //
582
+ // 这几行仍然留着,而且补上 opacity / max-width:它们是**兜底**——
583
+ // 万一真机上有哪一路没被摘干净(插件在别处的时机重新贴标记、或者别的插件用同一种
584
+ // 「视觉隐藏」的手法),名字也不至于再隐身一次。实测过:`opacity:0` 不带 !important,
585
+ // 我们这条带上就赢;`max-width:0` 会把 `width:max-content` 掐死,所以也必须一起覆盖。
586
+ + SETTINGS_PANEL + ' [class*="_navLabel"]{'
587
+ + 'flex:0 0 auto !important;min-width:max-content !important;width:max-content !important;'
588
+ + 'max-width:none !important;opacity:1 !important;'
589
+ + 'clip:auto !important;height:auto !important;position:static !important;'
590
+ + 'overflow:visible !important;white-space:nowrap !important;font-size:13px !important}'
591
+ // ⑤ **「插件市场」那一节的标题行要折行**(2026-10-07 在真面板上量出来的)。
592
+ //
593
+ // 用户截图里那一节整个是竖的:「插件市场」四个字竖着排、右边按钮也竖着排、
594
+ // 最右边那个还缺了一半。逐张样式表 `matches()` 量下来,**跟我们摘的那些样式无关**——
595
+ // 那一节自己的 `dshmarket/Market.module.css`(67179 字)一直躺在页面里,
596
+ // 我们的通用层一条都没摘它(它 571 条顶层规则里只有 5 条是媒体查询,判不成「整份只在窄屏」)。
597
+ // 病灶是它自己这一条:
598
+ // `.xxx_titleRow{display:flex;align-items:center;gap:10px}` ← 没写 flex-wrap,默认 nowrap
599
+ // 这一行里塞了 7 个东西(图标 22 + 标题 + 仓库名 66 + 版本 44 + 三个按钮),
600
+ // 各自的最小宽度加起来 ≈ **308px**(gap 10×6 就占掉 60),而面板在手机上只给得出 **286px**。
601
+ // 中文可以按字断行,于是浏览器把那几个「缩得动的」**全压到只剩一个字宽**——实测:
602
+ // 标题 `H2._title` **16×96**(四个字竖着排)、三个按钮 **32 / 36 / 32** 宽(字竖着排)、
603
+ // 最后一个按钮右缘 **359** > 市场区右缘 **342**(超 17px,被裁掉,用户看到的缺字就是它)。
604
+ // 给它一条折行就够(同一次实测):标题回到 **63×24**、按钮回到 **92 / 87 / 116** 宽、
605
+ // 溢出 17px → **0**,整行 96 → 100px 高(换成三行,高度几乎没变)。
606
+ // **选择器锁在市场根里面**(`[data-dsh-market-root]` 是它自己的固定标记):
607
+ // 只碰这一行,别的布局一条都不动;它以后改了类名,这条就自动失配而不是改坏别人。
608
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_titleRow"]{flex-wrap:wrap !important}'
609
+ // ⑥ **进了这一节,把被它藏掉的导航要回来**。它自带一条窄屏规则:
610
+ // `@media (max-width:560px){ [role="dialog"]:has([data-dsh-market-root]) > nav{display:none} }`
611
+ // ——进了「插件市场」,整条导航就被它藏了(实测 nav 计算 `display:none`、0×0;
612
+ // 用户截图里那排图标也没了),而且**没有导航就切不回别的节**。
613
+ // 那是它给「手机上的整页市场」写的,在这一页不该生效(方向:这一页的适配由我们定)。
614
+ // 它的特异性 (0,2,1);两边都带 `!important` 时**特异性高的赢**(这一条我们领教过多次),
615
+ // 所以这里写到 (0,3,2)。`:has(…)` 保证只在它真的在页面里时才出手。
616
+ // 实测:加上之后 nav 回到 **342×55**,和别的节排布完全一致。
617
+ + SETTINGS_PANEL + '[role="dialog"]:has([data-dsh-market-root]) > nav{display:flex !important}'
618
+ // ⑦ **同一节那排页签也要能划**:它 7 个页签总宽实测 **427px**(scrollWidth),
619
+ // 给它的只有 **286px**;偏偏它自己不滚也不折行(`.tabs{display:flex;gap:2px}`),
620
+ // 于是右边三个页签**被裁掉**(实测最右页签右缘 **479** > 市场区右缘 **342**,
621
+ // 用户截图上「已屏」后面就断了)。给它一条横向滚动,裁掉的页签划得出来。
622
+ // 只加滚动,不动它的大小和间距——页签条的高度和别处一样。
623
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_tabs"]{'
624
+ + 'overflow-x:auto !important;overflow-y:hidden !important}'
625
+ // ⑧⑨⑩⑪⑫⑬⑭ **把「插件市场」里那块能滚的列表窗口做大**(2026-10-07 逐项在真面板上量的)。
626
+ //
627
+ // 用户报:「显示那些供下载的插件的窗口高度还是太矮了」。先在活页面上把这一节的纵向账
628
+ // 逐项量了一遍(390×844,量的是 `[data-dsh-market-root]` 里那个 `_body` 的 clientHeight,
629
+ // 也就是**能滚的那个窗口**):
630
+ // 面板高 798 里,面板上下居中各留掉 23;我们的导航 55;内容区那条头(打开配置文件 + X)54;
631
+ // 市场自己的 `_head` **307**(标题行 100 + 社区那格 56 + 页签 39 + 提示条 66 + 留白 46);
632
+ // 列表区自己上下留白 36;吸附的搜索/分类 116 —— 最后**只剩 356px** 给列表。
633
+ // 下面的七条都是「该省的重复信息和富余留白」,**没有一条去藏有用的东西**:
634
+ // 那三个按钮、搜索框、分类筛选、提示条、页签、导出日志全部照旧在(⑪ 藏掉的三样是
635
+ // 图标、仓库名 `dsh-market`、版本号 `v1.66.6`——用户已经知道自己在哪一节,这三样是重复的)。
636
+ //
637
+ // **全部锁在市场根里**(`:has([data-dsh-market-root])` 或 `[data-dsh-market-root]`):
638
+ // 活服务上实测过「切到别的节之后市场根会被**卸载**」(`_options` 下挂着的市场数从 1 变 0),
639
+ // 所以 `:has()` 只在真的停在这一节时才命中,别的节一个像素都不会动。
640
+ //
641
+ // ⑧ **面板本身撑高**:官方给面板的高度是 `min(800px, calc(100vh - 48px))` 再上下居中,
642
+ // 在手机上白留掉上下各 23px。面板的父级是 `position:fixed; inset:0` 的浮层(实测 844 高),
643
+ // 所以 `calc(100% - 10px)` 有确定的参照。实测 **798 → 836,列表窗口 +38px**。
644
+ // 只在市场节生效——别的节照旧是官方那个高度。
645
+ + SETTINGS_PANEL + ':has([data-dsh-market-root]){height:calc(100% - 10px) !important;max-height:none !important}'
646
+ // ⑨ **内容区那条头的上内边距 20px → 6px**:官方 `.header{padding:20px 14px 8px 10px}`。
647
+ // 那 20px 只是留白(量过:nav 底到按钮顶之间**没有任何元素**,也没有伪元素、没有拖拽把手),
648
+ // 实测 **列表窗口 +14px**。
649
+ //
650
+ // **`height:auto` 那半句不能省,它才是这条规则能不能生效的关键**(第一版就漏了,
651
+ // 量出来一点没变):官方给这条头写的是 `box-sizing:border-box; height:54px`
652
+ // ——高度写死了,光改内边距它纹丝不动(量到改前改后都是 54px,这条规则等于没写)。
653
+ // 把高度交还给内容(28px)+ 内边距(6+6)之后才真的收到 40px。
654
+ + SETTINGS_PANEL + ':has([data-dsh-market-root]) [class*="_content"] > [class*="_header"]{'
655
+ + 'padding:6px 14px 6px 10px !important;height:auto !important}'
656
+ // ⑩ **`_options` 那 24px 下内边距在这一节是白留的**:官方 `._options{padding:0 24px 24px}`,
657
+ // 而市场根是 `height:100%`——正好量到「市场根 663 + 24 = 选项区 687」,
658
+ // 这 24px 谁也用不到。实测去掉之后**列表窗口 +24px**。
659
+ // **只去掉下边那一条**,左右那 24px 留着(那是内容的左右留白)。
660
+ // 按「内容区的直接子元素」点它(`[class*="_options"]` 这个子串在整棵面板里未必只有一个)。
661
+ + SETTINGS_PANEL + ':has([data-dsh-market-root]) [class*="_content"] > [class*="_options"]{'
662
+ + 'padding-bottom:0 !important}'
663
+ // ⑪ **标题行里重复的信息藏掉**:前图标(22px)、仓库名 `dsh-market`、版本号 `v1.66.6`。
664
+ // 用户定的线是「该省的只有重复的信息」——这一节在哪、叫什么,导航格子上已经写着并且高亮着。
665
+ // 省下的宽度让两件事发生(实测):那一行从**三行 100px 收到两行 66px**,
666
+ // 「更新插件市场 / 全部更新 (7)」两个按钮回到标题同一行(**列表窗口 +34px**)。
667
+ // **三个按钮一个都不动**,`重启前都不再提醒` 也还在(它落到第二行)。
668
+ // **后两样都再写一遍 `_titleRow`**(不是省字):`[class*="…"]` 是**子串**匹配,
669
+ // `_version` 这种名字要是不限定在标题行里,将来市场给卡片里的版本号起个
670
+ // `xxx_version` 的类名,就会被这条规则连卡片内容一起藏掉——那是「压扁/截断卡片」,
671
+ // 用户明确不许。⑬ 那条 `_cats` 已经栽过一次(见那里的注释),这里先写死。
672
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_titleRow"] > svg,'
673
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_titleRow"] [class*="_repoLink"],'
674
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_titleRow"] [class*="_version"]{display:none !important}'
675
+ // ⑫ **市场头部那几块之间的留白**:`gap 12px → 6px`、`padding 4px 4px 6px → 4px 4px 2px`。
676
+ // 四块之间本来有三道 12px 的空档(36px),收成 6px。实测 **列表窗口 +22px**。
677
+ // 用**直接子元素** `>`:`_head` 这个名字短,`[class*="_head"]` 会连 `_header` 一起命中
678
+ // (官方面板那条头就叫 `_header`),所以这里按「市场根的直接子元素」来点它。
679
+ + SETTINGS_PANEL + ' [data-dsh-market-root] > [class*="_head"]{gap:6px !important;padding:4px 4px 2px !important}'
680
+ // ⑬ **吸附头的留白**(搜索那一行 + 分类那一行,它 `position:sticky` 钉在列表顶上,
681
+ // 所以它每高 1px,**看卡片的地方就少 1px**,而且是**一直在少**):
682
+ // `_tabSearchRow{padding:0 4px 12px}` → 下边 6px;`_cats{padding:12px 4px 4px}` → `6px 4px 2px`。
683
+ // 实测吸附头 **116 → 102**,看卡片的地方 +14px。**搜索框和分类筛选一个都不动**,只收留白。
684
+ //
685
+ // **第一版写的是 `[class*="_cats"]`,量出来反而高了 8px**(72 → 80):子串匹配把
686
+ // `_catsRow`(56 → 72)和 `_catsWrap`(56 → 64)也一起命中了,三层各自多了一层内边距。
687
+ // 所以这两条都按**吸附头的直接子元素**点(`_stickyHead > _tabSearchRow` / `_stickyHead > _cats`)。
688
+ // ——跟本文件开头那些「按后缀命中」的教训是同一个坑,只是这次是我们自己踩的。
689
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_stickyHead"] > [class*="_tabSearchRow"]{'
690
+ + 'padding-bottom:6px !important}'
691
+ + SETTINGS_PANEL + ' [data-dsh-market-root] [class*="_stickyHead"] > [class*="_cats"]{'
692
+ + 'padding:6px 4px 2px !important}'
693
+ // ⑭ **列表滚动区自己的上下留白**:`padding:12px 4px 24px` → `6px 4px 12px`。
694
+ // 这两条**不改变滚动窗口的高度**(窗口高是外面 flex 算好的),改的是**窗口里能看见卡片的净高**:
695
+ // 实测看卡片的地方 **+18px**(上 6 + 下 12)。列表有 5000 多 px,下边那点留白只在滚到底时才看得见。
696
+ // 同样用直接子元素(`_body` 也是短名字)。
697
+ + SETTINGS_PANEL + ' [data-dsh-market-root] > [class*="_body"]{'
698
+ + 'padding-top:6px !important;padding-bottom:12px !important}'
699
+ // ⑯ **「社区介绍」那一整格都藏**(2026-10-07 用户第二次点名:「导出日志也可以隐藏」)。
700
+ //
701
+ // 真面板上把这一格拆开量了一遍(390×844,`[data-dsh-market-root] > _head > _sub`,
702
+ // 整格 56px 高,四件东西):
703
+ // ① 介绍文字 `span`——**一个 class 都没有**(这一格里唯一没有 class 的元素),
704
+ // 188×36(两行 18px),**整格的高度就是它撑起来的**;
705
+ // ② 「申请收录插件 ↗」`a._submitLink`——188×18,**给插件作者用的入口**;
706
+ // ③ `span._grow`——空占位,这一档宽度下它自己就是 `display:none`,不占高;
707
+ // ④ 「导出日志」`button._exportLogBtn`——90×28,**排查问题时用的按钮**。
708
+ //
709
+ // **上一轮只藏前两件、把导出日志留着**(那是我们主动做的偏离,用户当时没点名它);
710
+ // 这一轮用户点名「导出日志也可以隐藏」,四件一起走。实测(`scratch/mkt-hide2.txt`):
711
+ // 上一轮(只藏前两件)→ 那一格 28px、列表窗口 **526px**、看卡片 **406px**;
712
+ // **这一轮(整格都藏)→ 那一格 0px、列表窗口 **560px**、看卡片 **440px**。
713
+ // **+34 = 导出日志那 28px + `_head` 那一道 6px 的间隔**。
714
+ // 这也是为什么藏整格比「只把 `_exportLogBtn` 加进隐藏名单」更划算,多赚那 6px:
715
+ // 那一格留在 DOM 里哪怕高度是 0,`_head` 的 flex gap 照样占一道。
716
+ //
717
+ // 两道锁,都是子串匹配踩过的坑:
718
+ // · `[class*="_sub"]` **会同时命中 `_submitLink`**(`_sub` 是它的前缀子串)。
719
+ // 所以按「市场根 > `_head` > `_sub`」的**直接子元素**链来点:那个 `a` 不在这条链上,
720
+ // 自然不匹配;`_head` 也必须带 `>`(`[class*="_head"]` 还会命中官方面板那条
721
+ // `_header`,⑫ 已经栽过一次)。
722
+ // · 再加一道 `:has(> [class*="_submitLink"])`,要求「这一格确实是我们量过的那个形状」——
723
+ // **整格藏比只藏两个子元素下手更重**,更该有这道闸:将来市场改结构时**失配**
724
+ // (什么都不藏)比**误伤**(藏了别人)好。
725
+ + SETTINGS_PANEL + ' [data-dsh-market-root] > [class*="_head"] > [class*="_sub"]'
726
+ + ':has(> [class*="_submitLink"]){display:none !important}'
727
+ // ⑰ **共用那条「内容区头」里的动作区**(2026-10-07 用户点名:「打开配置文件也可以隐藏」)。
728
+ // **这一条故意不带市场限定**——理由见文件开头 ⑰ 那一段:那条头是面板自己的,
729
+ // 16 个设置节实测一模一样,`_actions` 里**只有「打开配置文件」一个按钮**。
730
+ // 所以它影响**每一个**设置节,藏的是「每个节都能开配置文件」这个入口。
731
+ // **收益 0**(真面板实测:头高 40/54、内容区高、能滚的像素三个数前后一个都没变):
732
+ // 那一条头的高度本来就是 ✕ 撑着的。
733
+ // 用「内容区的直接子元素」把 `_header` 钉住,再点它的直接子 `_actions`——
734
+ // `_action` 这个子串在别的插件里也可能出现,链子写全才锁得住。
735
+ + SETTINGS_PANEL + ' [class*="_content"] > [class*="_header"] > [class*="_actions"]{display:none !important}'
736
+ // 右对齐这一条是**给上面那条规则自己的副作用收尾**:官方那条头是 `space-between`,
737
+ // 而动作区自己带 `margin-left:auto`(实测解出来 188px)、被它顶在最右边;动作区一藏,
738
+ // 只剩 ✕ 一个孩子,`space-between` 就把它放到最左边——✕ 从 x=324 跳到 x=34。
739
+ // ✕ 是用户唯一能退出面板的出口,不该因为它左边那件东西消失就换到另一头。
740
+ // 实测加上之后 ✕ 回到 **x=324**,28×28 原位、真指针点得中、点了面板就关。
741
+ + SETTINGS_PANEL + ' [class*="_content"] > [class*="_header"]{justify-content:flex-end !important}'
742
+ // ⑱ **侧栏自己不能滚 → 最底下的「设置」永远够不着**(2026-10-07 在活页面上量出来的)。
743
+ //
744
+ // 用户报:「导航栏其实没显示全,设置按钮是看不到的,能否搞个滚动条」。截图里侧栏
745
+ // (deepseek 标志 / 新会话 / 插件 / 自动化任务 / 任务看板 / 记忆系统 / 余额 / Go 5h /
746
+ // 今日 / 法定假日 / 导入会话 / 有更新 / 今日用量)一路排到屏幕外,而「设置」在侧栏最底下。
747
+ //
748
+ // **逐层量下来的实况**(390×660 的外层视口、框内 **599**、侧栏展开):
749
+ // · 侧栏那一列 `[data-pane="sidebar"]`(官方的 `_sidebarCol`,`position:absolute`):
750
+ // rect 321×599、`overflow-y:hidden`,而 **scrollHeight 696 / clientHeight 599
751
+ // ——有 97px 被裁掉**;
752
+ // · 里面那层 `_2H3hWW_root`(`height:100%` 的竖排 flex):`overflow-y:visible`,
753
+ // 它自己的 scrollHeight 也是 696 —— **内容确实比框高,但两层都不滚**;
754
+ // · 那 696px 是三块 `flex:0 0 auto` 的固定高度加起来的
755
+ // (logoRow 60 + newSession 38 + panelList 156 + footArea 412),
756
+ // 中间那块 `_regionArea`(`flex:1 1 0%`)**早就被压到 0 了**——它已经让完了;
757
+ // · 最底下的 `_settingsArea` 在 646..696,**「设置」那两个字在 660..682,
758
+ // 整条都在 599 这条线下面**,用户怎么点都点不到。
759
+ //
760
+ // 这**不是** ④ 那种「高度链断在某一层」(缺 `min-height:0`):这里每一层的高度都对,
761
+ // **缺的是一个滚动容器**。官方侧栏是按桌面窗口设计的(它自己在 root 上写了
762
+ // `--dsh-scrollbar-thumb` 那套变量,说明滚动本来是它预期里的东西),
763
+ // 窗口高 ≥ 696 时看不出问题;手机框内只有 ~600,就露馅了。
764
+ //
765
+ // 修法:**只把那一列自己的 `overflow` 从 hidden 放开成「竖着能滚、横着照旧裁」**。
766
+ // 选这一层是因为**它才是那个 scrollHeight 已经大于 clientHeight 的层**(696 vs 599),
767
+ // 也正是那个写着 `overflow:hidden` 的层——数据指向它,别的层一个都不动。
768
+ // 用 `[data-pane="sidebar"]`(官方渲染在侧栏那一列上的固定标记,全文档实测只有 1 个),
769
+ // 不按类名哈希猜;里面那层 `_root` 的类名是「哈希_名字」,每次构建都变。
770
+ //
771
+ // `overflow-x:hidden` **必须一起写**:侧栏收起成图标条时(那一列只有 52px 宽、
772
+ // 里面的 root 仍是 280px),横向本来就是靠裁的——放开成 auto 会平白多出一条横向滚动。
773
+ // 实测收起态 `scrollWidth 103 / clientWidth 52 / 纵向 599 对 599(本来就不用滚)`,前后一致。
774
+ + '[data-pane="sidebar"]{overflow-y:auto !important;overflow-x:hidden !important}'
775
+ // ⑲ **中间那块 `_regionArea` 被压成 0 → 「工作区 / 历史会话」整块看不见**(2026-10-08 量的)。
776
+ //
777
+ // 用户报:「电脑端界面的导航栏,现在看不到历史会话了」,截图里「记忆系统」下面是一整块空白。
778
+ // ⑱ 让那一列能滚之后,**第一个被让掉的就是这一层**:它是那一列里唯一的 `flex:1 1 0%`
779
+ // (另外三块 logoRow / newSession / panelList 和底下的 footArea 都是 `flex:0 0 auto`),
780
+ // 列里一没富余,它先缩到 0。
781
+ //
782
+ // 活页面上量到的实况(390×660 外层视口、框内 **599**、侧栏展开;真机 390×~579 的框一致):
783
+ // · `_regionArea`(`_2H3hWW_regionArea`)rect **[8,284,272,0]**、
784
+ // `scrollHeight 58 / clientHeight 0`、`overflow:hidden`、`flex:1 1 0%`、`min-height:0`
785
+ // ——**高 0**;
786
+ // · 它里面装的「工作区」那一整节**一样没少,只是被压没 + 裁干净**:
787
+ // `_sectionHeader` **[12,286,260,36]**(「工作区」标签 + `_searchButton`「搜索会话」
788
+ // + `_iconButton`「视图选项」/`_iconButton`「添加工作区」)
789
+ // └ `_listArea`(`flex:1 1 0%` + `min-height:0`)rect `[8,326,272,0]`
790
+ // └ `_treeBody` rect `[12,326,268,0]`
791
+ // └ **`_list`(aria-label「会话」)= 会话列表本体**:`overflow-y:auto`、
792
+ // `scrollHeight 1722`、窗口只有 **16px**;里面 7 条 `sessionRow`(每条 32px,
793
+ // 首行 `[12,400,256,32]`、末行 `[12,604,256,32]`)+「展开其余 2 个会话」
794
+ // (`_sessionOverflowButton`)+渐隐条(`_fade`)。
795
+ // · 于是**同一份内容被裁了两次**:外面那层高 0、里面那个列表窗口只有 16px——
796
+ // 改前实测「列视口内 **0 条**会话」。
797
+ //
798
+ // 修法(**用户点名要的那一路:不参与压缩、列表全排出来、往下滑交给整条侧栏**):
799
+ // **`flex:0 0 auto`——这一层不再参与压缩,高度按内容走。**
800
+ // · **为什么这一条就够**(逐层实测,改前 → 这一版):
801
+ // `_regionArea` 0 → **1764**(`sh 1764 / ch 1764`)
802
+ // `_listArea` 0 → **1722**(`flex:1 1 0%` 的百分比基准这时是「不确定的高度」,按内容算)
803
+ // `_treeBody` 0 → **1722**
804
+ // `_list` 窗口 16 → **1722**=它自己的 `scrollHeight`(**能滚量 0,不再自己滚**)
805
+ // 整条列 `scrollHeight 696 → 2460`、`can 97 → 1861`;一眼看得见 **0 → 6 条**会话(共 7 条)。
806
+ // · **候选写法量的是同一组数**(`_regionArea` 高 / `_list` 窗口 / 列能滚):
807
+ // `flex:0 0 auto` 1764 / 1722 / 1861 ← 取这一条
808
+ // 再加 `min-height:0` 1764 / 1722 / 1861(官方原值就是 `0`,白加)
809
+ // 再加 `height:auto` 1764 / 1722 / 1861(本来就是 auto,白加)
810
+ // `flex:1 0 auto`(留下「有富余就长」) 1764 / 1722 / 1861(这一列 2460 > 599,
811
+ // **永远没有富余**,grow 分不到东西——留着也没用,所以取最简的 `0 0 auto`)
812
+ // 四个候选「设置够不够得着」也一样(滚到底 rect `[42,563,28,22]`、点得开)。
813
+ // 这就是「用数据选、不猜」:四条写法的数字一字不差,取最短的那条。
814
+ // · **甲那条 `min-height` 一条都不留**:它只让这一层「露一点头」(列表窗口 158、
815
+ // 能滚量 1564、只看得到 3 条),和「整条排出来」是两条路——两条一起写就是两套写法打架。
816
+ // · **链上别的层一个都不动**:候选里再加 `_listArea{flex:0 0 auto}` + 把 `_list` 的
817
+ // `overflow-y` 关掉,`_regionArea` 1764 / `_listArea` 1722 / 列 2460 / 设置那几个数
818
+ // **一个都没变**——那两条是死规则(`_list` 窗口本来就是 1722=内容高,不用再关它)。
819
+ // · **不给这一层写 `overflow-y:auto`**:铺开之后它自己也没有可滚量(`sh 1764 / ch 1764`)。
820
+ // · **不为收起态写例外**:收起成图标条时官方本来就把这一层 `display:none`
821
+ // (实测收起态 `areaDisp:"none"`、`areaRect 0×0`);`flex` 再怎么写也显示不出一个
822
+ // `display:none` 的元素(收起态列 `scrollWidth 54 / clientWidth 52`、纵向 `599/599`,
823
+ // 改前 / 甲 / 这一版三个阶段**一个数都没动**,`scrollLeft` 赋 99999 都停在 2)。
824
+ //
825
+ // **代价(用户已知并选它)**:所有会话排出来之后这一列有 **2460px**,599 的框要滚
826
+ // **1861px**(3 屏多)才够得着最底下的「设置」——甲那一档只多滚 200px(297 对 97)。
827
+ // 换来的是「会话一眼看全、整列只有一条滚动条」。
828
+ //
829
+ // 按后缀命中类名(`_regionArea`):前缀「哈希_」每次构建都变,只有后半段稳——同 ⑱。
830
+ + '[class*="_regionArea"]{flex:0 0 auto !important}'
831
+ + '}'
832
+ + '</style>'
833
+
834
+ /**
835
+ * **诊断模式**:只在 `?diag=1` 时注入(开关在 `lib/server.js` 的镜像路由那一段)。
836
+ *
837
+ * ## 为什么非要让真机自己报数据
838
+ *
839
+ * 「进设置后导航栏只有图标、随便点一下文字才出来」这件事,2026-10-06 之前所有结论
840
+ * **都是用一个假面板(拿官方真实类名拼的)在本地量出来的**,量不到真机上的运行时状态:
841
+ * 运行时挂上去的行内样式、`color` / `-webkit-text-fill-color` / `opacity` / `visibility`、
842
+ * 字体有没有到、有没有透明层盖在字上面。本地又复现不了(无头浏览器里那个「设置」按钮
843
+ * 在折叠侧栏内、尺寸 0×0,点不开;程序化点开之后页面又因加载十几个设置小节卡死)。
844
+ * **那就让真机自己把数据报回来。**
845
+ *
846
+ * ## 一次打开就定死根因:采几批样本对比
847
+ *
848
+ * · `baseline` 面板出现 4 秒后、**用户还没碰屏幕**时的样子(避开入场动画);
849
+ * · `pointerdown` 手指按下那一刻,**capture 阶段同步取**——早于页面任何处理;
850
+ * · `after-click-*` 按下之后 0.3 / 1.2 / 3 秒的样子,看到底是哪个属性变好了。
851
+ *
852
+ * 每批都带上**同一个格子里那个 `svg` 图标**做对照:图标看得见、字看不见,
853
+ * **两者的差异本身就是最强的线索**;再沿祖先链逐层量一遍,看宽度塌在哪一层。
854
+ *
855
+ * ## 上报口与日志
856
+ *
857
+ * POST 回我们自己的 `/mini/api/mirror-diag`(同源,靠入口那一下写下的 cookie 过关),
858
+ * 服务端追加写到 `scratch/diag.log`。**用户不用看日志**,开发者来读。
859
+ *
860
+ * 屏幕上那条黑底小条(`pointer-events:none`,贴在屏幕底部)是给用户看的:
861
+ * 告诉他「什么时候该点」以及「上报成没成功」——他只做两件事:打开、点一下。
862
+ * **贴在底部**是必须的:压在导航条上的话,命中测试会先命中它自己,
863
+ * 「有没有透明层盖着字」那一条就白测了;下面 `stack()` 里还把它自己过滤掉。
864
+ *
865
+ * ## 注入顺序
866
+ *
867
+ * 排在 `ADAPT_CSS` **之后**:量到的必须是「我们的适配已经生效」之后的样子,
868
+ * 那才是用户实际看到的状态。
869
+ */
870
+ const DIAG_SCRIPT = `<script>(function(){
871
+ var LOG = '/mini/api/mirror-diag';
872
+ var PANEL = '[data-shortcut-modal="settings"]';
873
+ var CHIP = 'mini-mirror-diag-chip';
874
+ var session = Math.random().toString(36).slice(2, 10);
875
+ var sent = {};
876
+ var chip = null;
877
+
878
+ function say(text){
879
+ try {
880
+ if (!chip) {
881
+ chip = document.createElement('div');
882
+ chip.id = CHIP;
883
+ chip.setAttribute('style', 'position:fixed;left:0;right:0;bottom:0;z-index:2147483647;'
884
+ + 'background:rgba(17,24,39,.92);color:#fff;font:12px/1.6 sans-serif;padding:6px 10px;'
885
+ + 'pointer-events:none;text-align:center');
886
+ (document.body || document.documentElement).appendChild(chip);
887
+ }
888
+ // body 是后来才建出来的(这段脚本插在 head 最前面),建出来就把小条挪进去。
889
+ if (document.body && chip.parentElement !== document.body) document.body.appendChild(chip);
890
+ chip.textContent = text;
891
+ } catch (e) {}
892
+ }
893
+
894
+ function round(n){ return Math.round(n * 10) / 10 }
895
+ function rect(el){ var r = el.getBoundingClientRect();
896
+ return { x: round(r.x), y: round(r.y), w: round(r.width), h: round(r.height) } }
897
+ function name(el){
898
+ if (!el) return null;
899
+ var c = el.className;
900
+ if (c && c.baseVal !== undefined) c = c.baseVal; // svg 的 className 是个对象
901
+ return el.tagName + '.' + String(c || '').slice(0, 120);
902
+ }
903
+ /** 这个点上从上到下压着谁——用来发现「透明层盖在字上面」。 */
904
+ function stack(el){
905
+ if (!el) return null;
906
+ var r = el.getBoundingClientRect();
907
+ var x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
908
+ var list = [];
909
+ try { list = document.elementsFromPoint(x, y) } catch (e) { return ['elementsFromPoint 不可用'] }
910
+ return list.filter(function (e) { return e && e.id !== CHIP })
911
+ .slice(0, 5).map(function (e) { return name(e) });
912
+ }
913
+ function styleOf(el){
914
+ var s = getComputedStyle(el);
915
+ return {
916
+ color: s.color, webkitTextFillColor: s.webkitTextFillColor, opacity: s.opacity,
917
+ visibility: s.visibility, display: s.display, width: s.width, height: s.height,
918
+ overflow: s.overflow, overflowX: s.overflowX, whiteSpace: s.whiteSpace,
919
+ textOverflow: s.textOverflow, flex: s.flex, minWidth: s.minWidth, position: s.position,
920
+ clip: s.clip, webkitBackgroundClip: s.webkitBackgroundClip,
921
+ backgroundClip: s.backgroundClip, mixBlendMode: s.mixBlendMode, filter: s.filter,
922
+ transform: s.transform, fontFamily: s.fontFamily, fontSize: s.fontSize,
923
+ fontWeight: s.fontWeight, lineHeight: s.lineHeight, letterSpacing: s.letterSpacing,
924
+ textIndent: s.textIndent, textRendering: s.textRendering, colorScheme: s.colorScheme,
925
+ zIndex: s.zIndex,
926
+ };
927
+ }
928
+ function box(el){
929
+ return { what: name(el), rect: rect(el), inline: el.getAttribute('style'),
930
+ offset: { w: el.offsetWidth, h: el.offsetHeight },
931
+ scroll: { w: el.scrollWidth, h: el.scrollHeight },
932
+ client: { w: el.clientWidth, h: el.clientHeight } };
933
+ }
934
+ /** 沿祖先链逐层量——看宽度到底塌在哪一层。 */
935
+ function chain(el){
936
+ var out = [], n = el, guard = 0;
937
+ while (n && n.nodeType === 1 && guard < 10) {
938
+ var s = getComputedStyle(n);
939
+ out.push({ what: name(n), rect: rect(n), display: s.display, flex: s.flex,
940
+ minWidth: s.minWidth, width: s.width, overflow: s.overflow, overflowX: s.overflowX,
941
+ position: s.position, zIndex: s.zIndex, opacity: s.opacity, visibility: s.visibility });
942
+ n = n.parentElement; guard += 1;
943
+ }
944
+ return out;
945
+ }
946
+ function fonts(){
947
+ var faces = [];
948
+ try {
949
+ document.fonts.forEach(function (f) {
950
+ if (faces.length < 20) faces.push(f.family + ' / ' + f.weight + ' / ' + f.status);
951
+ });
952
+ } catch (e) {}
953
+ return { status: document.fonts.status, size: document.fonts.size, faces: faces };
954
+ }
955
+
956
+ function collect(phase){
957
+ var out = { session: session, phase: phase, at: Date.now(),
958
+ since: round(performance.now()), ua: navigator.userAgent,
959
+ viewport: { w: innerWidth, h: innerHeight, dpr: devicePixelRatio,
960
+ narrow: matchMedia('(max-width:640px)').matches,
961
+ vv: window.visualViewport
962
+ ? { w: round(visualViewport.width), h: round(visualViewport.height), scale: round(visualViewport.scale) }
963
+ : null },
964
+ visibility: document.visibilityState };
965
+ var panel = document.querySelector(PANEL);
966
+ if (!panel) { out.panel = null; return out }
967
+ out.panel = { rect: rect(panel) };
968
+ var label = panel.querySelector('[class*="_navLabel"]'); // 只看**第一个**
969
+ out.labelCount = panel.querySelectorAll('[class*="_navLabel"]').length;
970
+ if (label) {
971
+ out.label = box(label);
972
+ out.label.text = label.textContent;
973
+ out.label.textLen = String(label.textContent || '').length;
974
+ // 字在不在 DOM 里,和「画没画出来」是两件事——先分开。
975
+ out.label.textNodes = (function () {
976
+ var n = 0;
977
+ for (var i = 0; i < label.childNodes.length; i += 1) {
978
+ if (label.childNodes[i].nodeType === 3) n += 1;
979
+ }
980
+ return n;
981
+ })();
982
+ out.label.clientRects = label.getClientRects().length;
983
+ out.label.computed = styleOf(label);
984
+ out.label.hitAtLabel = stack(label);
985
+ out.label.html = String(label.outerHTML || '').slice(0, 500);
986
+ }
987
+ var cell = label ? label.closest('button,[class*="_navCell"]') : panel.querySelector('[class*="_navCell"]');
988
+ if (cell) { out.cell = box(cell); out.cell.hitAtCell = stack(cell) }
989
+ // 对照组:同一个格子里的图标。图标可见而文字不可见,差异就藏在这两坨里。
990
+ var icon = cell ? cell.querySelector('svg') : null;
991
+ if (icon) { out.icon = box(icon); out.icon.computed = styleOf(icon); out.icon.hitAtIcon = stack(icon) }
992
+ var list = panel.querySelector('nav[class*="_nav"]');
993
+ if (list) {
994
+ var ls = getComputedStyle(list);
995
+ out.nav = { what: name(list), rect: rect(list), display: ls.display,
996
+ flexDirection: ls.flexDirection, overflowX: ls.overflowX, flexWrap: ls.flexWrap,
997
+ scrollLeft: list.scrollLeft, scrollWidth: list.scrollWidth, clientWidth: list.clientWidth };
998
+ }
999
+ out.ancestors = chain(label || cell);
1000
+ out.fonts = fonts();
1001
+ return out;
1002
+ }
1003
+
1004
+ function post(phase, data){
1005
+ if (sent[phase]) return;
1006
+ sent[phase] = 1;
1007
+ fetch(LOG, { method: 'POST', headers: { 'content-type': 'application/json' },
1008
+ credentials: 'same-origin', body: JSON.stringify(data) })
1009
+ .then(function (r) {
1010
+ return r.text().then(function (t) {
1011
+ return { ok: r.ok, status: r.status, text: String(t || '').slice(0, 200) };
1012
+ });
1013
+ }, function (e) { return { ok: false, status: 0, text: String((e && e.message) || e) } })
1014
+ .then(function (res) {
1015
+ if (!res.ok) say('上报失败(' + phase + ' 那批):' + res.status + ' ' + res.text);
1016
+ else if (phase === 'baseline') say('首帧样本已上报 ✅ 现在请在设置页上随便点一下屏幕');
1017
+ else if (phase === 'after-click-3000') say('点击后的样本也上报了 ✅ 可以关掉这一页了');
1018
+ })
1019
+ .catch(function (e) { say('上报失败:' + String((e && e.message) || e)) });
1020
+ }
1021
+ function taken(phase){ post(phase, collect(phase)) }
1022
+
1023
+ function onPanel(){
1024
+ say('设置面板已出现,4 秒后取第一份样本(现在先别碰屏幕)…');
1025
+ setTimeout(function () { taken('baseline') }, 4000);
1026
+ var armed = false;
1027
+ function onDown(){
1028
+ if (armed) return;
1029
+ armed = true;
1030
+ document.removeEventListener('pointerdown', onDown, true);
1031
+ document.removeEventListener('touchstart', onDown, true);
1032
+ taken('pointerdown'); // 按下那一刻同步取,早于页面任何处理
1033
+ setTimeout(function () { taken('after-click-300') }, 300);
1034
+ setTimeout(function () { taken('after-click-1200') }, 1200);
1035
+ setTimeout(function () { taken('after-click-3000') }, 3000);
1036
+ }
1037
+ // pointerdown 是新标准;touchstart 兜一层,免得某个 WebView 只发后者。
1038
+ document.addEventListener('pointerdown', onDown, true);
1039
+ document.addEventListener('touchstart', onDown, true);
1040
+ }
1041
+
1042
+ say('诊断模式已开启,正在等设置面板出现…');
1043
+ var wait = setInterval(function () {
1044
+ if (!document.querySelector(PANEL)) return;
1045
+ clearInterval(wait);
1046
+ onPanel();
1047
+ }, 300);
1048
+ // 两分钟等不到就收手,别一直空转。
1049
+ setTimeout(function () { clearInterval(wait) }, 120000);
1050
+ })();</script>`
1051
+
1052
+ /** 把 `/remote` 前缀剥掉(见上面 REMOTE_CHANNEL_PREFIX 的说明)。 */
1053
+ function stripRemotePrefix(rawUrl) {
1054
+ if (rawUrl === REMOTE_CHANNEL_PREFIX) return '/'
1055
+ if (rawUrl.startsWith(`${REMOTE_CHANNEL_PREFIX}/`)) return rawUrl.slice(REMOTE_CHANNEL_PREFIX.length)
1056
+ return rawUrl
1057
+ }
1058
+
1059
+ /**
1060
+ * 把几样东西插进 HTML 的**最前面**:官方界面要的「你是主机」标记、
1061
+ * **我们自己**的适配(含「把别的插件的适配挡在门外」那一段),
1062
+ * 以及**只在诊断模式**(`?diag=1`)下额外加上去的那段采样脚本。
1063
+ *
1064
+ * 插在 `<head>` 之后:启动项是内联脚本、跑在 head 里,晚一步就读不到了。
1065
+ * 找不到 `<head>` 就退回插在开头——**宁可位置差一点,也不要什么都不插**:
1066
+ * 少了它,界面会把自己当成远程客户端,配置面整片不给。
1067
+ *
1068
+ * `diag` 缺省就是关的:**正常路径一个字节都不多**(这一条有测试钉着)。
1069
+ */
1070
+ function injectHostHook(html, diag) {
1071
+ const inject = HOST_HOOK + STRIP_FOREIGN_ADAPT + ADAPT_CSS
1072
+ + (diag ? DIAG_SCRIPT : '')
1073
+ const at = html.search(/<head[^>]*>/i)
1074
+ if (at >= 0) {
1075
+ const end = html.indexOf('>', at) + 1
1076
+ return html.slice(0, end) + inject + html.slice(end)
1077
+ }
1078
+ return inject + html
1079
+ }
1080
+
1081
+ /**
1082
+ * ## 窗口会话的寿命:**跟着这个宿主进程走**(2026-10-15 用户拍板)
1083
+ *
1084
+ * 原来写的是「12 小时」。那个数是凭空定的,而且会留下一种半死状态:面板开着超过
1085
+ * 12 小时再刷新,服务器这边会话已经作废、浏览器里那枚 cookie 还在,用户看到的
1086
+ * 是一条莫名其妙的 401。
1087
+ *
1088
+ * 现在**没有固定寿命**:进程一起来随机生成一串密钥(就是下面这个 `PROCESS_SECRET`),
1089
+ * 每一枚窗口会话都用它**签**过,形状是 `v1.<随机串>.<签名>`。判定只有一条——
1090
+ * **签名对不对**。换一个进程就是换一串密钥,于是**上一个进程签发的会话签名当场
1091
+ * 对不上,一律不认**。
1092
+ *
1093
+ * 这样做的三个好处,正好对上用户提的三条:
1094
+ * · **不落盘**:密钥只活在这个进程的内存里,重启即失效,不需要扫磁盘;
1095
+ * · **不需要定时清理**:会话里根本没有「到期时刻」这种字段,也就没有清理任务;
1096
+ * · **不引入新的固定寿命**:寿命就是「这个进程活着」这一件事。
1097
+ *
1098
+ * ## 会话里还签着「这一扇窗是谁的」(2026-10-16 第四步)
1099
+ *
1100
+ * 光有签名只说明「**是本进程签的**」,**不认是哪台设备**。于是有一处撤不掉的洞:
1101
+ * 把一台设备从名册里删掉之后,它**已经开着的那扇窗照旧能用**,一直到宿主重启为止。
1102
+ * 所以身份要签进会话体里:`v1.<随机串>~<身份>.<签名>`。
1103
+ *
1104
+ * 身份是两样之一(由兑票那一下决定,见 lib/server.js 的 ticketWindowOwner):
1105
+ * · **一台设备的 id**——名册里那条记录的 id(撤销就是删掉它,见 lib/devices.js);
1106
+ * · **`local`**——本机那一半(桌面端面板走的就是这一条,见 lib/server.js)。
1107
+ *
1108
+ * **签名仍然只有进程密钥**:换一个宿主进程,旧会话的签名照样当场对不上(上面那三条
1109
+ * 一个字都没变)。身份这一节**不是权限判据**——它只是让验完会话的人能**再回头查一次
1110
+ * 名册**:记录还在不在,由 lib/server.js 的 windowSession 每次现查。
1111
+ */
1112
+ const PROCESS_SECRET = randomBytes(32)
1113
+ const WINDOW_SESSION_VERSION = 'v1'
1114
+
1115
+ /**
1116
+ * 随机串和身份之间的分隔符。
1117
+ *
1118
+ * 挑 `~` 是因为 base64url 的字母表里没有它(`A-Za-z0-9-_`),所以切得干净;
1119
+ * 也用不着转义——两侧都是我们自己生成的(`randomBytes(...).toString('base64url')`
1120
+ * 和一个 16 字节随机数转的 hex,或者 `local`)。
1121
+ */
1122
+ const SESSION_ID_SEP = '~'
1123
+
1124
+ /**
1125
+ * 签一枚窗口会话:`v1.<随机串>[~<身份>].<签名>`。
1126
+ *
1127
+ * @param {string} [deviceId] 这一扇窗是谁的:设备 id 或 `local`。
1128
+ * **给空就签一枚不带身份的会话**(老形状);生产路径上不会出现——
1129
+ * lib/server.js 兑票那一下拿不到身份就干脆不兑现(见 ticketWindowOwner)。
1130
+ */
1131
+ function mintWindowSession(deviceId) {
1132
+ const nonce = randomBytes(32).toString('base64url')
1133
+ const who = typeof deviceId === 'string' ? deviceId : ''
1134
+ const body = `${WINDOW_SESSION_VERSION}.${who ? `${nonce}${SESSION_ID_SEP}${who}` : nonce}`
1135
+ return `${body}.${createHmac('sha256', PROCESS_SECRET).update(body).digest('base64url')}`
1136
+ }
1137
+
1138
+ /** 验一枚窗口会话:形状对,而且签名是**这个进程**签的。 */
1139
+ function windowSessionValid(value) {
1140
+ if (typeof value !== 'string' || !value) return false
1141
+ const parts = value.split('.')
1142
+ if (parts.length !== 3 || parts[0] !== WINDOW_SESSION_VERSION) return false
1143
+ const body = `${parts[0]}.${parts[1]}`
1144
+ const expected = createHmac('sha256', PROCESS_SECRET).update(body).digest()
1145
+ const given = Buffer.from(parts[2], 'base64url')
1146
+ return given.length === expected.length && timingSafeEqual(given, expected)
1147
+ }
1148
+
1149
+ /**
1150
+ * 这枚窗口会话是**谁的**:设备 id、`local`、`''`(签出来的老形状,没带身份),
1151
+ * 或者 `null`(形状不对/不是这个进程签的——**签名道理和 windowSessionValid 一样**)。
1152
+ *
1153
+ * **它本身不判权限**:签名对只说明「是本进程签的」。设备身份要拿去**回头查名册**
1154
+ * (见 lib/server.js 的 windowSession);记录不在了就当场拒——撤销靠的正是那一步。
1155
+ */
1156
+ function windowSessionDevice(value) {
1157
+ if (!windowSessionValid(value)) return null
1158
+ const payload = value.split('.')[1]
1159
+ const at = payload.indexOf(SESSION_ID_SEP)
1160
+ return at < 0 ? '' : payload.slice(at + 1)
1161
+ }
1162
+
1163
+ /**
1164
+ * 建一个镜像代理。
1165
+ *
1166
+ * @param {object} opts
1167
+ * @param {string | () => (string | null)} opts.upstream 上游地址(电脑端界面),
1168
+ * 如 `http://127.0.0.1:19387`。**可以传函数,而且推荐传函数**——宿主的 webServer
1169
+ * 服务就绪得晚,地址要到那一刻才知道(见 lib/index.js 里 mirrorUpstream 那段)。
1170
+ * @param {() => string | null} opts.tokenUrl 取「带官方令牌的地址」;拿不到返回 null。
1171
+ * 由插件注入(见 lib/index.js 里对 connection.authenticatedUrl 的调用)——
1172
+ * 这个模块**不认识宿主**,只认识一个返回字符串的函数,好测。
1173
+ * @param {(msg:string) => void} [opts.log]
1174
+ * @returns {{ handle, upgrade, reset, available, stats, issueTicket, redeemTicket, windowValid, windowDevice, ticketOwner, ticketGranted, ticketTtlMs }}
1175
+ */
1176
+ export function createMirror({ upstream, tokenUrl, log }) {
1177
+ /**
1178
+ * 「远程窗口」的两样一次性的东西:**票**和**票换来的窗口会话**。
1179
+ *
1180
+ * ## 为什么要有票
1181
+ *
1182
+ * 桌面端的「远程窗口」面板要把这一层嵌进 iframe,而进这一层的凭据是我们的
1183
+ * **令牌**。令牌一旦拼进 iframe 的地址,就会留在那台电脑的浏览历史里
1184
+ * (参考插件 `dsh-remote-web-ui` 就栽在这上面)。所以改成两步:
1185
+ *
1186
+ * ① 宿主那一半(本机回环上的软件)拿一张**一次性票**;
1187
+ * ② 浏览器带着票进一次门:票在那一刻兑现成一枚**窗口会话 cookie**(iframe 里
1188
+ * 那些请求——静态资源、`/api/*`、WebSocket——全靠它认人),而票自己
1189
+ * **只授权那一次**。地址里留着的那张票随后只用来回答「这一页是我嵌出来的吗」
1190
+ * (取景政策靠它,别的什么都打不开,见 `ticketGranted`)。
1191
+ *
1192
+ * 为什么要留票在地址里:iframe 会重新加载同一条地址,而「许不许被跨源嵌」这件事
1193
+ * 必须有个**cookie 之外**的判据——桌面端是跨站的,那枚 cookie 会被浏览器一路带着,
1194
+ * 光靠它就等于任何站点都能把这一页嵌进自己的页面(见 lib/server.js 的取景政策)。
1195
+ *
1196
+ * ## 票的规矩(照设计稿:短命、一次性、用完即废)
1197
+ *
1198
+ * · **短命**:30 秒。宿主拿到票到浏览器发出请求之间只差一次跳转,够用;
1199
+ * · **一次性**:`redeemTicket` 无论成败**先删再判**,并发来两次也只有一次能成;
1200
+ * · **换来的会话**:**没有固定寿命,跟着这个宿主进程走**(见上面 PROCESS_SECRET)。
1201
+ *
1202
+ * ## 票上还记着「这一扇窗是谁的」(2026-10-16 第五步)
1203
+ *
1204
+ * 跨机取票那条路上,**票是在对面那台电脑的浏览器里打开的**:那台浏览器
1205
+ * **没有设备凭据**(凭据只活在对面那个宿主进程里,永远不进浏览器),也**不是本机回环**。
1206
+ * 所以「兑票时看请求带了什么」这个判据在那条路上必然落空——身份只能在**发票那一刻**
1207
+ * 定下来,跟着票一起交给兑票那一下(见 lib/server.js 的 ticketWindowOwner)。
1208
+ *
1209
+ * **记在服务端这张表里,不是签进票里**:票本身照旧是一串 256 位随机数,设备 id
1210
+ * **不进对面浏览器的地址栏、不进浏览历史、也不进中间那几条链路的日志**——这条 URL
1211
+ * 正是我们一直在防泄漏的那一处。而一次性本来就得靠服务端记住「这张票用过了」,
1212
+ * 所以把主人寄存在这里**一个字节的状态都没多**。
1213
+ *
1214
+ * **窗口会话不是令牌**:它只够进镜像这一层(见 `lib/server.js` 里怎么用),
1215
+ * 令牌始终没进过浏览器。
1216
+ */
1217
+ const TICKET_TTL_MS = 30_000
1218
+ /** 两张表都不许无限长:正常情况下同时只有个位数条目。 */
1219
+ const MAX_ENTRIES = 64
1220
+
1221
+ /**
1222
+ * 还没兑现的票:`票 -> { expiresAt, owner }`。
1223
+ *
1224
+ * `owner` 就是**这一扇窗是谁的**(一台设备的 id,或者 `local`),由发票那一方当场
1225
+ * 定下(见 lib/index.js 的两条取票路由)。**兑票那一下原样取回来用**:它不判权限,
1226
+ * 名册那一关在 lib/server.js(每次放行都还要再查一次)。
1227
+ */
1228
+ const tickets = new Map()
1229
+ const grants = new Set() // 已经兑现过的票(只用于取景放行;进程一走就没了)
1230
+
1231
+ const randomId = () => randomBytes(32).toString('base64url')
1232
+ const dropExpired = (map, now) => {
1233
+ for (const [key, rec] of map) if (rec.expiresAt <= now) map.delete(key)
1234
+ }
1235
+
1236
+ /**
1237
+ * 发一张票(只可能是宿主那一半调用:本机的 `/mini-remote/window`、跨机的
1238
+ * `/mini-remote/remote/window`,两条都在 lib/index.js)。
1239
+ *
1240
+ * @param {string} [owner] **这一扇窗是谁的**:本机那一半给 `local`,跨机那条给
1241
+ * 刚验过的设备 id(见 lib/index.js 里两处的注释)。给空就发一张**无主的票**
1242
+ * ——它能被读到,但兑不出来(lib/server.js 定不下身份就不兑现,fail closed)。
1243
+ */
1244
+ function issueTicket(owner) {
1245
+ const now = Date.now()
1246
+ dropExpired(tickets, now)
1247
+ if (tickets.size >= MAX_ENTRIES) tickets.delete(tickets.keys().next().value)
1248
+ const ticket = randomId()
1249
+ tickets.set(ticket, {
1250
+ expiresAt: now + TICKET_TTL_MS,
1251
+ owner: typeof owner === 'string' ? owner : '',
1252
+ })
1253
+ return ticket
1254
+ }
1255
+
1256
+ /**
1257
+ * 这张票是**谁的**——**只看一眼,不消费**(消费是 `redeemTicket` 那一下)。
1258
+ *
1259
+ * 回 `''`(无主的票)、回 `null`(这张票不在表里:没发过、已经兑现过、或者过期被清了)。
1260
+ * **它本身不判权限**:lib/server.js 拿到之后还要按名册再查一次,本机那一半另外还要
1261
+ * 求"这一下是这台电脑自己发的"。
1262
+ *
1263
+ * 为什么要留这么一口「只看不消费」:身份**定不下来**时(无主、或者那台设备刚被撤销)
1264
+ * 票**不该被烧掉**——烧掉只让用户在网络抖一下之后白等 30 秒重新点一次,一个字节的
1265
+ * 安全都换不来(同一条道理见 lib/index.js 那条「限速只查不记」)。
1266
+ */
1267
+ function ticketOwner(ticket) {
1268
+ if (typeof ticket !== 'string' || !ticket) return null
1269
+ const rec = tickets.get(ticket)
1270
+ return rec ? rec.owner : null
1271
+ }
1272
+
1273
+ /**
1274
+ * 用票换窗口会话。**成不成这张票都不再能授权**(一次性)。
1275
+ * 回 `{ session }`,票不对/过期/已经用过一律回 `null`。
1276
+ *
1277
+ * `deviceId` 是**这一扇窗是谁的**(设备 id 或 `local`),由调用方判——判据不在
1278
+ * 这个模块里(它不认识名册,也不认识"回环"这件事,见 lib/server.js 的
1279
+ * ticketWindowOwner)。**调用方照 `ticketOwner(ticket)` 读出来的那个值传进来就行。**
1280
+ * **身份跟着会话一起被签**,所以它改不了:拿到会话的人没法把自己说成另一台设备
1281
+ * (见上面 SESSION_ID_SEP 那一段)。
1282
+ *
1283
+ * 兑现过的那张票会被记进 `grants`:它**不再有任何进门的能力**,只用来回答
1284
+ * 「这一页是我嵌出来的吗」(见 `ticketGranted`)。留着它是因为 iframe 会重新加载
1285
+ * 同一条地址——那时靠它放行取景,靠窗口会话进门。
1286
+ */
1287
+ function redeemTicket(ticket, deviceId) {
1288
+ if (typeof ticket !== 'string' || !ticket) return null
1289
+ const rec = tickets.get(ticket)
1290
+ // **先删再判**:并发拿同一张票来两次,第二次必然落空。
1291
+ tickets.delete(ticket)
1292
+ if (!rec || rec.expiresAt <= Date.now()) return null
1293
+ if (grants.size >= MAX_ENTRIES) grants.delete(grants.values().next().value)
1294
+ grants.add(ticket)
1295
+ return { session: mintWindowSession(deviceId) }
1296
+ }
1297
+
1298
+ /**
1299
+ * 这张票是不是**我们发过、而且已经兑现过**的。
1300
+ *
1301
+ * 只用来定取景政策:**「只有带合法票的请求才放行被嵌」**(设计稿门 1)。
1302
+ * 它本身什么都打不开——进门要的是窗口会话或者刚兑现的那一下。
1303
+ * 这样别有用心的站点就算把 `http://127.0.0.1:<端口>/mini/mirror/` 嵌进自己的页面,
1304
+ * 地址里没有票,取景政策就是「只许同源」,浏览器当场拒绝显示。
1305
+ */
1306
+ function ticketGranted(ticket) {
1307
+ if (typeof ticket !== 'string' || !ticket) return false
1308
+ return grants.has(ticket)
1309
+ }
1310
+
1311
+ /**
1312
+ * 这枚窗口会话还作不作数:**签名是这个进程签的**就算数(见 PROCESS_SECRET)。
1313
+ *
1314
+ * **它只管签名**:身份那一节要另外查名册,由 lib/server.js 的 windowSession
1315
+ * 拿 windowDevice() 的返回值去查(撤销的当场失效就落在那里)。
1316
+ */
1317
+ function windowValid(session) {
1318
+ return windowSessionValid(session)
1319
+ }
1320
+
1321
+ /** 这枚窗口会话是**谁的**(设备 id / `local` / `''`),不是本进程签的就回 null。 */
1322
+ function windowDevice(session) {
1323
+ return windowSessionDevice(session)
1324
+ }
1325
+
1326
+ /** 上游地址**解析一次就记住**:它的 Host 要用来签 cookie,不能每次变。 */
1327
+ let target = null
1328
+ let targetHost = null
1329
+
1330
+ function resolve() {
1331
+ if (target) return target
1332
+ const raw = typeof upstream === 'function' ? upstream() : upstream
1333
+ if (!raw) return null
1334
+ try {
1335
+ target = new URL(raw)
1336
+ } catch {
1337
+ return null
1338
+ }
1339
+ // 固定成上游自己的 host:port——官方 cookie 的「权威域名」按 Host 签,必须每次都一样。
1340
+ targetHost = target.host
1341
+ return target
1342
+ }
1343
+
1344
+ /**
1345
+ * 等上游地址就绪——**宿主刚起来那一小段里它是问不到的**。
1346
+ *
1347
+ * 2026-10-06 真机(用户报「又一直在转进不去」):重启完立刻打开镜像,我们问不到界面
1348
+ * 地址就回了 502;而那个界面外壳**拿到 502 不会重试**,于是永远卡在
1349
+ * 「Loading plugins…」——**等宿主起来了也不会自己好**,必须手动重开一次。
1350
+ * 这就是那句「重开一下就好了」的来历。所以这里等一下再试。
1351
+ *
1352
+ * 只在「还没解析出来」时等;一旦解析成功,`resolve()` 立刻返回,不引入任何延迟。
1353
+ */
1354
+ async function resolveReady(timeoutMs = 10000) {
1355
+ const deadline = Date.now() + timeoutMs
1356
+ for (;;) {
1357
+ const up = resolve()
1358
+ if (up) return up
1359
+ if (Date.now() >= deadline) return null
1360
+ await new Promise((r) => setTimeout(r, 200))
1361
+ }
1362
+ }
1363
+
1364
+ /** 官方会话 cookie。**只存在服务端**,绝不转发给手机。 */
1365
+ let cookie = null
1366
+ /** 正在铸的那一次;并发请求共用,不重复铸。 */
1367
+ let minting = null
1368
+ let mints = 0
1369
+ let lastError = null
1370
+
1371
+ function note(msg) {
1372
+ lastError = msg
1373
+ if (log) log(msg)
1374
+ }
1375
+
1376
+ /**
1377
+ * 走一次官方认证,把 cookie 拿到手。
1378
+ *
1379
+ * 拿到的是 303 + `set-cookie: dsh-auth-<hash>=v1.<payload>.<sig>`,
1380
+ * 我们只要那个 cookie,不要它跳转的目的地(跳转是给浏览器看的)。
1381
+ */
1382
+ function mint() {
1383
+ if (minting) return minting
1384
+ minting = new Promise((resolvePromise, reject) => {
1385
+ // **这里也要等**(和 forward 同一个道理):宿主刚起来时地址还问不到,
1386
+ // 而铸 cookie 是每次转发的前置步骤——这里立刻失败,前面那些等待就白等了。
1387
+ resolveReady().then((up) => {
1388
+ if (!up) {
1389
+ reject(new Error('这台电脑上找不到可用的电脑端界面地址。'))
1390
+ return
1391
+ }
1392
+ const url = tokenUrl ? tokenUrl() : null
1393
+ if (!url) {
1394
+ reject(new Error('这台电脑上的 DSH 没提供「带令牌的界面地址」的能力。'))
1395
+ return
1396
+ }
1397
+ let parsed
1398
+ try {
1399
+ parsed = new URL(url)
1400
+ } catch (err) {
1401
+ reject(new Error(`拿到的地址不是合法地址:${String(url).slice(0, 120)}`))
1402
+ return
1403
+ }
1404
+ const req = httpRequest({
1405
+ protocol: up.protocol,
1406
+ hostname: up.hostname,
1407
+ port: up.port,
1408
+ // 用带令牌的那条路径+查询去问(一般是 `/?token=xxx`)。
1409
+ path: `${parsed.pathname}${parsed.search}`,
1410
+ method: 'GET',
1411
+ // ① 固定 Host:cookie 的权威域名靠它,铸和用必须是同一份。
1412
+ headers: { host: targetHost, accept: 'text/html' },
1413
+ }, (res) => {
1414
+ const setCookie = res.headers['set-cookie']
1415
+ const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : [])
1416
+ // 只要官方那枚会话 cookie;`dsh-auth-` 是官方 BrowserAuth 里的 COOKIE_PREFIX。
1417
+ const hit = list.map((c) => String(c).split(';')[0]).find((c) => c.startsWith('dsh-auth-'))
1418
+ // 无论成没成都要把响应体读完,否则连接不释放。
1419
+ res.resume()
1420
+ res.on('end', () => {
1421
+ if (hit) resolvePromise(hit)
1422
+ else reject(new Error(`官方认证没有给出会话 cookie(状态 ${res.statusCode})。`))
1423
+ })
1424
+ })
1425
+ req.on('error', (err) => reject(new Error(`连不上电脑端界面(${up.host}):${err.message}`)))
1426
+ // 这一步是本机回环,不该拖太久。
1427
+ req.setTimeout(8000, () => req.destroy(new Error('官方认证超时')))
1428
+ req.end()
1429
+ }).catch(reject)
1430
+ }).then((value) => {
1431
+ cookie = value
1432
+ mints += 1
1433
+ lastError = null
1434
+ return value
1435
+ }).catch((err) => {
1436
+ note(err.message)
1437
+ throw err
1438
+ }).finally(() => {
1439
+ minting = null
1440
+ })
1441
+ return minting
1442
+ }
1443
+
1444
+ async function ensureCookie() {
1445
+ if (cookie) return cookie
1446
+ return mint()
1447
+ }
1448
+
1449
+ /** 把手机这一侧的请求头整理成给上游的。 */
1450
+ function upstreamHeaders(req) {
1451
+ const headers = {}
1452
+ for (const [k, v] of Object.entries(req.headers)) {
1453
+ const key = k.toLowerCase()
1454
+ if (HOP_BY_HOP.has(key)) continue
1455
+ // ② 我们自己那枚令牌的 cookie 不给上游;上游不认识它。
1456
+ if (key === 'cookie') continue
1457
+ // ③ **我们自己的凭据,一个请求头都不给上游**(2026-10-16)。
1458
+ //
1459
+ // cookie 那条路早就断了(上面②),头这两条原来照原样转出去:手机页每一趟
1460
+ // fetch 都带着 `X-Mini-Token`,设备身份那枚 `X-Mini-Device` 同理——
1461
+ // 转出去就是**把口令写进上游的日志和链路**。上游压根不认识它们,
1462
+ // 摘掉不影响任何功能(这是「凭据绝不跟着请求转发」那条规矩的前半截;
1463
+ // 后半截在 lib/server.js 的 stripOurSecrets,管的是地址里那几个参数)。
1464
+ if (key === 'x-mini-token' || key === 'x-mini-device') continue
1465
+ if (key === 'host') continue
1466
+ headers[key] = v
1467
+ }
1468
+ headers.host = targetHost
1469
+ // **`Origin` 也固定成上游自己。**
1470
+ //
1471
+ // 上游只认自己的来源,看到 `http://<手机那头>:3090` 就回 403。而**浏览器发 POST
1472
+ // 和 WebSocket 握手时一定会带 `Origin`**(普通 GET 不带)——所以症状很有欺骗性:
1473
+ // 页面、脚本、样式全都正常加载,**只有那些 POST 出来的数据全空**,界面上就是
1474
+ // 「权限那栏写着 403」「插件那一列只剩图标」。2026-10-06 两次真机都栽在这一个头上,
1475
+ // 第二次是 WebSocket 那条(当时只修了它),这一次是普通 POST。
1476
+ headers.origin = `http://${targetHost}`
1477
+ if (cookie) headers.cookie = cookie
1478
+ // **压缩照旧转发(不再摘掉 `accept-encoding`)。**
1479
+ //
1480
+ // 第一版把它摘了,理由是「HTML 保持明文好注入」——**但那只对 HTML 成立,
1481
+ // 我把所有东西都关掉了**:整个界面裸着搬,一次 34 MB(2026-10-06 量出来的,
1482
+ // 121 个请求)。本机把那些东西压一遍是原体积的 24%~29%。
1483
+ // 现在照旧转发;HTML 那一份在 writeOut 里单独解开再注入。
1484
+ return headers
1485
+ }
1486
+
1487
+ /** 一次转发。返回上游响应,交给调用方决定是一遍过还是重试。 */
1488
+ function forward(req, res, headers) {
1489
+ return new Promise((resolvePromise, reject) => {
1490
+ // 等上游就绪(宿主刚起来那一段问不到地址,见 resolveReady 的说明)。
1491
+ resolveReady().then((up) => {
1492
+ if (!up) {
1493
+ reject(new Error('这台电脑上找不到可用的电脑端界面地址。'))
1494
+ return
1495
+ }
1496
+ const call = httpRequest({
1497
+ protocol: up.protocol,
1498
+ hostname: up.hostname,
1499
+ port: up.port,
1500
+ // 剥掉那条门控通道的前缀(见 REMOTE_CHANNEL_PREFIX)。
1501
+ path: stripRemotePrefix(req.url),
1502
+ method: req.method,
1503
+ headers,
1504
+ }, (upRes) => resolvePromise(upRes))
1505
+ call.on('error', reject)
1506
+ // 上游是本机回环,但如果是一条 SSE 长连接,**不能设总超时**,只能设建连超时。
1507
+ call.setTimeout(15000, () => call.destroy(new Error('转发超时')))
1508
+ // 请求体直接管道过去(上传之类);GET 没有体,pipe 也无害。
1509
+ req.pipe(call)
1510
+ }).catch(reject)
1511
+ })
1512
+ }
1513
+
1514
+ /**
1515
+ * 把上游的响应发出去。
1516
+ *
1517
+ * `embed` = 「这一次是持票进来的」——由 `lib/server.js` 判定(票换来的窗口会话还在),
1518
+ * 传到这里只做一件事:**决定这一页许不许被跨源嵌**(见 FRAME_SELF_ONLY)。
1519
+ * 缺省是 false:正常路径(手机页那一层)一个字节都不变。
1520
+ */
1521
+ function writeOut(upRes, res, reqUrl, diag, embed) {
1522
+ const headers = {}
1523
+ for (const [k, v] of Object.entries(upRes.headers)) {
1524
+ const key = k.toLowerCase()
1525
+ if (HOP_BY_HOP.has(key)) continue
1526
+ if (FRAME_BLOCKERS.has(key)) continue
1527
+ // 官方那枚会话 cookie **不转发给手机**:它只属于服务端这一侧。
1528
+ if (key === 'set-cookie') continue
1529
+ headers[key] = v
1530
+ }
1531
+
1532
+ /**
1533
+ * 取景政策:上游那一份(上面已丢掉)换成**我们自己的**。
1534
+ * 不持票 = 只许同源(手机页那一层是相对地址,照常能嵌;桌面端跨源那一层要凭票)。
1535
+ */
1536
+ if (!embed) for (const [k, v] of Object.entries(FRAME_SELF_ONLY)) headers[k] = v
1537
+
1538
+ // **给带内容指纹的静态资源补上长期缓存**(见 CACHEABLE 的说明)。
1539
+ // 只补「上游本来没给」的:它给了 `no-cache` 之类就尊重它,别去覆盖别人的判断。
1540
+ const path = String(reqUrl ?? '').split('?')[0]
1541
+ if (upRes.statusCode === 200 && CACHEABLE.test(path) && !headers['cache-control']) {
1542
+ headers['cache-control'] = LONG_CACHE
1543
+ }
1544
+
1545
+ // HTML 要**改一处再发**:注入「你是主机」那个标记(见 HOST_HOOK)和那段适配样式。
1546
+ // 这一步要缓冲,所以只对 HTML 做——**其余一律照旧直接管道**,
1547
+ // 那条常驻连接(SSE)绝不能因为这里多等一下而卡住。
1548
+ const type = String(upRes.headers['content-type'] ?? '')
1549
+ if (type.includes('text/html')) {
1550
+ const chunks = []
1551
+ upRes.on('data', (chunk) => chunks.push(chunk))
1552
+ upRes.on('end', () => {
1553
+ let buf = Buffer.concat(chunks)
1554
+ // 上游可能按压缩发的(上面不再摘 `accept-encoding` 了)。**先解开再注入**,
1555
+ // 然后明文发出去:就这一份文档,几十 KB,不值得再压回去。
1556
+ const enc = String(upRes.headers['content-encoding'] ?? '').toLowerCase()
1557
+ try {
1558
+ if (enc.includes('br')) buf = brotliDecompressSync(buf)
1559
+ else if (enc.includes('gzip')) buf = gunzipSync(buf)
1560
+ else if (enc.includes('deflate')) buf = inflateSync(buf)
1561
+ } catch (err) {
1562
+ // 解不开就别动它——**宁可注入不了,也不能把一份好文档弄坏**。
1563
+ note(`HTML 解压失败,原样转发:${err.message}`)
1564
+ res.writeHead(upRes.statusCode ?? 502, headers)
1565
+ res.end(buf)
1566
+ return
1567
+ }
1568
+ const patched = injectHostHook(buf.toString('utf8'), diag)
1569
+ delete headers['content-length'] // 长度变了,让 Node 自己算
1570
+ delete headers['content-encoding'] // 发的是明文
1571
+ res.writeHead(upRes.statusCode ?? 502, headers)
1572
+ res.end(patched)
1573
+ })
1574
+ upRes.on('error', () => { try { res.end() } catch { /* 已经断了 */ } })
1575
+ return
1576
+ }
1577
+
1578
+ res.writeHead(upRes.statusCode ?? 502, headers)
1579
+ // ③ 不缓冲:直接管道,SSE 才流得动。
1580
+ upRes.pipe(res)
1581
+ }
1582
+
1583
+ /**
1584
+ * 转发一条 WebSocket(`Upgrade: websocket`)。
1585
+ *
1586
+ * **为什么非做不可**(2026-10-06 真机报「一直正在重新连接」之后查出来的):
1587
+ * 手机和宿主之间那条**常驻连接**就是 WebSocket——官方客户端里写着
1588
+ * `url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'`,路径 `/api/remote.mux`。
1589
+ * 它建不起来,界面就一直在重连,而且**看起来像一堆不相干的显示毛病**:
1590
+ * 插件那一列只剩图标没有文字、权限那栏写「permission catalog has no active Host connection」
1591
+ * ——其实全是同一条连接没通。
1592
+ *
1593
+ * 和普通转发只有两处不同:
1594
+ * ① `Upgrade` / `Connection` 这两个头**必须原样留着**——它们是握手本身,
1595
+ * 上面那个 upstreamHeaders() 会按「逐跳头」把它们摘掉,这里补回来;
1596
+ * ② 握手之后这条 TCP 连接要**双向裸转**,不能再按 HTTP 解析。
1597
+ */
1598
+ async function upgrade(req, socket, head) {
1599
+ try {
1600
+ await ensureCookie()
1601
+ } catch (err) {
1602
+ note(err.message)
1603
+ socket.destroy()
1604
+ return
1605
+ }
1606
+ const up = await resolveReady()
1607
+ // 上游永远是本机回环的 http;真出现别的协议就如实拒绝,不硬猜。
1608
+ if (!up || up.protocol !== 'http:') {
1609
+ note(`不支持把 WebSocket 转给 ${up ? up.protocol : '(没有上游)'}`)
1610
+ socket.destroy()
1611
+ return
1612
+ }
1613
+
1614
+ const headers = upstreamHeaders(req)
1615
+ if (req.headers.upgrade) headers.upgrade = req.headers.upgrade
1616
+ headers.connection = 'Upgrade'
1617
+ // `Origin` 已经在 upstreamHeaders() 里统一换过了(普通 POST 和握手是同一个坑)。
1618
+
1619
+ const call = netConnect({ host: up.hostname, port: up.port })
1620
+ call.on('error', () => socket.destroy())
1621
+ socket.on('error', () => call.destroy())
1622
+ // 两头任意一端断开,另一端也要跟着断——**否则会漏套接字**:
1623
+ // 手机那边关掉页面时,这条到上游的连接会一直挂着(测试里表现为
1624
+ // `server.close()` 永远等不到,真机上表现为连接越攒越多)。
1625
+ socket.on('close', () => call.destroy())
1626
+ call.on('close', () => socket.destroy())
1627
+
1628
+ call.on('connect', () => {
1629
+ const lines = [`${req.method} ${stripRemotePrefix(req.url)} HTTP/1.1`]
1630
+ for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`)
1631
+ call.write(`${lines.join('\r\n')}\r\n\r\n`)
1632
+ // 握手请求后面可能已经跟了一段数据(head),不能丢。
1633
+ if (head && head.length) call.write(head)
1634
+ socket.pipe(call)
1635
+ call.pipe(socket)
1636
+ })
1637
+ }
1638
+
1639
+ /**
1640
+ * 转发一条请求。
1641
+ *
1642
+ * `opts.diag` = 「这一页是诊断模式打开的」,由 `lib/server.js` 从 `?diag=1` 里读出来传进来
1643
+ * (查询串在入口那一下就被整个丢掉了,所以只能这么传,不能到这儿再自己解析)。
1644
+ * **缺省是关的**:正常路径注入的东西一个字节都不变。
1645
+ *
1646
+ * `opts.embed` = 「这一次是持票进来的」,同上由 `lib/server.js` 判定后传进来
1647
+ * (票换来的窗口会话还作数就是 true)。**缺省 false**,取景政策回到「只许同源」。
1648
+ */
1649
+ async function handle(req, res, opts) {
1650
+ const diag = Boolean(opts && opts.diag)
1651
+ const embed = Boolean(opts && opts.embed)
1652
+ try {
1653
+ await ensureCookie()
1654
+ } catch (err) {
1655
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
1656
+ res.end(`拿不到电脑端界面的访问凭据:${err.message}\n`)
1657
+ return
1658
+ }
1659
+
1660
+ let upRes
1661
+ try {
1662
+ upRes = await forward(req, res, upstreamHeaders(req))
1663
+ } catch (err) {
1664
+ note(err.message)
1665
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
1666
+ res.end(`转发到电脑端界面失败:${err.message}\n`)
1667
+ return
1668
+ }
1669
+
1670
+ // 401 = 那枚 cookie 不认了(进程重启过、或者过期)。重铸一次再试一遍。
1671
+ // 只重试一次:再来一次还是 401,说明不是「cookie 陈旧」这么简单,
1672
+ // 该把真实原因露出来,而不是在这里转圈。
1673
+ if (upRes.statusCode === 401) {
1674
+ upRes.resume()
1675
+ cookie = null
1676
+ try {
1677
+ await ensureCookie()
1678
+ upRes = await forward(req, res, upstreamHeaders(req))
1679
+ } catch (err) {
1680
+ note(err.message)
1681
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
1682
+ res.end(`重新取凭据后仍然失败:${err.message}\n`)
1683
+ return
1684
+ }
1685
+ }
1686
+
1687
+ writeOut(upRes, res, req.url, diag, embed)
1688
+ }
1689
+
1690
+ return {
1691
+ handle,
1692
+ upgrade,
1693
+ /**
1694
+ * 「现在能不能用」——上游地址解析得出来才算能用。
1695
+ *
1696
+ * 界面那一侧靠它决定要不要露出这一项:宿主的 webServer 就绪得晚,
1697
+ * 插件加载的那一刻往往还问不到地址,所以**不能只看「建没建出这个镜像」**。
1698
+ */
1699
+ available: () => Boolean(resolve()),
1700
+ /** 上游会话可能会失效(例如 DSH 重启),提供一个显式作废的入口给测试和上层用。 */
1701
+ reset: () => { cookie = null },
1702
+ stats: () => ({ mints, lastError }),
1703
+ // 「远程窗口」那一对:发票(只给宿主那一半)/ 用票换窗口会话 / 查窗口会话还作不作数 /
1704
+ // 查这枚会话是哪台设备的(拿去回头查名册,见 lib/server.js)/ 查**这张票是谁的**
1705
+ // (只看不消费,兑票那一下要先拿它定身份)/ 查这张票是不是兑现过的(取景政策用)。
1706
+ issueTicket,
1707
+ redeemTicket,
1708
+ windowValid,
1709
+ windowDevice,
1710
+ ticketOwner,
1711
+ ticketGranted,
1712
+ /** 票能活多久。宿主要把这个数告诉界面半边(`expiresIn`),所以露出来。 */
1713
+ ticketTtlMs: TICKET_TTL_MS,
1714
+ }
1715
+ }