@mrrisega/dsh-remote 0.6.6-beta.2 → 0.6.6-beta.4

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.
@@ -106,7 +106,9 @@ const STYLE = `
106
106
  opacity: 0; pointer-events: none;
107
107
  transition: opacity .22s ease;
108
108
  }
109
- html.dsh-ma-sidebar-open div.dsh-ma-scrim,
109
+ /* 拦截点击只认 dsh-ma-scrim-on(由 sync 按"用户意图或几何可见"决定)——
110
+ 避免"判定说展开了、其实侧栏还在屏外"时遮罩挡住整屏。 */
111
+ html.dsh-ma-scrim-on div.dsh-ma-scrim,
110
112
  html.dsh-ma-details-open div.dsh-ma-scrim { opacity: 1; pointer-events: auto; }
111
113
 
112
114
  /* --- 顶部小鲸鱼/菜单按钮(侧栏收起时可见) --- */
@@ -114,7 +116,9 @@ const STYLE = `
114
116
  position: fixed;
115
117
  top: max(10px, env(safe-area-inset-top));
116
118
  left: max(10px, env(safe-area-inset-left));
117
- z-index: 260;
119
+ /* 必须高于遮罩(290):抽屉展开时遮罩会拦截整屏点击,若菜单按钮压在遮罩下面
120
+ 就会出现"抽屉开了、按钮点不动"——用户实测反馈的正是这个。 */
121
+ z-index: 320;
118
122
  width: 42px; height: 42px; padding: 0;
119
123
  display: flex; align-items: center; justify-content: center;
120
124
  border-radius: 12px;
@@ -216,6 +220,76 @@ const STYLE = `
216
220
  }
217
221
  }
218
222
 
223
+ /* ===== 手机端字号档位(2026-09-16 新增) =====
224
+ 需求:4 档(最小=当前/中/大/超大),设置面板里用滑块调,**只在手机端生效**(本文件整体在
225
+ @media (max-width: 820px) 内,PC 完全不受影响)。
226
+ 实现:官方组件大量用写死的 px 字号,只改 :root font-size 对它们无效;
227
+ 因此"尽力而为地叠加"两种手段:
228
+ ① html { font-size: 计算后的 px } —— 对用 rem 的地方生效;
229
+ ② html { -webkit-text-size-adjust: 百分比 } —— 移动端 Safari/Chrome 对文本做整体缩放
230
+ (只影响文字、不影响布局,比 zoom/transform 安全得多);
231
+ ③ 正文气泡走官方自己的变量 --dsh-content-font-size / --dsh-content-font-delta
232
+ (官方聊天正文就是这么取值的),换算后同步设置,保证正文一定变大。
233
+ 适配层自己注入的 UI(.dsh-ma-*)写成固定 px,不参与缩放。 */
234
+ html { --dsh-ma-fs-scale: 1; font-size: calc(16px * var(--dsh-ma-fs-scale)); }
235
+ html[data-dsh-ma-fs="1"] { --dsh-ma-fs-scale: 1.08; -webkit-text-size-adjust: 108%; }
236
+ html[data-dsh-ma-fs="2"] { --dsh-ma-fs-scale: 1.18; -webkit-text-size-adjust: 118%; }
237
+ html[data-dsh-ma-fs="3"] { --dsh-ma-fs-scale: 1.32; -webkit-text-size-adjust: 132%; }
238
+ /* 注意:不要再给 body 设 font-size —— html 的字号已经缩放过了,
239
+ 1rem 此时等于"缩放后的字号",乘上 scale 会把放大平方(1.32 → 1.74)并连累所有子元素。
240
+ 子元素走 -webkit-text-size-adjust(文本整体缩放) + inherit 即可。 */
241
+
242
+ /* 字号设置面板:右下角浮动按钮 + 展开的小卡片(自适应、可点外部关闭) */
243
+ button.dsh-ma-fsbtn {
244
+ position: fixed;
245
+ right: max(10px, env(safe-area-inset-right));
246
+ bottom: calc(84px + env(safe-area-inset-bottom));
247
+ z-index: 310;
248
+ width: 44px; height: 44px; padding: 0;
249
+ display: flex; align-items: center; justify-content: center;
250
+ border-radius: 50%;
251
+ font: 600 15px/1 -apple-system, system-ui, sans-serif;
252
+ background: var(--dsw-alias-button-elevated-fill, rgba(255,255,255,.96));
253
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
254
+ box-shadow: 0 2px 10px rgba(0,0,0,.18);
255
+ color: var(--dsw-alias-label-primary, #111);
256
+ cursor: pointer; touch-action: manipulation;
257
+ -webkit-tap-highlight-color: transparent;
258
+ }
259
+ button.dsh-ma-fsbtn:active { transform: scale(.94); }
260
+ div.dsh-ma-fspanel {
261
+ position: fixed;
262
+ right: max(10px, env(safe-area-inset-right));
263
+ bottom: calc(136px + env(safe-area-inset-bottom));
264
+ z-index: 311;
265
+ width: min(78vw, 300px);
266
+ box-sizing: border-box;
267
+ padding: 12px 14px 14px;
268
+ border-radius: 14px;
269
+ background: var(--dsw-alias-bg-elevated, #fff);
270
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.28));
271
+ box-shadow: 0 8px 28px rgba(0,0,0,.24);
272
+ color: var(--dsw-alias-label-primary, #111);
273
+ font: 400 14px/1.45 -apple-system, system-ui, sans-serif;
274
+ }
275
+ div.dsh-ma-fspanel[hidden] { display: none; }
276
+ div.dsh-ma-fspanel .dsh-ma-fs-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
277
+ div.dsh-ma-fspanel .dsh-ma-fs-sub { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 12px; }
278
+ div.dsh-ma-fspanel .dsh-ma-fs-val { font-variant-numeric: tabular-nums; font-weight: 600; }
279
+ div.dsh-ma-fspanel input[type="range"] {
280
+ width: 100%; margin: 10px 0 2px; height: 28px;
281
+ accent-color: var(--dsw-alias-brand-primary, #4d6bfe);
282
+ }
283
+ div.dsh-ma-fspanel .dsh-ma-fs-ticks {
284
+ display: flex; justify-content: space-between;
285
+ color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px;
286
+ }
287
+ div.dsh-ma-fspanel .dsh-ma-fs-preview {
288
+ margin-top: 10px; padding-top: 9px;
289
+ border-top: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.22));
290
+ font-size: 14px;
291
+ }
292
+
219
293
  /* 桌面宽屏(>820px):本文件无任何规则 → 对直连/桌面零影响。 */
220
294
  `;
221
295
 
@@ -498,10 +572,11 @@ const SCRIPT = `(() => {
498
572
  document.body.appendChild(scrim);
499
573
  scrim.addEventListener("click", (e) => {
500
574
  e.stopPropagation();
575
+ userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
501
576
  const t = toggleOf();
502
- if (t) { t.click(); return; }
503
- /* 兜底:找不到官方 toggle 时**至少让用户能自救**(否则就是"点哪都没反应"的死局) */
504
- document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
577
+ if (t) { t.click(); }
578
+ /* 兜底 + 保险:无论官方 toggle 在不在,都确保"点空白处"能关掉/不再拦截 */
579
+ document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open", "dsh-ma-scrim-on");
505
580
  });
506
581
 
507
582
  /* 顶部小鲸鱼/菜单按钮 */
@@ -513,12 +588,91 @@ const SCRIPT = `(() => {
513
588
  document.body.appendChild(hamburger);
514
589
  hamburger.addEventListener("click", (e) => {
515
590
  e.stopPropagation(); e.preventDefault();
591
+ const nowOpen = document.documentElement.classList.contains("dsh-ma-sidebar-open");
592
+ // 先登记用户意图:即便官方 toggle 不存在、或几何判定一时不满足,这次展开也必须成立
593
+ userWantsOpen = !nowOpen;
516
594
  const t = toggleOf();
517
- if (t) { t.click(); return; }
518
- /* 兜底(无官方 toggle):按当前状态切换,保证"开得了也关得掉" */
519
- const open = document.documentElement.classList.contains("dsh-ma-sidebar-open");
520
- if (open) document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
521
- else document.documentElement.classList.add("dsh-ma-sidebar-open");
595
+ if (t && !nowOpen) { t.click(); } // 官方 toggle 存在 → 交给官方(state 一致)
596
+ else if (t && nowOpen) { t.click(); }
597
+ sync(); // 立刻按意图生效(不依赖官方属性回调)
598
+ });
599
+
600
+ /* ===== 手机端字号档位(2026-09-16 新增) =====
601
+ 4 档:0=最小(当前) / 1=中 / 2=大 / 3=超大;滑块调;localStorage 记住;只作用于手机端
602
+ (样式在 @media (max-width:820px) 内,PC 不受影响)。
603
+ 实现见 STYLE 里 "手机端字号档位" 段:改 :root 字号 + -webkit-text-size-adjust,
604
+ 并同步官方正文变量(--dsh-content-font-size),保证聊天正文一定跟着变。 */
605
+ const FS_KEY = "dsh-ma-font-scale";
606
+ const FS_LEVELS = [
607
+ { name: "最小", scale: 1.00, content: 14 },
608
+ { name: "中", scale: 1.08, content: 15 },
609
+ { name: "大", scale: 1.18, content: 16.5 },
610
+ { name: "超大", scale: 1.32, content: 18.5 }
611
+ ];
612
+ const readFsLevel = () => {
613
+ try {
614
+ const raw = window.localStorage && window.localStorage.getItem(FS_KEY);
615
+ const i = Number(raw);
616
+ return Number.isInteger(i) && i >= 0 && i < FS_LEVELS.length ? i : 0;
617
+ } catch (e) { return 0; }
618
+ };
619
+ const applyFsLevel = (level) => {
620
+ const L = FS_LEVELS[level] || FS_LEVELS[0];
621
+ const root = document.documentElement;
622
+ if (level === 0) root.removeAttribute("data-dsh-ma-fs");
623
+ else root.setAttribute("data-dsh-ma-fs", String(level));
624
+ // 官方聊天正文:直接喂它自己的变量(它就是这样取值的)
625
+ try {
626
+ root.style.setProperty("--dsh-content-font-size", L.content + "px");
627
+ root.style.setProperty("--dsh-content-font-delta", (L.content - 14) + "px");
628
+ } catch (e2) { /* 忽略 */ }
629
+ return L;
630
+ };
631
+
632
+ const fsPanel = document.createElement("div");
633
+ fsPanel.className = "dsh-ma-fspanel";
634
+ fsPanel.hidden = true;
635
+ fsPanel.innerHTML =
636
+ '<div class="dsh-ma-fs-title">显示字号</div>' +
637
+ '<div class="dsh-ma-fs-sub">仅本机手机端生效,电脑端不受影响 · 当前 <span class="dsh-ma-fs-val">最小</span></div>' +
638
+ '<input type="range" min="0" max="3" step="1" value="0" aria-label="显示字号">' +
639
+ '<div class="dsh-ma-fs-ticks"><span>最小</span><span>中</span><span>大</span><span>超大</span></div>' +
640
+ '<div class="dsh-ma-fs-preview">预览:手机远程控制,随手可用。</div>';
641
+ document.body.appendChild(fsPanel);
642
+ const fsBtn = document.createElement("button");
643
+ fsBtn.type = "button";
644
+ fsBtn.className = "dsh-ma-fsbtn";
645
+ fsBtn.setAttribute("aria-label", "显示字号");
646
+ fsBtn.textContent = "字";
647
+ document.body.appendChild(fsBtn);
648
+
649
+ const fsRange = fsPanel.querySelector('input[type="range"]');
650
+ const fsVal = fsPanel.querySelector(".dsh-ma-fs-val");
651
+ const fsPreview = fsPanel.querySelector(".dsh-ma-fs-preview");
652
+ const setLevel = (level, persist) => {
653
+ const L = applyFsLevel(level);
654
+ if (fsVal) fsVal.textContent = L.name;
655
+ if (fsRange && Number(fsRange.value) !== level) fsRange.value = String(level);
656
+ if (fsPreview) fsPreview.style.fontSize = L.content + "px";
657
+ if (persist) { try { window.localStorage && window.localStorage.setItem(FS_KEY, String(level)); } catch (e3) { /* 忽略 */ } }
658
+ };
659
+ setLevel(readFsLevel(), false);
660
+
661
+ if (fsRange) {
662
+ fsRange.addEventListener("input", (e) => { e.stopPropagation(); setLevel(Number(fsRange.value), true); });
663
+ fsRange.addEventListener("click", (e) => e.stopPropagation());
664
+ fsRange.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
665
+ }
666
+ fsBtn.addEventListener("click", (e) => {
667
+ e.stopPropagation(); e.preventDefault();
668
+ fsPanel.hidden = !fsPanel.hidden;
669
+ });
670
+ // 点面板外部关闭(面板内的事件都 stopPropagation)
671
+ document.addEventListener("click", (e) => {
672
+ if (fsPanel.hidden) return;
673
+ const t = e.target;
674
+ if (t === fsBtn || (t && t.closest && t.closest(".dsh-ma-fspanel, .dsh-ma-fsbtn"))) return;
675
+ fsPanel.hidden = true;
522
676
  });
523
677
 
524
678
  /* 侧栏是否**真的**滑进了视口。这是"遮罩能不能拦截点击"的最后一道保险:
@@ -530,11 +684,19 @@ const SCRIPT = `(() => {
530
684
  catch (e2) { return false; }
531
685
  };
532
686
  const sidebarInView = () => inView(sidebar || document.querySelector(".dsh-ma-sidebar"));
533
- /* 状态唯一真源 = 官方 frame 的 data 属性(判定)+ 真实几何(放行遮罩) */
687
+ /* 状态来源(按优先级):
688
+ ① 用户意图 userWantsOpen —— 点菜单按钮打开 / 点遮罩关闭,期间**不被几何判定推翻**;
689
+ ② 官方 frame 的 data 属性 —— 用户没表达意图时(初始、官方自己切换)以它为准。
690
+ ⚠️ 上一版把"几何判定"直接作用在展开 class 上,等于「侧栏还没滑进来就不许展开」:
691
+ 点菜单按钮加 class → 动画未开始、rect 仍在屏外 → sync 立刻把 class 摘掉 → 抽屉永远打不开。
692
+ 现在只让几何判定决定**遮罩能否拦截点击**(最后一道保险),不再否决用户的展开意图。 */
693
+ let userWantsOpen = null; // null=未表达意图(跟随官方); true/false=用户已明确开/关
534
694
  const sync = () => {
535
- const open = expanded();
536
- const scrimOn = open && sidebarInView();
537
- document.documentElement.classList.toggle("dsh-ma-sidebar-open", scrimOn);
695
+ const open = userWantsOpen !== null ? userWantsOpen : expanded();
696
+ document.documentElement.classList.toggle("dsh-ma-sidebar-open", open);
697
+ /* 只给遮罩加闸:即使判定/几何有偏差,也绝不让"看不见的遮罩"拦住整屏点击 */
698
+ const scrimOn = open && (userWantsOpen === true || sidebarInView());
699
+ document.documentElement.classList.toggle("dsh-ma-scrim-on", scrimOn);
538
700
  /* 汉堡按钮**始终可见**:以前判定为"展开"时会把它 display:none 隐藏,
539
701
  于是判定一旦出错(或遮罩因异常常亮),用户既点不动内容、也没有任何入口 —— 死局。
540
702
  保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。 */
@@ -567,8 +729,10 @@ const SCRIPT = `(() => {
567
729
  const row = t.closest('[role="treeitem"][aria-selected]');
568
730
  const isNew = t.closest('[aria-label*="新建会话"], [aria-label*="New session" i]');
569
731
  if (!row && !isNew) return;
732
+ userWantsOpen = false;
570
733
  const togg = toggleOf();
571
734
  if (togg) setTimeout(() => { try { togg.click(); } catch (e5) { /* ignore */ } }, 0);
735
+ setTimeout(sync, 0);
572
736
  } catch (e6) { /* 忽略 */ }
573
737
  });
574
738
  }
@@ -35,7 +35,8 @@ class El {
35
35
  this.children = [];
36
36
  this._cls = new Set();
37
37
  this._listeners = new Map();
38
- this.style = {};
38
+ this.style = { setProperty: (k, v) => { if (!this._cssVars) this._cssVars = {}; this._cssVars[k] = v; }, removeProperty: (k) => { if (this._cssVars) delete this._cssVars[k]; } };
39
+ this._cssVars = {};
39
40
  this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
40
41
  this.classList = {
41
42
  add: (...cs) => cs.forEach((c) => this._cls.add(c)),
@@ -44,18 +45,47 @@ class El {
44
45
  toggle: (c, on) => { if (on === undefined) { this._cls.has(c) ? this._cls.delete(c) : this._cls.add(c); } else if (on) this._cls.add(c); else this._cls.delete(c); return this._cls.has(c); },
45
46
  };
46
47
  }
48
+ // 极简 innerHTML:只认本用例需要的那点结构(class / input[type=range] / 文本)
49
+ set innerHTML(html) {
50
+ this._html = String(html);
51
+ this.children = [];
52
+ for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
53
+ const tag = m[1];
54
+ const attrs = m[2] || "";
55
+ const el = new El(tag);
56
+ const cls = /class="([^"]*)"/.exec(attrs);
57
+ if (cls) el.className = cls[1];
58
+ const type = /type="([^"]*)"/.exec(attrs);
59
+ if (type) el.attrs.type = type[1];
60
+ this.children.push(el);
61
+ }
62
+ }
63
+ get innerHTML() { return this._html || ""; }
64
+ get textContent() { return this._text || ""; }
65
+ set textContent(v) { this._text = String(v); }
47
66
  get className() { return [...this._cls].join(" "); }
48
67
  set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
49
68
  getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
50
69
  setAttribute(n, v) { this.attrs[n] = String(v); }
70
+ removeAttribute(n) { delete this.attrs[n]; }
71
+ prepend() {} focus() {} blur() {}
51
72
  hasAttribute(n) { return n in this.attrs; }
52
73
  appendChild(c) { c.parent = this; this.children.push(c); return c; }
53
74
  addEventListener(t, fn) { (this._listeners.get(t) || this._listeners.set(t, []).get(t)).push(fn); }
54
75
  removeEventListener() {}
55
76
  dispatch(t, ev = {}) { for (const fn of this._listeners.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
56
77
  getBoundingClientRect() { return this._rect; }
57
- querySelector() { return null; }
58
- querySelectorAll() { return []; }
78
+ querySelector(sel) {
79
+ const s2 = String(sel);
80
+ const match = (el) => {
81
+ if (s2.includes('input[type="range"]')) return el.tagName === "INPUT" && el.attrs.type === "range";
82
+ if (s2.startsWith(".")) return el._cls.has(s2.slice(1));
83
+ if (s2.startsWith("input")) return el.tagName === "INPUT";
84
+ return false;
85
+ };
86
+ return this.children.find(match) || null;
87
+ }
88
+ querySelectorAll(sel) { const one = this.querySelector(sel); return one ? [one] : []; }
59
89
  matches() { return false; }
60
90
  click() { this._clicked = (this._clicked || 0) + 1; this.dispatch("click"); }
61
91
  get isConnected() { return true; }
@@ -114,7 +144,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
114
144
  innerWidth: width,
115
145
  innerHeight: 800,
116
146
  devicePixelRatio: 1,
117
- localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
147
+ localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
118
148
  addEventListener() {},
119
149
  removeEventListener() {},
120
150
  getComputedStyle: () => ({ getPropertyValue: () => "" }),
@@ -148,6 +178,8 @@ const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
148
178
  function runAdapter(env) {
149
179
  try {
150
180
  vm.runInContext(adapterScript(), env.ctx, { timeout: 5000 });
181
+ // 让脚本内 setTimeout(0) 的收尾逻辑跑一轮
182
+ for (const fn of env._pendingTimers || []) { try { fn(); } catch { /* ignore */ } }
151
183
  } catch (e) {
152
184
  if (ADAPTER_DEBUG) console.error("ADAPTER THREW:", e && e.message);
153
185
  throw e;
@@ -161,20 +193,41 @@ const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-
161
193
 
162
194
  // ── 用例 ─────────────────────────────────────────────────────────────────────
163
195
 
164
- test("运行时:官方属性带值写法 =false 时不得判定为展开 → 遮罩不拦截点击(用户报的整屏阴影)", () => {
165
- // 官方若输出 data-sidebar-collapsed="false"(属性存在但语义为未折叠),旧实现 hasAttribute → 判定展开 → 遮罩常亮
166
- const env = runAdapter(harness({ collapsedAttr: "false", sidebarRect: null }));
167
- assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
168
- "属性值 false = 未折叠 → 不应加 sidebar-open(否则遮罩会拦住整屏点击)");
196
+ test("运行时:官方属性带值写法 =false 时遮罩不得拦截点击(用户报的整屏阴影)", () => {
197
+ // 官方若输出 data-sidebar-collapsed="true"(折叠),旧实现 hasAttribute 只看存在性;
198
+ // 带值写法下必须按值判断 —— 否则"折叠态"被当成"展开",遮罩常亮拦住整屏点击。
199
+ const env = runAdapter(harness({ collapsedAttr: "true", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 } }));
200
+ assert.ok(!env.html.classList.contains("dsh-ma-scrim-on"), "折叠态 → 遮罩不得拦截点击");
201
+ assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"), "折叠态 → 不应处于展开态");
202
+ // 对照:=false 属"未折叠",是展开态(但遮罩仍只在几何可见时拦截)
203
+ const env2 = runAdapter(harness({ collapsedAttr: "false", sidebarRect: { left: -300, right: 0, top: 0, bottom: 800, width: 300, height: 800 } }));
204
+ assert.ok(!env2.html.classList.contains("dsh-ma-scrim-on"), "侧栏在屏外 → 遮罩绝不拦截");
169
205
  });
170
206
 
171
- test("运行时:即使判定为展开,侧栏没真的滑进视口时也不得拦截点击", () => {
172
- // 属性"存在且无值"在旧实现里就是展开;这里再叠加"侧栏几何不在视口内"(left:0/right:0)→ 仍不得拦截
207
+ test("运行时:侧栏没真的滑进视口时,遮罩不得拦截点击(几何兜底只管遮罩,不否决展开)", () => {
173
208
  const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 0, top: 0, bottom: 800, width: 0, height: 800 } }));
174
- assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
175
- "侧栏未滑入视口 → 遮罩必须保持 pointer-events:none(几何兜底)");
176
- const scrim = findScrim(env);
177
- assert.ok(scrim, "遮罩元素本身仍应创建(关闭态只是不拦截)");
209
+ // 判定/几何可能出偏差,但"看不见的遮罩拦住整屏点击"必须不可能发生
210
+ assert.ok(!env.html.classList.contains("dsh-ma-scrim-on"),
211
+ "侧栏未滑入视口 → 遮罩必须保持不拦截(dsh-ma-scrim-on 不生效)");
212
+ assert.ok(findScrim(env), "遮罩元素本身仍应创建(关闭态只是不拦截)");
213
+ // ⚠️ 注意:展开 class 不再由几何否决 —— 否则点菜单按钮后动画还没开始、rect 仍在屏外,
214
+ // sync 会把 class 立刻摘掉,抽屉永远打不开(这正是 0.6.6-beta.3 的回归)。
215
+ });
216
+
217
+ test("运行时:点菜单按钮必须能真的打开抽屉(上一版的回归点)", () => {
218
+ // 复现现场:侧栏初始在屏外(translateX(-103%) → rect 仍在外侧),点汉堡
219
+ // 从**收起态**开始(collapsedAttr="true" = 折叠),侧栏几何仍在屏外(动画未开始)
220
+ const env = runAdapter(harness({ collapsedAttr: "true", sidebarRect: { left: -300, right: 0, top: 0, bottom: 800, width: 300, height: 800 }, withToggle: false }));
221
+ assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"), "前提:初始为收起态");
222
+ const ham = findHamburger(env);
223
+ assert.ok(ham, "菜单按钮应存在");
224
+ assert.equal(ham.style.display, "", "菜单按钮不得被隐藏");
225
+ ham.dispatch("click");
226
+ assert.ok(env.html.classList.contains("dsh-ma-sidebar-open"),
227
+ "点菜单按钮后必须处于展开态(不能被几何判定立刻摘掉)—— 这正是上一版回归的点");
228
+ // 再点一次应能关掉
229
+ ham.dispatch("click");
230
+ assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"), "再次点击应关闭");
178
231
  });
179
232
 
180
233
  test("运行时:侧栏真的滑入视口(right>8 且有宽度)时才允许拦截点击", () => {
@@ -206,3 +259,49 @@ test("运行时:能拿到官方 toggle 时,点遮罩仍走官方 toggle(
206
259
  scrim.dispatch("click");
207
260
  assert.ok(env.toggle._clicked >= 1, "应点击官方 toggle");
208
261
  });
262
+
263
+ // ── 字号档位(2026-09-16 需求:4 档滑块、仅手机端生效) ──────────────────────
264
+
265
+ const findFsBtn = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fsbtn"));
266
+ const findFsPanel = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-fspanel"));
267
+
268
+ test("字号:菜单/字号按钮与面板都应创建(面板默认收起)", () => {
269
+ const env = runAdapter(harness({}));
270
+ const btn = findFsBtn(env);
271
+ const panel = findFsPanel(env);
272
+ assert.ok(btn, "应有字号按钮");
273
+ assert.ok(panel, "应有字号面板");
274
+ assert.equal(panel.hidden, true, "面板默认收起(点按钮才展开)");
275
+ assert.ok((panel.querySelector ? panel : {}).querySelector === undefined || true);
276
+ });
277
+
278
+ test("字号:默认档 = 最小(不加缩放属性,保持当前大小)", () => {
279
+ const env = runAdapter(harness({}));
280
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "默认不应带缩放属性");
281
+ assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), null);
282
+ });
283
+
284
+ test("字号:滑块改档 → 属性/正文变量/持久化都生效", () => {
285
+ const env = runAdapter(harness({}));
286
+ const panel = findFsPanel(env);
287
+ const range = panel && panel.querySelector && panel.querySelector('input[type="range"]');
288
+ assert.ok(range, "面板里应有 range 滑块");
289
+ // 打到「大」(level 2)
290
+ range.value = "2";
291
+ range.dispatch("input");
292
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "2", "应写入缩放属性(CSS 据此放大字号)");
293
+ assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "2", "应持久化到 localStorage");
294
+ const contentVar = env.html.style["--dsh-content-font-size"] || env.html._cssVars && env.html._cssVars["--dsh-content-font-size"];
295
+ assert.ok(contentVar, "应同步官方正文变量 --dsh-content-font-size(聊天正文才会变大)");
296
+ });
297
+
298
+ test("字号:恢复「最小」时移除缩放属性(回到当前大小)", () => {
299
+ const env = runAdapter(harness({}));
300
+ const panel = findFsPanel(env);
301
+ const range = panel.querySelector('input[type="range"]');
302
+ range.value = "3"; range.dispatch("input");
303
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), "3");
304
+ range.value = "0"; range.dispatch("input");
305
+ assert.equal(env.html.getAttribute("data-dsh-ma-fs"), null, "level 0 = 最小 → 不应留缩放属性");
306
+ assert.equal(env.win.localStorage.getItem("dsh-ma-font-scale"), "0");
307
+ });
package/docs/telemetry.md CHANGED
@@ -61,10 +61,17 @@
61
61
  ### 失败码白名单(`fail_code`)
62
62
 
63
63
  `node_missing` · `node_too_old` · `npm_unreachable` · `npm_eacces` · `platform_unsupported` ·
64
- `runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` · `unknown`
64
+ `runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` ·
65
+ `npx_cmd_unavailable` · `registry_timeout` · `install_script_missing` · `npx_exit_nonzero` ·
66
+ `npx_output_encoding` · `unknown`
65
67
 
66
68
  > 原始错误文本**绝不外发**(它可能含文件路径、用户名、主机名):只做白名单归类,
67
69
  > 归不进去的一律记 `unknown`。
70
+ >
71
+ > `npx_cmd_unavailable` / `registry_timeout` / `install_script_missing` / `npx_exit_nonzero` /
72
+ > `npx_output_encoding` 是 2026-09 为定位「Windows 上装不上运行环境」而拆细的形态
73
+ > (命令调不起来 / 注册表超时 / 包或安装脚本缺失 / 退出码非零 / 输出乱码)——
74
+ > 它们仍然只是**枚举值**,不包含任何原始文本。
68
75
 
69
76
  ## 3. 不采集什么(硬边界)
70
77
 
package/dsh-setup.mjs CHANGED
@@ -70,8 +70,48 @@ function findDepWs(startDir) {
70
70
  return null;
71
71
  }
72
72
 
73
+ /** 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。 */
74
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs"];
75
+
76
+ /**
77
+ * 把"客户端脚本"补齐到配置目录。
78
+ *
79
+ * ⚠️ 2026-09-15 实测的坑:只按**版本号**判断"要不要同步"是不够的 ——
80
+ * 手机端遮罩 bug 的修复落在 `clients/dsh-remote/mobile-adapter.mjs` 里(bridge 会把它注入官方页面后
81
+ * 直接发给手机)。用户升级到新版本时,若配置目录里那份旧脚本没被覆盖,
82
+ * **修复就永远到不了手机**(现场表现:改了、发了版,用户还是整屏阴影)。
83
+ * 所以这里按**内容**比对:只要与装置器手里的不一致就覆盖(幂等、无副作用)。
84
+ * @returns {number} 实际覆盖的文件数
85
+ */
86
+ function syncRuntimeClientFiles() {
87
+ const destRoot = path.join(CONFIG_DIR, "clients", "dsh-remote");
88
+ let n = 0;
89
+ for (const name of RUNTIME_CLIENT_FILES) {
90
+ const src = path.join(THIS_DIR, "clients", "dsh-remote", name);
91
+ if (!fs.existsSync(src)) continue;
92
+ const dst = path.join(destRoot, name);
93
+ try {
94
+ if (fs.existsSync(dst) && fs.readFileSync(src).equals(fs.readFileSync(dst))) continue; // 一致 → 不动
95
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
96
+ fs.copyFileSync(src, dst);
97
+ n += 1;
98
+ } catch { /* 单个文件失败不影响整体 */ }
99
+ }
100
+ return n;
101
+ }
102
+
73
103
  function ensureRuntimeCopy() {
74
- if (!IS_NPM_INSTALL) return; // 仓库开发形态:原地使用
104
+ // 仓库开发形态:装置器要使用**仓库里这份**(实时生效),但配置目录里的运行时副本
105
+ // 仍必须与之一致 —— 否则 bridge 会把陈旧脚本发给手机(上面的 syncRuntimeClientFiles 说明)。
106
+ if (!IS_NPM_INSTALL) {
107
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
108
+ const srcSetup = path.join(THIS_DIR, "dsh-setup.mjs");
109
+ const dstSetup = path.join(CONFIG_DIR, "dsh-setup.mjs");
110
+ try { if (!fs.existsSync(dstSetup) || !fs.readFileSync(srcSetup).equals(fs.readFileSync(dstSetup))) fs.copyFileSync(srcSetup, dstSetup); } catch { /* ignore */ }
111
+ const n = syncRuntimeClientFiles();
112
+ if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
113
+ return;
114
+ }
75
115
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
76
116
  const ver = pkgVersion();
77
117
  const setupTarget = path.join(CONFIG_DIR, "dsh-setup.mjs");
@@ -79,7 +119,12 @@ function ensureRuntimeCopy() {
79
119
  const verFile = path.join(CONFIG_DIR, ".dsh-setup-version");
80
120
  let cur = "";
81
121
  try { cur = fs.readFileSync(verFile, "utf8").trim(); } catch { /* 首次 */ }
82
- if (fs.existsSync(setupTarget) && cur === ver) return; // 同版本幂等跳过
122
+ if (fs.existsSync(setupTarget) && cur === ver) {
123
+ // 版本没变也要确保客户端脚本一致(版本号相同但脚本被修过的情况真实存在)
124
+ const n = syncRuntimeClientFiles();
125
+ if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
126
+ return;
127
+ }
83
128
  fs.cpSync(path.join(THIS_DIR, "dsh-setup.mjs"), setupTarget);
84
129
  fs.cpSync(path.join(THIS_DIR, "clients"), path.join(CONFIG_DIR, "clients"), { recursive: true, force: true });
85
130
  const wsSrc = findDepWs(THIS_DIR);
@@ -895,6 +940,42 @@ function stripPluginEntries(patch) {
895
940
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
896
941
  }
897
942
 
943
+ /**
944
+ * 语义化版本比较(只处理本仓库用到的形态:X.Y.Z 与 X.Y.Z-<pre>.<n>)。
945
+ * 为什么需要:装置器必须判断"本地这份包 vs profile 里已装的那份"谁更新 ——
946
+ * 用字符串比较会把 0.6.6-beta.2 与 0.6.5 比错(预设版低于同号正式版),
947
+ * 于是要么跳过本该做的升级、要么把新版降级。任一侧解析不了返回 null(调用方保守处理)。
948
+ * @returns {number|null} a>b → 1;a<b → -1;相等 → 0;无法解析 → null
949
+ */
950
+ function compareVersionStrings(a, b) {
951
+ const parse = (v) => {
952
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
953
+ if (!m) return null;
954
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
955
+ };
956
+ const x = parse(a);
957
+ const y = parse(b);
958
+ if (!x || !y) return null;
959
+ for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
960
+ if (!x.pre && !y.pre) return 0;
961
+ if (!x.pre) return 1; // 预设版 < 同号正式版(semver §11)
962
+ if (!y.pre) return -1;
963
+ const len = Math.max(x.pre.length, y.pre.length);
964
+ for (let i = 0; i < len; i += 1) {
965
+ const p1 = x.pre[i];
966
+ const p2 = y.pre[i];
967
+ if (p1 === undefined) return -1;
968
+ if (p2 === undefined) return 1;
969
+ const n1 = /^\d+$/.test(p1) ? Number(p1) : null;
970
+ const n2 = /^\d+$/.test(p2) ? Number(p2) : null;
971
+ if (n1 !== null && n2 !== null) { if (n1 !== n2) return n1 > n2 ? 1 : -1; continue; }
972
+ if (n1 !== null) return -1;
973
+ if (n2 !== null) return 1;
974
+ if (p1 !== p2) return p1 > p2 ? 1 : -1;
975
+ }
976
+ return 0;
977
+ }
978
+
898
979
  /**
899
980
  * 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
900
981
  * 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
@@ -952,7 +1033,7 @@ function writePatchAtomic(patchFile, text) {
952
1033
  * 并导致「plugin tree failed to load」——整个插件树加载失败(实测日志)。
953
1034
  * 因此所有 return 之前都要保证:要么只有 patch 行、要么只有 bundles 条目。
954
1035
  */
955
- function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
1036
+ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
956
1037
  const original = fs.readFileSync(patchFile, "utf8");
957
1038
  const pkgBundles = () => {
958
1039
  try {
@@ -961,11 +1042,21 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
961
1042
  ? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
962
1043
  } catch { return []; }
963
1044
  };
964
- // 基座就是「插件已在 bundles」→ 保持 bundles 形态(不写 patch 行),避免两个激活点并存。
965
- // 这一路等价于插件市场安装形态:需要重启 dsh web 才装载。
966
- if (pkgBundles().length) {
1045
+ // 插件已在 bundles 时默认**不**写 patch 行(两条激活路径互斥,否则 duplicate id 崩树)。
1046
+ // 但有两个例外必须主动改写激活点:
1047
+ // · forcePatch:源码已被我们换成本地这份(升级场景)→ 必须转 patch 行才能热加载;
1048
+ // · 插件市场形态(bundles 里的条目由包管理器管)→ 保持 bundles,不碰。
1049
+ const inBundles = pkgBundles();
1050
+ if (inBundles.length && !opts.forcePatch) {
967
1051
  return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
968
1052
  }
1053
+ if (inBundles.length && opts.forcePatch) {
1054
+ // 自己动手摘掉 bundles 条目(不依赖调用方的执行顺序),再走 patch 行激活
1055
+ removeBundleEntry(pkgFile);
1056
+ if (pkgBundles().length) {
1057
+ return { ok: false, reason: "无法移除 dsh.profile.bundles 中的本插件条目", changed: false, basePatch: original };
1058
+ }
1059
+ }
969
1060
  const base = validatePatchBase(original);
970
1061
  if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
971
1062
  const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
@@ -1138,6 +1229,37 @@ function removeBundleEntry(pkgFile) {
1138
1229
  return true;
1139
1230
  }
1140
1231
 
1232
+ /**
1233
+ * 把我们手上这份插件包落到 profile 并激活(唯一激活点 = patch 行 → 热加载)。
1234
+ * 两条路径共用:① 无依赖或 file: 依赖(我们自己管源码);② 市场装法但本地版本更新。
1235
+ * @returns {{hotPatch:boolean, changed:boolean, basePatch?:string}|undefined}
1236
+ */
1237
+ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, opts = {}) {
1238
+ const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
1239
+ // forcePatch 由 ensurePatchActivation 内部处理(摘 bundles → 写 patch 行),
1240
+ // 这样不依赖调用顺序:源码既已换成本地这份,激活点就必须跟着改,否则装完仍看不到新版。
1241
+
1242
+ const entryFile = path.join(pluginLocalDir, "lib", "index.js");
1243
+ if (!fs.existsSync(entryFile)) {
1244
+ console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
1245
+ process.exit(1);
1246
+ }
1247
+ declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1248
+ ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1249
+ const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
1250
+ if (r.ok) {
1251
+ console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
1252
+ return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1253
+ }
1254
+ if (r.bundleOnly) {
1255
+ console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1256
+ } else {
1257
+ console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1258
+ ensureBundleEntry(pkgFile);
1259
+ }
1260
+ return { hotPatch: false, changed: true, basePatch: patch };
1261
+ }
1262
+
1141
1263
  /**
1142
1264
  * 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
1143
1265
  * 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
@@ -1196,6 +1318,22 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1196
1318
  stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
1197
1319
  const depName = PLUGIN_ALL_IDS.find((id) => pkg.dependencies && pkg.dependencies[id] !== undefined) || PLUGIN_ID;
1198
1320
  const before = readInstalledPluginVersion(profileDir, depName);
1321
+ // ⚠️ 关键:装置器**手上这份包的版本**才是权威(用户跑的就是它)。
1322
+ // 以前这里无条件"去 npm 装 market 渠道的最新版",于是本地跑 0.6.6-beta.2 时,
1323
+ // profile 里市场装的 0.6.5 被判成"已是最新"→ 什么都不做(测试自己就踩到:
1324
+ // 「✅ 插件包已是市场最新版(0.6.5)」,明明带的是 beta)。
1325
+ // 现在:本地严格更新 → 直接落本地包(顺带把激活点换成 patch 行,拿到热加载);
1326
+ // 相等 → 不做;本地更旧 → 不动(绝不降级)。
1327
+ const localVer = pkgVersion();
1328
+ const cmpLocal = compareVersionStrings(localVer, before);
1329
+ if (cmpLocal !== null && cmpLocal > 0) {
1330
+ console.log(`ℹ 本地包更新(${before ?? "未装"} → ${localVer}),落本地版本并改用热加载激活…`);
1331
+ return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
1332
+ }
1333
+ if (cmpLocal !== null && cmpLocal < 0) {
1334
+ console.log(`ℹ profile 里已是更新版本(${before} > ${localVer}),保持不动(不降级)。`);
1335
+ return;
1336
+ }
1199
1337
  console.log(`ℹ 插件市场安装形态(bundles+dependency):源码归包管理器管,正在升级到最新…(安装前 ${before ?? "未知"})`);
1200
1338
  const { ok, cmd } = upgradeMarketManagedPlugin(profileDir, depName, dep);
1201
1339
  const after = readInstalledPluginVersion(profileDir, depName);
@@ -1212,31 +1350,9 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1212
1350
  return;
1213
1351
  }
1214
1352
 
1215
- if (managedByUs) {
1216
- const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
1217
- const entryFile = path.join(pluginLocalDir, "lib", "index.js");
1218
- if (!fs.existsSync(entryFile)) {
1219
- console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
1220
- process.exit(1);
1221
- }
1222
- declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1223
- ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1224
- // 唯一激活点:patch 行(+ 顺带把 bundles 里的本插件条目移除,避免两处并存)
1225
- const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile);
1226
- if (r.ok) {
1227
- console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
1228
- return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1229
- }
1230
- // patch 行不能写(文件形态异常,或插件已由 bundles 声明)→ 回退 bundles(需重启),保证功能可用。
1231
- // 这条路的输出要如实说明"装完要重启一次",不能让人以为已经热加载了。
1232
- if (r.bundleOnly) {
1233
- console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1234
- } else {
1235
- console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1236
- ensureBundleEntry(pkgFile); // 回退激活点:插件仍可用,代价是必须重启一次
1237
- }
1238
- return { hotPatch: false, changed: true, basePatch: patch };
1239
- }
1353
+ // 源码归我们(无依赖或 file: 依赖)→ 必然用本地这份,激活点也必须是 patch 行(热加载)。
1354
+ if (managedByUs) return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
1355
+
1240
1356
 
1241
1357
  // 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
1242
1358
  console.log(`ℹ 检测到依赖 ${PLUGIN_ID}(${dep}) 但未声明在 dsh.profile.bundles——插件不会激活。`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.6-beta.2",
3
+ "version": "0.6.6-beta.4",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -1812,7 +1812,7 @@ const PLUGIN_ID = "dsh-remote-web";
1812
1812
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1813
1813
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1814
1814
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1815
- const PLUGIN_VERSION = "0.6.6-beta.2";
1815
+ const PLUGIN_VERSION = "0.6.6-beta.4";
1816
1816
  const UPDATE_LOG = ".dsh-update.log";
1817
1817
  const UPDATE_MARKER = ".dsh-update-running";
1818
1818
 
@@ -2436,10 +2436,17 @@ const TELEMETRY_EVENT_NAMES = new Set([
2436
2436
  "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
2437
2437
  "update_started", "update_failed",
2438
2438
  ]);
2439
- /** fail_code 白名单(白名单外的分类一律落 unknown)。 */
2439
+ /**
2440
+ * fail_code 白名单 —— **必须与服务端 TELEMETRY_FAIL_CODES 完全一致**。
2441
+ * 两个用途:① 构造事件时校验(白名单外会被清成空串,等于归因白做);
2442
+ * ② 受信边界:只有枚举值能出网络,原始错误文本永不上报。
2443
+ * 2026-09-15 教训:新增归因码时忘了同步这里,1659 次 Windows 失败仍然只会显示 unknown。
2444
+ */
2440
2445
  const TELEMETRY_FAIL_CODES = new Set([
2441
2446
  "node_missing", "node_too_old", "npm_unreachable", "npm_eacces", "platform_unsupported",
2442
- "runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit", "unknown",
2447
+ "runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit",
2448
+ "npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding",
2449
+ "unknown",
2443
2450
  ]);
2444
2451
  /** node 半只发 source=plugin。 */
2445
2452
  const TELEMETRY_SOURCE = "plugin";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.6-beta.2",
3
+ "version": "0.6.6-beta.4",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -182,19 +182,21 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
182
182
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
183
  });
184
184
 
185
- test("硬约束:插件已在 bundles 时**绝不**写 patch 行(两个激活点 = duplicate id 崩溃)", () => {
185
+ test("硬约束:源码归我们时**必须**把激活点从 bundles 转成 patch 行(否则热加载失效、且两处并存会崩)", () => {
186
186
  // 事故复盘:patch 行与插件自带 bundle patch 同时生效时,dsh web 启动即
187
187
  // TypeError: duplicate loader entry id: dsh-remote-web
188
188
  // Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include)
189
- // 整个插件树加载失败(实测生产日志)。所以这两条激活路径必须互斥。
189
+ // 所以两条激活路径必须互斥。而当装置器**要把本地这份源码装进去**时(升级/自装),
190
+ // 正确做法不是"保持 bundles",而是**摘掉 bundles 条目 + 写 patch 行**:
191
+ // · 只留 bundles → 只在启动时读取,装完看不到新版、也拿不到热加载(实测踩到);
192
+ // · 两处并存 → 启动即崩。
190
193
  const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
191
194
  const relay = join(profile, "relay");
192
195
  try {
193
196
  const out = installPlugin(profile, relay);
194
- assert.match(out, /已由 dsh\.profile\.bundles 声明/, "应识别并保持 bundles 形态");
195
- assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 0,
196
- "bundles 已声明时不得再写 patch 行(否则重复 ID)");
197
- assert.ok(readBundles(profile).includes("dsh-remote-web"), "bundles 形态应保持");
197
+ assert.match(out, /激活点已转为 patch 行/, "应把激活点转成 patch 行(热加载)");
198
+ assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 1, "应恰好写一条 patch 行");
199
+ assert.ok(!readBundles(profile).includes("dsh-remote-web"), "bundles 条目必须被摘掉(单一激活点)");
198
200
  } finally { cleanup(profile); }
199
201
  });
200
202
 
@@ -83,6 +83,22 @@ test("一键更新:前端在进程结束但没成功时必须报错,而不
83
83
  assert.match(indexSrc, /failure: failure \? \{ detail: failure\.detail/, "update-log 应下发 failure");
84
84
  });
85
85
 
86
+ test("运行时同步:客户端脚本必须按**内容**补齐(否则手机端修复永远到不了用户)", () => {
87
+ // 事故背景(2026-09-15 实测):手机端遮罩 bug 的修复落在 clients/dsh-remote/mobile-adapter.mjs,
88
+ // 而 bridge 是把**配置目录里那份**脚本注入官方页面后发给手机的。装置器原先只在"版本号变化"时
89
+ // 才覆盖运行时副本,于是出现过:改了、发了版、用户升级了,但手机端拿到的仍是旧脚本 —— 修复不生效。
90
+ // 现在按内容比对,只要与装置器手里的不一致就覆盖。
91
+ const setup = readFileSync(join(REPO, "dsh-setup.mjs"), "utf8");
92
+ assert.match(setup, /function syncRuntimeClientFiles/, "应有按内容同步的客户端脚本同步函数");
93
+ assert.match(setup, /mobile-adapter\.mjs/, "必须覆盖 mobile-adapter.mjs(手机端注入层)");
94
+ assert.match(setup, /dsh-bridge\.mjs/, "必须覆盖 dsh-bridge.mjs");
95
+ assert.match(setup, /equals\(/, "应按内容比对(而非只看版本号)");
96
+ assert.match(setup, /syncRuntimeClientFiles\(\)/, "应实际调用同步");
97
+ // 仓库开发形态也必须同步(否则本地测试拿到的是陈旧副本,测了个寂寞)
98
+ const devBranch = setup.slice(setup.indexOf("if (!IS_NPM_INSTALL) {"), setup.indexOf("fs.mkdirSync(CONFIG_DIR, { recursive: true });\n const ver = pkgVersion();"));
99
+ assert.match(devBranch, /syncRuntimeClientFiles\(\)/, "仓库形态也要同步客户端脚本");
100
+ });
101
+
86
102
  test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
87
103
  assert.match(indexSrc, /PROVISION_RETRY_BACKOFF_MS\s*=\s*\[30_000, 120_000, 600_000\]/, "应有 30s→2m→10m 退避");
88
104
  assert.match(indexSrc, /function provisionRetryGate/, "应有退避闸门");
@@ -90,18 +106,29 @@ test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
90
106
  assert.match(indexSrc, /noteProvisionSuccess\(relayDir\)/, "成功要清退避");
91
107
  });
92
108
 
93
- test("失败归因:Windows 真实错误不再全部落到 unknown(1659 次无法定位的教训)", () => {
109
+ test("失败归因:客户端白名单必须与服务端逐字一致,且事件真的带上新码", () => {
94
110
  const codes = ["npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding"];
95
- for (const c of codes) {
96
- assert.ok(indexSrc.includes(`"${c}"`), `客户端归因应包含 ${c}`);
97
- }
98
- // 与服务端白名单保持一致(服务端不认的码会被静默丢弃 → 又变成 unknown)
99
- const server = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
100
- if (existsSync(server)) {
101
- const apiSrc = readFileSync(server, "utf8");
102
- for (const c of codes) {
103
- assert.ok(apiSrc.includes(`"${c}"`), `服务端 TELEMETRY_FAIL_CODES 必须接受 ${c}(否则上报被丢弃)`);
104
- }
111
+
112
+ // ① 行为断言:构造一个带新码的事件,payload 里必须真的有这个码。
113
+ // (教训:只断言"文件里有这个字面量"是不够的 —— 客户端白名单没同步时,
114
+ // telemetryEventOf 会把白名单外的码清成空串,1659 次 Windows 失败仍然只显示 unknown。)
115
+ const out = JSON.parse(execFileSync(process.execPath, ["-e", `
116
+ import("${INDEX}").then((m) => {
117
+ const ev = m.__telemetryInternals.eventOf("install_failed", { fail_code: "npx_cmd_unavailable" });
118
+ console.log(JSON.stringify({ fail_code: ev && ev.fail_code, queued: m.__telemetryInternals.failCodes }));
119
+ });
120
+ `], { encoding: "utf8" }).trim());
121
+ assert.equal(out.fail_code, "npx_cmd_unavailable", "客户端白名单必须放行新码,否则归因被清空");
122
+ for (const c of codes) assert.ok(out.queued.includes(c), `客户端 failCodes 应含 ${c}`);
123
+
124
+ // ② 与服务端白名单**集合级**一致(少一个码 = 上报被丢弃、多一个 = 服务端拒收)
125
+ const serverPath = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
126
+ if (existsSync(serverPath)) {
127
+ const apiSrc = readFileSync(serverPath, "utf8");
128
+ const block = /const TELEMETRY_FAIL_CODES = new Set\(\[([\s\S]*?)\]\);/.exec(apiSrc);
129
+ assert.ok(block, "应能读到服务端白名单");
130
+ const serverCodes = [...block[1].matchAll(/"([a-z_]+)"/g)].map((m) => m[1]).sort();
131
+ assert.deepEqual([...out.queued].sort(), serverCodes, "客户端与服务端的 fail_code 白名单必须完全一致");
105
132
  }
106
133
  });
107
134