dsh-remote-web 0.6.2 → 0.6.4-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 +163 -4
- package/lib/index.js +577 -11
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -408,6 +408,13 @@ window.__ModuleLoader__.load({
|
|
|
408
408
|
// 导致 busy 护栏失效(重复请求)或误拦(该重试却跳过)。
|
|
409
409
|
var akeyInFlight = { v: false };
|
|
410
410
|
var devInFlight = { v: false };
|
|
411
|
+
/** 连接阶段轮询的在途标记(同上:必须放组件外,否则闭包过期会重复请求)。 */
|
|
412
|
+
var connInFlight = { v: false };
|
|
413
|
+
/** 最近一次连接阶段(供自调度定时器决定快/慢轮询节奏,避免依赖渲染闭包)。 */
|
|
414
|
+
var connPhaseRef = { v: "" };
|
|
415
|
+
/** 连接阶段轮询节奏:未连通时 2.5s(自动推进),已连接后退避到 15s;页面隐藏时完全停下。 */
|
|
416
|
+
var CONN_POLL_FAST_MS = 2500;
|
|
417
|
+
var CONN_POLL_SLOW_MS = 15000;
|
|
411
418
|
function cancelAkeyRetry() { clearLater(akeyRetry.timer); akeyRetry.timer = null; }
|
|
412
419
|
function cancelDevRetry() { clearLater(devRetry.timer); devRetry.timer = null; }
|
|
413
420
|
|
|
@@ -1182,6 +1189,12 @@ window.__ModuleLoader__.load({
|
|
|
1182
1189
|
// ── 💬 交流群(「加入交流群」按钮 + 弹窗):同样放在全部既有字段之后 ──
|
|
1183
1190
|
var communityArr = useState(null); var community = communityArr[0]; var setCommunity = communityArr[1]; // {qrcode,wechat};null=未加载
|
|
1184
1191
|
var commOpenArr = useState(false); var commOpen = commOpenArr[0]; var setCommOpen = commOpenArr[1];
|
|
1192
|
+
// ── 🔗 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ──
|
|
1193
|
+
// conn 来自 GET /dsh-remote/bridge-status(node 半在返回前会自动补装/拉起/退避重试),
|
|
1194
|
+
// 面板据它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
|
|
1195
|
+
// 并用短轮询自动推进到 online —— 用户不需要点按钮、也不需要刷新页面。
|
|
1196
|
+
var connArr = useState(null); var conn = connArr[0]; var setConn = connArr[1];
|
|
1197
|
+
var copiedDiagArr = useState(false); var copiedDiag = copiedDiagArr[0]; var setCopiedDiag = copiedDiagArr[1];
|
|
1185
1198
|
|
|
1186
1199
|
var refresh = useCallback(function () {
|
|
1187
1200
|
setBusy("status");
|
|
@@ -1222,6 +1235,13 @@ window.__ModuleLoader__.load({
|
|
|
1222
1235
|
// 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
|
|
1223
1236
|
var restartInfo = (st && st.restart) || {};
|
|
1224
1237
|
var restartPending = !!restartInfo.pending;
|
|
1238
|
+
// 连接阶段(node 半下发):轮询拿到的新鲜结果优先,其次用 /status 里带的那一份;
|
|
1239
|
+
// 旧版 host(响应里没有 connect 字段)→ 回退到原来的 service.running 文案(行为不变)。
|
|
1240
|
+
var connInfo = conn || (st && st.connect) || null;
|
|
1241
|
+
var connPhase = (connInfo && connInfo.phase) || "";
|
|
1242
|
+
var connOnline = connPhase === "online";
|
|
1243
|
+
var connText = (connInfo && connInfo.text) || "";
|
|
1244
|
+
var connError = connInfo && connInfo.error ? connInfo.error : null;
|
|
1225
1245
|
// 账号标识(掩码手机号):登录成功/换账号时变化 → 触发上方的重取 effect(比布尔 loggedIn 更敏感)
|
|
1226
1246
|
var phoneKey = (st && st.config && st.config.phone) || "";
|
|
1227
1247
|
var launchdPid = st && st.service && st.service.launchd && st.service.launchd.pid;
|
|
@@ -1531,6 +1551,87 @@ window.__ModuleLoader__.load({
|
|
|
1531
1551
|
};
|
|
1532
1552
|
}, [view, mode, loggedIn, phoneKey, serviceRunning]);
|
|
1533
1553
|
|
|
1554
|
+
// ── 登录后自动闭环(0.6.4):连接阶段短轮询 ──────────────────────────────
|
|
1555
|
+
// 登录成功(含从别处已登录、被面板读到)后立刻开始推进连接,之后自调度:
|
|
1556
|
+
// 非 online → 2.5s 一次(阶段自动前进,用户零操作);online → 15s 一次(退避,只做保活/新设备发现)。
|
|
1557
|
+
// 页面隐藏时 pollConnect 直接返回(不空转),回前台由 visibilitychange 立即补一次。
|
|
1558
|
+
// 依赖里只有「视图/模式/登录态」:阶段变化不重建定时器,避免每次推进都多发一次请求。
|
|
1559
|
+
useEffect(function () {
|
|
1560
|
+
if (view !== "home" || mode !== "saas" || !loggedIn) return undefined;
|
|
1561
|
+
var stopped = false;
|
|
1562
|
+
var timer = null;
|
|
1563
|
+
var tick = function () {
|
|
1564
|
+
if (stopped) return;
|
|
1565
|
+
clearLater(timer);
|
|
1566
|
+
timer = later(function () {
|
|
1567
|
+
timer = null;
|
|
1568
|
+
tick();
|
|
1569
|
+
}, connPhaseRef.v === "online" ? CONN_POLL_SLOW_MS : CONN_POLL_FAST_MS);
|
|
1570
|
+
pollConnect();
|
|
1571
|
+
};
|
|
1572
|
+
var onVisible = function () { if (!document.hidden && !stopped) pollConnect(); };
|
|
1573
|
+
try { document.addEventListener("visibilitychange", onVisible); } catch (e) { /* 环境无 document */ }
|
|
1574
|
+
if (!document.hidden) { pollConnect(); tick(); }
|
|
1575
|
+
return function () {
|
|
1576
|
+
stopped = true;
|
|
1577
|
+
clearLater(timer);
|
|
1578
|
+
try { document.removeEventListener("visibilitychange", onVisible); } catch (e) { /* 忽略 */ }
|
|
1579
|
+
};
|
|
1580
|
+
}, [view, mode, loggedIn]);
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* 连接阶段轮询:GET /dsh-remote/bridge-status(node 半在返回前自动补装运行环境 / 拉起 bridge /
|
|
1584
|
+
* 清理卡死标记,并自带退避),面板据此自动推进「准备运行环境 → 启动 Bridge → 连接中继 → 已连接」。
|
|
1585
|
+
* 关键点:
|
|
1586
|
+
* - document.hidden 时完全不发请求(后台标签页不空转);回前台由 visibilitychange 立即补一次;
|
|
1587
|
+
* - 阶段推进到 online 时自动重取二维码与已授权设备列表 —— 用户不用刷新页面就能扫码。
|
|
1588
|
+
*/
|
|
1589
|
+
function pollConnect() {
|
|
1590
|
+
if (document.hidden) return;
|
|
1591
|
+
if (connInFlight.v) return;
|
|
1592
|
+
connInFlight.v = true;
|
|
1593
|
+
api("/dsh-remote/bridge-status").then(function (b) {
|
|
1594
|
+
if (!b || !b.ok || !b.connect) return;
|
|
1595
|
+
var prev = connPhaseRef.v;
|
|
1596
|
+
connPhaseRef.v = b.connect.phase || "";
|
|
1597
|
+
setConn(b.connect);
|
|
1598
|
+
if (b.connect.phase === "online") {
|
|
1599
|
+
// 已连接:立刻补一次二维码/设备列表(首次连上时 prev 不是 online → 无条件重取)
|
|
1600
|
+
if (prev !== "online") {
|
|
1601
|
+
akeyRetry.attempt = 0; devRetry.attempt = 0;
|
|
1602
|
+
loadAccessKey();
|
|
1603
|
+
loadDevices();
|
|
1604
|
+
} else if (devSessions !== null) {
|
|
1605
|
+
refreshDeviceList(); // 手机扫码后设备列表自动出现,无需刷新页面
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}).catch(function () { /* 轮询失败静默:下一轮自动重试 */ })
|
|
1609
|
+
.finally(function () { connInFlight.v = false; });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** 「重试」按钮:POST /dsh-remote/connect/retry(清退避 + 立刻再走一遍闭环),随后刷新阶段。 */
|
|
1613
|
+
var retryConnect = function () {
|
|
1614
|
+
setBusy("connect-retry");
|
|
1615
|
+
post("/dsh-remote/connect/retry").then(function (b) {
|
|
1616
|
+
if (b && b.connect) { connPhaseRef.v = b.connect.phase || ""; setConn(b.connect); }
|
|
1617
|
+
setMsg("ok", "已重新开始连接,正在自动重试…");
|
|
1618
|
+
}).catch(function (e) {
|
|
1619
|
+
setMsg("err", "重试失败:" + e.message);
|
|
1620
|
+
}).finally(function () { setBusy(""); });
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
/** 「复制诊断信息」:版本 / 配置目录 / 阶段 / 最近错误 / 进程状态 / 日志路径(node 半已拼好)。 */
|
|
1624
|
+
var copyDiagnostics = function () {
|
|
1625
|
+
var text = (connInfo && connInfo.diagnostics) || "";
|
|
1626
|
+
if (!text) return;
|
|
1627
|
+
try {
|
|
1628
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
1629
|
+
setCopiedDiag(true);
|
|
1630
|
+
later(function () { setCopiedDiag(false); }, 1500);
|
|
1631
|
+
});
|
|
1632
|
+
} catch (e) { /* 剪贴板不可用:文案里已给出日志路径,用户可自行查看 */ }
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1534
1635
|
function setMsg(kind, text) { setMessage({ kind: kind, text: text }); }
|
|
1535
1636
|
|
|
1536
1637
|
// ---------- 登录(密码/短信) ----------
|
|
@@ -1588,7 +1689,10 @@ window.__ModuleLoader__.load({
|
|
|
1588
1689
|
if (rpass.length < 8) { setMsg("err", "密码至少 8 位"); return; }
|
|
1589
1690
|
if (rpass !== rpass2) { setMsg("err", "两次输入的密码不一致"); return; }
|
|
1590
1691
|
setBusy("register");
|
|
1591
|
-
|
|
1692
|
+
// reg_source:注册来源(增长口径)——面板内注册 = panel_register;
|
|
1693
|
+
// 手机端网页注册走 clients/dsh-web/native.html(= remoteweb_register)。
|
|
1694
|
+
// 节点半 /dsh-remote/register 全量透传 body,企业端白名单外的值会归一成 api_unknown。
|
|
1695
|
+
var regPayload = { phone: rphone.trim(), sms_code: rsms.trim(), password: rpass, reg_source: "panel_register" };
|
|
1592
1696
|
var invite = rInvite.trim().toUpperCase();
|
|
1593
1697
|
if (invite) regPayload.invite_code = invite;
|
|
1594
1698
|
if (rcap) { regPayload.captcha_id = rcap.id; regPayload.captcha_answer = rcapTxt.trim(); }
|
|
@@ -1947,12 +2051,59 @@ window.__ModuleLoader__.load({
|
|
|
1947
2051
|
var expMs = akey ? toMs(akey.expires_at) : 0;
|
|
1948
2052
|
var remainMs = expMs ? expMs - nowTick : 0;
|
|
1949
2053
|
var loggedInSaaS = !!(st && st.config && st.config.phone);
|
|
2054
|
+
// 状态行优先用连接阶段文案(node 半下发的 user-facing 文案):
|
|
2055
|
+
// 「正在准备运行环境(首次约 1~2 分钟)… / 正在启动 Bridge… / 正在连接中继… / 已连接 ✅ …」;
|
|
2056
|
+
// 旧版 host 没有 connect 字段 → 完全回退到原来的 running 文案(既有行为不变)。
|
|
1950
2057
|
var statusTxt = st === null
|
|
1951
2058
|
? "查询中…"
|
|
1952
2059
|
: !loggedInSaaS
|
|
1953
2060
|
? "请先登录(下方账号卡片)后启用远程访问"
|
|
1954
|
-
: serviceRunning ? "已连接(可远程访问)" : "等待设备连接";
|
|
1955
|
-
var
|
|
2061
|
+
: connInfo ? connText : serviceRunning ? "已连接(可远程访问)" : "等待设备连接";
|
|
2062
|
+
var dotOn = loggedInSaaS ? (connInfo ? connOnline : serviceRunning) : false;
|
|
2063
|
+
var dotCls = "dru-dot " + (dotOn ? "dru-dot-on" : "dru-dot-off");
|
|
2064
|
+
|
|
2065
|
+
/**
|
|
2066
|
+
* 连接阶段区块:把「环境 → bridge 进程 → 中继注册」的自动推进过程如实展示出来,
|
|
2067
|
+
* 面向非技术用户——非 online 阶段一律说明「正在自动进行,无需操作」;
|
|
2068
|
+
* 只有失败(error)才给可操作项:重试 / 复制诊断信息 / 日志路径,绝不出现死胡同。
|
|
2069
|
+
*/
|
|
2070
|
+
function renderConnectBlock() {
|
|
2071
|
+
if (!loggedInSaaS || !connInfo) return null;
|
|
2072
|
+
var phase = connInfo.phase || "";
|
|
2073
|
+
if (phase === "online") {
|
|
2074
|
+
return h("div", { className: "dru-meta", style: { marginTop: 6 } },
|
|
2075
|
+
connInfo.registerSource === "account_api" || connInfo.registerSource === "account" || connInfo.registerSource === "state_account"
|
|
2076
|
+
? "设备已登记到你的账号:手机端「设备列表」可以看到这台电脑。"
|
|
2077
|
+
: "设备已注册到中继:手机端扫码或打开链接即可进入这台电脑。");
|
|
2078
|
+
}
|
|
2079
|
+
var rows = [];
|
|
2080
|
+
if (phase === "error") {
|
|
2081
|
+
rows.push(h("div", { className: "dru-msg dru-msg-err", style: { marginTop: 6 } },
|
|
2082
|
+
"⚠️ " + ((connError && connError.message) || connText)));
|
|
2083
|
+
if (connInfo.detail) rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } }, connInfo.detail));
|
|
2084
|
+
if (connInfo.nextRetryInMs > 0) {
|
|
2085
|
+
rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
2086
|
+
"已自动重试 " + (connInfo.attempts || 0) + " 次,约 " + Math.ceil(connInfo.nextRetryInMs / 1000) + " 秒后自动再试(也可以点下面按钮立刻重试)"));
|
|
2087
|
+
}
|
|
2088
|
+
rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
2089
|
+
h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "重试中…" : "重试"),
|
|
2090
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
|
|
2091
|
+
rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
2092
|
+
"查看日志:bridge " + (connInfo.logPath || "") + " ,安装 " + (connInfo.installLogPath || "") + "(把「复制诊断信息」的内容发给客服可加速定位)"));
|
|
2093
|
+
return h("div", null, rows);
|
|
2094
|
+
}
|
|
2095
|
+
// 非错误阶段:只解释「正在自动做什么」,并说明不需要任何操作
|
|
2096
|
+
if (connInfo.detail) rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } }, connInfo.detail));
|
|
2097
|
+
if (connInfo.installing || phase === "no_runtime") {
|
|
2098
|
+
rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
2099
|
+
"首次安装会自动下载并配置,期间请不要关闭 DeepSeek;装完会自动启动 bridge,无需任何操作。"));
|
|
2100
|
+
}
|
|
2101
|
+
if (connInfo.deviceId) rows.push(h("div", { className: "dru-meta" }, "设备 ID:" + connInfo.deviceId));
|
|
2102
|
+
rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
2103
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "立即重试中…" : "立即重试"),
|
|
2104
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
|
|
2105
|
+
return h("div", null, rows);
|
|
2106
|
+
}
|
|
1956
2107
|
// Phase-5:端到端加密(E2EE)状态行 —— 未登录/旧 host 未下发 e2ee 一律不渲染
|
|
1957
2108
|
// (桌面宽屏与手机镜像共用同一面板:纯文字状态行、不弹层不打扰);启用=绿点绿字(🔒已启用),
|
|
1958
2109
|
// 未启用=灰字 + 中性原因文案(回退普通 HTTPS 连接,不宣称“灰度等待”)。
|
|
@@ -1969,6 +2120,7 @@ window.__ModuleLoader__.load({
|
|
|
1969
2120
|
h("span", { className: dotCls }),
|
|
1970
2121
|
h("span", null, statusTxt)
|
|
1971
2122
|
),
|
|
2123
|
+
renderConnectBlock(),
|
|
1972
2124
|
renderE2eeBadge(),
|
|
1973
2125
|
akeyMsg
|
|
1974
2126
|
? h("div", { className: "dru-msg dru-msg-" + akeyMsg.kind, style: { marginTop: 8 } },
|
|
@@ -1999,7 +2151,9 @@ window.__ModuleLoader__.load({
|
|
|
1999
2151
|
),
|
|
2000
2152
|
h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
2001
2153
|
!serviceRunning
|
|
2002
|
-
?
|
|
2154
|
+
? (connInfo && connPhase !== "online"
|
|
2155
|
+
? "本机 bridge 正在自动准备中(" + connText + "):连上后这个链接/二维码即可使用,无需其他操作。"
|
|
2156
|
+
: "本机 bridge 未运行:先在下方「🖥 Bridge 服务」卡启动。")
|
|
2003
2157
|
: "打开链接/扫码进入即登录态;同设备重复扫码只更新授权,不新增设备。")
|
|
2004
2158
|
)
|
|
2005
2159
|
)
|
|
@@ -2195,6 +2349,11 @@ window.__ModuleLoader__.load({
|
|
|
2195
2349
|
h("span", null, st ? serviceStateText : "查询中…"),
|
|
2196
2350
|
launchdPid ? h("span", { className: "dru-meta", style: { marginTop: 0 } }, "(pid=" + launchdPid + ")") : null
|
|
2197
2351
|
),
|
|
2352
|
+
// 自动连接进度(登录后由面板 2.5s 短轮询自动推进):让用户在 bridge 卡也能一眼看到
|
|
2353
|
+
// 「到底走到哪一步了」——进程在跑≠能用,注册到中继才算。
|
|
2354
|
+
connInfo && connPhase && connPhase !== "no_account"
|
|
2355
|
+
? h("div", { className: "dru-hint", style: { marginTop: 6 } }, "自动连接进度:" + connText)
|
|
2356
|
+
: null,
|
|
2198
2357
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
2199
2358
|
!serviceRunning
|
|
2200
2359
|
? h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: function () { toggleBridge(true); } }, busy === "start" ? "启动中…" : "启动 bridge")
|
package/lib/index.js
CHANGED
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
// 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
|
|
12
12
|
// 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
|
|
13
13
|
// - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
|
|
14
|
+
// - 登录后自动闭环(0.6.4):GET /dsh-remote/bridge-status 下发连接阶段
|
|
15
|
+
// (no_account/no_runtime/installing/starting/connecting/online/error),并在返回前自动补装运行环境、
|
|
16
|
+
// 拉起 bridge、清理卡死标记(带退避);区分「bridge 进程在跑」与「设备已在中继注册成功(online)」。
|
|
17
|
+
// 面板用 2~3s 短轮询自动推进到 online(无需点按钮/刷新页面),error 才给重试与诊断信息;
|
|
18
|
+
// 另 POST /dsh-remote/connect/retry 手动重试、POST <api>/api/install-report 上报安装口径
|
|
19
|
+
// (install_source/version + host_os/arch,详见 installSourceOf)。
|
|
14
20
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
15
21
|
//
|
|
16
22
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
17
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, statSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
|
|
18
24
|
import { join, dirname, sep } from "node:path";
|
|
19
25
|
import { execSync, spawn } from "node:child_process";
|
|
20
26
|
import { homedir, platform } from "node:os";
|
|
@@ -371,6 +377,10 @@ function manualStatus() {
|
|
|
371
377
|
})();
|
|
372
378
|
const watcher = [];
|
|
373
379
|
const bridge = [];
|
|
380
|
+
// 全部 bridge 子进程(含 launchd 托管链里的):连接阶段判定「bridge 进程真的在跑」用它。
|
|
381
|
+
// launchd 作业本身跑的是 watcher(dsh-setup.mjs run),它再拉起 dsh-bridge.mjs 子进程——
|
|
382
|
+
// 只看 launchd.running 会把「watcher 在、bridge 子进程没起来」误判成「进程在跑」。
|
|
383
|
+
const allBridge = [];
|
|
374
384
|
// 取候选进程的父 pid,判断是否属于 launchd 托管链
|
|
375
385
|
const parentOf = (pid) => {
|
|
376
386
|
const r = sh(`ps -o ppid= -p ${pid}`);
|
|
@@ -383,11 +393,17 @@ function manualStatus() {
|
|
|
383
393
|
if (!m) continue;
|
|
384
394
|
const pid = Number(m[1]);
|
|
385
395
|
if (pid === process.pid || pid === launchdPid) continue;
|
|
386
|
-
|
|
387
|
-
if (/dsh-setup\.mjs/.test(m[2]))
|
|
388
|
-
|
|
396
|
+
const launchdManaged = launchdPid !== null && parentOf(pid) === launchdPid;
|
|
397
|
+
if (/dsh-setup\.mjs/.test(m[2])) {
|
|
398
|
+
if (!launchdManaged) watcher.push(pid);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (/dsh-bridge\.mjs/.test(m[2])) {
|
|
402
|
+
allBridge.push(pid);
|
|
403
|
+
if (!launchdManaged) bridge.push(pid); // launchd 托管的 bridge 子进程(原语义:不计入 manual.bridge)
|
|
404
|
+
}
|
|
389
405
|
}
|
|
390
|
-
return { watcher, bridge };
|
|
406
|
+
return { watcher, bridge, allBridge };
|
|
391
407
|
}
|
|
392
408
|
|
|
393
409
|
// ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
|
|
@@ -395,6 +411,13 @@ function manualStatus() {
|
|
|
395
411
|
const PROVISION_MARKER = ".dsh-setup-installing";
|
|
396
412
|
const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
|
|
397
413
|
const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
|
|
414
|
+
/**
|
|
415
|
+
* 「本次运行环境是插件自愈补装的」记录文件(install_source 判据):
|
|
416
|
+
* 插件市场只装面板插件时,桌面运行环境由插件后台 `npx @mrrisega/dsh-remote` 补上,
|
|
417
|
+
* 这条路径归为 install_source=plugin_market;由用户自己跑安装器的(plist 里带
|
|
418
|
+
* DSH_BRIDGE_INSTALL_SOURCE=npx)归为 npx。见 installSourceOf()。
|
|
419
|
+
*/
|
|
420
|
+
const PROVISIONED_MARKER = ".dsh-provisioned-by-plugin";
|
|
398
421
|
/**
|
|
399
422
|
* 自愈 watcher 轮询间隔(默认 12s)。DSH_RELAY_SELFHEAL_MS 可覆盖——仅供测试把
|
|
400
423
|
* 崩溃循环自愈链路压到亚秒级验证,生产不必设置。调用时读取(而非模块加载时固定),
|
|
@@ -436,10 +459,18 @@ function ensureRuntime(relayDir) {
|
|
|
436
459
|
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
437
460
|
const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
|
|
438
461
|
detached: true,
|
|
439
|
-
|
|
462
|
+
// DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
|
|
463
|
+
// 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
|
|
464
|
+
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market" }),
|
|
440
465
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
441
466
|
});
|
|
442
467
|
writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
|
|
468
|
+
// 记下「本机运行环境是插件自愈补的」:登录后的 /api/install-report 与 bridge 设备登记
|
|
469
|
+
// 都据此上报 install_source=plugin_market(区分用户自己跑 npx 安装器的那条路径)。
|
|
470
|
+
try {
|
|
471
|
+
writeFileSync(join(relayDir, PROVISIONED_MARKER),
|
|
472
|
+
JSON.stringify({ at: Date.now(), version: PLUGIN_VERSION, source: "plugin_market" }));
|
|
473
|
+
} catch { /* 非关键:上报时按缺省判据降级 */ }
|
|
443
474
|
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
444
475
|
child.on("exit", (code) => {
|
|
445
476
|
clear();
|
|
@@ -476,7 +507,7 @@ function writeAutostartFile(relayDir) {
|
|
|
476
507
|
<key>ThrottleInterval</key><integer>10</integer>
|
|
477
508
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
478
509
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
479
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
|
|
510
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
|
|
480
511
|
</dict></plist>`;
|
|
481
512
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
482
513
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -901,6 +932,8 @@ async function relayFetch(relayDir, pathname, init) {
|
|
|
901
932
|
try {
|
|
902
933
|
// 6s 超时:relay 不可达时快速降级,不拖慢面板
|
|
903
934
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(6000) });
|
|
935
|
+
// 企业端拒绝当前 JWT(过期/轮换)→ 立刻让 token 缓存失效,下一次请求重新认证
|
|
936
|
+
if (res.status === 401 || res.status === 403) invalidateRelayToken(relayDir);
|
|
904
937
|
const text = await res.text();
|
|
905
938
|
let body = null;
|
|
906
939
|
try {
|
|
@@ -1014,6 +1047,38 @@ const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端
|
|
|
1014
1047
|
|
|
1015
1048
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1016
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* relay token 短期缓存(60s):连接阶段的 2~3s 短轮询会反复取 token,若每次都走 device-login
|
|
1052
|
+
* (即账号密码认证),就会**每几秒打一次企业端认证接口**——审计日志刷屏、可能触发限流,且纯属浪费。
|
|
1053
|
+
* - TTL 60s:远短于 JWT 自身有效期;账号密码/共享密钥变更最多 60s 生效;
|
|
1054
|
+
* - 键含 api_url + 账号 + 模式:换账号、切自建/云端、换服务端会自动 miss;
|
|
1055
|
+
* - 并发合并(relayTokenInflight):同一时刻多个请求共用一次认证;
|
|
1056
|
+
* - 只缓存成功结果;企业端回 401/403(JWT 过期/轮换)时 relayFetch 立即 invalidateRelayToken。
|
|
1057
|
+
*/
|
|
1058
|
+
const RELAY_TOKEN_TTL_MS = 60_000;
|
|
1059
|
+
const relayTokenCache = new Map(); // relayDir → { key, token, exp, status }
|
|
1060
|
+
const relayTokenInflight = new Map(); // relayDir → { key, promise }
|
|
1061
|
+
/** 缓存键:同一台机器换了账号/服务端/模式时自动失效,避免把别人的 token 用错地方。 */
|
|
1062
|
+
function relayTokenKeyOf(cfg) {
|
|
1063
|
+
return [
|
|
1064
|
+
(cfg.api_url || DEFAULT_API).replace(/\/+$/, ""),
|
|
1065
|
+
cfg.local_key ? "local" : "saas",
|
|
1066
|
+
cfg.local_key ? String(cfg.local_key).slice(0, 8) : (cfg.phone || cfg.email || ""),
|
|
1067
|
+
].join("|");
|
|
1068
|
+
}
|
|
1069
|
+
/** 丢弃某 relayDir 的 token 缓存(账号变更、退出登录、企业端拒绝 JWT 时调用)。 */
|
|
1070
|
+
function invalidateRelayToken(relayDir) {
|
|
1071
|
+
if (!relayDir) return;
|
|
1072
|
+
relayTokenCache.delete(relayDir);
|
|
1073
|
+
deviceProbeCache.delete(relayDir); // 认证失效时「设备探活」结论同样过期(否则 15s 内不会重试)
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* 账号设备表探活缓存(见 accountDeviceBound):企业端每次 GET /api/devices 都会写一行活跃明细
|
|
1077
|
+
* (user_activity),因此绝不跟着 2.5s 的 UI 轮询打——15s 一次,且只在与中继注册证据缺失时才探。
|
|
1078
|
+
*/
|
|
1079
|
+
const DEVICE_PROBE_TTL_MS = 15_000;
|
|
1080
|
+
const deviceProbeCache = new Map(); // relayDir → { at, value }
|
|
1081
|
+
|
|
1017
1082
|
/** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
|
|
1018
1083
|
async function deviceLoginOnce(api, cfg, secret) {
|
|
1019
1084
|
try {
|
|
@@ -1065,8 +1130,35 @@ async function localLoginOnce(cfg, api) {
|
|
|
1065
1130
|
* ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
|
|
1066
1131
|
* @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
|
|
1067
1132
|
*/
|
|
1068
|
-
async function relayTokenWithReason(relayDir, cfgOverride) {
|
|
1133
|
+
async function relayTokenWithReason(relayDir, cfgOverride, opts = {}) {
|
|
1069
1134
|
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
1135
|
+
const key = relayTokenKeyOf(cfg);
|
|
1136
|
+
const now = Date.now();
|
|
1137
|
+
const hit = relayTokenCache.get(relayDir);
|
|
1138
|
+
if (!opts.force && hit && hit.key === key && hit.token && now < hit.exp) {
|
|
1139
|
+
return { token: hit.token, reason: "", status: hit.status || 200, detail: "", cached: true };
|
|
1140
|
+
}
|
|
1141
|
+
// 并发合并:同一时刻的多个请求(状态轮询 / 二维码 / 设备列表 / 上报)共用一次认证
|
|
1142
|
+
const flying = relayTokenInflight.get(relayDir);
|
|
1143
|
+
if (!opts.force && flying && flying.key === key) return flying.promise;
|
|
1144
|
+
const promise = relayTokenUncached(relayDir, cfg, cfgOverride).then((res) => {
|
|
1145
|
+
if (res && res.token) {
|
|
1146
|
+
relayTokenCache.set(relayDir, { key, token: res.token, exp: Date.now() + RELAY_TOKEN_TTL_MS, status: res.status || 200 });
|
|
1147
|
+
} else {
|
|
1148
|
+
relayTokenCache.delete(relayDir); // 失败不缓存:下一轮立刻重试
|
|
1149
|
+
}
|
|
1150
|
+
relayTokenInflight.delete(relayDir);
|
|
1151
|
+
return res;
|
|
1152
|
+
}, (e) => {
|
|
1153
|
+
relayTokenInflight.delete(relayDir);
|
|
1154
|
+
throw e;
|
|
1155
|
+
});
|
|
1156
|
+
relayTokenInflight.set(relayDir, { key, promise });
|
|
1157
|
+
return promise;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** 未缓存的真实取 token 逻辑(relayTokenWithReason 的缓存/合并外壳在这里之上一层)。 */
|
|
1161
|
+
async function relayTokenUncached(relayDir, cfg, cfgOverride) {
|
|
1070
1162
|
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1071
1163
|
if (cfg.local_key) {
|
|
1072
1164
|
let r = await localLoginOnce(cfg, api);
|
|
@@ -1470,6 +1562,10 @@ async function composeStatus(relayDir) {
|
|
|
1470
1562
|
// 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
|
|
1471
1563
|
// {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
|
|
1472
1564
|
restart: readRestartState(relayDir),
|
|
1565
|
+
// 连接阶段(登录后自动闭环:环境 → bridge 进程 → 设备已在中继注册):
|
|
1566
|
+
// 面板用它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
|
|
1567
|
+
// 并用 /dsh-remote/bridge-status 以 2~3s 短轮询自动推进(无需用户点按钮或刷新页面)。
|
|
1568
|
+
connect: await composeConnect(relayDir, { cfg, launchd, manual }),
|
|
1473
1569
|
// 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
|
|
1474
1570
|
};
|
|
1475
1571
|
}
|
|
@@ -1577,7 +1673,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1577
1673
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1578
1674
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1579
1675
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1580
|
-
const PLUGIN_VERSION = "0.6.
|
|
1676
|
+
const PLUGIN_VERSION = "0.6.4-beta.1";
|
|
1581
1677
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1582
1678
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1583
1679
|
|
|
@@ -1608,7 +1704,9 @@ function spawnUpdater(relayDir, extraEnv) {
|
|
|
1608
1704
|
return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
|
|
1609
1705
|
detached: true,
|
|
1610
1706
|
cwd: homedir(),
|
|
1611
|
-
|
|
1707
|
+
// PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
|
|
1708
|
+
// (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
|
|
1709
|
+
env: spawnEnv({ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir), ...(extraEnv || {}) }),
|
|
1612
1710
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
1613
1711
|
});
|
|
1614
1712
|
}
|
|
@@ -1714,6 +1812,440 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
|
1714
1812
|
// ---------- 路由 ----------
|
|
1715
1813
|
|
|
1716
1814
|
/** 路由表:{method, path, handler}。 */
|
|
1815
|
+
// ---------- 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ----------
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* 生产现场(2026-09,id 42~52 共 11 个真实注册用户):7 人注册后手机端 /api/devices 一直是空列表,
|
|
1819
|
+
* 多人反复点「生成访问链接」(auth.key_create 10~23 次)而 device.bind 始终为 0——他们的电脑端
|
|
1820
|
+
* bridge 从未连上过中继。典型路径:注册 → 手机端拿不到设备 → 反复轮询 → 放弃。
|
|
1821
|
+
*
|
|
1822
|
+
* 缺口不是某个按钮坏了,而是「登录 → 设备已在中继注册成功」这条链路既不可见也不自动:
|
|
1823
|
+
* 用户看不出卡在哪一步(运行环境没装?bridge 没起?还是没注册上中继?),也不知道要不要刷新页面。
|
|
1824
|
+
*
|
|
1825
|
+
* 本段把链路拆成可查询的阶段(GET /dsh-remote/bridge-status),并刻意区分两个极易混淆的状态:
|
|
1826
|
+
* - starting :「bridge 进程已在跑」——只说明进程起来了;
|
|
1827
|
+
* - online :「设备已在中继注册成功」——手机端能看到并进入这台电脑,这才是用户要的「能用了」。
|
|
1828
|
+
* 阶段推进所需的动作(补装运行环境 / 拉起 bridge / 可重试错误退避重试)全部由 ensureConnection
|
|
1829
|
+
* 在轮询里自动完成,用户零操作、零刷新;只有「彻底失败」才给按钮,且永远留一条可走的路。
|
|
1830
|
+
*/
|
|
1831
|
+
const BRIDGE_LOG_FILE = ".dsh-bridge.log";
|
|
1832
|
+
const BRIDGE_STATE_FILE = ".dsh-bridge-state.json";
|
|
1833
|
+
const BIND_ERROR_FILE = ".bind-error.json";
|
|
1834
|
+
/** 安装标记超时(进程已死或超 10 分钟)→ 视为卡死,清掉标记重新补装(否则 marker 会把补装永久挡住)。 */
|
|
1835
|
+
const INSTALL_STALE_MS = 10 * 60 * 1000;
|
|
1836
|
+
/** 设备登记失败提示只在一小时内当作「当前故障」(.bind-error.json 是持久文件,成功时才被清掉)。 */
|
|
1837
|
+
const BIND_ERROR_FRESH_MS = 60 * 60 * 1000;
|
|
1838
|
+
/** 自动重试退避(毫秒):可重试错误按此节奏自动重试,用尽后停在 60s(仍会自动重试,不出现死胡同)。 */
|
|
1839
|
+
const CONNECT_BACKOFF_MS = [2000, 4000, 8000, 16_000, 30_000, 60_000];
|
|
1840
|
+
|
|
1841
|
+
/** 连接阶段文案(面向非技术用户;node 半算好,面板直接显示,避免两端各写一套)。 */
|
|
1842
|
+
const CONNECT_TEXT = {
|
|
1843
|
+
no_account: "请先登录手机号账号(下方「🔑 账号」卡片),登录后会自动完成剩余步骤",
|
|
1844
|
+
no_runtime: "正在准备运行环境(首次约 1~2 分钟)…",
|
|
1845
|
+
installing: "正在准备运行环境(首次约 1~2 分钟)…",
|
|
1846
|
+
starting: "正在启动 Bridge…",
|
|
1847
|
+
connecting: "正在连接中继…",
|
|
1848
|
+
online: "已连接 ✅ 现在可以用手机扫码访问",
|
|
1849
|
+
error: "连接失败,请按下方提示处理",
|
|
1850
|
+
};
|
|
1851
|
+
const CONNECT_PHASES = ["no_account", "no_runtime", "installing", "starting", "connecting", "online", "error"];
|
|
1852
|
+
|
|
1853
|
+
/** 每个 relayDir 的连接状态簿(尝试次数 / 退避 / 上报节流):放内存,进程重启即重置。 */
|
|
1854
|
+
const connectBooks = new Map();
|
|
1855
|
+
function connectBook(relayDir) {
|
|
1856
|
+
let b = connectBooks.get(relayDir);
|
|
1857
|
+
if (!b) {
|
|
1858
|
+
b = { attempts: 0, lastAttemptAt: 0, reportAt: 0 };
|
|
1859
|
+
connectBooks.set(relayDir, b);
|
|
1860
|
+
}
|
|
1861
|
+
return b;
|
|
1862
|
+
}
|
|
1863
|
+
/** 已尝试 attempts 次后的退避时长。 */
|
|
1864
|
+
function connectBackoffMs(attempts) {
|
|
1865
|
+
const i = Math.min(Math.max(attempts, 0), CONNECT_BACKOFF_MS.length - 1);
|
|
1866
|
+
return CONNECT_BACKOFF_MS[i];
|
|
1867
|
+
}
|
|
1868
|
+
/** 本机是否已有可用账号凭据(SaaS 账号或自建访问密钥)——没有就谈不上自动连接,先引导登录。 */
|
|
1869
|
+
function hasAccountCreds(cfg) {
|
|
1870
|
+
return Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/** 读文件尾部(日志可能很长,只取末尾用于判定最近一次连接结果)。 */
|
|
1874
|
+
function readTail(filePath, maxBytes = 64 * 1024) {
|
|
1875
|
+
try {
|
|
1876
|
+
const fd = openSync(filePath, "r");
|
|
1877
|
+
try {
|
|
1878
|
+
const size = fstatSync(fd).size;
|
|
1879
|
+
const start = Math.max(0, size - maxBytes);
|
|
1880
|
+
const buf = Buffer.alloc(size - start);
|
|
1881
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
1882
|
+
return buf.toString("utf8");
|
|
1883
|
+
} finally {
|
|
1884
|
+
closeSync(fd);
|
|
1885
|
+
}
|
|
1886
|
+
} catch { return ""; }
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** 读取 bridge 写的状态文件(新版 bridge 会写;旧版没有 → 走日志/账号兜底判据)。 */
|
|
1890
|
+
function bridgeStateFile(relayDir) {
|
|
1891
|
+
try {
|
|
1892
|
+
const j = JSON.parse(readFileSync(join(relayDir, BRIDGE_STATE_FILE), "utf8"));
|
|
1893
|
+
return j && typeof j === "object" ? j : null;
|
|
1894
|
+
} catch { return null; }
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* 从 bridge 日志尾部判定「最近一次连接是否注册成功」。
|
|
1899
|
+
* 依据 dsh-bridge.mjs 的既有输出:`✅ 设备已登记到账号` / `✅ router 注册成功`;
|
|
1900
|
+
* 出现新一轮连接(`隧道已连`)或登记失败/拒绝时,之前那次成功不再代表当前状态。
|
|
1901
|
+
*/
|
|
1902
|
+
function bridgeLogState(relayDir) {
|
|
1903
|
+
const tail = readTail(join(relayDir, BRIDGE_LOG_FILE));
|
|
1904
|
+
if (!tail) return { tunnelRegistered: false, accountBound: false, lastError: "" };
|
|
1905
|
+
let tunnelRegistered = false;
|
|
1906
|
+
let accountBound = false;
|
|
1907
|
+
let lastError = "";
|
|
1908
|
+
for (const raw of tail.split("\n")) {
|
|
1909
|
+
const line = raw.trim();
|
|
1910
|
+
if (!line) continue;
|
|
1911
|
+
if (/✅\s*router 注册成功/.test(line)) { tunnelRegistered = true; lastError = ""; continue; }
|
|
1912
|
+
if (/✅\s*设备已登记到账号/.test(line)) { accountBound = true; lastError = ""; continue; }
|
|
1913
|
+
if (/隧道已连|\[dsh-remote\] dsh web 在线,启动 bridge|bridge 退出/.test(line)) {
|
|
1914
|
+
tunnelRegistered = false;
|
|
1915
|
+
accountBound = false;
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
if (/拒绝注册|注册发送失败|设备登记失败|无法连接账号 API|设备数已达上限/.test(line)) {
|
|
1919
|
+
tunnelRegistered = false;
|
|
1920
|
+
accountBound = false;
|
|
1921
|
+
lastError = line.slice(0, 300);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return { tunnelRegistered, accountBound, lastError };
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/** 读取设备登记失败提示(bridge 在 409/网络失败时写 .bind-error.json)。 */
|
|
1928
|
+
function readBindError(relayDir) {
|
|
1929
|
+
try {
|
|
1930
|
+
const f = join(relayDir, BIND_ERROR_FILE);
|
|
1931
|
+
if (!existsSync(f)) return null;
|
|
1932
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
1933
|
+
return j && typeof j === "object" ? j : null;
|
|
1934
|
+
} catch { return null; }
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/** 运行环境安装状态:installing=安装子进程在跑;stale=标记超时/进程已死(可清掉重来)。 */
|
|
1938
|
+
function installStateOf(relayDir) {
|
|
1939
|
+
const marker = join(relayDir, PROVISION_MARKER);
|
|
1940
|
+
if (!existsSync(marker)) return { installing: false, stale: false, at: 0 };
|
|
1941
|
+
const info = readMarkerInfo(marker) || {};
|
|
1942
|
+
const at = Number(info.at) || 0;
|
|
1943
|
+
const pidDead = info.pid ? pidAlive(Number(info.pid)) === false : false;
|
|
1944
|
+
const stale = pidDead || (at > 0 && Date.now() - at > INSTALL_STALE_MS);
|
|
1945
|
+
return { installing: !stale, stale, at };
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/** 清掉卡死的安装标记(下轮 ensureRuntime 才能重新拉起补装)。 */
|
|
1949
|
+
function clearStaleInstallMarker(relayDir) {
|
|
1950
|
+
try {
|
|
1951
|
+
rmSync(join(relayDir, PROVISION_MARKER), { force: true });
|
|
1952
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, "[dsh-remote-web] 清理卡死的安装标记(安装进程已退出或超时),将重新补装运行环境");
|
|
1953
|
+
return true;
|
|
1954
|
+
} catch { return false; }
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/** 账号设备表里是否已登记本机 device_id(手机端 /api/devices 能看到的那一份)+ 节流缓存。 */
|
|
1958
|
+
async function accountDeviceBound(relayDir, cfg) {
|
|
1959
|
+
const deviceId = String(cfg.device_id || "");
|
|
1960
|
+
if (!deviceId) return { bound: false, reason: "no_device_id" };
|
|
1961
|
+
const cached = deviceProbeCache.get(relayDir);
|
|
1962
|
+
if (cached && Date.now() - cached.at < DEVICE_PROBE_TTL_MS) return cached.value;
|
|
1963
|
+
const done = (value) => { deviceProbeCache.set(relayDir, { at: Date.now(), value }); return value; };
|
|
1964
|
+
const token = await relayToken(relayDir).catch(() => "");
|
|
1965
|
+
if (!token) return done({ bound: false, reason: "no_token" });
|
|
1966
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1967
|
+
try {
|
|
1968
|
+
const r = await fetch(`${api}/api/devices`, {
|
|
1969
|
+
headers: { authorization: `Bearer ${token}`, "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}` },
|
|
1970
|
+
signal: AbortSignal.timeout(5000),
|
|
1971
|
+
});
|
|
1972
|
+
if (!r.ok) {
|
|
1973
|
+
if (r.status === 401 || r.status === 403) invalidateRelayToken(relayDir); // JWT 过期/轮换 → 下次重新认证
|
|
1974
|
+
return done({ bound: false, reason: `http_${r.status}` });
|
|
1975
|
+
}
|
|
1976
|
+
const body = await r.json().catch(() => null);
|
|
1977
|
+
const list = Array.isArray(body?.devices) ? body.devices : Array.isArray(body) ? body : [];
|
|
1978
|
+
const bound = list.some((d) => String(d?.id || d?.device_id || "") === deviceId);
|
|
1979
|
+
return done({ bound, reason: bound ? "" : "not_in_list", count: list.length });
|
|
1980
|
+
} catch (e) {
|
|
1981
|
+
return done({ bound: false, reason: `unreachable: ${e.message}` });
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* 计算当前连接阶段(纯读,不做任何系统操作)。
|
|
1987
|
+
* 阶段:no_account | no_runtime | installing | starting | connecting | online | error
|
|
1988
|
+
* 判据严格区分「进程在跑」与「已在中继注册」:
|
|
1989
|
+
* online 需要 bridge 进程在跑 **且** 有中继注册证据(状态文件 > 日志 > 账号设备表)。
|
|
1990
|
+
*/
|
|
1991
|
+
async function composeConnect(relayDir, opts = {}) {
|
|
1992
|
+
const cfg = opts.cfg || loadConfig(relayDir);
|
|
1993
|
+
const launchd = opts.launchd || launchdStatus();
|
|
1994
|
+
const manual = opts.manual || manualStatus();
|
|
1995
|
+
const book = connectBook(relayDir);
|
|
1996
|
+
const now = Date.now();
|
|
1997
|
+
const hasAcct = hasAccountCreds(cfg);
|
|
1998
|
+
const runtime = runtimeReady(relayDir);
|
|
1999
|
+
const inst = installStateOf(relayDir);
|
|
2000
|
+
const bridgePids = Array.isArray(manual.allBridge) ? manual.allBridge : [];
|
|
2001
|
+
const bridgeRunning = Boolean(launchd.running || bridgePids.length > 0 || (manual.bridge || []).length > 0);
|
|
2002
|
+
const bindError = readBindError(relayDir);
|
|
2003
|
+
const bindFresh = Boolean(bindError && (!bindError.at || now - Number(bindError.at) < BIND_ERROR_FRESH_MS));
|
|
2004
|
+
const log = bridgeLogState(relayDir);
|
|
2005
|
+
const state = bridgeStateFile(relayDir);
|
|
2006
|
+
const deviceId = String(cfg.device_id || "");
|
|
2007
|
+
|
|
2008
|
+
// ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
|
|
2009
|
+
let registered = false;
|
|
2010
|
+
let registerSource = "";
|
|
2011
|
+
if (runtime && bridgeRunning) {
|
|
2012
|
+
if (state && state.phase === "online" && Number(state.tunnel_registered_at) > 0) {
|
|
2013
|
+
registered = true; registerSource = "state";
|
|
2014
|
+
} else if (log.tunnelRegistered) {
|
|
2015
|
+
registered = true; registerSource = "tunnel";
|
|
2016
|
+
} else if (state && Number(state.account_bound_at) > 0) {
|
|
2017
|
+
registered = true; registerSource = "state_account";
|
|
2018
|
+
} else if (log.accountBound) {
|
|
2019
|
+
registered = true; registerSource = "account";
|
|
2020
|
+
} else if (hasAcct) {
|
|
2021
|
+
const probe = await accountDeviceBound(relayDir, cfg);
|
|
2022
|
+
if (probe.bound) { registered = true; registerSource = "account_api"; }
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
let phase;
|
|
2027
|
+
let error = null;
|
|
2028
|
+
let detail = "";
|
|
2029
|
+
let retryable = true;
|
|
2030
|
+
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
2031
|
+
phase = "error"; retryable = false;
|
|
2032
|
+
error = { code: "uninstalled", message: "本插件已彻底卸载,重启 dsh web 后不再自动启动 bridge" };
|
|
2033
|
+
detail = error.message;
|
|
2034
|
+
} else if (!hasAcct) {
|
|
2035
|
+
phase = "no_account"; retryable = false;
|
|
2036
|
+
detail = "尚未登录账号:登录后会自动补装运行环境 → 启动 bridge → 连接中继,全程无需其他操作。";
|
|
2037
|
+
} else if (!runtime) {
|
|
2038
|
+
if (inst.stale) {
|
|
2039
|
+
phase = "error";
|
|
2040
|
+
error = {
|
|
2041
|
+
code: "install_stuck",
|
|
2042
|
+
message: "运行环境安装没有完成(上次安装进程已退出或超时),已清理残留标记并准备重新安装",
|
|
2043
|
+
};
|
|
2044
|
+
detail = error.message;
|
|
2045
|
+
} else {
|
|
2046
|
+
phase = inst.installing ? "installing" : "no_runtime";
|
|
2047
|
+
detail = inst.installing
|
|
2048
|
+
? "正在后台安装运行环境(bridge 本体),首次约 1~2 分钟,装完会自动启动。"
|
|
2049
|
+
: "检测到缺少运行环境,正在后台自动安装(首次约 1~2 分钟),无需任何操作。";
|
|
2050
|
+
}
|
|
2051
|
+
} else if (!bridgeRunning) {
|
|
2052
|
+
if (launchd.crashing) {
|
|
2053
|
+
phase = "error";
|
|
2054
|
+
error = {
|
|
2055
|
+
code: "launchd_crash",
|
|
2056
|
+
message: `后台服务反复启动失败(launchd state=${launchd.state || "?"},lastExit=${launchd.lastExitCode ?? "?"})`,
|
|
2057
|
+
};
|
|
2058
|
+
detail = "已自动清理失效的自启动项并重新拉起,稍候会自动恢复。";
|
|
2059
|
+
} else if (bindFresh && bindError) {
|
|
2060
|
+
phase = "error";
|
|
2061
|
+
error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
|
|
2062
|
+
detail = "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
|
|
2063
|
+
} else {
|
|
2064
|
+
phase = "starting";
|
|
2065
|
+
detail = launchd.running
|
|
2066
|
+
? "后台服务已启动,正在等待 bridge 进程就绪…"
|
|
2067
|
+
: "正在启动后台服务(bridge),通常几秒内完成,无需任何操作。";
|
|
2068
|
+
}
|
|
2069
|
+
} else if (registered) {
|
|
2070
|
+
phase = "online";
|
|
2071
|
+
detail = registerSource === "account_api" || registerSource === "account" || registerSource === "state_account"
|
|
2072
|
+
? "设备已登记到你的账号:手机端「设备列表」能看到这台电脑,扫码/打开链接即可进入。"
|
|
2073
|
+
: "bridge 已注册到中继:手机端扫码/打开链接即可进入这台电脑。";
|
|
2074
|
+
} else if (bindFresh && bindError) {
|
|
2075
|
+
phase = "error";
|
|
2076
|
+
error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
|
|
2077
|
+
detail = bindError.code === "device_limit_exceeded"
|
|
2078
|
+
? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
|
|
2079
|
+
: "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
|
|
2080
|
+
} else {
|
|
2081
|
+
phase = "connecting";
|
|
2082
|
+
detail = "bridge 进程已在运行,正在等待设备注册到中继…通常几秒内完成。";
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const wait = connectBackoffMs(book.attempts);
|
|
2086
|
+
const elapsed = book.lastAttemptAt ? now - book.lastAttemptAt : wait;
|
|
2087
|
+
const autoRetrying = phase !== "online" && phase !== "no_account" && retryable !== false;
|
|
2088
|
+
const nextRetryInMs = autoRetrying ? Math.max(0, wait - elapsed) : 0;
|
|
2089
|
+
const conn = {
|
|
2090
|
+
phase,
|
|
2091
|
+
online: phase === "online",
|
|
2092
|
+
text: CONNECT_TEXT[phase] || "正在连接…",
|
|
2093
|
+
detail,
|
|
2094
|
+
retryable: retryable !== false,
|
|
2095
|
+
deviceId,
|
|
2096
|
+
runtimeReady: runtime,
|
|
2097
|
+
installing: inst.installing,
|
|
2098
|
+
installStale: inst.stale,
|
|
2099
|
+
bridgeRunning,
|
|
2100
|
+
bridgePids,
|
|
2101
|
+
launchdState: launchd.state || "",
|
|
2102
|
+
launchdCrashLoop: Boolean(launchd.crashing),
|
|
2103
|
+
registered,
|
|
2104
|
+
registerSource,
|
|
2105
|
+
attempts: book.attempts,
|
|
2106
|
+
nextRetryInMs,
|
|
2107
|
+
error,
|
|
2108
|
+
bindError: bindFresh && bindError ? { code: bindError.code || "", message: bindError.message || "" } : null,
|
|
2109
|
+
logPath: join(relayDir, BRIDGE_LOG_FILE),
|
|
2110
|
+
installLogPath: join(relayDir, AUTO_INSTALL_LOG),
|
|
2111
|
+
};
|
|
2112
|
+
conn.diagnostics = buildConnectDiagnostics(relayDir, conn, log);
|
|
2113
|
+
return conn;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/** 可复制的诊断信息(面板「复制诊断信息」按钮):版本 / relayDir / 阶段 / 最近错误 / 进程状态 / 日志路径。 */
|
|
2117
|
+
function buildConnectDiagnostics(relayDir, conn, log) {
|
|
2118
|
+
return [
|
|
2119
|
+
"dsh-remote 连接诊断",
|
|
2120
|
+
`插件版本: ${PLUGIN_VERSION}`,
|
|
2121
|
+
`配置目录: ${relayDir}`,
|
|
2122
|
+
`连接阶段: ${conn.phase}(${conn.text})`,
|
|
2123
|
+
`设备 ID: ${conn.deviceId || "(未生成)"}`,
|
|
2124
|
+
`运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
|
|
2125
|
+
`bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
|
|
2126
|
+
`中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
|
|
2127
|
+
`最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
|
|
2128
|
+
`自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
|
|
2129
|
+
`bridge 日志: ${conn.logPath}`,
|
|
2130
|
+
`安装日志: ${conn.installLogPath}`,
|
|
2131
|
+
`时间: ${new Date().toISOString()}`,
|
|
2132
|
+
].join("\n");
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* 自动闭环的动作执行(幂等、内部退避):
|
|
2137
|
+
* ① 缺运行环境 → ensureRuntime(后台 npx 补装;卡死的安装标记先清掉);
|
|
2138
|
+
* ② 不再缺环境但没账号 → 什么都不做(先把登录引导交给 UI);
|
|
2139
|
+
* ③ 有账号但 bridge 没在跑 → startBridge(运行环境缺失时它自己会拒绝并转补装)。
|
|
2140
|
+
* 由面板的短轮询驱动(GET /dsh-remote/bridge-status),因此用户在登录后不需要点任何按钮、
|
|
2141
|
+
* 也不需要刷新页面;退避避免失败时把 launchctl/npx 打成风暴。
|
|
2142
|
+
*/
|
|
2143
|
+
function ensureConnection(relayDir, opts = {}) {
|
|
2144
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return null;
|
|
2145
|
+
const book = connectBook(relayDir);
|
|
2146
|
+
if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
|
|
2147
|
+
const now = Date.now();
|
|
2148
|
+
if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
|
|
2149
|
+
const cfg = loadConfig(relayDir);
|
|
2150
|
+
if (!hasAccountCreds(cfg)) return null; // 未登录:不补装也不拉服务,先让用户登录
|
|
2151
|
+
if (!runtimeReady(relayDir)) {
|
|
2152
|
+
if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
|
|
2153
|
+
book.lastAttemptAt = now;
|
|
2154
|
+
book.attempts += 1;
|
|
2155
|
+
ensureRuntime(relayDir);
|
|
2156
|
+
return { action: "provision" };
|
|
2157
|
+
}
|
|
2158
|
+
const launchd = launchdStatus();
|
|
2159
|
+
const manual = manualStatus();
|
|
2160
|
+
if (launchd.running || (manual.allBridge || []).length || (manual.bridge || []).length) {
|
|
2161
|
+
book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
|
|
2162
|
+
book.lastAttemptAt = now;
|
|
2163
|
+
return { action: "none" };
|
|
2164
|
+
}
|
|
2165
|
+
if (launchd.crashing) reapBrokenAutostart(relayDir);
|
|
2166
|
+
book.lastAttemptAt = now;
|
|
2167
|
+
book.attempts += 1;
|
|
2168
|
+
const r = startBridge(relayDir);
|
|
2169
|
+
if (r && r.ok === false) {
|
|
2170
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
2171
|
+
`[dsh-remote-web] 自动启动 bridge 未成功(${r.status || "?"}): ${r.detail || ""}`);
|
|
2172
|
+
}
|
|
2173
|
+
return { action: "start", result: r };
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
2178
|
+
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
2179
|
+
* 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
|
|
2180
|
+
* body { device_id?, install_source, install_version, host_os, host_arch },成功 200 {ok:true};
|
|
2181
|
+
* 404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
2182
|
+
*/
|
|
2183
|
+
const INSTALL_REPORT_FILE = ".dsh-install-report.json";
|
|
2184
|
+
const INSTALL_REPORT_TTL_MS = 24 * 3600 * 1000;
|
|
2185
|
+
const INSTALL_SOURCES = new Set(["npx", "plugin_market"]);
|
|
2186
|
+
/** 本机安装来源判据:环境变量 > 插件自愈记录 > 有运行环境即安装器部署 > 默认插件路径。 */
|
|
2187
|
+
function installSourceOf(relayDir) {
|
|
2188
|
+
const env = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim();
|
|
2189
|
+
if (INSTALL_SOURCES.has(env)) return env;
|
|
2190
|
+
if (existsSync(join(relayDir, PROVISIONED_MARKER))) return "plugin_market";
|
|
2191
|
+
if (runtimeReady(relayDir)) return "npx";
|
|
2192
|
+
return "plugin_market";
|
|
2193
|
+
}
|
|
2194
|
+
async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
2195
|
+
const cfg = cfgOverride || loadConfig(relayDir);
|
|
2196
|
+
if (!hasAccountCreds(cfg)) return { ok: false, skipped: "no_credentials" };
|
|
2197
|
+
const source = installSourceOf(relayDir);
|
|
2198
|
+
const version = PLUGIN_VERSION;
|
|
2199
|
+
const marker = join(relayDir, INSTALL_REPORT_FILE);
|
|
2200
|
+
if (!opts.force) {
|
|
2201
|
+
try {
|
|
2202
|
+
const prev = JSON.parse(readFileSync(marker, "utf8"));
|
|
2203
|
+
if (prev && prev.version === version && prev.source === source && Date.now() - Number(prev.at || 0) < INSTALL_REPORT_TTL_MS) {
|
|
2204
|
+
return { ok: true, skipped: "already_reported" };
|
|
2205
|
+
}
|
|
2206
|
+
} catch { /* 首次上报 */ }
|
|
2207
|
+
}
|
|
2208
|
+
const token = await relayToken(relayDir).catch(() => "");
|
|
2209
|
+
if (!token) return { ok: false, skipped: "no_token" };
|
|
2210
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
2211
|
+
const body = {
|
|
2212
|
+
...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
|
|
2213
|
+
install_source: source,
|
|
2214
|
+
install_version: version,
|
|
2215
|
+
host_os: process.platform,
|
|
2216
|
+
host_arch: process.arch,
|
|
2217
|
+
};
|
|
2218
|
+
try {
|
|
2219
|
+
const r = await fetch(`${api}/api/install-report`, {
|
|
2220
|
+
method: "POST",
|
|
2221
|
+
headers: {
|
|
2222
|
+
authorization: `Bearer ${token}`,
|
|
2223
|
+
"content-type": "application/json",
|
|
2224
|
+
"x-dsh-client": `dsh-remote-web/${version}`,
|
|
2225
|
+
},
|
|
2226
|
+
body: JSON.stringify(body),
|
|
2227
|
+
signal: AbortSignal.timeout(6000),
|
|
2228
|
+
});
|
|
2229
|
+
// 老服务端没有该接口(404)或字段不认(400)→ 忽略,绝不影响 UI
|
|
2230
|
+
if (r.status === 400 || r.status === 404) return { ok: false, skipped: `http_${r.status}` };
|
|
2231
|
+
if (!r.ok) return { ok: false, status: r.status };
|
|
2232
|
+
try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source })); } catch { /* 非关键 */ }
|
|
2233
|
+
return { ok: true, status: r.status, install_source: source, install_version: version };
|
|
2234
|
+
} catch (e) {
|
|
2235
|
+
return { ok: false, skipped: `unreachable: ${e.message}` };
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
/** 上报节流入口(fire-and-forget):失败最多 60s 再试一次;成功则按 version/source 去重。 */
|
|
2239
|
+
function maybeReportInstall(relayDir, opts = {}) {
|
|
2240
|
+
const book = connectBook(relayDir);
|
|
2241
|
+
const now = Date.now();
|
|
2242
|
+
if (!opts.force && book.reportAt && now - book.reportAt < 60_000) return;
|
|
2243
|
+
book.reportAt = now;
|
|
2244
|
+
// 认证走 relayTokenWithReason(60s token 缓存 + 并发合并),因此启动/登录/轮询多次调用
|
|
2245
|
+
// 也只会打一次 device-login,不会放大企业端认证请求。
|
|
2246
|
+
reportInstallOnce(relayDir, null, opts).catch(() => { /* 静默失败:不影响 UI */ });
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1717
2249
|
function registerRoutes(ctx, relayDir) {
|
|
1718
2250
|
// 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
|
|
1719
2251
|
let profileDir = join(homedir(), ".dsh", "profiles", "web");
|
|
@@ -1796,6 +2328,30 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1796
2328
|
sendJson(res, 200, await composeStatus(relayDir));
|
|
1797
2329
|
},
|
|
1798
2330
|
},
|
|
2331
|
+
// 连接阶段短轮询端点(面板 2~3s 一次,非 online 时自动推进;online 后退避到 15s)。
|
|
2332
|
+
// 返回前先执行自动闭环动作(缺环境→补装 / 有账号没进程→拉起 / 卡死标记→清理),
|
|
2333
|
+
// 于是「登录后自动连上」不需要任何按钮或刷新;每步都自带退避,失败不会打成风暴。
|
|
2334
|
+
{
|
|
2335
|
+
method: "GET",
|
|
2336
|
+
path: "/dsh-remote/bridge-status",
|
|
2337
|
+
handler: async (_req, res) => {
|
|
2338
|
+
ensureConnection(relayDir);
|
|
2339
|
+
maybeReportInstall(relayDir);
|
|
2340
|
+
sendJson(res, 200, { ok: true, connect: await composeConnect(relayDir) });
|
|
2341
|
+
},
|
|
2342
|
+
},
|
|
2343
|
+
// 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
|
|
2344
|
+
{
|
|
2345
|
+
method: "POST",
|
|
2346
|
+
path: "/dsh-remote/connect/retry",
|
|
2347
|
+
handler: async (_req, res) => {
|
|
2348
|
+
const action = ensureConnection(relayDir, { force: true });
|
|
2349
|
+
maybeReportInstall(relayDir, { force: true });
|
|
2350
|
+
await sleep(600); // 给 launchctl 一点进入运行态的时间,重试点下去立刻能看到「正在启动 Bridge…」
|
|
2351
|
+
const connect = await composeConnect(relayDir);
|
|
2352
|
+
sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
|
|
2353
|
+
},
|
|
2354
|
+
},
|
|
1799
2355
|
{
|
|
1800
2356
|
method: "GET",
|
|
1801
2357
|
path: "/dsh-remote/account",
|
|
@@ -1919,7 +2475,10 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1919
2475
|
}
|
|
1920
2476
|
}
|
|
1921
2477
|
saveConfig(relayDir, cfg);
|
|
2478
|
+
invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
|
|
1922
2479
|
const bridgeRestart = startBridge(relayDir);
|
|
2480
|
+
// 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
|
|
2481
|
+
maybeReportInstall(relayDir, { force: true });
|
|
1923
2482
|
sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
|
|
1924
2483
|
},
|
|
1925
2484
|
},
|
|
@@ -1932,6 +2491,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1932
2491
|
delete cfg.phone;
|
|
1933
2492
|
delete cfg.password;
|
|
1934
2493
|
saveConfig(relayDir, cfg);
|
|
2494
|
+
invalidateRelayToken(relayDir); // 退出登录:token 缓存立即失效
|
|
1935
2495
|
sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
|
|
1936
2496
|
},
|
|
1937
2497
|
},
|
|
@@ -2053,7 +2613,10 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2053
2613
|
const smsCode = String(body.sms_code ?? "").trim();
|
|
2054
2614
|
const password = String(body.password ?? "");
|
|
2055
2615
|
if (!phone || !smsCode || !password) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与密码必填" });
|
|
2056
|
-
|
|
2616
|
+
// 全量透传 body(不挑字段):注册来源 reg_source(面板=panel_register/手机端网页=remoteweb_register)
|
|
2617
|
+
// 等扩展字段必须原样进企业端,否则「用户从哪注册的」这类增长口径会全部丢失。
|
|
2618
|
+
const payload = { ...body, phone, sms_code: smsCode, password };
|
|
2619
|
+
delete payload.__parseError;
|
|
2057
2620
|
const captchaId = body.captcha_id ?? body.captchaId;
|
|
2058
2621
|
const captchaAnswer = body.captcha_answer ?? body.captcha;
|
|
2059
2622
|
if (captchaId !== void 0) payload.captcha_id = String(captchaId);
|
|
@@ -2189,5 +2752,8 @@ export function apply(ctx, config = {}) {
|
|
|
2189
2752
|
if (!provisioned && !runtimeReady(relayDir)) {
|
|
2190
2753
|
ctx.logger?.info?.(`dsh-remote-web: 桌面运行环境缺失,已在后台自动安装(relayDir=${relayDir})`);
|
|
2191
2754
|
}
|
|
2755
|
+
// 插件启动即上报本机安装信息(install_source/version + 主机 OS/架构;仅有账号凭据时上报,失败静默)。
|
|
2756
|
+
// 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的口径。
|
|
2757
|
+
maybeReportInstall(relayDir);
|
|
2192
2758
|
ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
|
|
2193
2759
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4-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",
|
|
@@ -39,5 +39,8 @@
|
|
|
39
39
|
"url": "https://github.com/mrRisega/dsh-remote/issues"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-web#readme",
|
|
42
|
-
"license": "PolyForm-Noncommercial-1.0.0"
|
|
42
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
43
|
+
"engines": {
|
|
44
|
+
"dsh": ">=0.1.0-rc.6 <0.2.0-0"
|
|
45
|
+
}
|
|
43
46
|
}
|