@mrrisega/dsh-remote 0.6.17 → 0.6.18-beta.2
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/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
- package/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/docs/telemetry.md +7 -1
- package/dsh-setup.mjs +152 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -39
- package/packages/dsh-remote-web/lib/index.js +279 -6
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
- package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
510
|
-
inner.
|
|
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,15 +710,36 @@
|
|
|
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), passUrl = 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)) {
|
|
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
|
+
/* ★ 关键:改写结果要**留住**。原来不改写分支 return 的是原始 url,等于把
|
|
731
|
+
`/remote/<dev>/api/remote.mux` 又丢回根路径 —— 那正是 404 的来源。 */
|
|
732
|
+
passUrl = so.href;
|
|
733
|
+
/* ⚠️ 必须同时要求"有会话":URL 改写在无会话时也要做(这是 2026-09-26 事故的正题),
|
|
734
|
+
但**加密**没有会话就做不了 —— 少了这个判断,未解锁的页面上任何
|
|
735
|
+
`new WebSocket("/api/…")` 都会在 ctx.sess.sessId 上抛 TypeError,
|
|
736
|
+
把整个页面打挂(比原来"不装补丁"还糟)。 */
|
|
737
|
+
should = !!ctx.sess && seShouldEncryptWs(full);
|
|
738
|
+
}
|
|
616
739
|
} catch (e) { should = false; }
|
|
617
|
-
if (!should) return new NativeWS(
|
|
618
|
-
var target = seBuildWsTarget(
|
|
619
|
-
if (!target) return new NativeWS(
|
|
740
|
+
if (!should) return new NativeWS(passUrl, protocols);
|
|
741
|
+
var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
|
|
742
|
+
if (!target) return new NativeWS(passUrl, protocols);
|
|
620
743
|
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
|
|
621
744
|
}
|
|
622
745
|
SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
|
|
@@ -731,14 +854,66 @@
|
|
|
731
854
|
function _reportFail(message) {
|
|
732
855
|
_badge("err", "无法解密", String(message || "会话异常"));
|
|
733
856
|
}
|
|
857
|
+
/* 桥端回了 x-dsh-e2ee-error(unknown_session / not_verified / auth_failed …):
|
|
858
|
+
会话已经**不可恢复**,本机交接单已由 seNoteE2eeError 清掉 —— 这里只负责把"为什么"
|
|
859
|
+
说清楚(用户看到的必须是"需要重新解锁",而不是一个说不清的 502 或一句"已失效")。 */
|
|
860
|
+
var _STALE_HINT = {
|
|
861
|
+
unknown_session: "电脑端已不认识这个加密会话(通常是电脑端重启过,或会话太多被挤掉)。",
|
|
862
|
+
not_verified: "这个加密会话还没完成校验。",
|
|
863
|
+
auth_failed: "解密校验失败:密码与电脑端不一致,或传输被篡改。",
|
|
864
|
+
replay: "检测到重放。",
|
|
865
|
+
bad_envelope: "响应信封非法。",
|
|
866
|
+
bad_plain: "响应明文非法。"
|
|
867
|
+
};
|
|
868
|
+
function _reportStaleSession(code) {
|
|
869
|
+
var hint = _STALE_HINT[String(code || "")] || ("电脑端返回:" + String(code || "") + "。");
|
|
870
|
+
_badge("warn", "加密会话已失效,需要重新解锁", hint + " 已清除本机保存的会话;返回设备列表重新点选设备解锁即可恢复加密。");
|
|
871
|
+
}
|
|
872
|
+
/* 加密数据 WS(mux)**握手就没成功**:官方客户端只会安静地重连,页面不报错、数据全停。
|
|
873
|
+
这里把原因分档说清楚(会话/设备/网络),但**不做任何重连**(重连是官方的事,
|
|
874
|
+
我们再叠一层就变成"静默无限重连"——正是这次要修掉的形态)。 */
|
|
875
|
+
function _reportWsFail(label, detail, count) {
|
|
876
|
+
var n = Number(count) || 1;
|
|
877
|
+
var why = String(detail || "") === "error"
|
|
878
|
+
? "电脑端可能未连接(设备离线)、网络中断,或加密会话已失效。"
|
|
879
|
+
: ("连接被关闭(" + String(detail || "close") + "),多半是握手被拒或被中继/代理掐断。");
|
|
880
|
+
_badge("warn", "加密数据通道未建立(第 " + n + " 次)",
|
|
881
|
+
"与电脑端的加密 WebSocket 没连上:" + why + " 弱网时官方客户端会自动重连,稍等即可;"
|
|
882
|
+
+ "一直这样请返回设备列表重新解锁(会自动重建加密通道)。");
|
|
883
|
+
}
|
|
734
884
|
|
|
735
|
-
/* ----
|
|
885
|
+
/* ---- 补丁安装:**URL 改写永远装,加密按会话开关** ----
|
|
886
|
+
*
|
|
887
|
+
* 🔴 2026-09-27 修(P0-1 收尾):原来 fetch/WS 补丁只在"有会话"时安装(`_install(sess)`),
|
|
888
|
+
* 可**这正是它最需要生效的场景**:镜像页没有会话 → 不装补丁 → 官方客户端把 mux WS 拼成根路径
|
|
889
|
+
* `/api/remote.mux` → 中继 `resolveRoute` 只认 `dsh_device` cookie,cookie 过期/缺失就 404
|
|
890
|
+
* (`rejectUpgrade` 写状态行后立刻 destroy → nginx 只看到不透明的 009)→
|
|
891
|
+
* `e2ee-hello` 永远送不到宿主机 → **会话永远建不起来** → 之后每个受保护请求都是
|
|
892
|
+
* `unknown_session` 502。**死循环**:没会话→不装补丁→建不了会话。
|
|
893
|
+
* 生产已有实锤:上线 reject 分支日志后中继立刻打出
|
|
894
|
+
* `WS 升级被拒: reason=route_unresolved status=404 path=/api/remote.mux`。
|
|
895
|
+
*
|
|
896
|
+
* 现在:`_install()` 在 boot 阶段**无条件**安装(`ctx.sess` 先为 null),会话到了再填进去。
|
|
897
|
+
* 补丁里"不改写就原样放行"的分支也会带上改写后的 URL(见 seMakeWsCtor 的 passUrl)——
|
|
898
|
+
* 否则改写算出来又被丢掉,等于没做。
|
|
899
|
+
*/
|
|
736
900
|
var _installed = false;
|
|
901
|
+
var _ctx = null;
|
|
737
902
|
function _install(sess) {
|
|
903
|
+
if (sess) {
|
|
904
|
+
// 会话后到:只更新 ctx(补丁已装好,闭包读的是同一个对象)
|
|
905
|
+
if (_ctx) { _ctx.sess = sess; return; }
|
|
906
|
+
}
|
|
738
907
|
if (_installed) return;
|
|
739
908
|
_installed = true;
|
|
740
909
|
seFailureHook = _reportFail;
|
|
741
|
-
|
|
910
|
+
seStaleSessionHook = _reportStaleSession;
|
|
911
|
+
seWsFailHook = _reportWsFail;
|
|
912
|
+
/* deviceId 取自**页面路径**(`/remote/<deviceId>/…`,2026-09-19 起的标准入口):
|
|
913
|
+
WS 侧用它把根路径的 mux 改写成设备作用域形态,从而不再依赖会过期的 dsh_device cookie
|
|
914
|
+
(见 seScopeToDevice 的长注释与 2026-09-26 的 nginx 日志证据)。读不到就留空 = 不改写。 */
|
|
915
|
+
_ctx = { sess: sess || null, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
|
|
916
|
+
var ctx = _ctx;
|
|
742
917
|
try {
|
|
743
918
|
if (window.fetch) {
|
|
744
919
|
var origFetch = window.fetch.bind(window);
|
|
@@ -758,6 +933,7 @@
|
|
|
758
933
|
function _boot() {
|
|
759
934
|
try {
|
|
760
935
|
if (!(window.crypto && window.crypto.subtle && window.crypto.getRandomValues)) {
|
|
936
|
+
_install(null);
|
|
761
937
|
_badge("warn", "未加密:环境不支持", "当前浏览器环境不支持 WebCrypto(需 HTTPS 安全上下文),镜像页数据流保持明文。");
|
|
762
938
|
return;
|
|
763
939
|
}
|
|
@@ -772,16 +948,32 @@
|
|
|
772
948
|
try { lsSess = seSessionOfHandover(lsRaw); } catch (e) { lsSess = null; }
|
|
773
949
|
if (lsSess) {
|
|
774
950
|
_install(lsSess);
|
|
775
|
-
|
|
951
|
+
/* ⚠️ 0.6.18 起这句文案不再无条件说"加密好好的"(2026-09-26 事故的教训):
|
|
952
|
+
从 localStorage 恢复的会话**没法在本机验证**桥端是否还认它(桥端会话表是进程内存 +
|
|
953
|
+
24h TTL + CAP=32 LRU,桥重启/被挤掉之后这张交接单就是死票)。真实校验只能等
|
|
954
|
+
第一个受保护请求 —— 那一步由 seNoteE2eeError 兜底:一旦桥端回 unknown_session,
|
|
955
|
+
会话被清掉、徽标转 warn 并明说"需要重新解锁"。
|
|
956
|
+
这里**刻意仍用 ok 态**(而不是一刷新就报 ⚠):99% 的刷新会话是有效的,
|
|
957
|
+
照 R2 的建议无条件降级会让每次刷新都出现一个假告警 —— 本仓库一贯的取向是
|
|
958
|
+
"宁可漏报,也不要误报吓人"(见 mobile-adapter 的 load-warn 看门狗注释)。
|
|
959
|
+
折中做法:文案如实说明"恢复但未验证",并把出口写清楚。 */
|
|
960
|
+
_badge("ok", "端到端加密已开启(本机记忆恢复)",
|
|
961
|
+
"刷新后已自动恢复端到端加密(记住本机),密钥仅存内存。"
|
|
962
|
+
+ "注意:这次恢复的会话要到第一个数据请求才能确认电脑端仍认可它 —— "
|
|
963
|
+
+ "若稍后提示「加密会话已失效」,说明电脑端已重启或会话被挤掉,请返回设备列表重新解锁。");
|
|
776
964
|
return;
|
|
777
965
|
}
|
|
778
966
|
}
|
|
967
|
+
/* ⚠️ 即使没有会话也必须先装补丁:WS 的设备作用域改写与加密无关(见 _install 长注释)。
|
|
968
|
+
否则这里 return 掉 → mux 打根路径 → 404 → 会话永远建不起来(死循环)。 */
|
|
969
|
+
_install(null);
|
|
779
970
|
_badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
|
|
780
971
|
return;
|
|
781
972
|
}
|
|
782
973
|
var sess = null;
|
|
783
974
|
try { sess = seSessionOfHandover(raw); } catch (e) { sess = null; }
|
|
784
975
|
if (!sess) {
|
|
976
|
+
_install(null);
|
|
785
977
|
_badge("warn", "未加密:交接单无效", "一次性交接单无法解析(可能已损坏或过期)。请返回设备列表重新解锁。");
|
|
786
978
|
return;
|
|
787
979
|
}
|