dsh-remote-web 0.6.17 → 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.
- package/lib/client.js +754 -39
- package/lib/index.js +279 -6
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/e2ee-shim-script.js +168 -8
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/runtime/dsh-setup.mjs +152 -3
package/lib/index.js
CHANGED
|
@@ -2938,6 +2938,88 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
2938
2938
|
}
|
|
2939
2939
|
}
|
|
2940
2940
|
|
|
2941
|
+
/**
|
|
2942
|
+
* 把 `/dsh-remote/announcements*` 代理到云端公告 API(契约冻结见
|
|
2943
|
+
* dsh-relay-enterprise/docs/announcements-contract.md §3):
|
|
2944
|
+
* · GET /dsh-remote/announcements → 上游 GET {api}/api/announcements?client=desktop[&install_id=…]
|
|
2945
|
+
* · POST /dsh-remote/announcements/click → 上游 POST {api}/api/announcements/click {id[, install_id]}
|
|
2946
|
+
*
|
|
2947
|
+
* 三条刻意的设计(都是"服务端契约之外"的客户端纪律,写在这里免得后人"顺手优化"掉):
|
|
2948
|
+
*
|
|
2949
|
+
* ① **身份维度二选一,绝不混用**:能拿到账号 JWT 就只带 `Authorization: Bearer`(服务端按 user 维度去重,
|
|
2950
|
+
* 同一个人手机看过电脑就不再弹);拿不到才退回 `install_id`(装机维度)。
|
|
2951
|
+
* 两者**同时**带上会让唯一索引 (announcement_id, user_id, install_id) 把"同一人两台机器"
|
|
2952
|
+
* 算成两行 —— UV 直接翻倍。所以下面是有 Bearer 就不带 install_id,反之亦然。
|
|
2953
|
+
* ② **install_id 复用本机既有的 device_id,绝不新造 ID**:服务端那个维度就是"这台电脑",
|
|
2954
|
+
* 再生成一个 ID 只会让同一台机器在统计里变成两台(且新 ID 与反馈/设备列表的口径对不上)。
|
|
2955
|
+
* 注意 `cfg.device_id` 是**本机设备标识**,与遥测的 `.telemetry-install-id` 不是一回事:
|
|
2956
|
+
* 后者在 DSH_REMOTE_TELEMETRY=0 时根本不生成,公告不该被遥测开关牵连。
|
|
2957
|
+
* ③ **失败一律软着陆**:上游 404(老服务端)/ 不可达 / 超时都只回一个 JSON,**绝不抛**。
|
|
2958
|
+
* 浏览器半对这一切的既定反应是 fail closed(不显示入口、不弹窗),所以这里透传状态码即可,
|
|
2959
|
+
* 既不用把它改写成 200(那会让"接口通不通"这件事查不出来),也不会把面板打挂。
|
|
2960
|
+
*/
|
|
2961
|
+
async function proxyAnnouncements(relayDir, req, res, pathname) {
|
|
2962
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
2963
|
+
try {
|
|
2964
|
+
const cfg = loadConfig(relayDir);
|
|
2965
|
+
const api = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
2966
|
+
const suffix = pathname.replace(/^\/dsh-remote\/announcements/, "") || "/";
|
|
2967
|
+
const isClick = method === "POST" && suffix === "/click";
|
|
2968
|
+
// 未知子路径:老/新浏览器半都可能拼错,直接 404(不抛、不代理到奇怪的上游路径)
|
|
2969
|
+
if (!(isClick || (method === "GET" && (suffix === "/" || suffix === "")))) {
|
|
2970
|
+
return sendJson(res, 404, { ok: false, error: "unknown announcements route" });
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
const headers = { "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}` };
|
|
2974
|
+
// 浏览器已带的 Bearer 优先透传(与 proxyFeedback 同口径:那是请求方自己的身份);
|
|
2975
|
+
// 否则由 node 半附当前账号 JWT —— 只有"确实配了账号"才值得为此跑一次 device-login。
|
|
2976
|
+
const auth = req.headers.authorization;
|
|
2977
|
+
let bearer = "";
|
|
2978
|
+
if (auth && /^Bearer\s+/i.test(auth)) bearer = String(auth);
|
|
2979
|
+
else if (cfg.local_key || (cfg.phone && cfg.password)) {
|
|
2980
|
+
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
2981
|
+
if (t) bearer = `Bearer ${t}`;
|
|
2982
|
+
}
|
|
2983
|
+
if (bearer) headers.authorization = bearer;
|
|
2984
|
+
|
|
2985
|
+
let url;
|
|
2986
|
+
let init = { method, headers };
|
|
2987
|
+
if (isClick) {
|
|
2988
|
+
url = `${api}/api/announcements/click`;
|
|
2989
|
+
let payload = {};
|
|
2990
|
+
try {
|
|
2991
|
+
const buf = await readBodyBuffer(req);
|
|
2992
|
+
const parsed = JSON.parse(buf.toString("utf8") || "{}");
|
|
2993
|
+
if (parsed && typeof parsed === "object") payload = parsed;
|
|
2994
|
+
} catch { payload = {}; } // 请求体读不到/不是 JSON → 上游按无 id 处理(404),不在这里抛
|
|
2995
|
+
// 无账号维度时补 install_id,保证点击也能记到"这台电脑"名下(有 Bearer 时**不补**,见上面 ① )
|
|
2996
|
+
if (!bearer) {
|
|
2997
|
+
const installId = String(cfg.device_id || "").trim();
|
|
2998
|
+
if (installId && !payload.install_id) payload.install_id = installId;
|
|
2999
|
+
}
|
|
3000
|
+
init = { ...init, headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(payload) };
|
|
3001
|
+
} else {
|
|
3002
|
+
url = `${api}/api/announcements?client=desktop`;
|
|
3003
|
+
if (!bearer) {
|
|
3004
|
+
const installId = String(cfg.device_id || "").trim();
|
|
3005
|
+
if (installId) url += `&install_id=${encodeURIComponent(installId)}`;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
const r = await fetch(url, { ...init, signal: AbortSignal.timeout(8000) });
|
|
3010
|
+
if (r.status === 401) fbTokenCache = { token: "", exp: 0 }; // token 失效 → 下次请求重取
|
|
3011
|
+
const text = await r.text();
|
|
3012
|
+
res.writeHead(r.status, {
|
|
3013
|
+
"content-type": r.headers.get("content-type") || "application/json; charset=utf-8",
|
|
3014
|
+
"cache-control": "no-store",
|
|
3015
|
+
});
|
|
3016
|
+
res.end(text);
|
|
3017
|
+
} catch (e) {
|
|
3018
|
+
// 网络不可达 / 超时 / JSON 异常:一律 502 + 人话,绝不把异常抛回 webServer(那会变成 500 堆栈)
|
|
3019
|
+
return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${e.message}` });
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
2941
3023
|
// ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
|
|
2942
3024
|
|
|
2943
3025
|
/** 插件 id / 包名(2026-09 由 dsh-remote-ui 更名)。 */
|
|
@@ -3263,6 +3345,8 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3263
3345
|
// 既不把内测装机降级到稳定版,也不让它错过已发布的正式版。
|
|
3264
3346
|
const target = await resolveUpdateTarget();
|
|
3265
3347
|
const spec = `@mrrisega/dsh-remote@${target.tag}`;
|
|
3348
|
+
// 新一轮开始 → 先把上一轮的日志归档(见 rotateUpdateLog 注释:无界增长 + 面板黑框拉回旧尾巴)
|
|
3349
|
+
rotateUpdateLog(relayDir);
|
|
3266
3350
|
appendLogLine(relayDir, UPDATE_LOG,
|
|
3267
3351
|
`[update] 开始在线更新 ${spec}(通道 ${UPDATE_TAG};${target.why})(${new Date().toISOString()})`);
|
|
3268
3352
|
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
@@ -3343,6 +3427,8 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3343
3427
|
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
3344
3428
|
} else {
|
|
3345
3429
|
updateFailures.delete(String(relayDir));
|
|
3430
|
+
// 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
|
|
3431
|
+
markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
|
|
3346
3432
|
}
|
|
3347
3433
|
});
|
|
3348
3434
|
child.unref();
|
|
@@ -3417,6 +3503,19 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3417
3503
|
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
3418
3504
|
} else {
|
|
3419
3505
|
updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
|
|
3506
|
+
// 【2026-09-26】更新成功 → 写一条 kind="restart" 的待重启标记。
|
|
3507
|
+
//
|
|
3508
|
+
// 修的是"文案与状态机不一致":版本卡一直写「请重启 dsh web 后生效」(client.js 的
|
|
3509
|
+
// `updated` 分支),但**没有任何一处**在成功路径上写过待重启状态 —— 于是那句文案是个
|
|
3510
|
+
// 无人兑现的承诺,用户在页面上也看不到任何"要动一下"的强提示。
|
|
3511
|
+
//
|
|
3512
|
+
// kind 用 "restart" 而**不是**既有的 "refresh":两者是**不同的动作**
|
|
3513
|
+
// · refresh = 插件文件在运行中被改写,刷新浏览器即可(客户端半新的,node 半也是新的)
|
|
3514
|
+
// · restart = 在线更新刚换了插件包,需要重启 dsh web 才载入新的 node 半
|
|
3515
|
+
// 客户端按 kind 一一对应地换文案与主按钮(见 client.js 的 restartAlert),
|
|
3516
|
+
// 混用一个 kind 就会再次出现"文案说刷新、状态其实是重启"。
|
|
3517
|
+
// settleRestartState() 靠 bootId 自动结清 —— 用户真的重启后这条提示自己消失,不需要额外清理。
|
|
3518
|
+
markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
|
|
3420
3519
|
}
|
|
3421
3520
|
});
|
|
3422
3521
|
child.unref();
|
|
@@ -3445,6 +3544,28 @@ function tailOf(filePath, lines = 24) {
|
|
|
3445
3544
|
} catch { return ""; }
|
|
3446
3545
|
}
|
|
3447
3546
|
|
|
3547
|
+
/**
|
|
3548
|
+
* 新一轮在线更新**开始前**轮转 `.dsh-update.log`:旧内容归档成 `.dsh-update.log.1`(只留一份)。
|
|
3549
|
+
*
|
|
3550
|
+
* 为什么必须在"开始"时做,而不是"成功"时清空:
|
|
3551
|
+
* ① 这份日志是 `openSync(..., "a")` **只 append 从不截断**的 —— 一轮轮更新下来会无界增长,
|
|
3552
|
+
* 违反工作区 AGENTS.md「日志必须有轮转」;而失败现场恰恰是最需要保留的那一份,
|
|
3553
|
+
* 所以在成功时删反而会把证据毁掉。归档旧的一轮、新的一轮另起一页才是对的分界。
|
|
3554
|
+
* ② 面板的「更新日志」黑框读的就是这个文件的尾部(tailOf):不轮转的话,**刷新页面还会把
|
|
3555
|
+
* 上一次的尾巴拉回来**,用户看到的"更新黑框永不消失"有一半责任在这里。
|
|
3556
|
+
* 客户端那半已经按 running 状态 gate 住了(见 client.js 的 updShowLog),这一条是宿主侧的根治。
|
|
3557
|
+
* 轮转失败(权限/占用)不致命:退化成继续 append 的旧行为,绝不影响更新本身。
|
|
3558
|
+
*/
|
|
3559
|
+
function rotateUpdateLog(relayDir) {
|
|
3560
|
+
try {
|
|
3561
|
+
const file = join(relayDir, UPDATE_LOG);
|
|
3562
|
+
if (!existsSync(file)) return;
|
|
3563
|
+
const prev = `${file}.1`;
|
|
3564
|
+
try { rmSync(prev, { force: true }); } catch { /* 上一份归档删不掉就覆盖写,继续 */ }
|
|
3565
|
+
renameSync(file, prev);
|
|
3566
|
+
} catch { /* 轮转失败:静默,退化为 append */ }
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3448
3569
|
/**
|
|
3449
3570
|
* 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
|
|
3450
3571
|
*
|
|
@@ -4574,6 +4695,11 @@ const TELEMETRY_EVENT_NAMES = new Set([
|
|
|
4574
4695
|
"tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
|
|
4575
4696
|
"update_started", "update_failed",
|
|
4576
4697
|
"wechat_bound", "wechat_unbound",
|
|
4698
|
+
// 2026-09-26(926 迭代)新增:手机镜像页**就绪耗时**。这一条是"慢加载"唯一的量化来源 ——
|
|
4699
|
+
// 在此之前只能靠用户体感反馈。字段 ms(耗时)/ at(客户端事件时刻)/ outcome(ok|slow)
|
|
4700
|
+
// 由路由 /dsh-remote/telemetry/mirror-load 校验后带入(见 telemetryEventOf)。
|
|
4701
|
+
// 与服务端 TELEMETRY_EVENTS 必须逐字对齐(少一个 = 服务端静默丢弃)。
|
|
4702
|
+
"mirror_load",
|
|
4577
4703
|
]);
|
|
4578
4704
|
/**
|
|
4579
4705
|
* fail_code 白名单 —— **必须与服务端 TELEMETRY_FAIL_CODES 完全一致**。
|
|
@@ -4856,6 +4982,17 @@ function telemetryEventOf(name, extra = {}) {
|
|
|
4856
4982
|
const code = String(extra.fail_code || "");
|
|
4857
4983
|
ev.fail_code = TELEMETRY_FAIL_CODES.has(code) ? code : "unknown"; // 白名单外 → unknown
|
|
4858
4984
|
}
|
|
4985
|
+
// mirror_load:唯一带**数值**的事件。契约 §2 的两列语义:
|
|
4986
|
+
// · at = 客户端事件发生时刻(epoch ms)→ 服务端 at_ms
|
|
4987
|
+
// · ms = 耗时毫秒 → 服务端 value_ms
|
|
4988
|
+
// at 允许缺省(0 = 没给)→ 保留上面那个 Date.now()(= 服务端收包时刻的近似),
|
|
4989
|
+
// 但**绝不把 0 发出去**:0 在分析里会被读成"1970 年",比"没有值"更糟。
|
|
4990
|
+
// ms 同样只在有限数时写入(越界/非法由路由先挡掉,这里是第二道防线)。
|
|
4991
|
+
if (name === "mirror_load") {
|
|
4992
|
+
if (Number.isFinite(Number(extra.at)) && Number(extra.at) > 0) ev.at = Math.round(Number(extra.at));
|
|
4993
|
+
if (Number.isFinite(Number(extra.ms))) ev.ms = Math.max(0, Math.min(600000, Math.round(Number(extra.ms))));
|
|
4994
|
+
ev.outcome = extra.outcome === "slow" ? "slow" : "ok";
|
|
4995
|
+
}
|
|
4859
4996
|
return ev;
|
|
4860
4997
|
}
|
|
4861
4998
|
|
|
@@ -5708,6 +5845,36 @@ function registerRoutes(ctx, relayDir) {
|
|
|
5708
5845
|
sendJson(res, 200, { ok: true });
|
|
5709
5846
|
},
|
|
5710
5847
|
},
|
|
5848
|
+
// 镜像页就绪耗时(D1 在 clients/dsh-remote/mobile-adapter.mjs 里量,经此入队):
|
|
5849
|
+
// body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow" }
|
|
5850
|
+
// 契约(926 迭代 §2):事件名固定 mirror_load,ms 钳制 [0, 600000],outcome ∈ {ok, slow}。
|
|
5851
|
+
//
|
|
5852
|
+
// 🔒 入参校验失败**静默丢弃**但仍回 200 {ok:true}:这条上报是手机镜像页加载路径上的
|
|
5853
|
+
// fire-and-forget,任何 4xx 都可能被页面当成"功能坏了"而冒泡给用户;统计丢一条无所谓,
|
|
5854
|
+
// 打扰用户不可接受。校验放在**路由里**(而不是 telemetryRecord 里)是因为遥测入队
|
|
5855
|
+
// 必须保持"只认白名单、绝不抛"的既有语义,业务校验不属于那一层。
|
|
5856
|
+
{
|
|
5857
|
+
method: "POST",
|
|
5858
|
+
path: "/dsh-remote/telemetry/mirror-load",
|
|
5859
|
+
handler: async (req, res) => {
|
|
5860
|
+
try {
|
|
5861
|
+
const buf = await readBodyBuffer(req);
|
|
5862
|
+
let body = null;
|
|
5863
|
+
try { body = JSON.parse(buf.toString("utf8") || "{}"); } catch { body = null; }
|
|
5864
|
+
const ms = Number(body && body.ms);
|
|
5865
|
+
if (body && Number.isFinite(ms) && ms >= 0 && ms <= 600000) {
|
|
5866
|
+
const atRaw = Number(body.at);
|
|
5867
|
+
const at = Number.isFinite(atRaw) && atRaw > 0 ? Math.round(atRaw) : 0;
|
|
5868
|
+
telemetryRecord(relayDir, "mirror_load", {
|
|
5869
|
+
ms: Math.round(ms),
|
|
5870
|
+
at,
|
|
5871
|
+
outcome: body.outcome === "slow" ? "slow" : "ok",
|
|
5872
|
+
});
|
|
5873
|
+
}
|
|
5874
|
+
} catch { /* 体读不到/超限:静默 */ }
|
|
5875
|
+
sendJson(res, 200, { ok: true });
|
|
5876
|
+
},
|
|
5877
|
+
},
|
|
5711
5878
|
// 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
|
|
5712
5879
|
{
|
|
5713
5880
|
method: "POST",
|
|
@@ -6192,6 +6359,19 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6192
6359
|
await proxyFeedback(relayDir, req, res, url.pathname + url.search);
|
|
6193
6360
|
},
|
|
6194
6361
|
},
|
|
6362
|
+
// 📣 公告(面板「未读公告」入口 + 电脑端弹窗):
|
|
6363
|
+
// 声明成 prefix:true + 自行判别子路径,与 feedback 同一形态 ——
|
|
6364
|
+
// `/dsh-remote/announcements`(列表)与 `/dsh-remote/announcements/click`(点击/已读上报)
|
|
6365
|
+
// 是一对,拆成两条 exact 会在以后加子接口(比如详情)时又要动路由表。
|
|
6366
|
+
{
|
|
6367
|
+
method: "ALL",
|
|
6368
|
+
path: "/dsh-remote/announcements",
|
|
6369
|
+
prefix: true,
|
|
6370
|
+
handler: async (req, res) => {
|
|
6371
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
6372
|
+
await proxyAnnouncements(relayDir, req, res, url.pathname);
|
|
6373
|
+
},
|
|
6374
|
+
},
|
|
6195
6375
|
];
|
|
6196
6376
|
|
|
6197
6377
|
const disposers = [];
|
|
@@ -6389,6 +6569,101 @@ async function pinBrowseDirectoryPicker(ctx, { attempts = 6, gapMs = 700 } = {})
|
|
|
6389
6569
|
return pickerPinState;
|
|
6390
6570
|
}
|
|
6391
6571
|
|
|
6572
|
+
/** 不持有进程的 sleep(未 ref:宿主随时可以正常退出,不被我们的定时器拖住)。 */
|
|
6573
|
+
function sleepUnref(ms) {
|
|
6574
|
+
return new Promise((resolve) => {
|
|
6575
|
+
const t = setTimeout(resolve, ms);
|
|
6576
|
+
if (t && typeof t.unref === "function") t.unref();
|
|
6577
|
+
});
|
|
6578
|
+
}
|
|
6579
|
+
|
|
6580
|
+
/* ── 延后接线的闸门参数(全部只为"别在宿主启动期动 Loader",不是功能参数) ───────────── */
|
|
6581
|
+
const PICKER_PIN_READY_WAIT_MS = 120_000; // 等宿主 HTTP 服务 listen 的上限
|
|
6582
|
+
const PICKER_PIN_SETTLE_MS = 8_000; // listen 之后再静置,避开首轮 client-module compose
|
|
6583
|
+
const PICKER_PIN_PROBE_WAIT_MS = 30_000; // 等服务挂出来的上限(只等,不换)
|
|
6584
|
+
const PICKER_PIN_PROBE_GAP_MS = 3_000; // 上面那段轮询间隔
|
|
6585
|
+
|
|
6586
|
+
/**
|
|
6587
|
+
* 目录选择器的**延后**接线 —— 这是 2026-09-26 启动事故的直接修复。
|
|
6588
|
+
*
|
|
6589
|
+
* 🔴 事故:旧实现在 `apply()` 里**立刻**调 `pinBrowseDirectoryPicker(ctx)`(最多 6 次 × 700ms 轮询)。
|
|
6590
|
+
* `loader.remove()` / `loader.create()` 会触发 dsh 的 `internal/plugin` 事件,而
|
|
6591
|
+
* `@deepseek-ai/dsh-client-modules` 收到事件就**重新 compose 整张前端插件图**
|
|
6592
|
+
* (`ClientModuleRegistry.flush() → buildCombo() → newlineCount()/identitySectionMap()`)。
|
|
6593
|
+
* 启动期本来就在装载几十个插件,此时再改 Loader = 昂贵的重复组合。真机 CPU profile 与实测都
|
|
6594
|
+
* 指向这里:把 `DSH_REMOTE_NATIVE_PICKER=1` 写进 launchd plist 后 dsh web 立刻恢复正常启动
|
|
6595
|
+
* (3080 监听、401 约 2ms、单实例);不设时出现过"进程活着、3 分钟不监听、CPU 一直转、
|
|
6596
|
+
* 日志不更新"的假死。
|
|
6597
|
+
*
|
|
6598
|
+
* 所以现在(与旧实现的四点区别):
|
|
6599
|
+
* ① **等宿主把 HTTP 服务 listen 起来**(`boundWebPort() > 0`)—— 这是"宿主已启动"最可靠的信号;
|
|
6600
|
+
* `apply()` 时它还没 listen 完,旧实现正是在"插件图还在装配"的窗口里动 Loader,最坏;
|
|
6601
|
+
* ② listen 之后再**静置** `PICKER_PIN_SETTLE_MS`,让首轮 compose 先跑完;
|
|
6602
|
+
* ③ 之后只在"服务还认不出"时短轮询;**一旦认出 native 就换,且只换这一次**(换失败也不再来回折腾);
|
|
6603
|
+
* ④ 认不出/超时/出错 → **静默保留原生选择器**(= 原始行为),绝不影响宿主启动。
|
|
6604
|
+
*
|
|
6605
|
+
* @returns {() => void} 取消函数(供 `ctx.effect` 做清理)
|
|
6606
|
+
*/
|
|
6607
|
+
function schedulePickerPin(ctx) {
|
|
6608
|
+
let cancelled = false;
|
|
6609
|
+
const gone = () => cancelled;
|
|
6610
|
+
|
|
6611
|
+
const waitUntil = async (cond, timeoutMs, stepMs) => {
|
|
6612
|
+
const t0 = Date.now();
|
|
6613
|
+
for (;;) {
|
|
6614
|
+
if (gone()) return false;
|
|
6615
|
+
let ok = false;
|
|
6616
|
+
try { ok = !!cond(); } catch { ok = false; }
|
|
6617
|
+
if (ok) return true;
|
|
6618
|
+
if (Date.now() - t0 >= timeoutMs) return false;
|
|
6619
|
+
await sleepUnref(stepMs);
|
|
6620
|
+
}
|
|
6621
|
+
};
|
|
6622
|
+
|
|
6623
|
+
void (async () => {
|
|
6624
|
+
try {
|
|
6625
|
+
if (String(process.env.DSH_REMOTE_NATIVE_PICKER || "") === "1") {
|
|
6626
|
+
pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "DSH_REMOTE_NATIVE_PICKER=1,保留系统原生对话框" };
|
|
6627
|
+
return;
|
|
6628
|
+
}
|
|
6629
|
+
// ① 等宿主 listen(apply 时还没 listen 完)
|
|
6630
|
+
const up = await waitUntil(() => boundWebPort() > 0, PICKER_PIN_READY_WAIT_MS, 1000);
|
|
6631
|
+
if (gone()) return;
|
|
6632
|
+
if (!up) {
|
|
6633
|
+
pickerPinState = { kind: "", action: "skipped", detail: "宿主监听端口未在等待窗口内就绪,本次不干预目录选择器(保留原生)" };
|
|
6634
|
+
ctx.logger?.info?.("dsh-remote-web: 宿主监听端口未就绪,跳过目录选择器切换(不影响启动)");
|
|
6635
|
+
return;
|
|
6636
|
+
}
|
|
6637
|
+
// ② 静置,避开首轮 client-module compose
|
|
6638
|
+
await sleepUnref(PICKER_PIN_SETTLE_MS);
|
|
6639
|
+
if (gone()) return;
|
|
6640
|
+
// ③ 等服务挂出来(**只等不换**)
|
|
6641
|
+
await waitUntil(() => directoryPickerKind(ctx) !== "", PICKER_PIN_PROBE_WAIT_MS, PICKER_PIN_PROBE_GAP_MS);
|
|
6642
|
+
if (gone()) return;
|
|
6643
|
+
const kind = directoryPickerKind(ctx);
|
|
6644
|
+
if (!kind) {
|
|
6645
|
+
pickerPinState = { kind: "", action: "skipped", detail: "窗口内未确认到选择器服务,保留原生(下次重启再试)" };
|
|
6646
|
+
return;
|
|
6647
|
+
}
|
|
6648
|
+
if (kind === "browse") {
|
|
6649
|
+
pickerPinState = { kind, action: "none", detail: "已经是浏览器内选择器" };
|
|
6650
|
+
return;
|
|
6651
|
+
}
|
|
6652
|
+
if (kind !== "native") {
|
|
6653
|
+
pickerPinState = { kind, action: "skipped", detail: "选择器服务当前是 " + kind + ",不认识,保留原样" };
|
|
6654
|
+
return;
|
|
6655
|
+
}
|
|
6656
|
+
// ④ 只换一次(attempts:1 → 内部不会再轮询)
|
|
6657
|
+
await pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 0 });
|
|
6658
|
+
} catch (e) {
|
|
6659
|
+
// 目录选择器是**锦上添花**:任何异常都必须静默,绝不把宿主拖下水
|
|
6660
|
+
try { ctx.logger?.warn?.("dsh-remote-web: 目录选择器延后切换异常(已忽略):" + (e && e.message ? e.message : e)); } catch { /* ignore */ }
|
|
6661
|
+
}
|
|
6662
|
+
})();
|
|
6663
|
+
|
|
6664
|
+
return () => { cancelled = true; };
|
|
6665
|
+
}
|
|
6666
|
+
|
|
6392
6667
|
export function apply(ctx, config = {}) {
|
|
6393
6668
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
6394
6669
|
// 记住宿主 ctx:上游地址要问 dsh web 自己的监听端口(见 upstreamUrl)。
|
|
@@ -6429,11 +6704,9 @@ export function apply(ctx, config = {}) {
|
|
|
6429
6704
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
6430
6705
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
6431
6706
|
// 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
return () => { alive = false; void alive; };
|
|
6436
|
-
}, "dsh-remote-web: pin browser directory picker");
|
|
6707
|
+
// ⚠️ **必须延后**:绝不能在 apply() 里直接切 Loader —— 会把宿主启动拖成假死(2026-09-26 事故,
|
|
6708
|
+
// 见 schedulePickerPin 上方长注释)。schedulePickerPin 自己等"宿主已 listen + 静置"再动手。
|
|
6709
|
+
ctx.effect(() => schedulePickerPin(ctx), "dsh-remote-web: pin browser directory picker");
|
|
6437
6710
|
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
6438
6711
|
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
|
|
6439
6712
|
// 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
|
|
@@ -6455,6 +6728,6 @@ export function apply(ctx, config = {}) {
|
|
|
6455
6728
|
// test hooks:cordis 只读 name/inject/apply,这些导出只给用例(见 test/picker-pin.test.mjs)。
|
|
6456
6729
|
// 为什么不放进 apply 内部直接测:apply 是"装载即副作用"的同步函数,选择器固定是异步且带重试窗口的,
|
|
6457
6730
|
// 单独导出才能把「**先摘 auto、后建 browse**」这个顺序契约钉死。
|
|
6458
|
-
export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
|
|
6731
|
+
export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerPin as __schedulePickerPin, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
|
|
6459
6732
|
// 日志去重(同一条失败不刷屏)也要能被单测钉住 —— 它属于「诊断可读性」,见 test/linux-bridge.test.mjs
|
|
6460
6733
|
noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18-beta.1",
|
|
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",
|
|
@@ -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,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)) {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|