@mrrisega/dsh-remote 0.6.16 → 0.6.18-beta.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.
Files changed (35) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +34 -0
  2. package/clients/dsh-remote/e2ee-shim-script.js +168 -8
  3. package/clients/dsh-remote/mobile-adapter.mjs +497 -28
  4. package/clients/dsh-remote/test/bridge-html-inject.test.mjs +33 -1
  5. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +168 -4
  6. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
  7. package/clients/dsh-remote/wechat-runtime.mjs +25 -2
  8. package/docs/telemetry.md +7 -1
  9. package/dsh-setup.mjs +152 -3
  10. package/package.json +1 -1
  11. package/packages/dsh-remote-web/lib/client.js +781 -50
  12. package/packages/dsh-remote-web/lib/index.js +342 -6
  13. package/packages/dsh-remote-web/package.json +1 -1
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +34 -0
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +168 -8
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +497 -28
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
  18. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
  19. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
  20. package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
  21. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
  22. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
  23. package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
  24. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +17 -1
  25. package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
  26. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  27. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
  28. package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
  29. package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
  30. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  31. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
  32. package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
  33. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
  34. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
  35. package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
@@ -764,6 +764,7 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
764
764
  if (m.injected) {
765
765
  console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
766
766
  buf = m.buf;
767
+ armMirrorLoadWatch(path);
767
768
  }
768
769
  // E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
769
770
  if (e2ee.enabled) {
@@ -830,10 +831,39 @@ export function e2eeHttpResponseFrame(id, sessId, respEnv) {
830
831
  };
831
832
  }
832
833
 
834
+ /**
835
+ * 「镜像页交付了、但页面再也没回来」的看门狗(2026-09-25)。
836
+ *
837
+ * 【为什么需要】首屏提示层被整块删除后,页面上不再有任何东西能告诉用户"没加载完"
838
+ * (那正是业主的要求:不许再有会盖住界面的层)。但**服务端能看见一个更硬的信号**:
839
+ * 我们把镜像页 HTML 交付给手机后,官方前端一定会在几秒内发一串请求(模块、会话、mux…)。
840
+ * 若 60 秒内一个都没有,那这个页面几乎必然没起来 —— 这时在**日志**里留一条带时间戳的结论,
841
+ * 用户「复制诊断信息」或 `dsh-remote doctor` 时就能看到,不用再靠猜。
842
+ *
843
+ * 代价与边界:
844
+ * · 只是**一次**待办标记 + 一个 unref 定时器,没有额外请求、没有额外文件;
845
+ * · 60 秒很宽松(正常加载 1~3 秒就回来一串请求);用户"打开就关掉"最多多一行日志;
846
+ * · 只记日志,不做任何用户可见动作(不注入、不弹层、不改状态)。
847
+ */
848
+ let mirrorLoadWatch = null;
849
+ function armMirrorLoadWatch(path) {
850
+ mirrorLoadWatch = { at: Date.now(), path };
851
+ const timer = setTimeout(() => {
852
+ if (!mirrorLoadWatch) return;
853
+ const waited = Math.round((Date.now() - mirrorLoadWatch.at) / 1000);
854
+ console.error(`[bridge] ⚠️ 镜像页疑似没加载起来:交付 ${mirrorLoadWatch.path} 后 ${waited} 秒内没有任何后续请求`
855
+ + `(正常 1~3 秒就会有一串)。手机侧请刷新一次;仍不行把这条日志发给客服。`);
856
+ mirrorLoadWatch = null;
857
+ }, 60 * 1000);
858
+ timer.unref?.();
859
+ }
860
+
833
861
  export async function handleHttpFrame(dchOrSend, frame) {
834
862
  const send = toSender(dchOrSend);
835
863
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
836
864
  const t0 = Date.now();
865
+ // 页面还活着:任何后续请求都撤掉「镜像页加载」看门狗(见 armMirrorLoadWatch)
866
+ if (mirrorLoadWatch) { mirrorLoadWatch = null; }
837
867
  // 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
838
868
  if (method === "POST" && path === "/_e2ee/intro") {
839
869
  return answerIntro(send, id);
@@ -1675,6 +1705,10 @@ async function main() {
1675
1705
  return runTunnel();
1676
1706
  }
1677
1707
 
1708
+ // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
1709
+ export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
1710
+ export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
1711
+
1678
1712
  // 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
1679
1713
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1680
1714
  if (isMain) {
@@ -277,6 +277,46 @@
277
277
  }
278
278
 
279
279
  /* ---- 上游路径 / channel|device 形态归一(与 router resolveRoute / native wcUpstreamPathOf 同构) ---- */
280
+ /**
281
+ * 从镜像页路径取出 deviceId:`/remote/<deviceId>/…` → deviceId(其余形态返回 "")。
282
+ * 与 mobile-adapter.mjs 的 deviceIdOfPath() 同一套判据;这里不查任何 cookie。
283
+ */
284
+ function seDeviceIdOfPath(pathname) {
285
+ try {
286
+ var parts = String(pathname || "").split("/").filter(Boolean);
287
+ if (parts[0] !== "remote" || !parts[1]) return "";
288
+ var id = decodeURIComponent(parts[1]);
289
+ return /^[A-Za-z0-9._-]{1,64}$/.test(id) ? id : "";
290
+ } catch (e) { return ""; }
291
+ }
292
+ /**
293
+ * 把**根路径**的受保护请求改写成"设备作用域"形态:`/api/x` → `/remote/<deviceId>/api/x`。
294
+ *
295
+ * 为什么必须(2026-09-26 线上事故,nginx access/error 日志为证):
296
+ * 官方客户端把 mux WS 的地址固定按 **`location.origin`** 拼成 `/api/remote.mux`
297
+ * (@deepseek-ai/dsh-api-gateway/lib/client.js:544 的 remoteStreamUrl())—— 它**永远**不带设备前缀,
298
+ * 于是中继只能靠 **`dsh_device` cookie** 来判"这是哪台设备"(relay-router/src/index.mjs:626-627
299
+ * 的根路径兜底)。该 cookie 是会过期的:拿不到就 resolveRoute → null → upgrade 直接
300
+ * `rejectUpgrade(socket, 404)`(:1294),nginx 侧只看到 `009`/`404 9` 这种截断响应。
301
+ * 而设备作用域形态 `/remote/<deviceId>/api/remote.mux` 由**路径本身**携带设备身份,
302
+ * 不依赖任何 cookie(同 rc 的测试就是这么连的:relay-router/test/router-e2e.test.mjs:267)。
303
+ *
304
+ * 安全性:中继对两种形态会归一到**同一个上游路径**(resolveRoute 的 device 分支取 m[2]),
305
+ * bridge/宿主看到的 path/label 与改写前逐字节一致 —— 只是"设备凭据从 cookie 挪到了 URL"。
306
+ * 保守性:只有在**页面路径里能读到 deviceId**(`/remote/<id>/…`,也就是 2026-09-19 起的标准入口)
307
+ * 且该路径确实是根形态的受保护前缀时才改写;读不到(老入口/根路径打开)就原样不动,
308
+ * 行为与今天完全一致 —— 宁可退化,也绝不猜设备。
309
+ */
310
+ function seScopeToDevice(fullPath, deviceId) {
311
+ if (!deviceId) return String(fullPath || "");
312
+ var p = String(fullPath || "");
313
+ if (p.indexOf("/remote/") === 0) return p; // 已经是设备/通道形态 → 不动
314
+ var qIdx = p.indexOf("?");
315
+ var path = qIdx === -1 ? p : p.slice(0, qIdx);
316
+ var qs = qIdx === -1 ? "" : p.slice(qIdx);
317
+ if (!seIsProtectedPath(path)) return p; // 只改写受保护前缀(与加密策略同源)
318
+ return "/remote/" + encodeURIComponent(deviceId) + path + qs;
319
+ }
280
320
  function seUpstreamPathOf(fullPath) {
281
321
  var p = String(fullPath || "");
282
322
  var query = "";
@@ -384,6 +424,58 @@
384
424
  if (typeof console !== "undefined" && console.warn) console.warn("[dsh-e2ee-shim]", message);
385
425
  }
386
426
 
427
+ /* ---- 会话失效自愈(2026-09-26 线上事故:unknown_session 502 + 刷新也好不了) -------------
428
+ 链路:手机镜像页 → 加密信封 → 中继透明转发 → 桥端 e2ee-client 查会话表 → 查不到就回
429
+ **明文** 502 + `x-dsh-e2ee-error: unknown_session`(桥端会话表是进程内存 + 24h TTL + CAP=32 LRU,
430
+ 桥重启/被挤掉之后就没了)。而手机侧靠 **localStorage 持久交接单**长期复用同一个 sessId
431
+ (见 _boot 的"记住本机"分支)→ 每个受保护路径全 502,且**刷新只会把同一张失效交接单再装回去**。
432
+ 修法(只做两件事,绝不静默降级):
433
+ ① 收到该头 → 立刻清掉 sessionStorage/localStorage 的交接单 → 下一次进入镜像页时
434
+ "没有交接单"→ 走正常流程回设备列表重新解锁;
435
+ ② 通过 seStaleSessionHook 把**明确原因**交给 DOM 层写进徽标(需要重新解锁),
436
+ 不再让用户看到"加密好好的,但什么都点不动"。
437
+ ⚠️ 不改 SE_PROTECTED_PREFIXES:把 /sidebar 移出白名单会让 fs.read/write、pty.*、git.commit
438
+ 明文过境(见 :39 的策略表),那是安全红线。 */
439
+ var seStaleSessionHook = null;
440
+ var seLastStaleCode = "";
441
+ /** 清掉本机保存的 E2EE 交接单(sessionStorage 一次性 + localStorage 持久)。node 测试无 storage → 静默跳过。 */
442
+ function seForgetHandoverStores() {
443
+ try { if (typeof sessionStorage !== "undefined" && sessionStorage && sessionStorage.removeItem) sessionStorage.removeItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { /* ignore */ }
444
+ try { if (typeof localStorage !== "undefined" && localStorage && localStorage.removeItem) localStorage.removeItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { /* ignore */ }
445
+ }
446
+ /** 读响应头里的桥端 E2EE 错误码(非信封响应才有;取不到返回 "")。 */
447
+ function seE2eeErrorOf(res) {
448
+ try { return String((res && res.headers && res.headers.get && res.headers.get("x-dsh-e2ee-error")) || ""); } catch (e) { return ""; }
449
+ }
450
+ /** 收到带 x-dsh-e2ee-error 的响应 → 清失效会话 + 上报(同一个码只报一次,避免刷屏)。 */
451
+ function seNoteE2eeError(res) {
452
+ var code = seE2eeErrorOf(res);
453
+ if (!code) return "";
454
+ seForgetHandoverStores();
455
+ if (code !== seLastStaleCode) {
456
+ seLastStaleCode = code;
457
+ try { if (typeof seStaleSessionHook === "function") seStaleSessionHook(code); } catch (e) { /* ignore */ }
458
+ seNotifyFail("加密会话已失效(" + code + "):本机保存的会话已不可用,需要重新解锁。");
459
+ }
460
+ return code;
461
+ }
462
+
463
+ /* ---- 数据 WS 握手失败上报(2026-09-26:线上 mux 握手 404/009 → 用户只看到"什么都没发生") ----
464
+ mux WS 是镜像页与电脑端之间**唯一**的实时数据通道;它连不上时页面不报错,只是所有数据都停住。
465
+ 这里只做**可观测**:把"没连上"作为明确原因交给徽标(区分"会话未建立/设备离线/网络中断"),
466
+ 绝不在这里做重连 —— 官方客户端自己会重连,我们再加一层只会变成"静默无限重连"。 */
467
+ var seWsFailHook = null;
468
+ var seWsFailCount = 0;
469
+ var seWsFailReported = false;
470
+ function seNoteWsHandshakeFail(label, detail) {
471
+ seWsFailCount++;
472
+ if (seWsFailReported) return;
473
+ seWsFailReported = true;
474
+ try { if (typeof seWsFailHook === "function") seWsFailHook(String(label || ""), String(detail || ""), seWsFailCount); } catch (e) { /* ignore */ }
475
+ }
476
+ /** 握手成功后复位(下一次再断时还能再报一次;不清 count,用于展示"重试了几次")。 */
477
+ function seNoteWsOpened() { seWsFailReported = false; }
478
+
387
479
  /* ---- HTTP 信封传输包装(与 native e2eeFetchEnveloped 同构;输入/输出为 fetch 语义) ---- */
388
480
  var SE_OUTER_STRIP = {
389
481
  "host": 1, "origin": 1, "referer": 1, "cookie": 1, "connection": 1, "upgrade": 1,
@@ -464,7 +556,9 @@
464
556
  signal: req.signal
465
557
  });
466
558
  } catch (e) { throw e; }
467
- if (!seIsEnvelopeResponse(res)) return res; // router/桥端明文错误页原样透传
559
+ /* router/桥端明文错误页 → 原样透传(零行为),但**先看一眼**是不是桥端在说"会话我不认识"
560
+ (x-dsh-e2ee-error)——是的话清掉本机失效交接单并给出明确原因(见 seNoteE2eeError)。 */
561
+ if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res); return res; }
468
562
  var envResp = null;
469
563
  try { envResp = await res.json(); } catch (e) {
470
564
  seNotifyFail("响应信封非法");
@@ -506,10 +600,18 @@
506
600
  this._recvQ = Promise.resolve();
507
601
  this.binaryType = "blob";
508
602
  var inner = this._inner;
509
- inner.onopen = function (ev) { self._state = 1; if (typeof self._onopen === "function") self._onopen(ev || { type: "open", target: self }); };
510
- inner.onerror = function (ev) { if (typeof self._onerror === "function") self._onerror(ev || { type: "error", target: self }); };
603
+ this._opened = false; // 是否**曾经**握手成功(区分"没连上"与"连上后断开")
604
+ inner.onopen = function (ev) { self._state = 1; self._opened = true; seNoteWsOpened(); if (typeof self._onopen === "function") self._onopen(ev || { type: "open", target: self }); };
605
+ inner.onerror = function (ev) {
606
+ /* 握手上就失败(error 早于任何 open)→ 让用户知道"加密数据通道没建立"。
607
+ 这是 2026-09-26 事故里最缺的一条信号:mux 被 404 掐掉时页面**一声不响**,
608
+ 用户只看到所有数据都停住。 */
609
+ if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, "error");
610
+ if (typeof self._onerror === "function") self._onerror(ev || { type: "error", target: self });
611
+ };
511
612
  inner.onclose = function (ev) {
512
613
  self._state = 3;
614
+ if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, (ev && (ev.code || ev.reason)) || "close");
513
615
  if (typeof self._onclose === "function") self._onclose(ev || { type: "close", target: self });
514
616
  };
515
617
  inner.onmessage = function (ev) { self._enqueueInbound(ev); };
@@ -608,14 +710,28 @@
608
710
  /** 生成 window.WebSocket 的替换构造函数:命中策略才套 SeE2eeWs,否则原样放行。 */
609
711
  function seMakeWsCtor(NativeWS, ctx) {
610
712
  function SeWs(url, protocols) {
611
- var full = "", should = false;
713
+ var full = "", should = false, scoped = String(url);
612
714
  try {
613
715
  var base = ctx.origin || (typeof window !== "undefined" ? window.location.href : undefined);
614
716
  var uo = new URL(url, base);
615
- if (seSameWebContext(uo, ctx.origin)) { full = uo.pathname + uo.search; should = seShouldEncryptWs(full); }
717
+ if (seSameWebContext(uo, ctx.origin)) {
718
+ /* ★ 设备作用域改写(2026-09-26 事故):官方客户端把 mux WS 固定拼成**根路径**
719
+ `/api/remote.mux`(dsh-api-gateway/lib/client.js:544 用 location.origin 拼),
720
+ 于是中继只能靠 `dsh_device` cookie 判设备;cookie 一过期/缺失就是 404(upgrade 被
721
+ rejectUpgrade 掐掉,nginx 只看到 009)→ E2EE 会话永远建不起来 → 之后每个受保护请求
722
+ 全是 unknown_session 502。镜像页的标准入口是 `/remote/<deviceId>/`(2026-09-19 起),
723
+ deviceId 就在地址里 —— 拿它把 WS 改写成 `/remote/<deviceId>/api/remote.mux`,
724
+ 设备身份由路径携带,不再依赖任何 cookie(中继会把两种形态归一到同一上游路径)。
725
+ ⚠️ 只改 WS:HTTP 那条链路影响面大得多,且线上证据显示它当时是通的
726
+ (502 是桥端回的**信封响应**,说明 HTTP 请求已经到达该设备)。 */
727
+ scoped = seScopeToDevice(uo.pathname + uo.search, ctx.deviceId);
728
+ var so = scoped === (uo.pathname + uo.search) ? uo : new URL(scoped, base);
729
+ full = so.pathname + so.search;
730
+ should = seShouldEncryptWs(full);
731
+ }
616
732
  } catch (e) { should = false; }
617
733
  if (!should) return new NativeWS(url, protocols);
618
- var target = seBuildWsTarget(url, ctx.sess.sessId, seRandomHex(8), base);
734
+ var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
619
735
  if (!target) return new NativeWS(url, protocols);
620
736
  return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
621
737
  }
@@ -731,6 +847,33 @@
731
847
  function _reportFail(message) {
732
848
  _badge("err", "无法解密", String(message || "会话异常"));
733
849
  }
850
+ /* 桥端回了 x-dsh-e2ee-error(unknown_session / not_verified / auth_failed …):
851
+ 会话已经**不可恢复**,本机交接单已由 seNoteE2eeError 清掉 —— 这里只负责把"为什么"
852
+ 说清楚(用户看到的必须是"需要重新解锁",而不是一个说不清的 502 或一句"已失效")。 */
853
+ var _STALE_HINT = {
854
+ unknown_session: "电脑端已不认识这个加密会话(通常是电脑端重启过,或会话太多被挤掉)。",
855
+ not_verified: "这个加密会话还没完成校验。",
856
+ auth_failed: "解密校验失败:密码与电脑端不一致,或传输被篡改。",
857
+ replay: "检测到重放。",
858
+ bad_envelope: "响应信封非法。",
859
+ bad_plain: "响应明文非法。"
860
+ };
861
+ function _reportStaleSession(code) {
862
+ var hint = _STALE_HINT[String(code || "")] || ("电脑端返回:" + String(code || "") + "。");
863
+ _badge("warn", "加密会话已失效,需要重新解锁", hint + " 已清除本机保存的会话;返回设备列表重新点选设备解锁即可恢复加密。");
864
+ }
865
+ /* 加密数据 WS(mux)**握手就没成功**:官方客户端只会安静地重连,页面不报错、数据全停。
866
+ 这里把原因分档说清楚(会话/设备/网络),但**不做任何重连**(重连是官方的事,
867
+ 我们再叠一层就变成"静默无限重连"——正是这次要修掉的形态)。 */
868
+ function _reportWsFail(label, detail, count) {
869
+ var n = Number(count) || 1;
870
+ var why = String(detail || "") === "error"
871
+ ? "电脑端可能未连接(设备离线)、网络中断,或加密会话已失效。"
872
+ : ("连接被关闭(" + String(detail || "close") + "),多半是握手被拒或被中继/代理掐断。");
873
+ _badge("warn", "加密数据通道未建立(第 " + n + " 次)",
874
+ "与电脑端的加密 WebSocket 没连上:" + why + " 弱网时官方客户端会自动重连,稍等即可;"
875
+ + "一直这样请返回设备列表重新解锁(会自动重建加密通道)。");
876
+ }
734
877
 
735
878
  /* ---- 安装 fetch/WS 加密补丁(有会话才安装) ---- */
736
879
  var _installed = false;
@@ -738,7 +881,12 @@
738
881
  if (_installed) return;
739
882
  _installed = true;
740
883
  seFailureHook = _reportFail;
741
- var ctx = { sess: sess, origin: window.location.origin };
884
+ seStaleSessionHook = _reportStaleSession;
885
+ seWsFailHook = _reportWsFail;
886
+ /* deviceId 取自**页面路径**(`/remote/<deviceId>/…`,2026-09-19 起的标准入口):
887
+ WS 侧用它把根路径的 mux 改写成设备作用域形态,从而不再依赖会过期的 dsh_device cookie
888
+ (见 seScopeToDevice 的长注释与 2026-09-26 的 nginx 日志证据)。读不到就留空 = 不改写。 */
889
+ var ctx = { sess: sess, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
742
890
  try {
743
891
  if (window.fetch) {
744
892
  var origFetch = window.fetch.bind(window);
@@ -772,7 +920,19 @@
772
920
  try { lsSess = seSessionOfHandover(lsRaw); } catch (e) { lsSess = null; }
773
921
  if (lsSess) {
774
922
  _install(lsSess);
775
- _badge("ok", "端到端加密已开启", "刷新后已自动恢复端到端加密(记住本机);密钥仅存内存。");
923
+ /* ⚠️ 0.6.18 起这句文案不再无条件说"加密好好的"(2026-09-26 事故的教训):
924
+ 从 localStorage 恢复的会话**没法在本机验证**桥端是否还认它(桥端会话表是进程内存 +
925
+ 24h TTL + CAP=32 LRU,桥重启/被挤掉之后这张交接单就是死票)。真实校验只能等
926
+ 第一个受保护请求 —— 那一步由 seNoteE2eeError 兜底:一旦桥端回 unknown_session,
927
+ 会话被清掉、徽标转 warn 并明说"需要重新解锁"。
928
+ 这里**刻意仍用 ok 态**(而不是一刷新就报 ⚠):99% 的刷新会话是有效的,
929
+ 照 R2 的建议无条件降级会让每次刷新都出现一个假告警 —— 本仓库一贯的取向是
930
+ "宁可漏报,也不要误报吓人"(见 mobile-adapter 的 load-warn 看门狗注释)。
931
+ 折中做法:文案如实说明"恢复但未验证",并把出口写清楚。 */
932
+ _badge("ok", "端到端加密已开启(本机记忆恢复)",
933
+ "刷新后已自动恢复端到端加密(记住本机),密钥仅存内存。"
934
+ + "注意:这次恢复的会话要到第一个数据请求才能确认电脑端仍认可它 —— "
935
+ + "若稍后提示「加密会话已失效」,说明电脑端已重启或会话被挤掉,请返回设备列表重新解锁。");
776
936
  return;
777
937
  }
778
938
  }