@mrrisega/dsh-remote 0.6.14 → 0.6.15
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 +64 -13
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +161 -7
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +392 -49
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
|
@@ -56,6 +56,10 @@ function isWindows() {
|
|
|
56
56
|
function isDarwin() {
|
|
57
57
|
return osPlatform() === "darwin";
|
|
58
58
|
}
|
|
59
|
+
/** 是否 Linux:Desktop 之外的自建/主机部署 + **飞牛 fnOS / 群晖这类 NAS**(0.6.15 的实测环境)。 */
|
|
60
|
+
function isLinux() {
|
|
61
|
+
return osPlatform() === "linux";
|
|
62
|
+
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
61
65
|
* 当前进程 uid。**Windows 没有 process.getuid**(typeof !== "function"),
|
|
@@ -122,13 +126,88 @@ function boundWebPort() {
|
|
|
122
126
|
/**
|
|
123
127
|
* 本机 dsh web 的**回环**上游地址(bridge 的 loopback 围栏只认 127.0.0.1,
|
|
124
128
|
* 所以即使 dsh web 绑的是 0.0.0.0,上游也照样写回环地址)。
|
|
125
|
-
* 优先级:显式 DSH_BRIDGE_UPSTREAM(用户/部署覆盖)> 实际监听端口 > DSH_WEB_URL
|
|
129
|
+
* 优先级:显式 DSH_BRIDGE_UPSTREAM(用户/部署覆盖)> 实际监听端口 > **主动发现** > DSH_WEB_URL > 3080。
|
|
130
|
+
*
|
|
131
|
+
* 【0.6.15 新增「主动发现」那一层】飞牛 fnOS 用户实测(2026-09-25):
|
|
132
|
+
* `boundWebPort()` 在该环境下**取不到**自己的监听端口(诊断原文:未取到 dsh web 实际监听端口),
|
|
133
|
+
* 于是上游回落 3080,而他的 dsh web 在 **2298** —— 面板永远「不可达」,用户反复改端口也没用。
|
|
134
|
+
* 发现结果(`discoveredUpstream`)由 `discoverUpstreamSelf()` 异步填充(本机回环端口扫描 + 身份校验),
|
|
135
|
+
* 填上之后这里就直接用它,watcher/bridge 拿到的 `DSH_BRIDGE_UPSTREAM` 也随之正确。
|
|
126
136
|
*/
|
|
127
137
|
function upstreamUrl() {
|
|
128
138
|
const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
|
|
129
139
|
if (explicit) return explicit.replace(/\/+$/, "");
|
|
130
140
|
const port = boundWebPort();
|
|
131
|
-
|
|
141
|
+
if (port) return `http://127.0.0.1:${port}`;
|
|
142
|
+
if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return discoveredUpstream.url;
|
|
143
|
+
return FALLBACK_UPSTREAM;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 主动发现的上游(插件半自己扫出来的;TTL 见 UPSTREAM_DISCOVERY_TTL_MS)。 */
|
|
147
|
+
const discoveredUpstream = { at: 0, url: "" };
|
|
148
|
+
const UPSTREAM_DISCOVERY_TTL_MS = 5 * 60 * 1000;
|
|
149
|
+
let upstreamDiscoveryInFlight = null;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 插件半主动发现上游地址(只在"问不出自己端口"时才真的去扫)。
|
|
153
|
+
*
|
|
154
|
+
* 复用 **运行环境里那一份**发现实现(`runtime/clients/dsh-remote/upstream-discovery.mjs`,与
|
|
155
|
+
* watcher/bridge 是同一份代码),避免第三份实现漂移;运行环境缺失时(首次安装、纯插件半)
|
|
156
|
+
* 静默回落,行为与旧版一致。
|
|
157
|
+
* @param {string} relayDir
|
|
158
|
+
* @param {{force?: boolean}} [opts]
|
|
159
|
+
* @returns {Promise<string>} 发现到的地址(没发现返回上游回退值)
|
|
160
|
+
*/
|
|
161
|
+
async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
|
|
162
|
+
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return upstreamUrl();
|
|
163
|
+
if (boundWebPort()) return upstreamUrl(); // 已经问出来了,不必扫
|
|
164
|
+
if (skipsSystemOps()) {
|
|
165
|
+
// 测试/诊断隔离:零系统副作用,也不做端口扫描。顺手清掉上一条真实分支留下的发现结果,
|
|
166
|
+
// 否则同一个测试进程里后面的用例会读到"别人的"上游(真实踩到过:本机 3080 上跑着 dsh web)。
|
|
167
|
+
discoveredUpstream.at = 0;
|
|
168
|
+
discoveredUpstream.url = "";
|
|
169
|
+
return upstreamUrl();
|
|
170
|
+
}
|
|
171
|
+
// 平台被 DSH_RELAY_PLATFORM 覆盖 = 测试/诊断在模拟别的系统(生产永不设置):
|
|
172
|
+
// 这时**不做真实端口扫描** —— 扫描读到的是宿主这台机器的真端口,会让"模拟 win32"的用例
|
|
173
|
+
// 在本机恰好跑着 dsh web 时得到与 CI 不一样的结果(实测踩到)。
|
|
174
|
+
if (osPlatform() !== process.platform) {
|
|
175
|
+
discoveredUpstream.at = 0;
|
|
176
|
+
discoveredUpstream.url = "";
|
|
177
|
+
return upstreamUrl();
|
|
178
|
+
}
|
|
179
|
+
if (!force && discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return discoveredUpstream.url;
|
|
180
|
+
if (upstreamDiscoveryInFlight) return upstreamDiscoveryInFlight;
|
|
181
|
+
upstreamDiscoveryInFlight = (async () => {
|
|
182
|
+
try {
|
|
183
|
+
const mod = await import(new URL("../runtime/clients/dsh-remote/upstream-discovery.mjs", import.meta.url).href);
|
|
184
|
+
if (typeof mod?.discoverUpstream !== "function") return FALLBACK_UPSTREAM;
|
|
185
|
+
// ★ platform 必须跟着 osPlatform():整个插件的平台判定都走它(DSH_RELAY_PLATFORM 可覆盖),
|
|
186
|
+
// 发现逻辑不一致的话,"win32 模拟"用例会去枚举宿主真实端口(本机 3080 上有 dsh web 就会被命中)。
|
|
187
|
+
const found = await mod.discoverUpstream({ relayDir, platform: osPlatform(), log: (m) => console.log(`[dsh-remote-web] ${m}`) });
|
|
188
|
+
const url = String(found?.url || "");
|
|
189
|
+
if (url) {
|
|
190
|
+
discoveredUpstream.at = Date.now();
|
|
191
|
+
discoveredUpstream.url = url;
|
|
192
|
+
// 固化到端口文件:watcher/bridge 是**别的进程**,只认这个文件
|
|
193
|
+
try {
|
|
194
|
+
const file = join(relayDir, UPSTREAM_FILE);
|
|
195
|
+
let cur = "";
|
|
196
|
+
try { cur = readFileSync(file, "utf8").trim(); } catch { /* 首次 */ }
|
|
197
|
+
if (cur !== url) {
|
|
198
|
+
mkdirSync(relayDir, { recursive: true });
|
|
199
|
+
writeFileSync(file, url + "\n", { mode: 0o600 });
|
|
200
|
+
}
|
|
201
|
+
} catch { /* 非关键 */ }
|
|
202
|
+
}
|
|
203
|
+
return url || FALLBACK_UPSTREAM;
|
|
204
|
+
} catch {
|
|
205
|
+
return FALLBACK_UPSTREAM; // 运行环境还没装好 / 模块不在 → 保持旧行为
|
|
206
|
+
} finally {
|
|
207
|
+
upstreamDiscoveryInFlight = null;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
return upstreamDiscoveryInFlight;
|
|
132
211
|
}
|
|
133
212
|
|
|
134
213
|
/**
|
|
@@ -772,6 +851,120 @@ function launchAgentPath() {
|
|
|
772
851
|
return null;
|
|
773
852
|
}
|
|
774
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Linux 的 systemd user unit 路径(与 dsh-setup.mjs 的 `autostartFilePath()` **同一路径**)。
|
|
856
|
+
* 为什么插件半也要写这个文件:插件自愈(scheduleRuntime)是"用户在面板里点什么就修什么"的那条路,
|
|
857
|
+
* 它以前在 Linux 上只会调 `systemctl --user restart`,而 unit 可能压根不存在。
|
|
858
|
+
*/
|
|
859
|
+
function linuxUnitPath() {
|
|
860
|
+
if (!isLinux()) return null;
|
|
861
|
+
return join(homedir(), ".config/systemd/user/dsh-bridge.service");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 本机到底有没有**可用的 user systemd 会话**。
|
|
866
|
+
*
|
|
867
|
+
* 为什么必须先问:飞牛 fnOS / 群晖这类 NAS 上,插件跑在**系统服务**里(不是登录会话),
|
|
868
|
+
* 没有 `XDG_RUNTIME_DIR`、也没有 `systemd --user` 的 user manager —— 这时 `systemctl --user`
|
|
869
|
+
* 会直接报 "Failed to connect to bus",unit 写多少份都没用。以前插件在这条路上只会返回
|
|
870
|
+
* "不支持自启动",bridge 于是**从来没被拉起来过**(用户诊断里 `bridge 进程: 未运行`)。
|
|
871
|
+
*/
|
|
872
|
+
function systemdUserAvailable() {
|
|
873
|
+
if (!isLinux()) return false;
|
|
874
|
+
if (!process.env.XDG_RUNTIME_DIR) return false; // 没有 runtime dir 就不可能有 user manager
|
|
875
|
+
return sh("systemctl --user show-environment").ok;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* 上面那个判断的**带缓存**版本(面板 2~3 秒轮询一次 /status,不能每次都起一个 systemctl)。
|
|
880
|
+
* 60 秒 TTL:用户装了 systemd 会话/登出登录之后一分钟内就会跟上。
|
|
881
|
+
* 缓存键带上平台与 XDG_RUNTIME_DIR:测试/诊断会把它们换掉,换了就必须重新探(否则读到上一轮结论)。
|
|
882
|
+
*/
|
|
883
|
+
let systemdProbeCache = { at: 0, ok: false, key: "" };
|
|
884
|
+
function systemdUserAvailableCached(ttlMs = 60_000) {
|
|
885
|
+
const key = `${osPlatform()}|${process.env.XDG_RUNTIME_DIR || ""}|${process.env.PATH || ""}`;
|
|
886
|
+
if (systemdProbeCache.key === key && Date.now() - systemdProbeCache.at < ttlMs) return systemdProbeCache.ok;
|
|
887
|
+
const ok = systemdUserAvailable();
|
|
888
|
+
systemdProbeCache = { at: Date.now(), ok, key };
|
|
889
|
+
return ok;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** 写 Linux systemd user unit(与 dsh-setup.mjs 同内容)。写不了返回 null(不致命,还有脱离进程兜底)。 */
|
|
893
|
+
function writeLinuxUnit(relayDir) {
|
|
894
|
+
const unitPath = linuxUnitPath();
|
|
895
|
+
if (!unitPath) return null;
|
|
896
|
+
const setup = join(relayDir, "dsh-setup.mjs");
|
|
897
|
+
const unit = `[Unit]
|
|
898
|
+
Description=dsh-remote bridge (auto-starts with dsh web)
|
|
899
|
+
|
|
900
|
+
[Service]
|
|
901
|
+
ExecStart=${NODE_BIN} ${setup} run
|
|
902
|
+
Restart=on-failure
|
|
903
|
+
RestartSec=5
|
|
904
|
+
Environment=PATH=${SERVICE_PATH}
|
|
905
|
+
Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
906
|
+
Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
|
|
907
|
+
Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
|
|
908
|
+
|
|
909
|
+
[Install]
|
|
910
|
+
WantedBy=default.target
|
|
911
|
+
`;
|
|
912
|
+
try {
|
|
913
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
914
|
+
writeFileSync(unitPath, unit, { mode: 0o644 });
|
|
915
|
+
return unitPath;
|
|
916
|
+
} catch (e) {
|
|
917
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] 写入 systemd unit 失败: ${e.message}`);
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Linux:拉起 bridge 的阶梯(**与 macOS 同构:可自愈优先,可用兜底**)。
|
|
924
|
+
*
|
|
925
|
+
* 【0.6.15 事故根因】0.6.14 及更早,`startBridge` 只认 macOS 的 launchd,Linux 直接返回
|
|
926
|
+
* `unsupported: 当前平台(linux)暂不支持自启动服务`。于是:
|
|
927
|
+
* · 自愈轮次每轮都调用它、每轮都拿到 unsupported、**bridge 永远起不来**;
|
|
928
|
+
* · 用户面板上的表现是永远停在「正在启动 Bridge…」+「bridge 进程: 未运行」;
|
|
929
|
+
* · 用户以为是"端口不对",于是反复改端口号(改到 3080 也没用)——
|
|
930
|
+
* 因为**根本没人在听那个端口**,跟端口一点关系都没有。
|
|
931
|
+
* 实测环境:飞牛 fnOS(Debian 系 NAS),插件跑在系统服务里,没有 user systemd 会话。
|
|
932
|
+
*
|
|
933
|
+
* 阶梯:
|
|
934
|
+
* ①(有 user systemd 会话时)systemd --user enable --now dsh-bridge —— 开机自启 + 崩溃自愈;
|
|
935
|
+
* ②(没有会话 / ① 失败)脱离进程拉起 `node <relayDir>/dsh-setup.mjs run` —— 与 Windows 同一条路径,
|
|
936
|
+
* "现在一定能用",代价是只有"重开 dsh web 时插件会自动拉起"这一层保障(如实告知用户)。
|
|
937
|
+
*/
|
|
938
|
+
function startBridgeLinux(relayDir) {
|
|
939
|
+
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
940
|
+
if (!runtimeReady(relayDir)) {
|
|
941
|
+
ensureRuntime(relayDir);
|
|
942
|
+
return {
|
|
943
|
+
ok: false,
|
|
944
|
+
status: "provisioning",
|
|
945
|
+
runtimeMissing: true,
|
|
946
|
+
detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
publishUpstream(relayDir); // 脱离进程拉起的 watcher 没有本进程 env,只认这个文件
|
|
950
|
+
const unit = writeLinuxUnit(relayDir);
|
|
951
|
+
if (unit && systemdUserAvailable()) {
|
|
952
|
+
sh("systemctl --user daemon-reload");
|
|
953
|
+
const en = sh("systemctl --user enable dsh-bridge");
|
|
954
|
+
const rs = sh("systemctl --user restart dsh-bridge");
|
|
955
|
+
const act = rs.ok ? sh("systemctl --user is-active dsh-bridge") : { ok: false, stdout: "", stderr: "" };
|
|
956
|
+
if (rs.ok && act.ok && act.stdout.trim() === "active") {
|
|
957
|
+
return { ok: true, status: "running", pid: null, detail: "已由 systemd --user 托管 bridge(开机自启 + 崩溃自愈)" };
|
|
958
|
+
}
|
|
959
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
960
|
+
`[dsh-remote-web] systemd --user 拉起 bridge 失败(enable=${en.ok ? "ok" : "fail"}, restart=${rs.ok ? "ok" : "fail"}),改用后台进程`);
|
|
961
|
+
}
|
|
962
|
+
const d = startBridgeDetached(relayDir, unit
|
|
963
|
+
? "本机没有可用的 user systemd 会话(NAS / 容器 / 精简系统常见),已改为后台进程"
|
|
964
|
+
: "systemd unit 写入失败,已改为后台进程");
|
|
965
|
+
return d;
|
|
966
|
+
}
|
|
967
|
+
|
|
775
968
|
/**
|
|
776
969
|
* launchd 作业 target(`gui/<uid>/<label>`)。
|
|
777
970
|
* **Windows/Linux 上没有 launchctl、也没有 process.getuid**——旧实现无条件调
|
|
@@ -998,6 +1191,9 @@ function manualStatus(relayDir) {
|
|
|
998
1191
|
|
|
999
1192
|
// ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
|
|
1000
1193
|
|
|
1194
|
+
/** 「上一次自动启动失败的原话 + 连续次数」——同一条结论不重复刷日志(见 ensureConnection 的注释)。 */
|
|
1195
|
+
const lastAutostartFailure = new Map();
|
|
1196
|
+
|
|
1001
1197
|
const PROVISION_MARKER = ".dsh-setup-installing";
|
|
1002
1198
|
const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
|
|
1003
1199
|
const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
|
|
@@ -1403,7 +1599,7 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
|
1403
1599
|
}),
|
|
1404
1600
|
stdio: ["ignore", out, out],
|
|
1405
1601
|
windowsHide: true,
|
|
1406
|
-
onError: (e) => appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web]
|
|
1602
|
+
onError: (e) => appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] 脱离进程启动 bridge 失败(${osPlatform()}): ${e.message}`),
|
|
1407
1603
|
});
|
|
1408
1604
|
if (typeof out === "number") { try { closeSync(out); } catch { /* 已随子进程继承,父进程这份可以关 */ } }
|
|
1409
1605
|
if (error || !child) {
|
|
@@ -1455,6 +1651,9 @@ function startBridge(relayDir) {
|
|
|
1455
1651
|
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
1456
1652
|
}
|
|
1457
1653
|
if (isWindows()) return startBridgeDetached(relayDir);
|
|
1654
|
+
// Linux(含飞牛/群晖这类 NAS):阶梯见 startBridgeLinux —— 0.6.14 及更早这里只有
|
|
1655
|
+
// `unsupported` 一条死路,bridge 在 Linux 上**从来没被拉起来过**。
|
|
1656
|
+
if (isLinux()) return startBridgeLinux(relayDir);
|
|
1458
1657
|
const plistPath = launchAgentPath();
|
|
1459
1658
|
if (!plistPath) {
|
|
1460
1659
|
return {
|
|
@@ -1548,9 +1747,11 @@ function scheduleRuntime(relayDir) {
|
|
|
1548
1747
|
// 运行环境已在,看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
|
|
1549
1748
|
const st = launchdStatus();
|
|
1550
1749
|
if (st.running) { done = true; clearInterval(iv); return; }
|
|
1551
|
-
// Windows
|
|
1552
|
-
// done 永远不置位,watcher
|
|
1553
|
-
|
|
1750
|
+
// 非 macOS(Windows **与 Linux**)没有 launchd:以「watcher 进程在跑」为就绪判据。
|
|
1751
|
+
// 否则 launchdStatus 永远是 false,done 永远不置位,watcher 会一直空转。
|
|
1752
|
+
// ★ Linux 也必须走这一支:`startBridge` 现在能在 Linux 上真正拉起 bridge(0.6.15),
|
|
1753
|
+
// 但就绪判据漏了 Linux 的话,每轮自愈都会重复走一遍"写 unit + 起进程"。
|
|
1754
|
+
if (!isDarwin() && manualStatus(relayDir).watcher.length > 0) { done = true; clearInterval(iv); return; }
|
|
1554
1755
|
startBridge(relayDir); // 环境在但服务没起 → 拉起
|
|
1555
1756
|
} catch { /* 下一轮再试 */ }
|
|
1556
1757
|
}, selfhealIntervalMs());
|
|
@@ -1558,12 +1759,33 @@ function scheduleRuntime(relayDir) {
|
|
|
1558
1759
|
return () => clearInterval(iv);
|
|
1559
1760
|
}
|
|
1560
1761
|
|
|
1561
|
-
/** 停止 bridge:macOS
|
|
1762
|
+
/** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
|
|
1562
1763
|
function stopBridge(relayDir) {
|
|
1563
1764
|
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
1564
1765
|
// 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
|
|
1565
1766
|
// 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
|
|
1566
1767
|
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
1768
|
+
// ★ Linux(0.6.15):以前这里直接落到「没有 launchd 服务可停」的死路 —— Linux 用户的
|
|
1769
|
+
// 面板「停止」、以及**切换账号时的重置**都会失败(旧 bridge 继续用旧凭据跑)。
|
|
1770
|
+
// 现在:有 user systemd 就先停 unit,再统一用脱离进程那条路把 pid 文件/残留进程收干净。
|
|
1771
|
+
if (isLinux()) {
|
|
1772
|
+
let systemdStopped = false;
|
|
1773
|
+
if (systemdUserAvailableCached()) {
|
|
1774
|
+
sh("systemctl --user stop dsh-bridge");
|
|
1775
|
+
const a = sh("systemctl --user is-active dsh-bridge");
|
|
1776
|
+
systemdStopped = !(a.ok && a.stdout.trim() === "active");
|
|
1777
|
+
}
|
|
1778
|
+
const d = stopBridgeDetached(relayDir);
|
|
1779
|
+
if (d.ok) {
|
|
1780
|
+
return {
|
|
1781
|
+
ok: true,
|
|
1782
|
+
status: "stopped",
|
|
1783
|
+
pid: null,
|
|
1784
|
+
detail: systemdStopped ? "已停止 bridge(systemd --user 与后台进程均已结束)" : void 0,
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
return { ...d, detail: [d.detail, systemdStopped ? "systemd unit 已停" : ""].filter(Boolean).join(";") };
|
|
1788
|
+
}
|
|
1567
1789
|
const targets = launchTargets();
|
|
1568
1790
|
if (!targets.length) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
|
|
1569
1791
|
// 两个域都要停:作业可能由安装器装在 user/<uid>,而旧版插件只会去 gui/<uid> 找不到(实测事故)
|
|
@@ -2596,7 +2818,7 @@ async function composeStatus(relayDir) {
|
|
|
2596
2818
|
// 平台与服务管理器:面板据此给出平台正确的文案(Windows 没有"自启动服务"这回事,
|
|
2597
2819
|
// 也不该把 bridge 说成 launchd/systemd 服务)
|
|
2598
2820
|
platform: osPlatform(),
|
|
2599
|
-
serviceManager: isDarwin() ? "launchd" : isWindows() ? "detached" : osPlatform() === "linux" ? "systemd" : "none",
|
|
2821
|
+
serviceManager: isDarwin() ? "launchd" : isWindows() ? "detached" : osPlatform() === "linux" ? (systemdUserAvailableCached() ? "systemd" : "detached") : "none",
|
|
2600
2822
|
// Windows:登录任务(任务计划程序)是否已注册——面板据此给"能不能开机自启"的准确说法
|
|
2601
2823
|
autostartTask: isWindows() ? windowsTaskRegistered() : null,
|
|
2602
2824
|
launchd,
|
|
@@ -3725,8 +3947,11 @@ function runtimeVersionOf(relayDir) {
|
|
|
3725
3947
|
function upstreamSourceLabel() {
|
|
3726
3948
|
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return "来自 DSH_BRIDGE_UPSTREAM";
|
|
3727
3949
|
if (boundWebPort()) return "dsh web 实际监听端口";
|
|
3950
|
+
if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) {
|
|
3951
|
+
return "**自动发现**(本机回环端口扫描 + 身份校验:问不到宿主自己的监听端口时用这条)";
|
|
3952
|
+
}
|
|
3728
3953
|
if (String(process.env.DSH_WEB_URL || "").trim()) return "来自 DSH_WEB_URL";
|
|
3729
|
-
return "默认值 3080
|
|
3954
|
+
return "默认值 3080(尚未取到/发现 dsh web 实际监听端口)";
|
|
3730
3955
|
}
|
|
3731
3956
|
|
|
3732
3957
|
/**
|
|
@@ -3784,6 +4009,10 @@ async function accountDeviceBound(relayDir, cfg) {
|
|
|
3784
4009
|
*/
|
|
3785
4010
|
async function composeConnect(relayDir, opts = {}) {
|
|
3786
4011
|
const cfg = opts.cfg || loadConfig(relayDir);
|
|
4012
|
+
// 【0.6.15】上游端口问不出来时,先主动发现一次再算阶段:否则诊断里永远是"3080 不可达",
|
|
4013
|
+
// 而用户的 dsh web 其实好好地跑在 2298(飞牛/NAS 实测)—— 面板与用户都会往"改端口"这个
|
|
4014
|
+
// 错误方向使劲。发现很快(本机回环,全失败也就几百毫秒),失败不影响任何既有行为。
|
|
4015
|
+
await discoverUpstreamSelf(relayDir).catch(() => FALLBACK_UPSTREAM);
|
|
3787
4016
|
const launchd = opts.launchd || launchdStatus();
|
|
3788
4017
|
const manual = opts.manual || manualStatus(relayDir);
|
|
3789
4018
|
const book = connectBook(relayDir);
|
|
@@ -3835,7 +4064,9 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3835
4064
|
const upstreamBroken = Boolean(upstream && !upstream.ok);
|
|
3836
4065
|
const upstreamNote = upstreamBroken
|
|
3837
4066
|
? `\n⚠️ bridge 要连的本机 dsh web 地址 ${upstreamUrl()}(${upstreamSourceLabel()})探测不通:${upstream.error || "连接被拒绝"}。`
|
|
3838
|
-
+ "
|
|
4067
|
+
+ "本插件会自动发现 dsh web 的真实端口(0.6.15 起支持非默认端口),"
|
|
4068
|
+
+ "若仍不通:① 确认 dsh web 正在运行;② 点「一键更新」把运行环境升到最新版;"
|
|
4069
|
+
+ "③ 仍不行可在启动 dsh web 前设环境变量 `DSH_BRIDGE_UPSTREAM=http://127.0.0.1:<你的端口>`。"
|
|
3839
4070
|
: "";
|
|
3840
4071
|
|
|
3841
4072
|
let phase;
|
|
@@ -4177,11 +4408,13 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
4177
4408
|
watcherRunning: true, bridgeRunning: false, force: Boolean(opts.force), now,
|
|
4178
4409
|
observedSince: book.watcherSeenAt,
|
|
4179
4410
|
});
|
|
4180
|
-
if (wedge.wedged && !wedge.exhausted
|
|
4411
|
+
if (wedge.wedged && !wedge.exhausted) {
|
|
4181
4412
|
const rec = noteWatcherRestart(relayDir);
|
|
4182
|
-
// Windows
|
|
4413
|
+
// Windows 没有服务管理器;Linux 上没有被 systemd 托管时也是脱离进程:
|
|
4414
|
+
// 这两种都必须**显式结束卡死的守护**,否则 startBridge 幂等会直接返回、重启变成 no-op。
|
|
4183
4415
|
// macOS 走 launchctl,startBridge 内部已先 bootout 再 bootstrap,等价于重启。
|
|
4184
|
-
|
|
4416
|
+
// ★ 0.6.15:以前这里把 Linux 排除在外(那时 Linux 的 startBridge 是死路,重启没意义)。
|
|
4417
|
+
if (isWindows() || (isLinux() && !systemdUserAvailableCached())) stopBridgeDetached(relayDir);
|
|
4185
4418
|
const r = startBridge(relayDir);
|
|
4186
4419
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
4187
4420
|
`[dsh-remote-web] 后台守护已存活 ${Math.round(wedge.ageMs / 1000)} 秒仍无 bridge 进程`
|
|
@@ -4209,12 +4442,51 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
4209
4442
|
book.attempts += 1;
|
|
4210
4443
|
const r = startBridge(relayDir);
|
|
4211
4444
|
if (r && r.ok === false) {
|
|
4212
|
-
|
|
4213
|
-
|
|
4445
|
+
// ★ 2026-09-25:**同一条失败不要每轮都写一遍**。
|
|
4446
|
+
// 两条用户反馈(fb_7b4ee6ec9862 / fb_4cc2c9df749c)的安装日志里,同一行
|
|
4447
|
+
// `自动启动 bridge 未成功(unsupported): 当前平台(linux)暂不支持自启动服务`
|
|
4448
|
+
// 分别重复了 **19 次 / 13 次** —— 自愈每轮重试是对的,但把同一条结论一遍遍写进日志
|
|
4449
|
+
// 会变成噪音:用户复制过来的诊断里全是重复行,真正的"第一现场"反而被淹没
|
|
4450
|
+
// (那次我们只能靠数行数才能推出时间线)。现在:结论没变就只累加计数。
|
|
4451
|
+
noteAutostartFailure(relayDir, r.status, r.detail);
|
|
4452
|
+
} else if (r && r.ok) {
|
|
4453
|
+
clearAutostartFailure(relayDir); // 起来了 → 下次失败要重新记第一行
|
|
4214
4454
|
}
|
|
4215
4455
|
return { action: "start", result: r };
|
|
4216
4456
|
}
|
|
4217
4457
|
|
|
4458
|
+
/**
|
|
4459
|
+
* 记一条「自动启动 bridge 失败」,但**同一条结论只写一次**。
|
|
4460
|
+
*
|
|
4461
|
+
* 【2026-09-25 用户反馈】两条反馈的安装日志里,同一行
|
|
4462
|
+
* `自动启动 bridge 未成功(unsupported): 当前平台(linux)暂不支持自启动服务`
|
|
4463
|
+
* 分别重复了 **19 次 / 13 次**(自愈每轮重试,每次都写一行)。用户复制过来的诊断里全是重复行,
|
|
4464
|
+
* 第一现场被淹没 —— 那次我们只能靠"数行数 ÷ 每分钟行数"才推出时间线。
|
|
4465
|
+
* 现在:结论没变就只累加计数;只在 5/10/50/100/500 这些节点补一行「还在失败、已 N 次」。
|
|
4466
|
+
* (500 次失败总共只留 6 行,而不是 500 行 —— 结论没变的信息量是零。)
|
|
4467
|
+
* @returns {number} 累计次数
|
|
4468
|
+
*/
|
|
4469
|
+
function noteAutostartFailure(relayDir, status, detail) {
|
|
4470
|
+
const line = `自动启动 bridge 未成功(${status || "?"}): ${detail || ""}`;
|
|
4471
|
+
const prev = lastAutostartFailure.get(relayDir);
|
|
4472
|
+
if (!prev || prev.line !== line) {
|
|
4473
|
+
lastAutostartFailure.set(relayDir, { line, count: 1 });
|
|
4474
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] ${line}`);
|
|
4475
|
+
return 1;
|
|
4476
|
+
}
|
|
4477
|
+
prev.count += 1;
|
|
4478
|
+
if ([5, 10, 50, 100, 500].includes(prev.count)) {
|
|
4479
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
4480
|
+
`[dsh-remote-web] ${line}(已连续 ${prev.count} 次,结论未变,不再重复刷日志)`);
|
|
4481
|
+
}
|
|
4482
|
+
return prev.count;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
/** 启动成功后清掉去重状态(下次失败要重新记第一行)。 */
|
|
4486
|
+
function clearAutostartFailure(relayDir) {
|
|
4487
|
+
lastAutostartFailure.delete(relayDir);
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4218
4490
|
// ---------- 匿名装机/连接遥测(客户端半;契约与隐私边界见 docs/telemetry.md) ----------
|
|
4219
4491
|
//
|
|
4220
4492
|
// 背景(2026-09 生产诊断):11 个新注册用户里只有 3 个把设备连上——6 人电脑端从未安装、2 人装了但
|
|
@@ -5909,15 +6181,34 @@ function registerRoutes(ctx, relayDir) {
|
|
|
5909
6181
|
// 自己持有 webserver 配置(实测监听 43120 / 回环),我们的 LAN 绑定补丁并不生效,于是它一直
|
|
5910
6182
|
// 用的是原生那条路。**不能把"手机能不能选工作区"押在宿主的绑定地址上。**
|
|
5911
6183
|
//
|
|
5912
|
-
//
|
|
5913
|
-
//
|
|
6184
|
+
// 【0.6.15 事故修正 —— 0.6.14 的"先建 browse、后摘 auto"在 DSH Desktop 上必然失败】
|
|
6185
|
+
//
|
|
6186
|
+
// 现场(用户诊断日志):
|
|
6187
|
+
// error: service "directoryPicker" has been registered at .dLateDirectoryPicker3
|
|
6188
|
+
// at new DirectoryPicker (.../dsh-host-directory-picker/lib/index.js:1782)
|
|
6189
|
+
// at new BrowseDirectoryPicker (.../dsh-host-directory-picker-browse/lib/index.js:245)
|
|
6190
|
+
// at Fiber._execute
|
|
6191
|
+
// 用户看到的后果:插件装载失败(`entry did not activate`),而**原生那对还留着**
|
|
6192
|
+
// (auto 的析构没跑到 → 没被卸掉)。也就是说 0.6.14 这一版"修法"不但没修好,
|
|
6193
|
+
// 还额外制造了一条装载报错。
|
|
5914
6194
|
//
|
|
5915
|
-
//
|
|
6195
|
+
// 根因:`native` 与 `browse` 两个后端都是 `class DirectoryPicker extends Service`,
|
|
6196
|
+
// 注册的是**同一个服务名** `ctx.directoryPicker`。seam 的文档写得很直白:
|
|
6197
|
+
// "one implementation per context; loading a second throws, cordis' standard
|
|
6198
|
+
// duplicate-service behavior"(见 @deepseek-ai/dsh-host-directory-picker/lib/index.js)。
|
|
6199
|
+
// 所以"两者并存"从来就不成立 —— 只要 auto/native 还挂着,建 browse **一定**抛错。
|
|
6200
|
+
// 0.6.14 把顺序写反了:唯一能工作的顺序是**先摘 auto(释放服务名)、再建 browse**。
|
|
6201
|
+
//
|
|
6202
|
+
// 做法:先摘后建 + **失败回滚**(摘了却建不起来时把 auto 原样装回去,用户至少还有原生可用)。
|
|
6203
|
+
//
|
|
6204
|
+
// 四条护栏:
|
|
5916
6205
|
// ① 只在当前 kind === "native" 时动手;已经是 browse 就完全不碰(多数自建部署就是这样);
|
|
5917
|
-
// ②
|
|
5918
|
-
// ③
|
|
6206
|
+
// ② **先摘 auto、后建 browse** —— 两个后端抢同一个服务名,并存是不可能的;
|
|
6207
|
+
// ③ browse 建不起来(或建完仍不是 browse)→ 回滚:卸掉刚建的、把 auto 条目**按原 id** 建回来;
|
|
6208
|
+
// ④ 任何失败只记日志、绝不抛给宿主(插件不能把 dsh web 拖挂)。
|
|
5919
6209
|
// `DSH_REMOTE_NATIVE_PICKER=1` 可整体退出(想保留系统原生对话框的人)。
|
|
5920
6210
|
const PICKER_AUTO_ENTRY_ID = "directory-picker";
|
|
6211
|
+
const PICKER_AUTO_PACKAGE = "@deepseek-ai/dsh-host-directory-picker-auto";
|
|
5921
6212
|
const PICKER_BROWSE_PACKAGES = [
|
|
5922
6213
|
"@deepseek-ai/dsh-host-directory-picker-browse",
|
|
5923
6214
|
"@deepseek-ai/dsh-client-ui-directory-picker-browse"
|
|
@@ -5936,6 +6227,77 @@ function directoryPickerKind(ctx) {
|
|
|
5936
6227
|
}
|
|
5937
6228
|
}
|
|
5938
6229
|
|
|
6230
|
+
/**
|
|
6231
|
+
* 在 loader 里找条目(`store` 是扁平表;条目结构不同版本略有差异,故 options 也当来源之一)。
|
|
6232
|
+
* @returns {{id: string, options: object|null}|null}
|
|
6233
|
+
*/
|
|
6234
|
+
function loaderEntryOf(loader, id) {
|
|
6235
|
+
let entry = null;
|
|
6236
|
+
try { entry = loader?.store ? loader.store[id] : null; } catch { entry = null; }
|
|
6237
|
+
if (!entry) return null;
|
|
6238
|
+
const options = entry.options && typeof entry.options === "object" ? { ...entry.options } : null;
|
|
6239
|
+
return { id, options: options ? { ...options, id } : { id, name: PICKER_AUTO_PACKAGE } };
|
|
6240
|
+
}
|
|
6241
|
+
|
|
6242
|
+
/**
|
|
6243
|
+
* 把 auto 换成 browse 那一对(**先摘后建 + 失败回滚**)。前提:当前 kind === "native"。
|
|
6244
|
+
* @returns {Promise<{kind:string, action:string, detail:string}>}
|
|
6245
|
+
*/
|
|
6246
|
+
async function swapAutoToBrowse(ctx, loader, kind) {
|
|
6247
|
+
const auto = loaderEntryOf(loader, PICKER_AUTO_ENTRY_ID)
|
|
6248
|
+
|| { id: PICKER_AUTO_ENTRY_ID, options: { id: PICKER_AUTO_ENTRY_ID, name: PICKER_AUTO_PACKAGE } };
|
|
6249
|
+
// ① 先摘 auto:它的 effect 析构会卸掉 native 那一对,同时**释放 directoryPicker 服务名**。
|
|
6250
|
+
// 不先摘就建 browse,必然撞上 duplicate-service(0.6.14 就是这么坏掉的)。
|
|
6251
|
+
try {
|
|
6252
|
+
await loader.remove(PICKER_AUTO_ENTRY_ID);
|
|
6253
|
+
} catch (e) {
|
|
6254
|
+
pickerPinState = {
|
|
6255
|
+
kind,
|
|
6256
|
+
action: "kept_native",
|
|
6257
|
+
detail: `摘不掉 auto 条目,保留原生:${e && e.message ? e.message : e}`
|
|
6258
|
+
};
|
|
6259
|
+
ctx.logger?.warn?.(`dsh-remote-web: 目录选择器切换失败(摘 auto),保留原生:${e && e.message ? e.message : e}`);
|
|
6260
|
+
return pickerPinState;
|
|
6261
|
+
}
|
|
6262
|
+
// ② 再建 browse 那一对(宿主后端 + 界面半边)
|
|
6263
|
+
const created = [];
|
|
6264
|
+
const fail = async (e) => {
|
|
6265
|
+
for (const id of [...created].reverse()) { try { await loader.remove(id); } catch { /* 尽力回滚 */ } }
|
|
6266
|
+
// ③ 回滚 auto:**按原 id 建回来**,让用户至少还有原生选择器可用
|
|
6267
|
+
let restored = false;
|
|
6268
|
+
try { await loader.create(auto.options); restored = true; } catch { restored = false; }
|
|
6269
|
+
const why = e && e.message ? e.message : e;
|
|
6270
|
+
pickerPinState = {
|
|
6271
|
+
kind: directoryPickerKind(ctx),
|
|
6272
|
+
action: "kept_native",
|
|
6273
|
+
detail: restored
|
|
6274
|
+
? `浏览器内选择器装不起来,已还原原生:${why}`
|
|
6275
|
+
: `浏览器内选择器装不起来,且原生条目还原失败(请重启 DeepSeek harness):${why}`
|
|
6276
|
+
};
|
|
6277
|
+
ctx.logger?.warn?.(`dsh-remote-web: 目录选择器固定为 browse 失败${restored ? ",已还原原生" : ",且还原失败"}:${why}`);
|
|
6278
|
+
return pickerPinState;
|
|
6279
|
+
};
|
|
6280
|
+
for (const name of PICKER_BROWSE_PACKAGES) {
|
|
6281
|
+
try {
|
|
6282
|
+
created.push(await loader.create({ name }));
|
|
6283
|
+
} catch (e) {
|
|
6284
|
+
return await fail(e);
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6287
|
+
// ④ 复核:服务真的换成 browse 了吗(建成功了但服务没挂上也是白搭)
|
|
6288
|
+
const now = directoryPickerKind(ctx);
|
|
6289
|
+
if (now && now !== "browse") {
|
|
6290
|
+
return await fail(new Error(`建完 browse 后服务仍是 ${now}`));
|
|
6291
|
+
}
|
|
6292
|
+
pickerPinState = {
|
|
6293
|
+
kind: "browse",
|
|
6294
|
+
action: "pinned",
|
|
6295
|
+
detail: "原生对话框在远程时弹在电脑那台屏上(手机上看不到),已改为浏览器内选择器"
|
|
6296
|
+
};
|
|
6297
|
+
ctx.logger?.info?.("dsh-remote-web: 目录选择器已固定为浏览器内实现(原生对话框远程不可见)");
|
|
6298
|
+
return pickerPinState;
|
|
6299
|
+
}
|
|
6300
|
+
|
|
5939
6301
|
/**
|
|
5940
6302
|
* 把目录选择器固定成 browse(幂等)。服务要等 auto 挂完才存在,所以内部带重试窗口。
|
|
5941
6303
|
* @returns {Promise<{kind:string, action:string, detail:string}>}
|
|
@@ -5947,7 +6309,7 @@ async function pinBrowseDirectoryPicker(ctx, { attempts = 6, gapMs = 700 } = {})
|
|
|
5947
6309
|
}
|
|
5948
6310
|
let loader = null;
|
|
5949
6311
|
try { loader = typeof ctx.get === "function" ? ctx.get("loader") : null; } catch { loader = null; }
|
|
5950
|
-
if (!loader || typeof loader.create !== "function") {
|
|
6312
|
+
if (!loader || typeof loader.create !== "function" || typeof loader.remove !== "function") {
|
|
5951
6313
|
pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "没有 loader 服务,无法干预选择器" };
|
|
5952
6314
|
return pickerPinState;
|
|
5953
6315
|
}
|
|
@@ -5957,32 +6319,7 @@ async function pinBrowseDirectoryPicker(ctx, { attempts = 6, gapMs = 700 } = {})
|
|
|
5957
6319
|
pickerPinState = { kind, action: "none", detail: "已经是浏览器内选择器" };
|
|
5958
6320
|
return pickerPinState;
|
|
5959
6321
|
}
|
|
5960
|
-
if (kind === "native")
|
|
5961
|
-
const created = [];
|
|
5962
|
-
try {
|
|
5963
|
-
for (const name of PICKER_BROWSE_PACKAGES) created.push(await loader.create({ name }));
|
|
5964
|
-
} catch (e) {
|
|
5965
|
-
for (const id of [...created].reverse()) { try { await loader.remove(id); } catch { /* 尽力回滚 */ } }
|
|
5966
|
-
pickerPinState = { kind, action: "kept_native", detail: `浏览器内选择器装不起来,保留原生:${e && e.message ? e.message : e}` };
|
|
5967
|
-
ctx.logger?.warn?.(`dsh-remote-web: 目录选择器固定为 browse 失败,保留原生:${e && e.message ? e.message : e}`);
|
|
5968
|
-
return pickerPinState;
|
|
5969
|
-
}
|
|
5970
|
-
try {
|
|
5971
|
-
const entry = loader.store ? loader.store[PICKER_AUTO_ENTRY_ID] : null;
|
|
5972
|
-
if (entry) await loader.remove(PICKER_AUTO_ENTRY_ID);
|
|
5973
|
-
} catch (e) {
|
|
5974
|
-
// browse 已经起来了,选择器可用;只是 auto 没摘掉(两者并存时 browse 生效即可)
|
|
5975
|
-
pickerPinState = { kind: "browse", action: "pinned", detail: `已切到浏览器内选择器(auto 条目未摘除:${e && e.message ? e.message : e})` };
|
|
5976
|
-
return pickerPinState;
|
|
5977
|
-
}
|
|
5978
|
-
pickerPinState = {
|
|
5979
|
-
kind: "browse",
|
|
5980
|
-
action: "pinned",
|
|
5981
|
-
detail: "原生对话框在远程时弹在电脑那台屏上(手机上看不到),已改为浏览器内选择器"
|
|
5982
|
-
};
|
|
5983
|
-
ctx.logger?.info?.("dsh-remote-web: 目录选择器已固定为浏览器内实现(原生对话框远程不可见)");
|
|
5984
|
-
return pickerPinState;
|
|
5985
|
-
}
|
|
6322
|
+
if (kind === "native") return await swapAutoToBrowse(ctx, loader, kind);
|
|
5986
6323
|
await sleep(gapMs); // 服务还没挂上(auto 正在建)→ 下一轮再看
|
|
5987
6324
|
}
|
|
5988
6325
|
pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "启动窗口内未确认到选择器服务" };
|
|
@@ -5996,6 +6333,10 @@ export function apply(ctx, config = {}) {
|
|
|
5996
6333
|
webServerCtx = ctx;
|
|
5997
6334
|
// 上游地址落盘(0.6.12):系统自启拉起的守护没有本进程的 env,只认这个文件。
|
|
5998
6335
|
publishUpstream(relayDir);
|
|
6336
|
+
// 【0.6.15】问不到自己监听端口时(飞牛/NAS 实测就是这种情况)主动发现一次并固化:
|
|
6337
|
+
// watcher/bridge 是独立进程,只有这里知道 dsh web 真实在哪个端口。fire-and-forget,
|
|
6338
|
+
// 发现完 /dsh-remote/bridge-status 的下一轮轮询与自愈就会用上正确地址。
|
|
6339
|
+
void discoverUpstreamSelf(relayDir).catch(() => FALLBACK_UPSTREAM);
|
|
5999
6340
|
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
6000
6341
|
UNINSTALLED_DIRS.delete(relayDir);
|
|
6001
6342
|
// 匿名遥测:装载即初始化(读回上次进程遗留的磁盘队列,例如重启前那条 harness_restart,
|
|
@@ -6050,5 +6391,7 @@ export function apply(ctx, config = {}) {
|
|
|
6050
6391
|
|
|
6051
6392
|
// test hooks:cordis 只读 name/inject/apply,这些导出只给用例(见 test/picker-pin.test.mjs)。
|
|
6052
6393
|
// 为什么不放进 apply 内部直接测:apply 是"装载即副作用"的同步函数,选择器固定是异步且带重试窗口的,
|
|
6053
|
-
//
|
|
6054
|
-
export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES
|
|
6394
|
+
// 单独导出才能把「**先摘 auto、后建 browse**」这个顺序契约钉死。
|
|
6395
|
+
export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
|
|
6396
|
+
// 日志去重(同一条失败不刷屏)也要能被单测钉住 —— 它属于「诊断可读性」,见 test/linux-bridge.test.mjs
|
|
6397
|
+
noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
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",
|