@mrrisega/dsh-remote 0.6.0 → 0.6.1-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/package.json +4 -2
- package/packages/dsh-remote-web/lib/client.js +136 -14
- package/packages/dsh-remote-web/lib/index.js +248 -30
- package/packages/dsh-remote-web/package.json +2 -2
- package/packages/dsh-remote-web/test/first-login-selfheal.test.mjs +247 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +307 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1-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",
|
|
@@ -52,7 +52,9 @@
|
|
|
52
52
|
"test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/quotas.test.mjs",
|
|
53
53
|
"test:plugin": "node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
54
54
|
"test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
|
|
55
|
-
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh"
|
|
55
|
+
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh && npm run check:alias",
|
|
56
|
+
"sync:alias": "node scripts/sync-legacy-alias.mjs",
|
|
57
|
+
"check:alias": "node scripts/sync-legacy-alias.mjs --check"
|
|
56
58
|
},
|
|
57
59
|
"dependencies": {
|
|
58
60
|
"ws": "^8.18.0"
|
|
@@ -347,6 +347,42 @@ window.__ModuleLoader__.load({
|
|
|
347
347
|
return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
|
|
348
348
|
};
|
|
349
349
|
|
|
350
|
+
// ── 登录后首次加载的自愈(0.6.1-beta.1) ───────────────────────────────
|
|
351
|
+
// 现象(首次安装后立即登录):面板在 loggedIn 翻真的瞬间就请求 /dsh-remote/access-key 与
|
|
352
|
+
// /dsh-remote/mobile-sessions;此时中继握手可能尚未就绪(bridge 刚被重启、device-login 共享密钥
|
|
353
|
+
// 刚补齐或正在轮换、中继冷启动/网络抖动),旧版只报一次红字且不重试——必须手动刷新页面才行。
|
|
354
|
+
// 现在:可重试类失败按退避自动重试(成功即自动清除红字),并给红字配「重试」按钮。
|
|
355
|
+
var LOGIN_LOAD_RETRY_MS = [1200, 3000, 6000];
|
|
356
|
+
|
|
357
|
+
/** node 半标记 retryable(中继未就绪)或网络/5xx → 值得自动重试;未登录/密码失效不重试。 */
|
|
358
|
+
function retryableFail(e) {
|
|
359
|
+
var body = e && e.body;
|
|
360
|
+
if (body && body.retryable === true) return true;
|
|
361
|
+
if (body && typeof body.hint === "string" && /^relay_/.test(body.hint)) return true;
|
|
362
|
+
var st = e && e.status;
|
|
363
|
+
if (typeof st !== "number") return true; // fetch 本身失败(网络中断/被中止)
|
|
364
|
+
return st === 0 || st === 408 || st === 429 || st >= 500;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 定时器节流:tests/SSR 环境没有 setTimeout 时静默降级(不抛错、不阻塞面板)。 */
|
|
368
|
+
function later(fn, ms) {
|
|
369
|
+
try { return setTimeout(fn, ms); } catch (e) { return null; }
|
|
370
|
+
}
|
|
371
|
+
function clearLater(t) {
|
|
372
|
+
if (t == null) return;
|
|
373
|
+
try { clearTimeout(t); } catch (e) { /* 忽略 */ }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 一次性访问密钥 / 已授权设备列表的自动重试状态(放在组件外:不随渲染重建)。 */
|
|
377
|
+
var akeyRetry = { timer: null, attempt: 0 };
|
|
378
|
+
var devRetry = { timer: null, attempt: 0 };
|
|
379
|
+
// 请求在途标记同样放组件外:组件内 state 闭包会在定时器/轮询回调里过期,
|
|
380
|
+
// 导致 busy 护栏失效(重复请求)或误拦(该重试却跳过)。
|
|
381
|
+
var akeyInFlight = { v: false };
|
|
382
|
+
var devInFlight = { v: false };
|
|
383
|
+
function cancelAkeyRetry() { clearLater(akeyRetry.timer); akeyRetry.timer = null; }
|
|
384
|
+
function cancelDevRetry() { clearLater(devRetry.timer); devRetry.timer = null; }
|
|
385
|
+
|
|
350
386
|
/** 一次性访问 url → 升级/续费页 url(带同一 auth,进入后即登录态到 /app/promo)。 */
|
|
351
387
|
function promoUrlOf(u) {
|
|
352
388
|
var s = String(u || "");
|
|
@@ -1126,6 +1162,8 @@ window.__ModuleLoader__.load({
|
|
|
1126
1162
|
|
|
1127
1163
|
var loggedIn = !!(st && st.config && (st.config.phone || st.config.hasLocalKey));
|
|
1128
1164
|
var serviceRunning = !!(st && st.service && st.service.running);
|
|
1165
|
+
// 账号标识(掩码手机号):登录成功/换账号时变化 → 触发上方的重取 effect(比布尔 loggedIn 更敏感)
|
|
1166
|
+
var phoneKey = (st && st.config && st.config.phone) || "";
|
|
1129
1167
|
var launchdPid = st && st.service && st.service.launchd && st.service.launchd.pid;
|
|
1130
1168
|
|
|
1131
1169
|
// 已登录(SaaS)→ 拉我的信息/配额/公共配置
|
|
@@ -1184,20 +1222,26 @@ window.__ModuleLoader__.load({
|
|
|
1184
1222
|
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
1185
1223
|
}
|
|
1186
1224
|
|
|
1187
|
-
/**
|
|
1225
|
+
/**
|
|
1226
|
+
* 创建(或刷新)一次性访问密钥:GET /dsh-remote/access-key(node 半转发企业端 /api/auth-key)。
|
|
1227
|
+
* 失败自愈:中继未就绪类失败(node 半 retryable / 网络 / 5xx)按退避自动重试,
|
|
1228
|
+
* 见「登录后首次加载自愈」——首次安装立即登录时不再需要手动刷新页面。
|
|
1229
|
+
*/
|
|
1188
1230
|
function loadAccessKey() {
|
|
1189
|
-
if (
|
|
1231
|
+
if (akeyInFlight.v) return;
|
|
1190
1232
|
// 登录前不请求企业端(避免 401/404 噪音);由账号登录成功后触发
|
|
1191
1233
|
if (!(st && st.config && st.config.phone)) {
|
|
1192
1234
|
setAkeyMsg(null);
|
|
1235
|
+
cancelAkeyRetry();
|
|
1193
1236
|
return;
|
|
1194
1237
|
}
|
|
1238
|
+
akeyInFlight.v = true;
|
|
1195
1239
|
setAkeyBusy(true);
|
|
1196
1240
|
api("/dsh-remote/access-key").then(function (b) {
|
|
1197
1241
|
if (!b || !b.ok) {
|
|
1198
1242
|
var why = (b && (b.error || (b.body && b.body.error))) || "未知错误";
|
|
1199
|
-
|
|
1200
|
-
|
|
1243
|
+
var e = new Error(why); e.body = b || null;
|
|
1244
|
+
throw e;
|
|
1201
1245
|
}
|
|
1202
1246
|
// 契约容错:url 必须有;qr_data_url 取不到时仍展示链接/复制/直接打开
|
|
1203
1247
|
if (!b.url) {
|
|
@@ -1212,9 +1256,16 @@ window.__ModuleLoader__.load({
|
|
|
1212
1256
|
qr_data_url: b.qr_data_url != null ? b.qr_data_url : null
|
|
1213
1257
|
});
|
|
1214
1258
|
setAkeyMsg(null);
|
|
1259
|
+
akeyRetry.attempt = 0;
|
|
1215
1260
|
}).catch(function (e) {
|
|
1216
|
-
|
|
1217
|
-
|
|
1261
|
+
var text = "获取一次性访问地址失败:" + e.message;
|
|
1262
|
+
if (retryableFail(e) && akeyRetry.attempt < LOGIN_LOAD_RETRY_MS.length) {
|
|
1263
|
+
scheduleAkeyRetry(text);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
// 自动重试额度用尽(或不该重试)→ 红字 + 「立即重试」入口,不让用户只能刷新页面
|
|
1267
|
+
setAkeyMsg({ kind: "err", text: text, retry: retryAkeyManually });
|
|
1268
|
+
}).finally(function () { akeyInFlight.v = false; setAkeyBusy(false); });
|
|
1218
1269
|
}
|
|
1219
1270
|
|
|
1220
1271
|
var copyKeyUrl = function () {
|
|
@@ -1232,17 +1283,67 @@ window.__ModuleLoader__.load({
|
|
|
1232
1283
|
try { window.open(akey.url, "_blank", "noopener"); } catch (e) {}
|
|
1233
1284
|
};
|
|
1234
1285
|
|
|
1235
|
-
/** 加载已授权设备列表:GET /dsh-remote/mobile-sessions
|
|
1286
|
+
/** 加载已授权设备列表:GET /dsh-remote/mobile-sessions(失败同上:可重试类自动重试)。 */
|
|
1236
1287
|
function loadDevices() {
|
|
1237
|
-
if (devBusy !== "") return;
|
|
1288
|
+
if (devInFlight.v || devBusy !== "") return;
|
|
1289
|
+
devInFlight.v = true;
|
|
1238
1290
|
setDevBusy("list");
|
|
1239
1291
|
api("/dsh-remote/mobile-sessions").then(function (b) {
|
|
1240
1292
|
if (!b || !b.ok) throw new Error((b && b.error) || "加载已授权设备失败");
|
|
1241
1293
|
setDevSessions(Array.isArray(b.sessions) ? b.sessions : []);
|
|
1242
1294
|
setDevMsg(null);
|
|
1295
|
+
devRetry.attempt = 0;
|
|
1243
1296
|
}).catch(function (e) {
|
|
1244
|
-
|
|
1245
|
-
|
|
1297
|
+
var text = "加载已授权设备失败:" + e.message;
|
|
1298
|
+
if (retryableFail(e) && devRetry.attempt < LOGIN_LOAD_RETRY_MS.length) {
|
|
1299
|
+
scheduleDevRetry(text);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
setDevMsg({ kind: "err", text: text, retry: retryDevicesManually });
|
|
1303
|
+
}).finally(function () { devInFlight.v = false; setDevBusy(""); });
|
|
1304
|
+
}
|
|
1305
|
+
/** 手动重试(红字旁的「重试」按钮):重置退避计数后立即重新拉取。 */
|
|
1306
|
+
var retryAkeyManually = function () {
|
|
1307
|
+
cancelAkeyRetry();
|
|
1308
|
+
akeyRetry.attempt = 0;
|
|
1309
|
+
setAkeyMsg(null);
|
|
1310
|
+
loadAccessKey();
|
|
1311
|
+
};
|
|
1312
|
+
var retryDevicesManually = function () {
|
|
1313
|
+
cancelDevRetry();
|
|
1314
|
+
devRetry.attempt = 0;
|
|
1315
|
+
setDevMsg(null);
|
|
1316
|
+
loadDevices();
|
|
1317
|
+
};
|
|
1318
|
+
/** 定时重试一次性访问密钥(黄字提示 + 退避;成功时由 loadAccessKey 清掉提示)。 */
|
|
1319
|
+
function scheduleAkeyRetry(why) {
|
|
1320
|
+
cancelAkeyRetry();
|
|
1321
|
+
var wait = LOGIN_LOAD_RETRY_MS[akeyRetry.attempt] || LOGIN_LOAD_RETRY_MS[LOGIN_LOAD_RETRY_MS.length - 1];
|
|
1322
|
+
akeyRetry.attempt += 1;
|
|
1323
|
+
setAkeyMsg({
|
|
1324
|
+
kind: "warn",
|
|
1325
|
+
text: "中继连接尚未就绪," + Math.round(wait / 1000) + " 秒后自动重试(第 " + akeyRetry.attempt + "/" + LOGIN_LOAD_RETRY_MS.length + " 次):" + why,
|
|
1326
|
+
retry: retryAkeyManually
|
|
1327
|
+
});
|
|
1328
|
+
akeyRetry.timer = later(function () {
|
|
1329
|
+
akeyRetry.timer = null;
|
|
1330
|
+
loadAccessKey(); // 在途标记由上一个请求的 finally 释放,退避窗口内用户也可手动刷新
|
|
1331
|
+
}, wait);
|
|
1332
|
+
}
|
|
1333
|
+
/** 定时重试已授权设备列表(同上)。 */
|
|
1334
|
+
function scheduleDevRetry(why) {
|
|
1335
|
+
cancelDevRetry();
|
|
1336
|
+
var wait = LOGIN_LOAD_RETRY_MS[devRetry.attempt] || LOGIN_LOAD_RETRY_MS[LOGIN_LOAD_RETRY_MS.length - 1];
|
|
1337
|
+
devRetry.attempt += 1;
|
|
1338
|
+
setDevMsg({
|
|
1339
|
+
kind: "warn",
|
|
1340
|
+
text: "中继连接尚未就绪," + Math.round(wait / 1000) + " 秒后自动重试(第 " + devRetry.attempt + "/" + LOGIN_LOAD_RETRY_MS.length + " 次):" + why,
|
|
1341
|
+
retry: retryDevicesManually
|
|
1342
|
+
});
|
|
1343
|
+
devRetry.timer = later(function () {
|
|
1344
|
+
devRetry.timer = null;
|
|
1345
|
+
loadDevices();
|
|
1346
|
+
}, wait);
|
|
1246
1347
|
}
|
|
1247
1348
|
/** 操作(取消配对/删除记录/清理已解绑)成功后静默重拉列表,覆盖行内状态。 */
|
|
1248
1349
|
function refreshDeviceList() {
|
|
@@ -1346,13 +1447,22 @@ window.__ModuleLoader__.load({
|
|
|
1346
1447
|
|
|
1347
1448
|
// 已登录云端主视图停留期间:打开即取一把新 key + 已授权设备;之后每 ~25s 自动轮换
|
|
1348
1449
|
// (未登录不轮询,避免 401 空转;离开栏目/切视图/退出登录即清理定时器)
|
|
1450
|
+
//
|
|
1451
|
+
// 依赖里带上「账号」与「bridge 运行态」:登录成功(含换账号)与 bridge 拉起/重启后立即重取,
|
|
1452
|
+
// 首次安装后立即登录不再需要手动刷新页面;失败重试由 loadAccessKey/loadDevices 内部退避处理。
|
|
1349
1453
|
useEffect(function () {
|
|
1350
1454
|
if (view !== "home" || mode !== "saas" || !loggedIn) return undefined;
|
|
1455
|
+
cancelAkeyRetry(); cancelDevRetry();
|
|
1456
|
+
akeyRetry.attempt = 0; devRetry.attempt = 0;
|
|
1351
1457
|
loadAccessKey();
|
|
1352
1458
|
loadDevices();
|
|
1353
1459
|
var rotateIv = setInterval(function () { loadAccessKey(); }, KEY_AUTO_REFRESH_MS);
|
|
1354
|
-
return function () {
|
|
1355
|
-
|
|
1460
|
+
return function () {
|
|
1461
|
+
clearInterval(rotateIv);
|
|
1462
|
+
cancelAkeyRetry();
|
|
1463
|
+
cancelDevRetry();
|
|
1464
|
+
};
|
|
1465
|
+
}, [view, mode, loggedIn, phoneKey, serviceRunning]);
|
|
1356
1466
|
|
|
1357
1467
|
function setMsg(kind, text) { setMessage({ kind: kind, text: text }); }
|
|
1358
1468
|
|
|
@@ -1760,7 +1870,13 @@ window.__ModuleLoader__.load({
|
|
|
1760
1870
|
h("span", null, statusTxt)
|
|
1761
1871
|
),
|
|
1762
1872
|
renderE2eeBadge(),
|
|
1763
|
-
akeyMsg
|
|
1873
|
+
akeyMsg
|
|
1874
|
+
? h("div", { className: "dru-msg dru-msg-" + akeyMsg.kind, style: { marginTop: 8 } },
|
|
1875
|
+
akeyMsg.text,
|
|
1876
|
+
akeyMsg.retry
|
|
1877
|
+
? h("button", { type: "button", className: "dru-linkbtn", style: { marginLeft: 8 }, disabled: akeyBusy, onClick: akeyMsg.retry }, akeyBusy ? "重试中…" : "立即重试")
|
|
1878
|
+
: null)
|
|
1879
|
+
: null,
|
|
1764
1880
|
hasKey ? h("div", null, [
|
|
1765
1881
|
h("div", { className: "dru-url big", style: { marginTop: 8 } },
|
|
1766
1882
|
h("span", null, akey.url),
|
|
@@ -1883,7 +1999,13 @@ window.__ModuleLoader__.load({
|
|
|
1883
1999
|
}, devBusy === "list" ? "加载中…" : btnLabel)
|
|
1884
2000
|
),
|
|
1885
2001
|
devOpen ? renderDeviceList() : null,
|
|
1886
|
-
devMsg
|
|
2002
|
+
devMsg
|
|
2003
|
+
? h("div", { className: "dru-msg dru-msg-" + devMsg.kind, style: { marginTop: 6 } },
|
|
2004
|
+
devMsg.text,
|
|
2005
|
+
devMsg.retry
|
|
2006
|
+
? h("button", { type: "button", className: "dru-linkbtn", style: { marginLeft: 8 }, disabled: devBusy !== "", onClick: devMsg.retry }, devBusy === "list" ? "重试中…" : "立即重试")
|
|
2007
|
+
: null)
|
|
2008
|
+
: null
|
|
1887
2009
|
]);
|
|
1888
2010
|
}
|
|
1889
2011
|
|
|
@@ -612,15 +612,135 @@ async function relayFetch(relayDir, pathname, init) {
|
|
|
612
612
|
}
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
// ----------
|
|
615
|
+
// ---------- bridge_secret 自愈(device-login 共享密钥) ----------
|
|
616
616
|
|
|
617
|
-
/**
|
|
618
|
-
|
|
619
|
-
|
|
617
|
+
/**
|
|
618
|
+
* 企业端 POST /api/device-login 强制校验 `x-dsh-bridge-secret`(缺失/失效 → 401 "需要有效设备密钥"),
|
|
619
|
+
* 密钥由服务端公开配置 /api/public-config 下发、与 dsh-setup.mjs 的 `bridge_secret` 同源。
|
|
620
|
+
*
|
|
621
|
+
* 一键安装器(npx @mrrisega/dsh-remote → dsh-setup.mjs)会在安装时取一次并写入 .dsh-config.json;
|
|
622
|
+
* 但**只装插件**的路径(dsh plugin add / 插件市场安装)没有这一步,于是:
|
|
623
|
+
* 首次安装 → 打开设置面板立即登录 → 面板请求 /dsh-remote/access-key & mobile-sessions
|
|
624
|
+
* → relayToken 拿不到 token(401 需要设备密钥)→ 面板显示「尚未登录」(其实是密钥缺失)
|
|
625
|
+
* 直到后台自愈(scheduleRuntime → ensureRuntime 跑一次 npx 安装器)把 bridge_secret 写回配置,
|
|
626
|
+
* 或用户手动刷新页面才恢复——这正是「首次安装后立即登录,二维码/设备列表报红字」的根因。
|
|
627
|
+
*
|
|
628
|
+
* 这里在插件侧补上同一份自愈:缺密钥就取一次、落盘并缓存;失败短退避后可再试,不阻塞面板。
|
|
629
|
+
*/
|
|
630
|
+
let bridgeSecretCache = { dir: "", secret: "", failedAt: 0 };
|
|
631
|
+
const BRIDGE_SECRET_RETRY_MS = 15_000;
|
|
632
|
+
|
|
633
|
+
/** 读取配置里已保存的 bridge_secret(无则空串)。 */
|
|
634
|
+
function readBridgeSecret(relayDir) {
|
|
635
|
+
try {
|
|
636
|
+
return String(loadConfig(relayDir).bridge_secret || "").trim();
|
|
637
|
+
} catch {
|
|
638
|
+
return "";
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 取 device-login 共享密钥:配置已有 → 直接用;否则向企业端公开配置取一次并写回配置(内存缓存兜底)。
|
|
644
|
+
* @param {string} relayDir 配置目录
|
|
645
|
+
* @param {object} [cfgOverride] 尚未落盘的配置(登录请求刚写入 phone/password 时用),仅用于判断模式
|
|
646
|
+
* @param {{force?: boolean, timeoutMs?: number}} [opts] force=true 跳过本地文件/缓存强制重取;
|
|
647
|
+
* timeoutMs 限制单次公开配置请求耗时(登录路径用更短的上限,避免拖慢登录响应)
|
|
648
|
+
* @returns {Promise<string>} 密钥(取不到为空串,调用方按“中继未就绪”降级,不抛错)
|
|
649
|
+
*/
|
|
650
|
+
async function bridgeSecretOf(relayDir, cfgOverride, opts) {
|
|
651
|
+
const force = !!(opts && opts.force);
|
|
652
|
+
if (!force) {
|
|
653
|
+
const local = readBridgeSecret(relayDir);
|
|
654
|
+
if (local) {
|
|
655
|
+
bridgeSecretCache = { dir: relayDir, secret: local, failedAt: 0 };
|
|
656
|
+
return local;
|
|
657
|
+
}
|
|
658
|
+
if (bridgeSecretCache.dir === relayDir && bridgeSecretCache.secret) return bridgeSecretCache.secret;
|
|
659
|
+
if (bridgeSecretCache.dir === relayDir && Date.now() - bridgeSecretCache.failedAt < BRIDGE_SECRET_RETRY_MS) return ""; // 负缓存:别把公开配置打爆
|
|
660
|
+
}
|
|
661
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
662
|
+
if (cfg.local_key) return ""; // 自建模式走 /_login,不用设备密钥
|
|
620
663
|
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
664
|
+
const timeoutMs = Number((opts && opts.timeoutMs) || 0) > 0 ? Number(opts.timeoutMs) : 6000;
|
|
665
|
+
let secret = "";
|
|
666
|
+
try {
|
|
667
|
+
const r = await fetch(`${api}/api/public-config`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
668
|
+
if (r.ok) {
|
|
669
|
+
const d = await r.json();
|
|
670
|
+
secret = String((d && d.bridge_secret) || "").trim();
|
|
671
|
+
}
|
|
672
|
+
} catch { /* 网络抖动:负缓存短退避后再试 */ }
|
|
673
|
+
if (!secret) {
|
|
674
|
+
bridgeSecretCache = { dir: relayDir, secret: "", failedAt: Date.now() };
|
|
675
|
+
return "";
|
|
676
|
+
}
|
|
677
|
+
bridgeSecretCache = { dir: relayDir, secret, failedAt: 0 };
|
|
678
|
+
// 落盘(与 dsh-setup.mjs 同一份配置):即使本进程退出,下次启动也不再缺密钥/不再是旧密钥
|
|
679
|
+
let healed = false;
|
|
680
|
+
try {
|
|
681
|
+
const cur = loadConfig(relayDir);
|
|
682
|
+
if (!cur.bridge_secret || (force && cur.bridge_secret !== secret)) {
|
|
683
|
+
cur.bridge_secret = secret;
|
|
684
|
+
saveConfig(relayDir, cur);
|
|
685
|
+
healed = true;
|
|
686
|
+
}
|
|
687
|
+
} catch { /* 写盘失败不致命:内存缓存本次进程内仍生效 */ }
|
|
688
|
+
// 密钥是「从无到有」补上的:已经在跑的 bridge 是用空密钥起的,隧道认证同样会失败——
|
|
689
|
+
// 后台重启一次让它带上密钥(一次性事件,失败只记日志不打断面板请求)。
|
|
690
|
+
if (healed && !UNINSTALLED_DIRS.has(relayDir)) {
|
|
691
|
+
setTimeout(() => {
|
|
692
|
+
try {
|
|
693
|
+
const st = launchdStatus();
|
|
694
|
+
if (!st.running) return;
|
|
695
|
+
const r = startBridge(relayDir);
|
|
696
|
+
console.log(`[dsh-remote-web] 已补记 bridge_secret,后台重启 bridge 使其生效: ${r.status}`);
|
|
697
|
+
} catch (e) {
|
|
698
|
+
console.warn(`[dsh-remote-web] 补记 bridge_secret 后重启 bridge 失败: ${e.message}`);
|
|
699
|
+
}
|
|
700
|
+
}, 50).unref?.();
|
|
701
|
+
}
|
|
702
|
+
return secret;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
|
|
706
|
+
|
|
707
|
+
/** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
|
|
708
|
+
const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
|
|
709
|
+
const AUTH_BAD_CREDENTIALS = "bad_credentials"; // 账号密码被企业端拒绝 → 该重新登录
|
|
710
|
+
const AUTH_RELAY_UNREACHABLE = "relay_unreachable"; // 网络/5xx/超时 → 稍后重试
|
|
711
|
+
const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端未接受(缺失/轮换中的设备密钥等)→ 稍后重试
|
|
712
|
+
|
|
713
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
714
|
+
|
|
715
|
+
/** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
|
|
716
|
+
async function deviceLoginOnce(api, cfg, secret) {
|
|
717
|
+
try {
|
|
718
|
+
const r = await fetch(`${api}/api/device-login`, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
headers: { "content-type": "application/json", ...(secret ? { "x-dsh-bridge-secret": secret } : {}) },
|
|
721
|
+
body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
|
|
722
|
+
signal: AbortSignal.timeout(6000)
|
|
723
|
+
});
|
|
724
|
+
const text = await r.text();
|
|
725
|
+
let d = null;
|
|
726
|
+
try { d = JSON.parse(text); } catch { d = null; }
|
|
727
|
+
return {
|
|
728
|
+
ok: r.ok,
|
|
729
|
+
status: r.status,
|
|
730
|
+
token: (d && d.token) || "",
|
|
731
|
+
code: (d && d.error && d.error.code) || "",
|
|
732
|
+
detail: (d && d.error && (d.error.message || d.error.code)) || "",
|
|
733
|
+
};
|
|
734
|
+
} catch (e) {
|
|
735
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 单次本地认证(自建模式):POST {隧道同源}/_login。 */
|
|
740
|
+
async function localLoginOnce(cfg, api) {
|
|
741
|
+
const fallbackApi = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
742
|
+
try {
|
|
743
|
+
const u = new URL(cfg.tunnel_url || fallbackApi.replace(/^https?/, "wss"));
|
|
624
744
|
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
625
745
|
u.pathname = "/_login";
|
|
626
746
|
const r = await fetch(u.toString(), {
|
|
@@ -629,20 +749,59 @@ async function relayToken(relayDir) {
|
|
|
629
749
|
body: JSON.stringify({ key: cfg.local_key }),
|
|
630
750
|
signal: AbortSignal.timeout(6000)
|
|
631
751
|
});
|
|
632
|
-
if (!r.ok) return "";
|
|
752
|
+
if (!r.ok) return { ok: false, status: r.status, token: "", code: "", detail: "" };
|
|
633
753
|
const d = await r.json();
|
|
634
|
-
return d.token || "";
|
|
754
|
+
return { ok: true, status: 200, token: (d && d.token) || "", code: "", detail: "" };
|
|
755
|
+
} catch (e) {
|
|
756
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
635
757
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。
|
|
762
|
+
* 与旧版差异:① 缺 device-login 共享密钥时自愈补齐;② 服务端轮换密钥(401/403)自动重取重试;
|
|
763
|
+
* ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
|
|
764
|
+
* @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
|
|
765
|
+
*/
|
|
766
|
+
async function relayTokenWithReason(relayDir, cfgOverride) {
|
|
767
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
768
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
769
|
+
if (cfg.local_key) {
|
|
770
|
+
let r = await localLoginOnce(cfg, api);
|
|
771
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) { await sleep(350); r = await localLoginOnce(cfg, api); }
|
|
772
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
773
|
+
return {
|
|
774
|
+
token: "",
|
|
775
|
+
reason: r.status === 401 || r.status === 403 ? AUTH_BAD_CREDENTIALS : AUTH_RELAY_UNREACHABLE,
|
|
776
|
+
status: r.status,
|
|
777
|
+
detail: r.detail,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (!cfg.phone || !cfg.password) return { token: "", reason: AUTH_NO_CREDENTIALS, status: 0, detail: "" };
|
|
781
|
+
|
|
782
|
+
let secret = await bridgeSecretOf(relayDir, cfgOverride);
|
|
783
|
+
let r = await deviceLoginOnce(api, cfg, secret);
|
|
784
|
+
// 带了密钥仍被拒 → 可能是服务端轮换了共享密钥:丢弃缓存强制重取一次再试
|
|
785
|
+
if (!r.token && (r.status === 401 || r.status === 403) && secret) {
|
|
786
|
+
const fresh = await bridgeSecretOf(relayDir, cfgOverride, { force: true });
|
|
787
|
+
if (fresh && fresh !== secret) r = await deviceLoginOnce(api, cfg, fresh);
|
|
788
|
+
}
|
|
789
|
+
// 网络抖动/超时/5xx:短退避重试一次(首次安装后中继握手偶发失败很常见)
|
|
790
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) {
|
|
791
|
+
await sleep(350);
|
|
792
|
+
r = await deviceLoginOnce(api, cfg, await bridgeSecretOf(relayDir, cfgOverride));
|
|
793
|
+
}
|
|
794
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
795
|
+
if (r.code === "bad_credentials") return { token: "", reason: AUTH_BAD_CREDENTIALS, status: r.status, detail: r.detail };
|
|
796
|
+
// 凭证齐全但企业端没给 token(设备密钥缺失/轮换中、限流、其它 4xx)→ 可重试,不谎报“未登录”
|
|
797
|
+
if (r.status === 0 || r.status >= 500) return { token: "", reason: AUTH_RELAY_UNREACHABLE, status: r.status, detail: r.detail };
|
|
798
|
+
return { token: "", reason: AUTH_RELAY_NOT_READY, status: r.status, detail: r.detail };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** 兼容旧调用方(反馈/账号/配额等只需 token 字符串)。 */
|
|
802
|
+
async function relayToken(relayDir) {
|
|
803
|
+
const r = await relayTokenWithReason(relayDir).catch(() => ({ token: "" }));
|
|
804
|
+
return r.token || "";
|
|
646
805
|
}
|
|
647
806
|
|
|
648
807
|
/** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
|
|
@@ -722,6 +881,57 @@ function notLoggedInJson() {
|
|
|
722
881
|
return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
|
|
723
882
|
}
|
|
724
883
|
|
|
884
|
+
/**
|
|
885
|
+
* 凭证齐全但拿不到 relay token 时的统一返回:区分「密码失效需重新登录」与「中继暂未就绪可重试」。
|
|
886
|
+
* 关键:不再把中继未就绪/设备密钥缺失谎报成“尚未登录”(旧版 UI 因此显示误导性红字)。
|
|
887
|
+
*/
|
|
888
|
+
function relayAuthFailureJson(reason, detail) {
|
|
889
|
+
if (reason === AUTH_NO_CREDENTIALS) return { status: 401, body: notLoggedInJson() };
|
|
890
|
+
if (reason === AUTH_BAD_CREDENTIALS) {
|
|
891
|
+
return {
|
|
892
|
+
status: 401,
|
|
893
|
+
body: {
|
|
894
|
+
ok: false,
|
|
895
|
+
error: "本机保存的账号密码已被中继拒绝(可能已在别处修改过密码):请用新密码重新登录「账号」卡片后重试",
|
|
896
|
+
hint: "relogin_required",
|
|
897
|
+
retryable: false,
|
|
898
|
+
detail: String(detail || ""),
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const unreachable = reason === AUTH_RELAY_UNREACHABLE;
|
|
903
|
+
return {
|
|
904
|
+
status: 503,
|
|
905
|
+
body: {
|
|
906
|
+
ok: false,
|
|
907
|
+
error: unreachable
|
|
908
|
+
? "中继服务暂时不可达(网络波动或中继正在重启),请稍后重试"
|
|
909
|
+
: "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试",
|
|
910
|
+
hint: unreachable ? "relay_unreachable" : "relay_not_ready",
|
|
911
|
+
retryable: true,
|
|
912
|
+
detail: String(detail || ""),
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* 取 relay token 或直接回错误响应(面板代理统一入口)。
|
|
919
|
+
* @returns {Promise<string>} token;为空串表示已写出错误响应(调用方直接 return)
|
|
920
|
+
*/
|
|
921
|
+
async function relayTokenOrReject(relayDir, res, cfgOverride) {
|
|
922
|
+
const a = await relayTokenWithReason(relayDir, cfgOverride).catch((e) => ({
|
|
923
|
+
token: "",
|
|
924
|
+
reason: AUTH_RELAY_UNREACHABLE,
|
|
925
|
+
status: 0,
|
|
926
|
+
detail: e && e.message,
|
|
927
|
+
}));
|
|
928
|
+
if (a.token) return a.token;
|
|
929
|
+
const { status, body } = relayAuthFailureJson(a.reason, a.detail);
|
|
930
|
+
console.warn(`[dsh-remote-web] 取 relay token 失败(${a.reason}/${a.status}): ${a.detail || "无详情"}`);
|
|
931
|
+
sendJson(res, status, body);
|
|
932
|
+
return "";
|
|
933
|
+
}
|
|
934
|
+
|
|
725
935
|
/**
|
|
726
936
|
* 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
|
|
727
937
|
* 返回扁平对象;数组字段只取首层数组。
|
|
@@ -737,8 +947,8 @@ function flattenRelayBody(r) {
|
|
|
737
947
|
* 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
|
|
738
948
|
*/
|
|
739
949
|
async function proxyCreateAccessKey(relayDir, res) {
|
|
740
|
-
const token = await
|
|
741
|
-
if (!token) return
|
|
950
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
951
|
+
if (!token) return;
|
|
742
952
|
const r = await relayFetch(relayDir, "/api/auth-key", {
|
|
743
953
|
method: "POST",
|
|
744
954
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
@@ -766,8 +976,8 @@ async function proxyCreateAccessKey(relayDir, res) {
|
|
|
766
976
|
* GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
|
|
767
977
|
*/
|
|
768
978
|
async function proxyMobileSessions(relayDir, res) {
|
|
769
|
-
const token = await
|
|
770
|
-
if (!token) return
|
|
979
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
980
|
+
if (!token) return;
|
|
771
981
|
const r = await relayFetch(relayDir, "/api/mobile-sessions", {
|
|
772
982
|
method: "GET",
|
|
773
983
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -788,8 +998,8 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
|
|
|
788
998
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
789
999
|
const id = String(body.id ?? "").trim();
|
|
790
1000
|
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
791
|
-
const token = await
|
|
792
|
-
if (!token) return
|
|
1001
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1002
|
+
if (!token) return;
|
|
793
1003
|
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
|
|
794
1004
|
method: "POST",
|
|
795
1005
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -812,8 +1022,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
|
|
|
812
1022
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
813
1023
|
const id = String(body.id ?? "").trim();
|
|
814
1024
|
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
815
|
-
const token = await
|
|
816
|
-
if (!token) return
|
|
1025
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1026
|
+
if (!token) return;
|
|
817
1027
|
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
|
|
818
1028
|
method: "DELETE",
|
|
819
1029
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -831,8 +1041,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
|
|
|
831
1041
|
* (企业端 POST /api/mobile-sessions/purge,Bearer)。
|
|
832
1042
|
*/
|
|
833
1043
|
async function proxyPurgeMobileSessions(relayDir, res) {
|
|
834
|
-
const token = await
|
|
835
|
-
if (!token) return
|
|
1044
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1045
|
+
if (!token) return;
|
|
836
1046
|
const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
|
|
837
1047
|
method: "POST",
|
|
838
1048
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -1030,7 +1240,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1030
1240
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1031
1241
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1032
1242
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1033
|
-
const PLUGIN_VERSION = "0.6.
|
|
1243
|
+
const PLUGIN_VERSION = "0.6.1-beta.1";
|
|
1034
1244
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1035
1245
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1036
1246
|
|
|
@@ -1352,6 +1562,14 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1352
1562
|
}
|
|
1353
1563
|
// SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
|
|
1354
1564
|
applySaaSMode(cfg);
|
|
1565
|
+
// device-login 共享密钥自愈:只装插件的路径没有安装器那一步,缺密钥会导致
|
|
1566
|
+
// 「首次安装后立即登录」时面板拿不到二维码/设备列表(旧版要等后台自愈或手动刷新)。
|
|
1567
|
+
// 这里在启动 bridge 之前补齐,bridge 首次启动即可带上密钥。
|
|
1568
|
+
if (!cfg.bridge_secret) {
|
|
1569
|
+
// 3s 上限:这次取密钥在登录响应路径上,宁可先放行(后续请求还会自愈)也不拖慢登录
|
|
1570
|
+
const secret = await bridgeSecretOf(relayDir, cfg, { timeoutMs: 3000 });
|
|
1571
|
+
if (secret) cfg.bridge_secret = secret;
|
|
1572
|
+
}
|
|
1355
1573
|
}
|
|
1356
1574
|
saveConfig(relayDir, cfg);
|
|
1357
1575
|
const bridgeRestart = startBridge(relayDir);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1-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",
|
|
@@ -40,4 +40,4 @@
|
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-web#readme",
|
|
42
42
|
"license": "PolyForm-Noncommercial-1.0.0"
|
|
43
|
-
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// 首次安装后「立即登录」二维码/设备列表报红字 的回归(node 半)。
|
|
2
|
+
//
|
|
3
|
+
// 现场:全新安装只装插件(dsh plugin add / 插件市场)时,.dsh-config.json 里没有 bridge_secret
|
|
4
|
+
// (该字段原本只有一键安装器 dsh-setup.mjs 会写)。而企业端 POST /api/device-login 强制校验
|
|
5
|
+
// x-dsh-bridge-secret → 面板请求 /dsh-remote/access-key & /dsh-remote/mobile-sessions 时拿不到 token,
|
|
6
|
+
// 旧版一律回 401「尚未登录:请先在「账号」卡片登录手机号账号」——用户明明刚登录成功(误导性红字),
|
|
7
|
+
// 只能等后台自愈装完运行环境写回密钥、或手动刷新页面才恢复。
|
|
8
|
+
//
|
|
9
|
+
// 本用例锁死四点:
|
|
10
|
+
// 1. 缺 bridge_secret → 插件向 /api/public-config 自愈取一次并落盘,请求一次即成功(无需刷新);
|
|
11
|
+
// 2. 带了密钥仍失败(网络/5xx)→ 服务端退避重试一次;
|
|
12
|
+
// 3. 服务端轮换密钥 → 强制重取新密钥再试;
|
|
13
|
+
// 4. 凭证齐全却拿不到 token → 503 + retryable(绝不再谎报“尚未登录”);密码失效 → 401 relogin_required。
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import { apply } from "../lib/index.js";
|
|
21
|
+
|
|
22
|
+
const SECRET = "secret-aaa-111";
|
|
23
|
+
const NEW_SECRET = "secret-bbb-222";
|
|
24
|
+
const EXPIRES = 1893456000000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 假企业端(复刻生产行为):
|
|
28
|
+
* - GET /api/public-config:下发 bridge_secret(可关闭/可轮换)
|
|
29
|
+
* - POST /api/device-login:**强制**校验 x-dsh-bridge-secret,缺失/不符 → 401 需要有效设备密钥
|
|
30
|
+
* - POST /api/auth-key:需 Bearer → 返回一次性访问地址与二维码
|
|
31
|
+
*/
|
|
32
|
+
function startFakeRelay(opts = {}) {
|
|
33
|
+
const seen = [];
|
|
34
|
+
let secret = opts.secret === undefined ? SECRET : opts.secret;
|
|
35
|
+
let deviceLoginCalls = 0;
|
|
36
|
+
const srv = http.createServer(async (req, res) => {
|
|
37
|
+
const url = new URL(req.url, "http://x");
|
|
38
|
+
const send = (code, obj) => {
|
|
39
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
40
|
+
res.end(JSON.stringify(obj));
|
|
41
|
+
};
|
|
42
|
+
seen.push({
|
|
43
|
+
method: req.method,
|
|
44
|
+
path: url.pathname,
|
|
45
|
+
authorization: req.headers.authorization || "",
|
|
46
|
+
secret: req.headers["x-dsh-bridge-secret"] || "",
|
|
47
|
+
});
|
|
48
|
+
if (req.method === "GET" && url.pathname === "/api/public-config") {
|
|
49
|
+
if (opts.noPublicConfig) return send(503, { error: { message: "public-config down" } });
|
|
50
|
+
return send(200, { service: "dsh-remote", app_url: "https://app.test/", api_url: `http://127.0.0.1:${srv.address().port}`, bridge_secret: secret });
|
|
51
|
+
}
|
|
52
|
+
if (req.method === "POST" && url.pathname === "/api/device-login") {
|
|
53
|
+
deviceLoginCalls += 1;
|
|
54
|
+
// 前 N 次网络/5xx 类失败(模拟中继刚重启)
|
|
55
|
+
if (opts.deviceLoginFailTimes && deviceLoginCalls <= opts.deviceLoginFailTimes) {
|
|
56
|
+
if (opts.deviceLoginFailMode === "throw") { req.socket.destroy(); return; }
|
|
57
|
+
return send(503, { error: { code: "unavailable", message: "中继重启中" } });
|
|
58
|
+
}
|
|
59
|
+
if (opts.badCredentials) return send(401, { error: { code: "bad_credentials", message: "手机号或密码错误" } });
|
|
60
|
+
const got = req.headers["x-dsh-bridge-secret"] || "";
|
|
61
|
+
if (!secret || got !== secret) return send(401, { error: { code: "unauthorized", message: "device-login 需要有效设备密钥" } });
|
|
62
|
+
return send(200, { token: "jwt-abc" });
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "POST" && url.pathname === "/api/auth-key") {
|
|
65
|
+
if (!(req.headers.authorization || "").startsWith("Bearer ")) return send(401, { error: { code: "invalid_token", message: "无效或过期的 token" } });
|
|
66
|
+
return send(200, { ok: true, key: "K1", url: "https://app.test/a/K1", expires_at: EXPIRES, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
|
|
67
|
+
}
|
|
68
|
+
if (req.method === "GET" && url.pathname === "/api/mobile-sessions") {
|
|
69
|
+
if (!(req.headers.authorization || "").startsWith("Bearer ")) return send(401, { error: { code: "invalid_token", message: "无效或过期的 token" } });
|
|
70
|
+
return send(200, { ok: true, sessions: [{ id: "ms_1", label: "iPhone 15", created_at: 1700000000000, revoked_at: null }] });
|
|
71
|
+
}
|
|
72
|
+
send(404, { error: { code: "not_found" } });
|
|
73
|
+
});
|
|
74
|
+
return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({
|
|
75
|
+
srv, seen, port: srv.address().port,
|
|
76
|
+
rotateSecret(next) { secret = next === undefined ? NEW_SECRET : next; },
|
|
77
|
+
getSecret() { return secret; },
|
|
78
|
+
})));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 装载插件路由(boot),cfgExtra 覆盖默认配置。 */
|
|
82
|
+
async function bootRelay(relayPort, cfgExtra = {}, relayDirOverride) {
|
|
83
|
+
const tempDir = relayDirOverride || (await mkdtemp(path.join(os.tmpdir(), "dsh-first-login-")));
|
|
84
|
+
await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
|
|
85
|
+
phone: "13800000000",
|
|
86
|
+
password: "pw",
|
|
87
|
+
device_id: "dev-first-login",
|
|
88
|
+
api_url: `http://127.0.0.1:${relayPort}`,
|
|
89
|
+
...cfgExtra,
|
|
90
|
+
}));
|
|
91
|
+
const routes = new Map();
|
|
92
|
+
apply({
|
|
93
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
94
|
+
effect(register) { return register(); },
|
|
95
|
+
logger: { info() {}, warn() {} },
|
|
96
|
+
}, { relayDir: tempDir });
|
|
97
|
+
const host = http.createServer((req, res) => {
|
|
98
|
+
const url = new URL(req.url, "http://x");
|
|
99
|
+
const handler = routes.get(url.pathname);
|
|
100
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
101
|
+
});
|
|
102
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
103
|
+
return { host, base: `http://127.0.0.1:${host.address().port}`, tempDir };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readCfg(dir) {
|
|
107
|
+
return JSON.parse(await readFile(path.join(dir, ".dsh-config.json"), "utf8"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("首次安装(配置缺 bridge_secret)→ 自愈取密钥并落盘:二维码一次就拿到,不再需要刷新页面", async () => {
|
|
111
|
+
const relay = await startFakeRelay();
|
|
112
|
+
const { host, base, tempDir } = await bootRelay(relay.port); // 无 bridge_secret:模拟只装插件的全新安装
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
115
|
+
assert.equal(res.status, 200, "首次登录后应立即拿到一次性访问地址(旧版这里 401「尚未登录」)");
|
|
116
|
+
const r = await res.json();
|
|
117
|
+
assert.equal(r.ok, true);
|
|
118
|
+
assert.equal(r.url, "https://app.test/a/K1");
|
|
119
|
+
assert.equal(r.qr_data_url, "data:image/png;base64,AAAA", "二维码应随首次请求一起返回");
|
|
120
|
+
|
|
121
|
+
const dl = relay.seen.filter((s) => s.path === "/api/device-login");
|
|
122
|
+
assert.equal(dl.length, 1, "device-login 只应调用一次(自愈取到密钥后再发请求)");
|
|
123
|
+
assert.equal(dl[0].secret, SECRET, "device-login 必须带上自愈得到的共享密钥");
|
|
124
|
+
assert.equal((await readCfg(tempDir)).bridge_secret, SECRET, "密钥应落盘,供 bridge 与后续重启复用");
|
|
125
|
+
} finally {
|
|
126
|
+
host.close();
|
|
127
|
+
relay.srv.close();
|
|
128
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("设备列表同样自愈:缺 bridge_secret 时 mobile-sessions 一次成功", async () => {
|
|
133
|
+
const relay = await startFakeRelay();
|
|
134
|
+
const { host, base, tempDir } = await bootRelay(relay.port);
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
137
|
+
assert.equal(res.status, 200);
|
|
138
|
+
const r = await res.json();
|
|
139
|
+
assert.equal(r.ok, true);
|
|
140
|
+
assert.equal(r.sessions.length, 1);
|
|
141
|
+
assert.equal(r.sessions[0].label, "iPhone 15");
|
|
142
|
+
} finally {
|
|
143
|
+
host.close();
|
|
144
|
+
relay.srv.close();
|
|
145
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("有密钥但中继 5xx(刚重启)→ 服务端退避重试一次即成功", async () => {
|
|
150
|
+
const relay = await startFakeRelay({ deviceLoginFailTimes: 1 });
|
|
151
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
154
|
+
assert.equal(res.status, 200, "中继抖动一次应被服务端重试吸收");
|
|
155
|
+
assert.equal(relay.seen.filter((s) => s.path === "/api/device-login").length, 2, "应重试一次");
|
|
156
|
+
} finally {
|
|
157
|
+
host.close();
|
|
158
|
+
relay.srv.close();
|
|
159
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("网络层失败(连接被断开)→ 服务端重试一次", async () => {
|
|
164
|
+
const relay = await startFakeRelay({ deviceLoginFailTimes: 1, deviceLoginFailMode: "throw" });
|
|
165
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
168
|
+
assert.equal(res.status, 200, "连接抖动(fetch 抛错)也应重试后成功");
|
|
169
|
+
} finally {
|
|
170
|
+
host.close();
|
|
171
|
+
relay.srv.close();
|
|
172
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("服务端轮换 shared secret → 强制重取新密钥再试,且写回配置", async () => {
|
|
177
|
+
const relay = await startFakeRelay({ secret: SECRET });
|
|
178
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: "secret-stale-999" });
|
|
179
|
+
try {
|
|
180
|
+
relay.rotateSecret(NEW_SECRET); // 企业端换了密钥,本机还存着旧的
|
|
181
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
182
|
+
assert.equal(res.status, 200, "旧密钥被拒后应自动取新密钥重试");
|
|
183
|
+
assert.equal((await readCfg(tempDir)).bridge_secret, NEW_SECRET, "新密钥应覆盖写回配置");
|
|
184
|
+
const secrets = relay.seen.filter((s) => s.path === "/api/device-login").map((s) => s.secret);
|
|
185
|
+
assert.deepEqual(secrets, ["secret-stale-999", NEW_SECRET], "先旧后新:第二次必须用轮换后的密钥");
|
|
186
|
+
} finally {
|
|
187
|
+
host.close();
|
|
188
|
+
relay.srv.close();
|
|
189
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("凭证齐全但中继未就绪(重试后仍失败)→ 503 + retryable,绝不谎报「尚未登录」", async () => {
|
|
194
|
+
const relay = await startFakeRelay({ noPublicConfig: true }); // 取不到密钥 → device-login 401 需要设备密钥
|
|
195
|
+
const { host, base, tempDir } = await bootRelay(relay.port);
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
198
|
+
assert.equal(res.status, 503, "应回可重试状态码,而不是 401");
|
|
199
|
+
const r = await res.json();
|
|
200
|
+
assert.equal(r.ok, false);
|
|
201
|
+
assert.equal(r.retryable, true, "必须标记 retryable,浏览器半据此自动重试");
|
|
202
|
+
assert.equal(r.hint, "relay_not_ready");
|
|
203
|
+
assert.ok(!/尚未登录/.test(String(r.error)), "账号已登录,文案不得再引导用户去登录");
|
|
204
|
+
assert.match(String(r.error), /尚未就绪|稍后重试/);
|
|
205
|
+
} finally {
|
|
206
|
+
host.close();
|
|
207
|
+
relay.srv.close();
|
|
208
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("密码被企业端拒绝 → 401 relogin_required(不可重试,提示重新登录)", async () => {
|
|
213
|
+
const relay = await startFakeRelay({ badCredentials: true });
|
|
214
|
+
const { host, base, tempDir } = await bootRelay(relay.port, { bridge_secret: SECRET });
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(`${base}/dsh-remote/mobile-sessions`);
|
|
217
|
+
assert.equal(res.status, 401);
|
|
218
|
+
const r = await res.json();
|
|
219
|
+
assert.equal(r.hint, "relogin_required");
|
|
220
|
+
assert.equal(r.retryable, false, "密码失效重试无意义");
|
|
221
|
+
assert.match(String(r.error), /重新登录/);
|
|
222
|
+
assert.ok(!/尚未登录/.test(String(r.error)), "与「未登录」分开,文案要能指导下一步");
|
|
223
|
+
} finally {
|
|
224
|
+
host.close();
|
|
225
|
+
relay.srv.close();
|
|
226
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("自建模式不取设备密钥(走 /_login,不经 public-config)", async () => {
|
|
231
|
+
const relay = await startFakeRelay();
|
|
232
|
+
// 自建:local_key 优先于账号(假企业端未实现 /_login → 降级失败,但绝不能去 device-login/public-config)
|
|
233
|
+
const { host, base, tempDir } = await bootRelay(relay.port, {
|
|
234
|
+
local_key: "lk-1",
|
|
235
|
+
tunnel_url: `ws://127.0.0.1:${relay.port}`,
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch(`${base}/dsh-remote/access-key`);
|
|
239
|
+
assert.equal(res.status, 503, "自建且上游未实现 /_login → 可重试降级");
|
|
240
|
+
assert.ok(!relay.seen.some((s) => s.path === "/api/device-login" || s.path === "/api/public-config"),
|
|
241
|
+
"自建模式不得请求 device-login / public-config");
|
|
242
|
+
} finally {
|
|
243
|
+
host.close();
|
|
244
|
+
relay.srv.close();
|
|
245
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// 首次安装后「立即登录」面板自愈 的回归(浏览器半,真实 useEffect + 假定时器)。
|
|
2
|
+
//
|
|
3
|
+
// 现场:登录成功(loggedIn 翻真)后,面板立刻请求二维码与已授权设备列表;此时中继握手可能还没就绪
|
|
4
|
+
// (bridge 刚被重启、device-login 共享密钥刚补齐/正在轮换),旧版只显示一次红字、**不重试**,
|
|
5
|
+
// 必须手动刷新页面才恢复。本用例锁死新的自愈行为:
|
|
6
|
+
// ① 登录后自动发起 access-key + mobile-sessions;
|
|
7
|
+
// ② 首次失败(503 retryable) → 黄字「自动重试」+「立即重试」按钮(不吓人、可操作);
|
|
8
|
+
// ③ 退避到点自动重试 → 成功即自动清除提示、渲染二维码与设备列表(无需刷新页面);
|
|
9
|
+
// ④ 未登录时绝不请求企业端、也不出现误导性红字。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import vm from "node:vm";
|
|
14
|
+
|
|
15
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
16
|
+
|
|
17
|
+
function walk(node, visit) {
|
|
18
|
+
if (node == null) return;
|
|
19
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
20
|
+
if (typeof node !== "object") return;
|
|
21
|
+
visit(node);
|
|
22
|
+
for (const child of node.children || []) walk(child, visit);
|
|
23
|
+
}
|
|
24
|
+
function find(tree, predicate) {
|
|
25
|
+
let match;
|
|
26
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
27
|
+
return match;
|
|
28
|
+
}
|
|
29
|
+
function textHas(tree, substr) {
|
|
30
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
31
|
+
}
|
|
32
|
+
/** 取包含某文案的节点(用于点它的 onClick)。 */
|
|
33
|
+
function nodeWithText(tree, substr) {
|
|
34
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
38
|
+
const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "dev-x" }, service: { running: false }, remoteUrl: "https://app.test/" };
|
|
39
|
+
const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: true }, remoteUrl: "https://app.test/" };
|
|
40
|
+
const KEY_URL = "https://app.test/a/K1";
|
|
41
|
+
const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 迷你 React(真实 useEffect:依赖比较 + 提交后执行 + cleanup)+ 可控假定时器。
|
|
45
|
+
* @param {object} opts - { failAccessKey:[次数], failSessions:[次数], status: 状态响应 }
|
|
46
|
+
*/
|
|
47
|
+
function loadPlugin(opts = {}) {
|
|
48
|
+
let moduleFactory;
|
|
49
|
+
const registered = new Map();
|
|
50
|
+
const injects = new Map();
|
|
51
|
+
const requests = [];
|
|
52
|
+
const states = [];
|
|
53
|
+
let hook = 0;
|
|
54
|
+
let effectCursor = 0;
|
|
55
|
+
let effectSlots = [];
|
|
56
|
+
let pending = [];
|
|
57
|
+
|
|
58
|
+
// ── 假定时器(可精确推进退避窗口) ──
|
|
59
|
+
let clock = 0;
|
|
60
|
+
let timerSeq = 1;
|
|
61
|
+
const timers = new Map();
|
|
62
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 忽略:被测代码内部错误由断言暴露 */ } };
|
|
63
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
64
|
+
const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
|
|
65
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
66
|
+
async function advance(ms) {
|
|
67
|
+
const target = clock + ms;
|
|
68
|
+
for (let guard = 0; guard < 400; guard++) {
|
|
69
|
+
let due = null;
|
|
70
|
+
for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
|
|
71
|
+
if (!due) break;
|
|
72
|
+
const [id, t] = due;
|
|
73
|
+
clock = t.at;
|
|
74
|
+
if (t.every) t.at = clock + t.every; else timers.delete(id);
|
|
75
|
+
job(t.fn);
|
|
76
|
+
await flush(); await flush();
|
|
77
|
+
}
|
|
78
|
+
clock = target;
|
|
79
|
+
await flush(); await flush();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const react = {
|
|
83
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
84
|
+
useState(initial) {
|
|
85
|
+
const index = hook++;
|
|
86
|
+
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
|
|
87
|
+
return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
|
|
88
|
+
},
|
|
89
|
+
useEffect(fn, deps) {
|
|
90
|
+
const index = effectCursor++;
|
|
91
|
+
const prev = effectSlots[index];
|
|
92
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
93
|
+
if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
94
|
+
},
|
|
95
|
+
useCallback(fn) { return fn; },
|
|
96
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// 企业端代理响应:可控失败次数 + 默认成功
|
|
100
|
+
let statusBody = opts.status || NOT_LOGGED;
|
|
101
|
+
let keyFails = opts.failAccessKey || 0;
|
|
102
|
+
let sessFails = opts.failSessions || 0;
|
|
103
|
+
let keyCalls = 0;
|
|
104
|
+
let sessCalls = 0;
|
|
105
|
+
const response = (status, body) => Promise.resolve({
|
|
106
|
+
ok: status >= 200 && status < 300,
|
|
107
|
+
status,
|
|
108
|
+
text: async () => JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
const NOT_READY = opts.failBody || { ok: false, error: "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试", hint: "relay_not_ready", retryable: true };
|
|
111
|
+
const FAIL_STATUS = opts.failStatus || 503;
|
|
112
|
+
|
|
113
|
+
const makeEl = () => ({
|
|
114
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
115
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
116
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
117
|
+
appendChild(c) { this.children.push(c); },
|
|
118
|
+
addEventListener() {}, removeEventListener() {},
|
|
119
|
+
});
|
|
120
|
+
const doc = {
|
|
121
|
+
hidden: false,
|
|
122
|
+
createElement: makeEl,
|
|
123
|
+
head: makeEl(),
|
|
124
|
+
body: makeEl(),
|
|
125
|
+
querySelector() { return null; },
|
|
126
|
+
querySelectorAll() { return []; },
|
|
127
|
+
getElementById() { return null; },
|
|
128
|
+
addEventListener() {}, removeEventListener() {},
|
|
129
|
+
};
|
|
130
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
131
|
+
const localStorage = {
|
|
132
|
+
_s: new Map(),
|
|
133
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
134
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
135
|
+
removeItem(k) { this._s.delete(k); },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const sandbox = {
|
|
139
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
140
|
+
document: doc,
|
|
141
|
+
localStorage,
|
|
142
|
+
MutationObserver: MutationObserverMock,
|
|
143
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
144
|
+
setTimeout: fakeSetTimeout,
|
|
145
|
+
clearTimeout: fakeClear,
|
|
146
|
+
setInterval: fakeSetInterval,
|
|
147
|
+
clearInterval: fakeClear,
|
|
148
|
+
fetch(path, options = {}) {
|
|
149
|
+
requests.push({ path, method: options.method || "GET" });
|
|
150
|
+
if (path === "/dsh-remote/status") return response(200, statusBody);
|
|
151
|
+
if (path === "/dsh-remote/access-key") {
|
|
152
|
+
keyCalls += 1;
|
|
153
|
+
if (keyCalls <= keyFails) return response(FAIL_STATUS, NOT_READY);
|
|
154
|
+
return response(200, { ok: true, url: KEY_URL, key: "K1", expires_at: Date.now() + 1800000, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
|
|
155
|
+
}
|
|
156
|
+
if (path === "/dsh-remote/mobile-sessions") {
|
|
157
|
+
sessCalls += 1;
|
|
158
|
+
if (sessCalls <= sessFails) return response(FAIL_STATUS, NOT_READY);
|
|
159
|
+
return response(200, { ok: true, sessions: SESSIONS });
|
|
160
|
+
}
|
|
161
|
+
if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
|
|
162
|
+
if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
|
|
163
|
+
if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
|
|
164
|
+
return response(200, { ok: true });
|
|
165
|
+
},
|
|
166
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
170
|
+
const plugin = moduleFactory((name) => {
|
|
171
|
+
assert.equal(name, "react");
|
|
172
|
+
return react;
|
|
173
|
+
});
|
|
174
|
+
plugin.apply({
|
|
175
|
+
slots: {
|
|
176
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
177
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
requests,
|
|
183
|
+
states,
|
|
184
|
+
/** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
|
|
185
|
+
render() {
|
|
186
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
187
|
+
const tree = registered.get("dsh-remote")({ close() {} });
|
|
188
|
+
const todos = pending; pending = [];
|
|
189
|
+
for (const t of todos) {
|
|
190
|
+
const prev = effectSlots[t.index];
|
|
191
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
192
|
+
const cleanup = t.fn();
|
|
193
|
+
effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
194
|
+
}
|
|
195
|
+
return tree;
|
|
196
|
+
},
|
|
197
|
+
advance,
|
|
198
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树),模拟真实的重渲染节奏。 */
|
|
199
|
+
async settle(times = 2) {
|
|
200
|
+
let tree = null;
|
|
201
|
+
for (let i = 0; i < times; i++) {
|
|
202
|
+
tree = this.render();
|
|
203
|
+
await flush(); await flush();
|
|
204
|
+
}
|
|
205
|
+
return this.render();
|
|
206
|
+
},
|
|
207
|
+
/** 模拟「账号登录成功」:改写状态响应 + 推进 30s 轮询心跳,让面板看到登录态。 */
|
|
208
|
+
async login() {
|
|
209
|
+
statusBody = LOGGED;
|
|
210
|
+
await advance(30_000);
|
|
211
|
+
return this.settle();
|
|
212
|
+
},
|
|
213
|
+
counts() { return { keyCalls, sessCalls }; },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test("未登录:不请求企业端、不出误导性红字;登录后自动拉取二维码与设备列表", async () => {
|
|
218
|
+
const plugin = loadPlugin();
|
|
219
|
+
let tree = await plugin.settle(); // 挂载 → refresh() 读状态
|
|
220
|
+
|
|
221
|
+
assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "未登录不得请求二维码接口");
|
|
222
|
+
assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "未登录不得请求设备列表");
|
|
223
|
+
assert.ok(textHas(tree, "登录下方「🔑 账号」卡片中的手机号账号后"), "未登录应给登录引导而非报错");
|
|
224
|
+
assert.ok(!textHas(tree, "尚未登录"), "未登录也不该出现接口报错红字");
|
|
225
|
+
|
|
226
|
+
tree = await plugin.login();
|
|
227
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "登录后应自动请求一次性访问地址");
|
|
228
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "登录后应自动请求已授权设备列表");
|
|
229
|
+
assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "登录后应直接渲染二维码");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("登录瞬间中继未就绪:首次失败自动重试,退避到点即恢复(无需刷新页面)", async () => {
|
|
233
|
+
const plugin = loadPlugin({ failAccessKey: 1, failSessions: 1 });
|
|
234
|
+
let tree = await plugin.settle();
|
|
235
|
+
tree = await plugin.login();
|
|
236
|
+
|
|
237
|
+
// 首次失败:黄字提示 + 可点的「立即重试」,而不是死红字(旧版行为)
|
|
238
|
+
assert.equal(plugin.counts().keyCalls, 1, "登录后第一次请求二维码");
|
|
239
|
+
assert.equal(plugin.counts().sessCalls, 1, "登录后第一次请求设备列表");
|
|
240
|
+
assert.ok(textHas(tree, "中继连接尚未就绪"), "失败应给「中继连接尚未就绪」的可重试提示");
|
|
241
|
+
assert.ok(textHas(tree, "秒后自动重试"), "应说明会自动重试");
|
|
242
|
+
assert.ok(nodeWithText(tree, "立即重试"), "提示旁应提供「立即重试」按钮");
|
|
243
|
+
assert.ok(!find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "首次失败时还没有二维码");
|
|
244
|
+
|
|
245
|
+
// 退避 1.2s 后自动重试 → 成功 → 提示清除、二维码与设备列表就位
|
|
246
|
+
await plugin.advance(1300);
|
|
247
|
+
assert.equal(plugin.counts().keyCalls, 2, "退避到点应自动重试二维码");
|
|
248
|
+
assert.equal(plugin.counts().sessCalls, 2, "退避到点应自动重试设备列表");
|
|
249
|
+
|
|
250
|
+
tree = await plugin.settle();
|
|
251
|
+
assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "重试成功后应渲染二维码");
|
|
252
|
+
assert.ok(textHas(tree, "已授权设备 1"), "重试成功后设备列表应加载完成(无需手动展开/刷新)");
|
|
253
|
+
assert.ok(!textHas(tree, "中继连接尚未就绪"), "成功后应自动清除重试提示");
|
|
254
|
+
assert.ok(!textHas(tree, "加载已授权设备失败"), "成功路径上不得残留红字错误");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("点「立即重试」:不等退避窗口,立刻重发并恢复", async () => {
|
|
258
|
+
const plugin = loadPlugin({ failAccessKey: 1, failSessions: 1 });
|
|
259
|
+
await plugin.settle();
|
|
260
|
+
const tree0 = await plugin.login();
|
|
261
|
+
assert.equal(plugin.counts().keyCalls, 1);
|
|
262
|
+
|
|
263
|
+
const retryBtn = nodeWithText(tree0, "立即重试");
|
|
264
|
+
assert.ok(retryBtn, "应能找到「立即重试」按钮");
|
|
265
|
+
retryBtn.props.onClick();
|
|
266
|
+
const tree = await plugin.settle();
|
|
267
|
+
|
|
268
|
+
assert.equal(plugin.counts().keyCalls, 2, "手动重试应立即重发二维码请求");
|
|
269
|
+
assert.ok(find(tree, (n) => n.props?.src === "data:image/png;base64,AAAA"), "手动重试后应渲染二维码");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("持续失败:退避重试上限后停在红字并保留「立即重试」,不无限刷请求", async () => {
|
|
273
|
+
const plugin = loadPlugin({ failAccessKey: 99, failSessions: 99 });
|
|
274
|
+
await plugin.settle();
|
|
275
|
+
await plugin.login();
|
|
276
|
+
await plugin.advance(2000); // 第 1 次自动重试
|
|
277
|
+
await plugin.advance(4000); // 第 2 次
|
|
278
|
+
await plugin.advance(8000); // 第 3 次
|
|
279
|
+
await plugin.advance(60_000); // 之后不再自动重试(除 25s 轮换)
|
|
280
|
+
const after = plugin.counts();
|
|
281
|
+
assert.ok(after.sessCalls <= 4, `设备列表重试应有上限(实际 ${after.sessCalls})`);
|
|
282
|
+
const tree = await plugin.settle();
|
|
283
|
+
assert.ok(nodeWithText(tree, "立即重试"), "失败后应保留可操作的重试入口");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("不可重试失败(401 密码失效 / 未登录):直接红字提示,不做无意义重试", async () => {
|
|
287
|
+
const plugin = loadPlugin({
|
|
288
|
+
failAccessKey: 99,
|
|
289
|
+
failSessions: 99,
|
|
290
|
+
failStatus: 401,
|
|
291
|
+
failBody: { ok: false, error: "本机保存的账号密码已被中继拒绝:请用新密码重新登录", hint: "relogin_required", retryable: false },
|
|
292
|
+
});
|
|
293
|
+
await plugin.settle();
|
|
294
|
+
await plugin.login();
|
|
295
|
+
assert.equal(plugin.counts().sessCalls, 1);
|
|
296
|
+
|
|
297
|
+
await plugin.advance(10_000); // 远超退避窗口(但短于 25s 的二维码周期轮换)
|
|
298
|
+
assert.equal(plugin.counts().sessCalls, 1, "不可重试类失败不得自动重发(设备列表)");
|
|
299
|
+
assert.equal(plugin.counts().keyCalls, 1, "不可重试类失败不得自动重发(二维码)");
|
|
300
|
+
await plugin.advance(20_000); // 25s 轮换后设备列表仍不自动重发(仅二维码按周期换新)
|
|
301
|
+
assert.equal(plugin.counts().sessCalls, 1, "设备列表没有周期轮换,失败后不得空转");
|
|
302
|
+
|
|
303
|
+
const tree = await plugin.settle();
|
|
304
|
+
assert.ok(textHas(tree, "加载已授权设备失败:本机保存的账号密码已被中继拒绝"), "应显示真实原因红字");
|
|
305
|
+
assert.ok(textHas(tree, "获取一次性访问地址失败:本机保存的账号密码已被中继拒绝"), "二维码卡同样显示真实原因");
|
|
306
|
+
assert.ok(!textHas(tree, "秒后自动重试"), "不可重试时不应出现自动重试提示");
|
|
307
|
+
});
|