@mrrisega/dsh-remote 0.8.1 → 0.8.2-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/clients/dsh-remote/dsh-bridge.mjs +14 -5
- package/clients/dsh-remote/mobile-adapter.mjs +94 -0
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +76 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +6 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +14 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +94 -0
|
@@ -2531,12 +2531,21 @@ async function registerDeviceInAccount(token) {
|
|
|
2531
2531
|
...(Number.isFinite(Number(d.error?.limit ?? policy.limit)) ? { limit: Number(d.error?.limit ?? policy.limit) } : {}),
|
|
2532
2532
|
...(d.error?.upgrade_to || policy.upgrade_to ? { upgrade_to: d.error?.upgrade_to || policy.upgrade_to } : {})
|
|
2533
2533
|
};
|
|
2534
|
-
if (code === "device_bind_cap") {
|
|
2535
|
-
//
|
|
2534
|
+
if (code === "device_bind_cap" || code === "machine_already_bound") {
|
|
2535
|
+
// 永久性失败(用户不解绑就不会自愈)→ 专用退出码,watcher 按指数退避重拉(契约 §6.3/§6.4):
|
|
2536
|
+
// · device_bind_cap = 按套餐/防滥用硬上限,已绑定条数满了;
|
|
2537
|
+
// · machine_already_bound = 同一台电脑上已有另一份实例占位(2026-11-14 先到先得),
|
|
2538
|
+
// 必须由用户在手机端「管理电脑」解绑后才轮到这份登记。
|
|
2536
2539
|
persistBindError({ code, status: 409, message, ...details });
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
+
if (code === "machine_already_bound") {
|
|
2541
|
+
console.error(`[bridge] 这台电脑已有另一份 dsh-remote 在运行,本实例注册不上来:${message}`);
|
|
2542
|
+
console.error("[bridge] 若要用这一份:先关掉另一份实例,并在手机端「管理电脑」解绑那台设备后再重试。");
|
|
2543
|
+
console.error("[bridge] 这是服务端的「先到先得」策略,不是网络问题;守护会按指数退避自动重试,解绑后自动恢复。");
|
|
2544
|
+
} else {
|
|
2545
|
+
console.error(`[bridge] 设备绑定已达上限:${message}`);
|
|
2546
|
+
console.error("[bridge] 这是按套餐设定的防滥用硬上限(不是网络问题),本机不会反复空撞接口。");
|
|
2547
|
+
console.error("[bridge] 请在手机端「管理电脑」解绑不再使用的设备;守护会按指数退避自动重试,解绑后自动恢复。");
|
|
2548
|
+
}
|
|
2540
2549
|
process.exit(DEVICE_BIND_CAP_EXIT_CODE);
|
|
2541
2550
|
}
|
|
2542
2551
|
if (code === "device_limit_exceeded" || code === "device_locked") {
|
|
@@ -1281,6 +1281,8 @@ const SCRIPT = `(() => {
|
|
|
1281
1281
|
compatOfficial: maCompatOfficial,
|
|
1282
1282
|
scrim: maScrimStats,
|
|
1283
1283
|
retry: maRetryStats,
|
|
1284
|
+
/* 登录态失效(2026-11-14):只读镜像页自己的判定结果,供"复制诊断信息"与排障使用。 */
|
|
1285
|
+
authExpired: maAuthExpired === true,
|
|
1284
1286
|
load: { slow: maLoadWatch.slow, progress: maLoadWatch.progress, apiFails: maLoadWatch.apiFails,
|
|
1285
1287
|
lastApiStatus: maLoadWatch.lastApiStatus,
|
|
1286
1288
|
activeMs: (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : -1,
|
|
@@ -1447,6 +1449,10 @@ const SCRIPT = `(() => {
|
|
|
1447
1449
|
/* 界面已经就绪 = 这次失败没有卡住首屏。此时重建元素可能让模块/插件**再执行一遍**
|
|
1448
1450
|
(官方模块加载器是队列式),风险大于收益 —— 只记录、不重试。 */
|
|
1449
1451
|
if (maLoadReadyNow()) return false;
|
|
1452
|
+
/* 已经判定登录态失效 → 再重建元素也只会再吃一个 401,不再排队重试(省一条 499)。 */
|
|
1453
|
+
if (maAuthExpired) return false;
|
|
1454
|
+
/* 首屏没就绪时才值得怀疑"是不是登录态过期了"(见下方 maProbeAuthOnce 的长注释)。 */
|
|
1455
|
+
maProbeAuthOnce();
|
|
1450
1456
|
if (n >= MA_RES_RETRY_MAX) { maRetry.exhausted = true; return false; }
|
|
1451
1457
|
maRetry.perUrl[key] = n + 1;
|
|
1452
1458
|
var delay = MA_RES_RETRY_BASE_MS * Math.pow(2, n); // 2s → 4s(指数退避)
|
|
@@ -1455,6 +1461,90 @@ const SCRIPT = `(() => {
|
|
|
1455
1461
|
} catch (e) { return false; }
|
|
1456
1462
|
}
|
|
1457
1463
|
|
|
1464
|
+
/* ===== 会话过期探测:让"登录失效"自己说出来(2026-11-14)=====
|
|
1465
|
+
为什么需要(生产现场 dev-f9b47a20d23a):
|
|
1466
|
+
· 会话(dsh_token)过期时,中继对**页面导航**回 302 到 /app/?reason=expired(外壳有提示);
|
|
1467
|
+
· 但 <script>/<link> 这类**子资源**拿的是 401 JSON —— 浏览器把它当 JS 解析失败,只触发
|
|
1468
|
+
resource error,页面上看不到任何状态码;
|
|
1469
|
+
· 适配层既有的"会话过期条"只观察 fetch(且只认 401),**看不见子资源的状态码**。
|
|
1470
|
+
结果:首屏 12 个资源失败、自动重试 8 次全部打空(重试还是 401),用户干等 27 秒,
|
|
1471
|
+
只看到"资源加载失败",完全不知道该重新登录 —— 这就是"token 过期提醒不友好"。
|
|
1472
|
+
做法:首屏资源失败且界面还没就绪时,**节流地**探一次 /_quota(同源、需登录态、极轻)。
|
|
1473
|
+
拿到 401 → 判定登录态失效(不是资源/网络问题)→ 弹一条可点的「去重新登录」,
|
|
1474
|
+
并把自动重试停掉(再刷也是 401,只会制造 499)。
|
|
1475
|
+
纪律:只读探测;不动任何业务请求;不新增上报字段(诊断里只多一个 authExpired 布尔)。 */
|
|
1476
|
+
var maAuthExpired = false;
|
|
1477
|
+
var maAuthProbe = { at: 0, inflight: false };
|
|
1478
|
+
var AUTH_PROBE_MIN_MS = 8000;
|
|
1479
|
+
|
|
1480
|
+
/** 弹出极简"会话过期"浮层(幂等;不依赖菜单是否已建出来)。 */
|
|
1481
|
+
function maShowAuthOverlay() {
|
|
1482
|
+
try {
|
|
1483
|
+
var D = (typeof document !== "undefined" && document)
|
|
1484
|
+
|| (window && window.document)
|
|
1485
|
+
|| null;
|
|
1486
|
+
if (!D) return;
|
|
1487
|
+
if (D.getElementById && D.getElementById("dsh-ma-auth-expired")) return;
|
|
1488
|
+
var box = D.createElement("div");
|
|
1489
|
+
box.id = "dsh-ma-auth-expired";
|
|
1490
|
+
try { box.setAttribute("id", "dsh-ma-auth-expired"); } catch (eId) { /* 老实现只有属性反射时兜底 */ }
|
|
1491
|
+
box.setAttribute("style",
|
|
1492
|
+
"position:fixed;left:0;right:0;bottom:0;z-index:2147483000;padding:14px 16px;"
|
|
1493
|
+
+ "background:#1b1f24;color:#e6edf3;border-top:1px solid #30363d;"
|
|
1494
|
+
+ "font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
|
|
1495
|
+
+ "display:flex;gap:10px;align-items:center;flex-wrap:wrap");
|
|
1496
|
+
var msg = D.createElement("span");
|
|
1497
|
+
msg.setAttribute("style", "flex:1 1 200px;min-width:0");
|
|
1498
|
+
msg.textContent = "登录状态已过期,需要重新登录后才能进入。";
|
|
1499
|
+
var btn = D.createElement("button");
|
|
1500
|
+
btn.type = "button";
|
|
1501
|
+
btn.textContent = "去重新登录";
|
|
1502
|
+
btn.setAttribute("style",
|
|
1503
|
+
"flex:0 0 auto;padding:8px 14px;border-radius:8px;border:0;background:#2f81f7;color:#fff;font-weight:600");
|
|
1504
|
+
btn.onclick = function () {
|
|
1505
|
+
try { window.location.assign("/app/?reason=expired&from=mirror-auth"); }
|
|
1506
|
+
catch (e1) { try { window.location.href = "/app/?reason=expired&from=mirror-auth"; } catch (e2) { /* 忽略 */ } }
|
|
1507
|
+
};
|
|
1508
|
+
box.appendChild(msg);
|
|
1509
|
+
box.appendChild(btn);
|
|
1510
|
+
var host = D.body || D.documentElement;
|
|
1511
|
+
if (host) host.appendChild(box);
|
|
1512
|
+
} catch (e) { /* 浮层坏了也绝不影响页面 */ }
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** 判定登录态失效:停自动重试 + 出浮层 + 诊断字符串换档(原因=auth)。 */
|
|
1516
|
+
function maOnAuthExpired(why) {
|
|
1517
|
+
try {
|
|
1518
|
+
if (maAuthExpired) return;
|
|
1519
|
+
maAuthExpired = true;
|
|
1520
|
+
/* 停掉自动重试:401 不是"再试一次就好"的失败,重试只会继续制造 499 与白屏时间。 */
|
|
1521
|
+
maRetry.manual = true;
|
|
1522
|
+
maRetry.exhausted = true;
|
|
1523
|
+
maShowAuthOverlay();
|
|
1524
|
+
try { maBumpRetryStats("auth:" + String(why || "")); } catch (e0) { /* 忽略 */ }
|
|
1525
|
+
try { maPublishDiag(); } catch (e1) { /* 忽略 */ }
|
|
1526
|
+
} catch (e) { /* 忽略 */ }
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** 节流探测登录态(只认 401;网络错误/5xx 一律按未知处理,不误报"请重新登录")。 */
|
|
1530
|
+
function maProbeAuthOnce() {
|
|
1531
|
+
try {
|
|
1532
|
+
if (maAuthExpired) return;
|
|
1533
|
+
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
|
|
1534
|
+
var now = Date.now();
|
|
1535
|
+
if (maAuthProbe.inflight) return;
|
|
1536
|
+
if (now - (maAuthProbe.at || 0) < AUTH_PROBE_MIN_MS) return;
|
|
1537
|
+
maAuthProbe.at = now;
|
|
1538
|
+
maAuthProbe.inflight = true;
|
|
1539
|
+
var p = window.fetch("/_quota", { credentials: "same-origin", cache: "no-store" });
|
|
1540
|
+
if (!p || typeof p.then !== "function") { maAuthProbe.inflight = false; return; }
|
|
1541
|
+
p.then(function (r) {
|
|
1542
|
+
maAuthProbe.inflight = false;
|
|
1543
|
+
if (r && r.status === 401) maOnAuthExpired("probe401");
|
|
1544
|
+
}, function () { maAuthProbe.inflight = false; });
|
|
1545
|
+
} catch (e) { maAuthProbe.inflight = false; }
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1458
1548
|
/** 整页受控重试:仅当"官方界面还没就绪 + 没被用户手动接管 + 每标签页额度没用完"。 */
|
|
1459
1549
|
function maSchedulePageRetry(why) {
|
|
1460
1550
|
try {
|
|
@@ -1534,6 +1624,9 @@ const SCRIPT = `(() => {
|
|
|
1534
1624
|
说的"文案归因错误"——截图 09 面板同时显示 95ms 顺畅)。
|
|
1535
1625
|
W3:窗口改用**活跃时间**——挂起 8 分钟回来不该把"10 秒内有过 5xx"直接判成过期。 */
|
|
1536
1626
|
var act = (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : Date.now();
|
|
1627
|
+
/* 登录态失效优先于"资源/网络":否则用户看到的永远是"资源加载失败,正在重试…",
|
|
1628
|
+
而真实动作是「去重新登录」(2026-11-14:12 个首屏资源 401、8 次重试全空转)。 */
|
|
1629
|
+
if (maAuthExpired) return "auth";
|
|
1537
1630
|
if ((maLoadWatch.apiFails || 0) > 0 && act - (maLoadWatch.apiFailAt || 0) < 10000) return "offline";
|
|
1538
1631
|
if (maRetry.resFails.length > 0) return "resource";
|
|
1539
1632
|
} catch (e) { /* 落到 slow */ }
|
|
@@ -2022,6 +2115,7 @@ const SCRIPT = `(() => {
|
|
|
2022
2115
|
var tail = maLoadHiddenSuffix();
|
|
2023
2116
|
/* W3:12s(活跃时间)起先给中性进度——"正在准备界面…"不是故障判定,不吓人。 */
|
|
2024
2117
|
if (!maLoadWatch.slow) return "正在准备界面…(已等待 " + sec + " 秒" + tail + ")";
|
|
2118
|
+
if (reason === "auth") return "登录状态已过期,请重新登录(已等待 " + sec + " 秒" + tail + ")";
|
|
2025
2119
|
if (reason === "offline") return "电脑端可能未连接(已等待 " + sec + " 秒" + tail + ")";
|
|
2026
2120
|
if (reason === "resource") {
|
|
2027
2121
|
if (maRetry.exhausted) return "页面资源加载失败,自动重试已用尽(已等待 " + sec + " 秒" + tail + ")";
|
|
@@ -810,3 +810,79 @@ test("★ W3 诊断串:page/res 拆分、exhausted、挂起时长、最后失
|
|
|
810
810
|
assert.match(line, /挂起 518 秒/, "挂起时长");
|
|
811
811
|
assert.ok(line.length <= 400, `单行 ≤400 字符(实际 ${line.length})`);
|
|
812
812
|
});
|
|
813
|
+
|
|
814
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
815
|
+
// ⑦ 会话过期(token 过期)的**可识别性**(2026-11-14 生产现场)
|
|
816
|
+
//
|
|
817
|
+
// 现场:dev-f9b47a20d23a 手机端干等 27 秒,诊断串 原因=resource、首屏资源失败=12、自动重试=8。
|
|
818
|
+
// 根因:会话过期时中继对**页面导航**回 302(外壳有提示),对 <script>/<link> 子资源回 401 JSON ——
|
|
819
|
+
// 浏览器只报 resource error,拿不到状态码;适配层只观察 fetch,于是 8 次重试全打在 401 上,
|
|
820
|
+
// 用户看不到任何「请重新登录」。
|
|
821
|
+
// 修法:首屏资源失败时**节流探一次 /_quota**,401 → 判定登录态失效 → 弹「去重新登录」+ 停自动重试。
|
|
822
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
823
|
+
|
|
824
|
+
/** 造一个"探针会拿到 401"的 fetch(其它请求一律 200)。 */
|
|
825
|
+
function authProbeFetch(probeStatus, probeRejects) {
|
|
826
|
+
const calls = { quota: 0 };
|
|
827
|
+
const fetchImpl = (input) => {
|
|
828
|
+
const u = String(input);
|
|
829
|
+
if (u.indexOf("/_quota") >= 0) {
|
|
830
|
+
calls.quota += 1;
|
|
831
|
+
if (probeRejects) return Promise.reject(new Error("network down"));
|
|
832
|
+
return Promise.resolve({ ok: probeStatus === 200, status: probeStatus, headers: { get: () => null } });
|
|
833
|
+
}
|
|
834
|
+
return Promise.resolve({ ok: true, status: 200, headers: { get: () => null } });
|
|
835
|
+
};
|
|
836
|
+
return { fetchImpl, calls };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
test("★ 首屏资源失败 + /_quota 探到 401 → 弹「去重新登录」,并停掉自动重试", async () => {
|
|
840
|
+
const probe = authProbeFetch(401, false);
|
|
841
|
+
const env = runScript(harness({ fetch: probe.fetchImpl }));
|
|
842
|
+
const el = failingScript("/assets/index-abc.js", "module");
|
|
843
|
+
env.body.appendChild(el);
|
|
844
|
+
env.fireWindowEvent("error", { target: el });
|
|
845
|
+
await new Promise((r) => setImmediate(r));
|
|
846
|
+
|
|
847
|
+
assert.equal(probe.calls.quota, 1, "首屏资源失败应触发一次 /_quota 登录态探测");
|
|
848
|
+
const overlay = env.body.children.find((c) => c.attrs && c.attrs.id === "dsh-ma-auth-expired");
|
|
849
|
+
assert.ok(overlay, "★ 必须弹出可点的「去重新登录」浮层(旧实现只会说「资源加载失败,正在重试…」)");
|
|
850
|
+
assert.equal(env.win.__dshMaDiag.authExpired, true, "诊断出口要标出 authExpired(客服一看就知道是登录态)");
|
|
851
|
+
|
|
852
|
+
// 判定失效后,后续资源失败不再排队重试(否则继续吃 401、继续制造 499)
|
|
853
|
+
const before = env.countTimers(2000);
|
|
854
|
+
const el2 = failingScript("/assets/vendor-xyz.js", "module");
|
|
855
|
+
env.body.appendChild(el2);
|
|
856
|
+
env.fireWindowEvent("error", { target: el2 });
|
|
857
|
+
assert.equal(env.countTimers(2000), before, "auth 失效后不得再排新的资源重试");
|
|
858
|
+
assert.equal(probe.calls.quota, 1, "同一页最多一次探测(8 秒节流 + 已经判定就不再探)");
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("探针拿到 200(资源失败但不是登录问题)→ 不误报,照常重试", async () => {
|
|
862
|
+
const probe = authProbeFetch(200, false);
|
|
863
|
+
const env = runScript(harness({ fetch: probe.fetchImpl }));
|
|
864
|
+
const el = failingScript("/assets/index-abc.js", "module");
|
|
865
|
+
env.body.appendChild(el);
|
|
866
|
+
env.fireWindowEvent("error", { target: el });
|
|
867
|
+
await new Promise((r) => setImmediate(r));
|
|
868
|
+
|
|
869
|
+
assert.equal(probe.calls.quota, 1, "仍应探测(只是结论不是登录态)");
|
|
870
|
+
const overlay = env.body.children.find((c) => c.attrs && c.attrs.id === "dsh-ma-auth-expired");
|
|
871
|
+
assert.equal(overlay, undefined, "不是 401 就绝不提示「请重新登录」(会把人引到错误动作)");
|
|
872
|
+
assert.equal(env.win.__dshMaDiag.authExpired, false);
|
|
873
|
+
assert.equal(env.countTimers(2000) >= 1, true, "资源重试照常排队");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("探针网络错误(不是 401)→ 不误报", async () => {
|
|
877
|
+
const probe = authProbeFetch(0, true);
|
|
878
|
+
const env = runScript(harness({ fetch: probe.fetchImpl }));
|
|
879
|
+
const el = failingScript("/assets/index-abc.js", "module");
|
|
880
|
+
env.body.appendChild(el);
|
|
881
|
+
env.fireWindowEvent("error", { target: el });
|
|
882
|
+
await new Promise((r) => setImmediate(r));
|
|
883
|
+
|
|
884
|
+
assert.equal(probe.calls.quota, 1);
|
|
885
|
+
const overlay = env.body.children.find((c) => c.attrs && c.attrs.id === "dsh-ma-auth-expired");
|
|
886
|
+
assert.equal(overlay, undefined, "网络错误不等于登录过期");
|
|
887
|
+
assert.equal(env.win.__dshMaDiag.authExpired, false);
|
|
888
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.8.1",
|
|
3
|
+
"version": "0.8.2-beta.1",
|
|
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",
|
|
@@ -5576,9 +5576,13 @@ function bindFailSignatureOf(bindError) {
|
|
|
5576
5576
|
return [bindError.code || "", bindError.device_id || "", bindError.message || ""].join("|");
|
|
5577
5577
|
}
|
|
5578
5578
|
|
|
5579
|
-
/**
|
|
5579
|
+
/** 是否是「永久性」登记失败(用户不解绑就不会自愈,必须指数退避而不是每 10s 重试)。
|
|
5580
|
+
* · device_bind_cap:按套餐/防滥用硬上限,已绑定条数满了;
|
|
5581
|
+
* · machine_already_bound(2026-11-14):同一台电脑上已有一份实例在线(先到先得),
|
|
5582
|
+
* 后装的这份注册不上来 —— 必须由用户在手机端「管理电脑」解绑后才会自愈。 */
|
|
5580
5583
|
function isPermanentBindFailure(bindError) {
|
|
5581
|
-
|
|
5584
|
+
if (!bindError || typeof bindError !== "object") return false;
|
|
5585
|
+
return bindError.code === "device_bind_cap" || bindError.code === "machine_already_bound";
|
|
5582
5586
|
}
|
|
5583
5587
|
|
|
5584
5588
|
/** 第 step 次(从 1 开始)拉起后的退避时长:10s→1min→5min→15min,之后封顶 30min。 */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.8.1",
|
|
3
|
+
"version": "0.8.2-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",
|
|
@@ -2531,12 +2531,21 @@ async function registerDeviceInAccount(token) {
|
|
|
2531
2531
|
...(Number.isFinite(Number(d.error?.limit ?? policy.limit)) ? { limit: Number(d.error?.limit ?? policy.limit) } : {}),
|
|
2532
2532
|
...(d.error?.upgrade_to || policy.upgrade_to ? { upgrade_to: d.error?.upgrade_to || policy.upgrade_to } : {})
|
|
2533
2533
|
};
|
|
2534
|
-
if (code === "device_bind_cap") {
|
|
2535
|
-
//
|
|
2534
|
+
if (code === "device_bind_cap" || code === "machine_already_bound") {
|
|
2535
|
+
// 永久性失败(用户不解绑就不会自愈)→ 专用退出码,watcher 按指数退避重拉(契约 §6.3/§6.4):
|
|
2536
|
+
// · device_bind_cap = 按套餐/防滥用硬上限,已绑定条数满了;
|
|
2537
|
+
// · machine_already_bound = 同一台电脑上已有另一份实例占位(2026-11-14 先到先得),
|
|
2538
|
+
// 必须由用户在手机端「管理电脑」解绑后才轮到这份登记。
|
|
2536
2539
|
persistBindError({ code, status: 409, message, ...details });
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
+
if (code === "machine_already_bound") {
|
|
2541
|
+
console.error(`[bridge] 这台电脑已有另一份 dsh-remote 在运行,本实例注册不上来:${message}`);
|
|
2542
|
+
console.error("[bridge] 若要用这一份:先关掉另一份实例,并在手机端「管理电脑」解绑那台设备后再重试。");
|
|
2543
|
+
console.error("[bridge] 这是服务端的「先到先得」策略,不是网络问题;守护会按指数退避自动重试,解绑后自动恢复。");
|
|
2544
|
+
} else {
|
|
2545
|
+
console.error(`[bridge] 设备绑定已达上限:${message}`);
|
|
2546
|
+
console.error("[bridge] 这是按套餐设定的防滥用硬上限(不是网络问题),本机不会反复空撞接口。");
|
|
2547
|
+
console.error("[bridge] 请在手机端「管理电脑」解绑不再使用的设备;守护会按指数退避自动重试,解绑后自动恢复。");
|
|
2548
|
+
}
|
|
2540
2549
|
process.exit(DEVICE_BIND_CAP_EXIT_CODE);
|
|
2541
2550
|
}
|
|
2542
2551
|
if (code === "device_limit_exceeded" || code === "device_locked") {
|
|
@@ -1281,6 +1281,8 @@ const SCRIPT = `(() => {
|
|
|
1281
1281
|
compatOfficial: maCompatOfficial,
|
|
1282
1282
|
scrim: maScrimStats,
|
|
1283
1283
|
retry: maRetryStats,
|
|
1284
|
+
/* 登录态失效(2026-11-14):只读镜像页自己的判定结果,供"复制诊断信息"与排障使用。 */
|
|
1285
|
+
authExpired: maAuthExpired === true,
|
|
1284
1286
|
load: { slow: maLoadWatch.slow, progress: maLoadWatch.progress, apiFails: maLoadWatch.apiFails,
|
|
1285
1287
|
lastApiStatus: maLoadWatch.lastApiStatus,
|
|
1286
1288
|
activeMs: (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : -1,
|
|
@@ -1447,6 +1449,10 @@ const SCRIPT = `(() => {
|
|
|
1447
1449
|
/* 界面已经就绪 = 这次失败没有卡住首屏。此时重建元素可能让模块/插件**再执行一遍**
|
|
1448
1450
|
(官方模块加载器是队列式),风险大于收益 —— 只记录、不重试。 */
|
|
1449
1451
|
if (maLoadReadyNow()) return false;
|
|
1452
|
+
/* 已经判定登录态失效 → 再重建元素也只会再吃一个 401,不再排队重试(省一条 499)。 */
|
|
1453
|
+
if (maAuthExpired) return false;
|
|
1454
|
+
/* 首屏没就绪时才值得怀疑"是不是登录态过期了"(见下方 maProbeAuthOnce 的长注释)。 */
|
|
1455
|
+
maProbeAuthOnce();
|
|
1450
1456
|
if (n >= MA_RES_RETRY_MAX) { maRetry.exhausted = true; return false; }
|
|
1451
1457
|
maRetry.perUrl[key] = n + 1;
|
|
1452
1458
|
var delay = MA_RES_RETRY_BASE_MS * Math.pow(2, n); // 2s → 4s(指数退避)
|
|
@@ -1455,6 +1461,90 @@ const SCRIPT = `(() => {
|
|
|
1455
1461
|
} catch (e) { return false; }
|
|
1456
1462
|
}
|
|
1457
1463
|
|
|
1464
|
+
/* ===== 会话过期探测:让"登录失效"自己说出来(2026-11-14)=====
|
|
1465
|
+
为什么需要(生产现场 dev-f9b47a20d23a):
|
|
1466
|
+
· 会话(dsh_token)过期时,中继对**页面导航**回 302 到 /app/?reason=expired(外壳有提示);
|
|
1467
|
+
· 但 <script>/<link> 这类**子资源**拿的是 401 JSON —— 浏览器把它当 JS 解析失败,只触发
|
|
1468
|
+
resource error,页面上看不到任何状态码;
|
|
1469
|
+
· 适配层既有的"会话过期条"只观察 fetch(且只认 401),**看不见子资源的状态码**。
|
|
1470
|
+
结果:首屏 12 个资源失败、自动重试 8 次全部打空(重试还是 401),用户干等 27 秒,
|
|
1471
|
+
只看到"资源加载失败",完全不知道该重新登录 —— 这就是"token 过期提醒不友好"。
|
|
1472
|
+
做法:首屏资源失败且界面还没就绪时,**节流地**探一次 /_quota(同源、需登录态、极轻)。
|
|
1473
|
+
拿到 401 → 判定登录态失效(不是资源/网络问题)→ 弹一条可点的「去重新登录」,
|
|
1474
|
+
并把自动重试停掉(再刷也是 401,只会制造 499)。
|
|
1475
|
+
纪律:只读探测;不动任何业务请求;不新增上报字段(诊断里只多一个 authExpired 布尔)。 */
|
|
1476
|
+
var maAuthExpired = false;
|
|
1477
|
+
var maAuthProbe = { at: 0, inflight: false };
|
|
1478
|
+
var AUTH_PROBE_MIN_MS = 8000;
|
|
1479
|
+
|
|
1480
|
+
/** 弹出极简"会话过期"浮层(幂等;不依赖菜单是否已建出来)。 */
|
|
1481
|
+
function maShowAuthOverlay() {
|
|
1482
|
+
try {
|
|
1483
|
+
var D = (typeof document !== "undefined" && document)
|
|
1484
|
+
|| (window && window.document)
|
|
1485
|
+
|| null;
|
|
1486
|
+
if (!D) return;
|
|
1487
|
+
if (D.getElementById && D.getElementById("dsh-ma-auth-expired")) return;
|
|
1488
|
+
var box = D.createElement("div");
|
|
1489
|
+
box.id = "dsh-ma-auth-expired";
|
|
1490
|
+
try { box.setAttribute("id", "dsh-ma-auth-expired"); } catch (eId) { /* 老实现只有属性反射时兜底 */ }
|
|
1491
|
+
box.setAttribute("style",
|
|
1492
|
+
"position:fixed;left:0;right:0;bottom:0;z-index:2147483000;padding:14px 16px;"
|
|
1493
|
+
+ "background:#1b1f24;color:#e6edf3;border-top:1px solid #30363d;"
|
|
1494
|
+
+ "font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
|
|
1495
|
+
+ "display:flex;gap:10px;align-items:center;flex-wrap:wrap");
|
|
1496
|
+
var msg = D.createElement("span");
|
|
1497
|
+
msg.setAttribute("style", "flex:1 1 200px;min-width:0");
|
|
1498
|
+
msg.textContent = "登录状态已过期,需要重新登录后才能进入。";
|
|
1499
|
+
var btn = D.createElement("button");
|
|
1500
|
+
btn.type = "button";
|
|
1501
|
+
btn.textContent = "去重新登录";
|
|
1502
|
+
btn.setAttribute("style",
|
|
1503
|
+
"flex:0 0 auto;padding:8px 14px;border-radius:8px;border:0;background:#2f81f7;color:#fff;font-weight:600");
|
|
1504
|
+
btn.onclick = function () {
|
|
1505
|
+
try { window.location.assign("/app/?reason=expired&from=mirror-auth"); }
|
|
1506
|
+
catch (e1) { try { window.location.href = "/app/?reason=expired&from=mirror-auth"; } catch (e2) { /* 忽略 */ } }
|
|
1507
|
+
};
|
|
1508
|
+
box.appendChild(msg);
|
|
1509
|
+
box.appendChild(btn);
|
|
1510
|
+
var host = D.body || D.documentElement;
|
|
1511
|
+
if (host) host.appendChild(box);
|
|
1512
|
+
} catch (e) { /* 浮层坏了也绝不影响页面 */ }
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** 判定登录态失效:停自动重试 + 出浮层 + 诊断字符串换档(原因=auth)。 */
|
|
1516
|
+
function maOnAuthExpired(why) {
|
|
1517
|
+
try {
|
|
1518
|
+
if (maAuthExpired) return;
|
|
1519
|
+
maAuthExpired = true;
|
|
1520
|
+
/* 停掉自动重试:401 不是"再试一次就好"的失败,重试只会继续制造 499 与白屏时间。 */
|
|
1521
|
+
maRetry.manual = true;
|
|
1522
|
+
maRetry.exhausted = true;
|
|
1523
|
+
maShowAuthOverlay();
|
|
1524
|
+
try { maBumpRetryStats("auth:" + String(why || "")); } catch (e0) { /* 忽略 */ }
|
|
1525
|
+
try { maPublishDiag(); } catch (e1) { /* 忽略 */ }
|
|
1526
|
+
} catch (e) { /* 忽略 */ }
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** 节流探测登录态(只认 401;网络错误/5xx 一律按未知处理,不误报"请重新登录")。 */
|
|
1530
|
+
function maProbeAuthOnce() {
|
|
1531
|
+
try {
|
|
1532
|
+
if (maAuthExpired) return;
|
|
1533
|
+
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
|
|
1534
|
+
var now = Date.now();
|
|
1535
|
+
if (maAuthProbe.inflight) return;
|
|
1536
|
+
if (now - (maAuthProbe.at || 0) < AUTH_PROBE_MIN_MS) return;
|
|
1537
|
+
maAuthProbe.at = now;
|
|
1538
|
+
maAuthProbe.inflight = true;
|
|
1539
|
+
var p = window.fetch("/_quota", { credentials: "same-origin", cache: "no-store" });
|
|
1540
|
+
if (!p || typeof p.then !== "function") { maAuthProbe.inflight = false; return; }
|
|
1541
|
+
p.then(function (r) {
|
|
1542
|
+
maAuthProbe.inflight = false;
|
|
1543
|
+
if (r && r.status === 401) maOnAuthExpired("probe401");
|
|
1544
|
+
}, function () { maAuthProbe.inflight = false; });
|
|
1545
|
+
} catch (e) { maAuthProbe.inflight = false; }
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1458
1548
|
/** 整页受控重试:仅当"官方界面还没就绪 + 没被用户手动接管 + 每标签页额度没用完"。 */
|
|
1459
1549
|
function maSchedulePageRetry(why) {
|
|
1460
1550
|
try {
|
|
@@ -1534,6 +1624,9 @@ const SCRIPT = `(() => {
|
|
|
1534
1624
|
说的"文案归因错误"——截图 09 面板同时显示 95ms 顺畅)。
|
|
1535
1625
|
W3:窗口改用**活跃时间**——挂起 8 分钟回来不该把"10 秒内有过 5xx"直接判成过期。 */
|
|
1536
1626
|
var act = (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : Date.now();
|
|
1627
|
+
/* 登录态失效优先于"资源/网络":否则用户看到的永远是"资源加载失败,正在重试…",
|
|
1628
|
+
而真实动作是「去重新登录」(2026-11-14:12 个首屏资源 401、8 次重试全空转)。 */
|
|
1629
|
+
if (maAuthExpired) return "auth";
|
|
1537
1630
|
if ((maLoadWatch.apiFails || 0) > 0 && act - (maLoadWatch.apiFailAt || 0) < 10000) return "offline";
|
|
1538
1631
|
if (maRetry.resFails.length > 0) return "resource";
|
|
1539
1632
|
} catch (e) { /* 落到 slow */ }
|
|
@@ -2022,6 +2115,7 @@ const SCRIPT = `(() => {
|
|
|
2022
2115
|
var tail = maLoadHiddenSuffix();
|
|
2023
2116
|
/* W3:12s(活跃时间)起先给中性进度——"正在准备界面…"不是故障判定,不吓人。 */
|
|
2024
2117
|
if (!maLoadWatch.slow) return "正在准备界面…(已等待 " + sec + " 秒" + tail + ")";
|
|
2118
|
+
if (reason === "auth") return "登录状态已过期,请重新登录(已等待 " + sec + " 秒" + tail + ")";
|
|
2025
2119
|
if (reason === "offline") return "电脑端可能未连接(已等待 " + sec + " 秒" + tail + ")";
|
|
2026
2120
|
if (reason === "resource") {
|
|
2027
2121
|
if (maRetry.exhausted) return "页面资源加载失败,自动重试已用尽(已等待 " + sec + " 秒" + tail + ")";
|