@mrrisega/dsh-remote 0.6.7-beta.3 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +16 -0
- package/clients/dsh-remote/e2ee-shim-script.js +18 -1
- package/clients/dsh-remote/mobile-adapter.mjs +487 -170
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +91 -34
- package/dsh-setup.mjs +59 -6
- package/package.json +6 -5
- package/packages/dsh-remote-web/lib/client.js +22 -4
- package/packages/dsh-remote-web/lib/index.js +161 -40
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +307 -0
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +68 -1
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +18 -0
- package/scripts/test-net-guard.cjs +49 -0
|
@@ -546,11 +546,28 @@ function launchAgentPath() {
|
|
|
546
546
|
* `process.getuid()`,在 Windows 上直接 TypeError(这正是面板两个读状态接口 500 的根因)。
|
|
547
547
|
* 现在:非 macOS 或取不到 uid 一律返回 null,调用方必须先判空。
|
|
548
548
|
*/
|
|
549
|
-
|
|
550
|
-
|
|
549
|
+
/**
|
|
550
|
+
* launchd 域名阶梯(**必须与 dsh-setup.mjs 一致**)。
|
|
551
|
+
*
|
|
552
|
+
* 事故(2026-09-19 实测):安装器按 macOS 26 的修复把作业装在 **`user/<uid>`** 域
|
|
553
|
+
* (gui 域会被系统置为 on-demand-only,RunAtLoad/KeepAlive 失效),
|
|
554
|
+
* 但插件这边只认 `gui/<uid>` → 切换账号时那次"重启 bridge"实际执行的是
|
|
555
|
+
* launchctl bootout gui/501/com.dshremote.bridge → 找不到
|
|
556
|
+
* launchctl bootstrap gui/501 <plist> → 失败
|
|
557
|
+
* 结果:旧 bridge 继续用**旧账号**跑,新账号的设备列表里永远看不到这台机器。
|
|
558
|
+
* 现在两边用同一条阶梯:user/<uid> 优先,gui/<uid> 兜底。
|
|
559
|
+
*/
|
|
560
|
+
function launchDomains() {
|
|
561
|
+
if (!isDarwin()) return [];
|
|
551
562
|
const uid = currentUid();
|
|
552
|
-
|
|
553
|
-
|
|
563
|
+
return uid === null ? [] : [`user/${uid}`, `gui/${uid}`];
|
|
564
|
+
}
|
|
565
|
+
/** 所有可能承载本服务的 launchd target(按优先级)。 */
|
|
566
|
+
function launchTargets() {
|
|
567
|
+
return launchDomains().map((d) => `${d}/com.dshremote.bridge`);
|
|
568
|
+
}
|
|
569
|
+
function launchTarget() {
|
|
570
|
+
return launchTargets()[0] || null; // 首选域(user/<uid>)
|
|
554
571
|
}
|
|
555
572
|
|
|
556
573
|
/**
|
|
@@ -567,12 +584,21 @@ function launchTarget() {
|
|
|
567
584
|
* 现在:print 的 state 为准 + 附带 runs/lastExitCode/crashing 供面板与自愈解释原因;
|
|
568
585
|
* 仅在 print 本身不可用时才回退 list,且回退路径要求 pid 真实存活。
|
|
569
586
|
*/
|
|
587
|
+
const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
588
|
+
|
|
570
589
|
function launchdStatus() {
|
|
571
590
|
// 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
|
|
572
591
|
// 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
|
|
573
|
-
if (!isDarwin()) return {
|
|
574
|
-
|
|
575
|
-
|
|
592
|
+
if (!isDarwin()) return { ...NO_LAUNCHD };
|
|
593
|
+
// 逐域查询:安装器可能把作业装在 user/<uid>,插件以前只看 gui/<uid> → 会把"正在运行"看成"未运行"。
|
|
594
|
+
for (const target of launchTargets()) {
|
|
595
|
+
const st = launchdStatusIn(target);
|
|
596
|
+
if (st.running || st.crashing || st.state) return st;
|
|
597
|
+
}
|
|
598
|
+
return { ...NO_LAUNCHD };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function launchdStatusIn(target) {
|
|
576
602
|
const pr = sh(`launchctl print ${target}`);
|
|
577
603
|
if (pr.ok) {
|
|
578
604
|
const stateMatch = pr.stdout.match(/state\s*=\s*([^\n]+)/);
|
|
@@ -961,7 +987,7 @@ function reapBrokenAutostart(relayDir) {
|
|
|
961
987
|
if (!plistEntryMissing()) return false;
|
|
962
988
|
const st = launchdStatus();
|
|
963
989
|
if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
|
|
964
|
-
sh(`launchctl bootout ${
|
|
990
|
+
for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
|
|
965
991
|
try { rmSync(launchAgentPath(), { force: true }); } catch { /* 非关键 */ }
|
|
966
992
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
967
993
|
`[dsh-remote-web] 已摘除失效自启动:plist 入口 ${join(relayDir, "dsh-setup.mjs")} 不存在`
|
|
@@ -997,16 +1023,15 @@ function killPid(pid, signal = "SIGTERM") {
|
|
|
997
1023
|
}
|
|
998
1024
|
|
|
999
1025
|
/**
|
|
1000
|
-
*
|
|
1026
|
+
* 「脱离进程」方式启动 bridge(Windows 必用;macOS/Linux 在 launchd/systemd 托管不了时兜底)。
|
|
1001
1027
|
*
|
|
1002
|
-
* Windows
|
|
1003
|
-
*
|
|
1004
|
-
* 所以插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
|
|
1028
|
+
* Windows 上没有服务管理器自启动;macOS 上 launchd 被系统置为 on-demand-only(gui 域)时也托管不住。
|
|
1029
|
+
* 此时插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
|
|
1005
1030
|
* 这与用户手动跑 `dsh-remote run` 是**同一条代码路径**,watcher 自己盯着 127.0.0.1:3080,
|
|
1006
1031
|
* dsh web 活着就拉起 dsh-bridge.mjs、dsh web 退出就把 bridge 停掉。
|
|
1007
1032
|
* 代价(如实告知用户):不随开机自启;但 dsh web 每次启动后插件都会在自愈轮次里重新拉起它。
|
|
1008
1033
|
*/
|
|
1009
|
-
function
|
|
1034
|
+
function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
1010
1035
|
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
1011
1036
|
if (!runtimeReady(relayDir)) {
|
|
1012
1037
|
ensureRuntime(relayDir);
|
|
@@ -1046,16 +1071,18 @@ function startBridgeWindows(relayDir) {
|
|
|
1046
1071
|
ok: true,
|
|
1047
1072
|
status: "running",
|
|
1048
1073
|
pid: child.pid,
|
|
1049
|
-
//
|
|
1050
|
-
detail: windowsTaskRegistered()
|
|
1074
|
+
// 如实区分两条路径:装过(且平台支持)就有自启动;否则只有"重开 dsh web 自动拉起"这一层保障
|
|
1075
|
+
detail: isWindows() && windowsTaskRegistered()
|
|
1051
1076
|
? "已在后台启动 bridge 守护(已注册登录任务,下次登录会自动运行)"
|
|
1052
|
-
:
|
|
1077
|
+
: `已在后台启动 bridge 守护${fallbackReason ? `(${fallbackReason};不随开机自启,重开 dsh web 时插件会自动拉起)` : "(不随开机自启,重开 dsh web 时插件会自动拉起)"}`,
|
|
1053
1078
|
};
|
|
1054
1079
|
}
|
|
1055
1080
|
|
|
1056
|
-
/**
|
|
1057
|
-
function
|
|
1058
|
-
|
|
1081
|
+
/** 停止「脱离进程」方式运行的 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
|
|
1082
|
+
function stopBridgeDetached(relayDir, opts = {}) {
|
|
1083
|
+
// 账号切换时用 force:即使处于测试隔离模式也要把进程停掉(否则"换个账号"在测试里走不通),
|
|
1084
|
+
// 但隔离模式下仍只用信号结束**本插件记录的** pid,不碰任何系统服务。
|
|
1085
|
+
if (skipsSystemOps() && !opts.force) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
1059
1086
|
const targets = [];
|
|
1060
1087
|
for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
|
|
1061
1088
|
const pid = readPidFile(relayDir, name);
|
|
@@ -1082,7 +1109,7 @@ function startBridge(relayDir) {
|
|
|
1082
1109
|
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
1083
1110
|
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
1084
1111
|
}
|
|
1085
|
-
if (isWindows()) return
|
|
1112
|
+
if (isWindows()) return startBridgeDetached(relayDir);
|
|
1086
1113
|
const plistPath = launchAgentPath();
|
|
1087
1114
|
if (!plistPath) {
|
|
1088
1115
|
return {
|
|
@@ -1110,21 +1137,32 @@ function startBridge(relayDir) {
|
|
|
1110
1137
|
}
|
|
1111
1138
|
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
1112
1139
|
if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
|
|
1113
|
-
|
|
1114
|
-
|
|
1140
|
+
// 与 dsh-setup.mjs 同一条域阶梯:user/<uid> 优先(macOS 26 上唯一支持 RunAtLoad/KeepAlive 的域),
|
|
1141
|
+
// gui/<uid> 兜底(只能靠 kickstart 拉这一次,不保证崩溃自愈)。
|
|
1142
|
+
let lastDetail = "";
|
|
1143
|
+
for (const domain of launchDomains()) {
|
|
1144
|
+
const target = `${domain}/com.dshremote.bridge`;
|
|
1145
|
+
// 清掉**另一个**域的历史注册,避免两处并存导致重复实例(与安装器同构)
|
|
1146
|
+
for (const other of launchDomains()) {
|
|
1147
|
+
if (other !== domain) sh(`launchctl bootout ${other}/com.dshremote.bridge`);
|
|
1148
|
+
}
|
|
1115
1149
|
sh(`launchctl bootout ${target}`);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
let r = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
|
|
1119
|
-
if (!r.ok) {
|
|
1150
|
+
let boot = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
|
|
1151
|
+
if (!boot.ok) {
|
|
1120
1152
|
sh(`launchctl unload ${shQuote(plistPath)}`);
|
|
1121
|
-
|
|
1153
|
+
boot = sh(`launchctl load -w ${shQuote(plistPath)}`);
|
|
1122
1154
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1155
|
+
if (!boot.ok) { lastDetail = (boot.stderr || boot.stdout).trim() || `launchctl bootstrap ${domain} 失败`; continue; }
|
|
1156
|
+
// bootstrap 只是登记:立刻 kickstart 一次,确保"这一次"一定起来
|
|
1157
|
+
sh(`launchctl kickstart -k ${target}`);
|
|
1158
|
+
const st = launchdStatusIn(target);
|
|
1159
|
+
if (st.running) return { ok: true, status: "running", pid: st.pid, domain };
|
|
1160
|
+
lastDetail = st.state ? `已登记但未运行(state=${st.state}, runs=${st.runs ?? "?"})` : "已登记但查不到状态";
|
|
1161
|
+
}
|
|
1162
|
+
// launchd 都托管不了(macOS 26 的 on-demand-only / 权限异常等)→ 退化为脱离进程,
|
|
1163
|
+
// 与 Windows 走同一条路径:绝不出现"重启失败但用户无感、旧账号继续跑"这种静默失败。
|
|
1164
|
+
const d = startBridgeDetached(relayDir, `launchd 无法托管(${lastDetail || "未知原因"})`);
|
|
1165
|
+
return d.ok ? d : { ok: false, status: "failed", detail: `${lastDetail};脱离进程兜底也失败:${d.detail}` };
|
|
1128
1166
|
}
|
|
1129
1167
|
|
|
1130
1168
|
/**
|
|
@@ -1177,12 +1215,20 @@ function scheduleRuntime(relayDir) {
|
|
|
1177
1215
|
|
|
1178
1216
|
/** 停止 bridge:macOS/Linux 走 launchctl bootout;Windows 结束 watcher/bridge 进程。 */
|
|
1179
1217
|
function stopBridge(relayDir) {
|
|
1180
|
-
if (isWindows()) return
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1218
|
+
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
1219
|
+
// 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
|
|
1220
|
+
// 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
|
|
1221
|
+
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
1222
|
+
const targets = launchTargets();
|
|
1223
|
+
if (!targets.length) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
|
|
1224
|
+
// 两个域都要停:作业可能由安装器装在 user/<uid>,而旧版插件只会去 gui/<uid> 找不到(实测事故)
|
|
1225
|
+
let lastErr = "";
|
|
1226
|
+
for (const target of targets) {
|
|
1227
|
+
const r = sh(`launchctl bootout ${target}`);
|
|
1228
|
+
if (!r.ok) lastErr = (r.stderr || r.stdout).trim();
|
|
1229
|
+
}
|
|
1184
1230
|
const st = launchdStatus();
|
|
1185
|
-
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (
|
|
1231
|
+
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (lastErr || "停止失败").trim() : void 0 };
|
|
1186
1232
|
}
|
|
1187
1233
|
|
|
1188
1234
|
// ---------- 彻底卸载:bridge 自启动 / 残留进程 / 配置目录 ----------
|
|
@@ -2333,7 +2379,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
2333
2379
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
2334
2380
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
2335
2381
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
2336
|
-
const PLUGIN_VERSION = "0.6.7
|
|
2382
|
+
const PLUGIN_VERSION = "0.6.7";
|
|
2337
2383
|
const UPDATE_LOG = ".dsh-update.log";
|
|
2338
2384
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
2339
2385
|
|
|
@@ -2729,6 +2775,51 @@ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
|
|
|
2729
2775
|
}
|
|
2730
2776
|
}
|
|
2731
2777
|
|
|
2778
|
+
/** 账号指纹:账号 / 模式 / 设备身份任一变化都意味着"必须用新凭据重新登记"。 */
|
|
2779
|
+
function accountFingerprintOf(cfg) {
|
|
2780
|
+
const c = cfg || {};
|
|
2781
|
+
return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || "")].join("|");
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/**
|
|
2785
|
+
* 账号(或模式/设备身份)变化后,**必须让 bridge 用新凭据重新登记**。
|
|
2786
|
+
*
|
|
2787
|
+
* 事故(2026-09-19 实测):从 A 账号切到 B 账号后,配置已经改了、device_id 也清了,
|
|
2788
|
+
* 但**正在运行的 bridge 是启动时把账号/密码固化在环境变量里的**,它不会自己换账号;
|
|
2789
|
+
* 而那次"重启 bridge"又因 launchd 域名不一致(插件只认 gui/<uid>,安装器装在 user/<uid>)静默失败
|
|
2790
|
+
* → 设备继续留在 A 账号,B 账号的设备列表里永远看不到这台机器。
|
|
2791
|
+
*
|
|
2792
|
+
* 这里做三件事:① 停掉旧 bridge(含两个 launchd 域与脱离进程两种形态);
|
|
2793
|
+
* ② 删掉旧账号留下的状态文件(否则面板据 phase=online + 旧 device_id 谎报"已连接",
|
|
2794
|
+
* 自愈也就永远不会去纠正);③ 交给调用方随后重新启动。
|
|
2795
|
+
*/
|
|
2796
|
+
function resetBridgeForAccountChange(relayDir, why) {
|
|
2797
|
+
const stopped = stopBridge(relayDir);
|
|
2798
|
+
// 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
|
|
2799
|
+
try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
|
|
2800
|
+
try {
|
|
2801
|
+
if (bridgeStateFile(relayDir)) {
|
|
2802
|
+
rmSync(join(relayDir, BRIDGE_STATE_FILE), { force: true });
|
|
2803
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] ${why}:已清除过期的 bridge 状态记录(旧账号的注册证据作废)`);
|
|
2804
|
+
}
|
|
2805
|
+
} catch { /* 非关键 */ }
|
|
2806
|
+
return stopped;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* 运行中的 bridge 是否属于「当前账号」。
|
|
2811
|
+
* 判据:bridge 状态文件记录的 device_id 必须与配置里的一致;配置里没有 device_id
|
|
2812
|
+
* (= 刚切过账号、旧身份已作废)而状态里还有 → 说明跑的是**上一个账号**的 bridge。
|
|
2813
|
+
* @returns {boolean} true=属于当前账号;false=过期(必须重启);未记录 device_id 时按旧行为返回 true
|
|
2814
|
+
*/
|
|
2815
|
+
function bridgeMatchesCurrentAccount(relayDir, cfg) {
|
|
2816
|
+
const state = bridgeStateFile(relayDir);
|
|
2817
|
+
if (!state || !state.device_id) return true; // 无记录可判:不改变既有行为
|
|
2818
|
+
const want = String((cfg || loadConfig(relayDir)).device_id || "");
|
|
2819
|
+
if (!want) return false; // 配置里身份已清空 = 旧 bridge 的注册已作废
|
|
2820
|
+
return want === String(state.device_id);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2732
2823
|
/** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
|
|
2733
2824
|
function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
2734
2825
|
const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
|
|
@@ -2963,9 +3054,13 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
2963
3054
|
const deviceId = String(cfg.device_id || "");
|
|
2964
3055
|
|
|
2965
3056
|
// ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
|
|
3057
|
+
// ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
|
|
3058
|
+
// 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
|
|
3059
|
+
// 自愈也就永远不会去重启它(2026-09-19 实测事故)。
|
|
3060
|
+
const accountCurrent = bridgeMatchesCurrentAccount(relayDir, cfg);
|
|
2966
3061
|
let registered = false;
|
|
2967
3062
|
let registerSource = "";
|
|
2968
|
-
if (runtime && bridgeRunning) {
|
|
3063
|
+
if (runtime && bridgeRunning && accountCurrent) {
|
|
2969
3064
|
if (state && state.phase === "online" && Number(state.tunnel_registered_at) > 0) {
|
|
2970
3065
|
registered = true; registerSource = "state";
|
|
2971
3066
|
} else if (log.tunnelRegistered) {
|
|
@@ -3034,6 +3129,10 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3034
3129
|
detail = bindError.code === "device_limit_exceeded"
|
|
3035
3130
|
? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
|
|
3036
3131
|
: "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
|
|
3132
|
+
} else if (!accountCurrent) {
|
|
3133
|
+
// 进程在跑,但它是**上一个账号**的 bridge:必须重启才能用新账号登记
|
|
3134
|
+
phase = "starting";
|
|
3135
|
+
detail = "检测到后台 bridge 仍是上一个账号的身份(账号切换后需要重启它才能用新账号重新登记),正在自动重启…";
|
|
3037
3136
|
} else {
|
|
3038
3137
|
phase = "connecting";
|
|
3039
3138
|
detail = "bridge 进程已在运行,正在等待设备注册到中继…通常几秒内完成。";
|
|
@@ -3054,6 +3153,7 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3054
3153
|
installing: inst.installing,
|
|
3055
3154
|
installStale: inst.stale,
|
|
3056
3155
|
bridgeRunning,
|
|
3156
|
+
accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
|
|
3057
3157
|
bridgePids,
|
|
3058
3158
|
launchdState: launchd.state || "",
|
|
3059
3159
|
launchdCrashLoop: Boolean(launchd.crashing),
|
|
@@ -3081,6 +3181,7 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
3081
3181
|
`运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
|
|
3082
3182
|
`bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
|
|
3083
3183
|
`中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
|
|
3184
|
+
`bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
|
|
3084
3185
|
`最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
|
|
3085
3186
|
`自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
|
|
3086
3187
|
`bridge 日志: ${conn.logPath}`,
|
|
@@ -3115,6 +3216,17 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
3115
3216
|
const launchd = launchdStatus();
|
|
3116
3217
|
const manual = manualStatus(relayDir);
|
|
3117
3218
|
if (launchd.running || (manual.allBridge || []).length || (manual.bridge || []).length) {
|
|
3219
|
+
// 进程在跑 ≠ 跑对了账号:切换账号后旧 bridge 会一直用旧凭据,
|
|
3220
|
+
// 只看"有没有进程"会让自愈永久停摆(实测事故)。这里多判一次账号归属。
|
|
3221
|
+
if (!bridgeMatchesCurrentAccount(relayDir, cfg)) {
|
|
3222
|
+
book.lastAttemptAt = now;
|
|
3223
|
+
book.attempts += 1;
|
|
3224
|
+
resetBridgeForAccountChange(relayDir, "bridge 身份与当前账号不符");
|
|
3225
|
+
const r = startBridge(relayDir);
|
|
3226
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
3227
|
+
`[dsh-remote-web] 检测到旧账号的 bridge 仍在运行,已重启以用当前账号重新登记(${r.status || "?"})`);
|
|
3228
|
+
return { action: "restart", result: r };
|
|
3229
|
+
}
|
|
3118
3230
|
book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
|
|
3119
3231
|
book.lastAttemptAt = now;
|
|
3120
3232
|
return { action: "none" };
|
|
@@ -4003,8 +4115,11 @@ function registerRoutes(ctx, relayDir) {
|
|
|
4003
4115
|
if (secret) cfg.bridge_secret = secret;
|
|
4004
4116
|
}
|
|
4005
4117
|
}
|
|
4118
|
+
// 账号/模式变化 → 先停掉旧 bridge 并作废它的注册证据,否则新账号永远等不到这台设备
|
|
4119
|
+
const accountFpBefore = accountFingerprintOf(loadConfig(relayDir));
|
|
4006
4120
|
saveConfig(relayDir, cfg);
|
|
4007
4121
|
invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
|
|
4122
|
+
if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
|
|
4008
4123
|
const bridgeRestart = startBridge(relayDir);
|
|
4009
4124
|
// 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
|
|
4010
4125
|
maybeReportInstall(relayDir, { force: true });
|
|
@@ -4015,11 +4130,17 @@ function registerRoutes(ctx, relayDir) {
|
|
|
4015
4130
|
method: "POST",
|
|
4016
4131
|
path: "/dsh-remote/logout",
|
|
4017
4132
|
handler: async (_req, res) => {
|
|
4018
|
-
//
|
|
4133
|
+
// 退出登录:清除本机保存的账号(邮箱/密码),并**立刻停掉 bridge**。
|
|
4134
|
+
// 为什么必须停:bridge 的凭据是启动时固化的,不停就会继续用旧账号的隧道对外服务 ——
|
|
4135
|
+
// 用户以为"已退出登录",实际上手机端仍能访问这台电脑(安全语义不成立)。
|
|
4019
4136
|
const cfg = loadConfig(relayDir);
|
|
4020
4137
|
delete cfg.phone;
|
|
4021
4138
|
delete cfg.password;
|
|
4139
|
+
delete cfg.device_id; // 设备身份一并作废:下次登录会生成新身份并重新登记
|
|
4140
|
+
delete cfg.device_private_key;
|
|
4141
|
+
delete cfg.device_public_key;
|
|
4022
4142
|
saveConfig(relayDir, cfg);
|
|
4143
|
+
resetBridgeForAccountChange(relayDir, "退出登录");
|
|
4023
4144
|
invalidateRelayToken(relayDir); // 退出登录:token 缓存立即失效
|
|
4024
4145
|
sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
|
|
4025
4146
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.7
|
|
3
|
+
"version": "0.6.7",
|
|
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",
|