dsh-remote-web 0.6.18 → 0.7.0-beta.2
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 +62 -18
- package/lib/index.js +213 -19
- package/package.json +5 -2
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +108 -12
- package/runtime/clients/dsh-remote/dsh-events.mjs +66 -8
- package/runtime/clients/dsh-remote/wechat-runtime.mjs +13 -2
- package/runtime/dsh-setup.mjs +190 -18
package/lib/client.js
CHANGED
|
@@ -2626,6 +2626,11 @@ window.__ModuleLoader__.load({
|
|
|
2626
2626
|
// 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
|
|
2627
2627
|
var restartInfo = (st && st.restart) || {};
|
|
2628
2628
|
var restartPending = !!restartInfo.pending;
|
|
2629
|
+
// 宿主形态(node 半下发):桌面端(Electron)Host 由桌面应用托管,重启这个动作
|
|
2630
|
+
// 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
|
|
2631
|
+
// ① 不自动倒计时重启;② 保留提醒但改文案,引导"退出并重开桌面应用"。
|
|
2632
|
+
// 旧版 host 的响应里没有该字段 → false,行为与改动前完全一致。
|
|
2633
|
+
var desktopHost = !!(st && st.desktopHost);
|
|
2629
2634
|
// kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
|
|
2630
2635
|
// 其余情况(极为罕见)才提重启 dsh web。
|
|
2631
2636
|
var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
|
|
@@ -3400,8 +3405,16 @@ window.__ModuleLoader__.load({
|
|
|
3400
3405
|
waitHarnessBack(0, false);
|
|
3401
3406
|
})
|
|
3402
3407
|
.catch(function (e) {
|
|
3403
|
-
setMsg("err", "重启失败: " + e.message + " —— 请手动重启 DeepSeek harness(退出 dsh web 后重新启动)。");
|
|
3404
3408
|
setBusy("");
|
|
3409
|
+
// 桌面端(Electron 宿主):Host 由桌面应用托管,重启被**有意拒绝** —— 这不是故障,
|
|
3410
|
+
// 是"这个动作在这里不该做"。用中性提示告知正确做法,不要写成"重启失败"吓用户。
|
|
3411
|
+
var b = e && e.body;
|
|
3412
|
+
if (b && b.status === "desktop-managed") {
|
|
3413
|
+
setSt(b);
|
|
3414
|
+
setMsg("warn", "ℹ️ " + (b.error || "桌面端不能单独重启 Host") );
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
setMsg("err", "重启失败: " + e.message + " —— 请手动重启 DeepSeek harness(退出后重新启动)。");
|
|
3405
3418
|
});
|
|
3406
3419
|
};
|
|
3407
3420
|
|
|
@@ -4737,7 +4750,9 @@ window.__ModuleLoader__.load({
|
|
|
4737
4750
|
// ⚠️ isRestartHard(在线更新完成,kind="restart")同理**不**自动重启:这一种现在会在每次
|
|
4738
4751
|
// 在线更新成功后出现,若照旧 15 秒自动重启,等于"更新完必定打断用户正在做的事"。
|
|
4739
4752
|
useEffect(function () {
|
|
4740
|
-
|
|
4753
|
+
// ⚠️ desktopHost 时**不自动重启**:桌面端重启 Host 由 Electron 托管,插件无权也不该做
|
|
4754
|
+
// (见 node 半 restartHarness 的说明)。此时只保留文案提醒,动作交给用户「退出并重开应用」。
|
|
4755
|
+
if (!restartPending || isRefreshOnly || isRestartHard || desktopHost || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
4741
4756
|
var at = restartInfo.at || 0;
|
|
4742
4757
|
if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
|
|
4743
4758
|
var left = Math.round(AUTO_RESTART_DELAY_MS / 1000);
|
|
@@ -4751,7 +4766,7 @@ window.__ModuleLoader__.load({
|
|
|
4751
4766
|
restartHarness(); // 已有实现:重启 + 轮询等它回来 + 自动刷新页面
|
|
4752
4767
|
}, 1000);
|
|
4753
4768
|
return function () { clearInterval(iv); };
|
|
4754
|
-
}, [restartPending, isRefreshOnly, isRestartHard, restartInfo.at, busy, restartCancelled]);
|
|
4769
|
+
}, [restartPending, isRefreshOnly, isRestartHard, desktopHost, restartInfo.at, busy, restartCancelled]);
|
|
4755
4770
|
|
|
4756
4771
|
// ── 📣 公告(0.6.18):本组件的 hook 全部追加在**最后一个既有 hook 之后** ──────
|
|
4757
4772
|
// 🔒 hook 序号铁律(见本组件顶部 2029-2036 的注释):本组件大量 useState/useCallback/useEffect
|
|
@@ -4894,9 +4909,22 @@ window.__ModuleLoader__.load({
|
|
|
4894
4909
|
));
|
|
4895
4910
|
}
|
|
4896
4911
|
if (restartPending) {
|
|
4912
|
+
// 桌面端:刷新类照旧(刷新页面即可);需要重启 Host 的那一类**不给「立即重启」按钮** ——
|
|
4913
|
+
// 桌面端 Host 由 Electron 托管,插件点了也做不到,给个点不动的按钮只会让人以为是 bug。
|
|
4914
|
+
// 改成如实引导:退出并重开 DeepSeek Harness。
|
|
4915
|
+
// ⚠️ 非桌面端必须保持**原有判据 isRestartHard**:改成 isRefreshOnly 会让"历史遗留 kind"
|
|
4916
|
+
// 的文案从"刷新页面即可生效"变成"重启 dsh web 后生效" —— 属于发布版用户可见的文案变更,
|
|
4917
|
+
// 不该混在桌面端适配里顺手做掉。桌面端才走新文案。
|
|
4918
|
+
var restartRowText = desktopHost
|
|
4919
|
+
? (isRefreshOnly
|
|
4920
|
+
? "🔄 插件已更新:刷新页面即可生效"
|
|
4921
|
+
: "🔄 插件已更新:退出并重新打开 DeepSeek Harness 后生效")
|
|
4922
|
+
: (isRestartHard
|
|
4923
|
+
? "🔄 插件已更新:重启 dsh web 后生效"
|
|
4924
|
+
: "🔄 插件已更新:刷新页面即可生效");
|
|
4897
4925
|
rows.push(h("div", { className: "dru-access-tip-row", key: "restart" },
|
|
4898
|
-
h("span", null,
|
|
4899
|
-
h("button", {
|
|
4926
|
+
h("span", null, restartRowText),
|
|
4927
|
+
(isRefreshOnly || !desktopHost) ? h("button", {
|
|
4900
4928
|
type: "button",
|
|
4901
4929
|
className: "dru-linkbtn",
|
|
4902
4930
|
disabled: busy !== "",
|
|
@@ -4904,7 +4932,7 @@ window.__ModuleLoader__.load({
|
|
|
4904
4932
|
if (isRefreshOnly) { try { location.reload(); } catch (e) { /* 忽略 */ } }
|
|
4905
4933
|
else restartHarness();
|
|
4906
4934
|
}
|
|
4907
|
-
}, isRefreshOnly ? "刷新页面" : "立即重启")
|
|
4935
|
+
}, isRefreshOnly ? "刷新页面" : "立即重启") : null
|
|
4908
4936
|
));
|
|
4909
4937
|
}
|
|
4910
4938
|
rows.push(h("div", { className: "dru-access-tip-row", key: "hint" },
|
|
@@ -4945,15 +4973,23 @@ window.__ModuleLoader__.load({
|
|
|
4945
4973
|
h("div", { className: "dru-restart-alert-title" },
|
|
4946
4974
|
// 插件已改为 patch 热加载:装插件/在线更新都不再需要重启 harness,
|
|
4947
4975
|
// 唯一要用户动一下的情形是"插件文件在运行中被改写"→ 刷新页面即可。
|
|
4948
|
-
|
|
4976
|
+
// 桌面端:需要换 Host 进程的那一类改为引导「退出并重开应用」(插件无权重启 Host)。
|
|
4977
|
+
isRestartHard
|
|
4978
|
+
? (desktopHost ? "插件已更新,退出并重新打开 DeepSeek Harness 后生效"
|
|
4979
|
+
: "插件已更新,重启 dsh web 后生效")
|
|
4949
4980
|
: isRefreshOnly ? "插件已更新,刷新页面即可生效"
|
|
4950
|
-
: "
|
|
4981
|
+
: (desktopHost ? "需要重新打开 DeepSeek Harness 才能载入新插件"
|
|
4982
|
+
: "需要重启 dsh web 才能载入新插件")),
|
|
4951
4983
|
h("div", { className: "dru-restart-alert-sub" },
|
|
4952
4984
|
isRestartHard
|
|
4953
|
-
?
|
|
4985
|
+
? (desktopHost
|
|
4986
|
+
? "新版本已经装到这台电脑上,但当前运行的 DeepSeek Harness 还是旧代码(新的后台路由与面板要等它重新载入)。桌面端的运行环境由应用本身托管,插件不能单独重启它 —— 请退出并重新打开 DeepSeek Harness 应用。手机端连接与已登录状态都不受影响。"
|
|
4987
|
+
: "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。")
|
|
4954
4988
|
: isRefreshOnly
|
|
4955
4989
|
? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
|
|
4956
|
-
:
|
|
4990
|
+
: (desktopHost
|
|
4991
|
+
? "当前运行中的 DeepSeek Harness 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,请退出并重新打开 DeepSeek Harness 应用。"
|
|
4992
|
+
: "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。")),
|
|
4957
4993
|
// 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
|
|
4958
4994
|
restartCountdown !== null && restartCountdown > 0
|
|
4959
4995
|
? h("div", { className: "dru-restart-auto", role: "status" },
|
|
@@ -4974,7 +5010,9 @@ window.__ModuleLoader__.load({
|
|
|
4974
5010
|
onClick: function () { try { location.reload(); } catch (e) { /* 忽略 */ } }
|
|
4975
5011
|
}, "刷新页面")
|
|
4976
5012
|
: null,
|
|
4977
|
-
|
|
5013
|
+
// 桌面端且非"刷新即可"时**不摆重启按钮**:Host 由 Electron 托管,点了必然失败
|
|
5014
|
+
// (node 半会拒绝并说明原因)。缺省为 null 是 h() 认可的"什么都不渲染"。
|
|
5015
|
+
(desktopHost && !isRefreshOnly) ? null : h("button", {
|
|
4978
5016
|
type: "button",
|
|
4979
5017
|
className: "dru-btn " + (isRefreshOnly ? "dru-btn-ghost" : "dru-btn-primary"),
|
|
4980
5018
|
disabled: busy !== "",
|
|
@@ -5004,14 +5042,20 @@ window.__ModuleLoader__.load({
|
|
|
5004
5042
|
: renderHome()
|
|
5005
5043
|
),
|
|
5006
5044
|
// 常驻入口最底部:「重启 DeepSeek harness」按钮始终可达(首次安装/更新/排查都用它)
|
|
5045
|
+
// 桌面端:Host 由 Electron 托管,重启这个动作不存在 → 不摆一个点了必然失败的按钮,
|
|
5046
|
+
// 改为如实说明「退出并重开桌面应用」。后台服务与手机端连接在两种形态下都不受影响。
|
|
5007
5047
|
h("div", { className: "dru-restart-foot" },
|
|
5008
|
-
h("div", { className: "dru-hint" },
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5048
|
+
h("div", { className: "dru-hint" }, desktopHost
|
|
5049
|
+
? "重启 DeepSeek Harness:桌面端由应用本身托管运行环境,请直接退出并重新打开应用(无需其他操作)。日常安装/更新插件已自动热加载,装完刷新页面即可;重启不影响后台服务与手机端连接。"
|
|
5050
|
+
: "重启 dsh web:仅在「刷新页面后仍看不到面板」时才需要。日常安装/更新插件已自动热加载,装完刷新页面即可;重启只影响 dsh web 本身,不影响后台服务与手机端连接。"),
|
|
5051
|
+
desktopHost
|
|
5052
|
+
? null
|
|
5053
|
+
: h("button", {
|
|
5054
|
+
type: "button",
|
|
5055
|
+
className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
|
|
5056
|
+
disabled: busy !== "",
|
|
5057
|
+
onClick: function () { restartHarness(); }
|
|
5058
|
+
}, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
|
|
5015
5059
|
),
|
|
5016
5060
|
renderCommunityModal(),
|
|
5017
5061
|
// 📣 公告详情弹窗(与交流群弹窗同一层:都挂在面板根上,点遮罩即收起)
|
package/lib/index.js
CHANGED
|
@@ -327,20 +327,113 @@ function resolveExecutable(p) {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
/** 优先 node@20(node-datachannel 兼容性),回退当前 node。 */
|
|
330
|
-
|
|
330
|
+
/**
|
|
331
|
+
* 宿主是否跑在 **Electron 的 Node 模式**下(DeepSeek Harness 桌面端的 Host 就是)。
|
|
332
|
+
*
|
|
333
|
+
* 桌面端 `process.execPath` 是 **Electron 应用二进制**,不是 node:
|
|
334
|
+
* 直接 `spawn(process.execPath, [script])` 得到的不是「跑脚本的 node」,
|
|
335
|
+
* 而是**再启动一个 Electron 应用实例**(还会撞桌面端的单实例锁)。
|
|
336
|
+
* 必须补 `ELECTRON_RUN_AS_NODE=1`(官方桌面端自己也正是这么做的:
|
|
337
|
+
* 其内置 CLI 就是 `ELECTRON_RUN_AS_NODE=1 exec <Electron> --expose-internals <cli.js>`)。
|
|
338
|
+
*/
|
|
339
|
+
const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 本插件派生的**辅助脚本**(watcher 拉起、在线更新、doctor、运行环境清理、重启助手)
|
|
343
|
+
* 一律用**当前进程自己的可执行文件**跑 —— 保持既有行为不变(宿主是哪个 node 就用哪个,
|
|
344
|
+
* 不引入"另找一个 node 可能版本不同"的风险)。
|
|
345
|
+
* 但桌面端宿主是 Electron:必须补 `ELECTRON_RUN_AS_NODE=1`,否则不是"跑脚本"而是**又开一个桌面端应用**。
|
|
346
|
+
* 非 Electron 宿主下为空对象,行为与改动前完全一致。
|
|
347
|
+
*/
|
|
348
|
+
const SELF_NODE_ENV = IS_ELECTRON_HOST ? { ELECTRON_RUN_AS_NODE: "1" } : {};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 解析**自启动服务**里应当登记的解释器。
|
|
352
|
+
*
|
|
353
|
+
* 这里与 `SELF_NODE_ENV` 是两件事,不能混:服务由系统在**本应用退出之后**拉起,
|
|
354
|
+
* 所以它登记的解释器必须活过桌面端的退出、甚至更新(.app 路径会变、可能被换掉)。
|
|
355
|
+
* 因此**真实 node 优先**;只有找不到真实 node 时才回落宿主自身,
|
|
356
|
+
* 此时若宿主是 Electron,服务定义里也必须带 `ELECTRON_RUN_AS_NODE=1`。
|
|
357
|
+
*
|
|
358
|
+
* @returns {{bin:string, electron:boolean}} bin 是可执行文件绝对路径;electron=true 表示
|
|
359
|
+
* 该文件是 Electron 二进制,服务定义必须补 `ELECTRON_RUN_AS_NODE=1`。
|
|
360
|
+
*/
|
|
361
|
+
/**
|
|
362
|
+
* DeepSeek Harness **桌面端自带 Node** 的稳定落点候选。
|
|
363
|
+
*
|
|
364
|
+
* 为什么需要:桌面端主打的正是**机器上没有系统 node** 的用户;而自启动服务要活过
|
|
365
|
+
* 桌面端的退出、甚至升级/重装(`.app` 路径会变、可能被换掉)。桌面端把自带运行时装在
|
|
366
|
+
* **用户目录** `~/.dsh/dsh-runtimes/…`,它不随应用包更新而移动 —— 所以只要它存在,
|
|
367
|
+
* 就该优先于任何指向 `.app` 内部的路径。
|
|
368
|
+
*
|
|
369
|
+
* 目录布局随桌面端版本演进(可能多套一层 runtime/node),故按多个候选探测;
|
|
370
|
+
* `resolveExecutable()` 会过滤掉不存在的路径,多列无害。
|
|
371
|
+
* @returns {string[]} 候选可执行文件绝对路径(可能都不存在)。
|
|
372
|
+
*/
|
|
373
|
+
function desktopRuntimeNodeCandidates() {
|
|
374
|
+
const exe = isWindows() ? "node.exe" : "node";
|
|
375
|
+
const out = [];
|
|
376
|
+
// ① 稳定落点:装在**用户目录**,桌面端升级/重装都不动它。
|
|
377
|
+
// ⚠️ 真实布局是 `dependencies/node/bin/<exe>`(2026-10-07 拆官方 0.2.0-rc.2 DMG 实测:
|
|
378
|
+
// `…/Resources/runtime/primary-runtime/dependencies/node/bin/node` → Node v24.21.0)。
|
|
379
|
+
// 其余几个是兼容不同版本的兜底;resolveExecutable() 会过滤掉不存在的路径,多列无害。
|
|
380
|
+
const roots = [
|
|
381
|
+
process.env.DSH_DESKTOP_RUNTIME_DIR || "",
|
|
382
|
+
join(homedir(), ".dsh", "dsh-runtimes", "dsh-primary-runtime"),
|
|
383
|
+
join(homedir(), ".dsh", "dsh-runtimes", "primary-runtime"),
|
|
384
|
+
].filter(Boolean);
|
|
385
|
+
for (const root of roots) {
|
|
386
|
+
out.push(
|
|
387
|
+
join(root, "dependencies", "node", "bin", exe),
|
|
388
|
+
join(root, "bin", exe),
|
|
389
|
+
join(root, "node", "bin", exe),
|
|
390
|
+
join(root, "runtime", "bin", exe),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
// ② 应用自己把自带 node 的路径交给子进程(其 `runtime/bin/node` shim 就是靠这个变量)——
|
|
394
|
+
// 平台无关,且是"应用自己说的",比我们猜路径可靠。
|
|
395
|
+
if (process.env.DSH_DESKTOP_NODE_EXECUTABLE) out.push(process.env.DSH_DESKTOP_NODE_EXECUTABLE);
|
|
396
|
+
// ③ 最后才是指向应用包内部的绝对路径:它是**真实存在的 node**,但会随桌面端更新换路径,
|
|
397
|
+
// 所以排在稳定落点之后(自启动服务登记它属于"聊胜于无",好过登记 Electron 本体)。
|
|
398
|
+
if (isWindows()) {
|
|
399
|
+
const local = process.env.LOCALAPPDATA || "";
|
|
400
|
+
if (local) out.push(join(local, "Programs", "DeepSeek Harness", "resources", "runtime", "primary-runtime", "dependencies", "node", exe));
|
|
401
|
+
} else {
|
|
402
|
+
out.push(
|
|
403
|
+
"/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
|
|
404
|
+
"/Applications/DSH Desktop.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function resolveServiceInterpreter() {
|
|
331
411
|
const candidates = [
|
|
332
412
|
"/opt/homebrew/opt/node@20/bin/node",
|
|
333
413
|
"/usr/local/opt/node@20/bin/node",
|
|
414
|
+
...desktopRuntimeNodeCandidates(),
|
|
334
415
|
process.env.DSH_SETUP_NODE20 || "",
|
|
335
416
|
].filter(Boolean);
|
|
336
417
|
for (const p of candidates) {
|
|
337
418
|
const real = resolveExecutable(p);
|
|
338
|
-
if (real) return real;
|
|
419
|
+
if (real) return { bin: real, electron: false };
|
|
339
420
|
}
|
|
340
|
-
return resolveExecutable(process.execPath) || process.execPath;
|
|
421
|
+
return { bin: resolveExecutable(process.execPath) || process.execPath, electron: IS_ELECTRON_HOST };
|
|
341
422
|
}
|
|
342
423
|
|
|
343
|
-
const
|
|
424
|
+
const SERVICE_INTERPRETER = resolveServiceInterpreter();
|
|
425
|
+
const NODE_BIN = SERVICE_INTERPRETER.bin;
|
|
426
|
+
/**
|
|
427
|
+
* 服务定义里(plist 的 EnvironmentVariables / systemd 的 Environment=)必须注入的变量。
|
|
428
|
+
* 形如 `{ELECTRON_RUN_AS_NODE:"1"}` 或 `{}`。
|
|
429
|
+
*/
|
|
430
|
+
const SERVICE_NODE_ENV = SERVICE_INTERPRETER.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {};
|
|
431
|
+
/** plist `EnvironmentVariables` 里要追加的解释器变量(真实 node 时为空串 → 模板不变)。 */
|
|
432
|
+
const SERVICE_NODE_ENV_PLIST = Object.entries(SERVICE_NODE_ENV)
|
|
433
|
+
.map(([k, v]) => `<key>${k}</key><string>${v}</string>`).join("");
|
|
434
|
+
/** systemd unit 里要追加的 `Environment=` 行(真实 node 时为空串 → 模板不变)。 */
|
|
435
|
+
const SERVICE_NODE_ENV_UNIT = Object.entries(SERVICE_NODE_ENV)
|
|
436
|
+
.map(([k, v]) => `Environment=${k}=${v}\n`).join("");
|
|
344
437
|
|
|
345
438
|
/**
|
|
346
439
|
* 解析 npx 的调用方式。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
|
|
@@ -397,6 +490,10 @@ function npxInvocation() {
|
|
|
397
490
|
|
|
398
491
|
/** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
|
|
399
492
|
function spawnEnv(extra) {
|
|
493
|
+
// ⚠️ 这里刻意用 **process.execPath**,不是 NODE_BIN:PATH 首位的用途是让 npx 的
|
|
494
|
+
// `#!/usr/bin/env node` 解析到**正在跑本插件的那个 node**(与 npxInvocation 同口径)。
|
|
495
|
+
// NODE_BIN 回答的是另一个问题——"自启动服务里该登记哪个解释器"(真实 node 优先)。
|
|
496
|
+
// 两者语义不同,混用会悄悄改掉 web 版上 npx 用哪个 node。
|
|
400
497
|
const nodeDir = dirname(process.execPath);
|
|
401
498
|
const base = process.env.PATH || "";
|
|
402
499
|
// PATH 分隔符必须按平台取(Windows 是 ";",POSIX 是 ":"):写死 ":" 会把整条 PATH
|
|
@@ -406,7 +503,10 @@ function spawnEnv(extra) {
|
|
|
406
503
|
// 而 bridge 要用 ioreg 之类系统命令(缺了它 → 机器指纹静默退化,见 SERVICE_PATH 注释)。
|
|
407
504
|
const posixDirs = isWindows() ? [] : ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
408
505
|
const PATH = [nodeDir, base, ...posixDirs].filter(Boolean).join(delimiter);
|
|
409
|
-
|
|
506
|
+
// ⚠️ SELF_NODE_ENV 展开在 extra **之前**(= 保持既有展开顺序,extra 依旧最后说了算)。
|
|
507
|
+
// 它只含 ELECTRON_RUN_AS_NODE 一个键,而没有任何调用方会去设置这个键,故不存在被误覆盖的场景。
|
|
508
|
+
// ⚠️ 也不要无条件设置(真实 node 下设了会污染子进程环境、并让 node 版本探测类工具困惑)。
|
|
509
|
+
return { ...process.env, ...SELF_NODE_ENV, PATH, ...(extra || {}) };
|
|
410
510
|
}
|
|
411
511
|
|
|
412
512
|
/**
|
|
@@ -640,6 +740,34 @@ function applySaaSMode(cfg) {
|
|
|
640
740
|
* 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
|
|
641
741
|
*/
|
|
642
742
|
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
743
|
+
/**
|
|
744
|
+
* Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
|
|
745
|
+
*
|
|
746
|
+
* 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
|
|
747
|
+
* 随机端口)是**两个宿主进程**,各自的插件都往里写"自己 authority 的 Cookie" → 互相覆盖。
|
|
748
|
+
* 桥撞 401 后重读磁盘,拿到的是另一个宿主刚写的 Cookie → 重试 → 还是 401 → 再读 → 又被
|
|
749
|
+
* 覆盖回去……**乒乓,永不自愈**,手机端持续白页(现象与"自愈坏了"一模一样,极易误判)。
|
|
750
|
+
* 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
|
|
751
|
+
* (`:` → `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖。旧版共享文件继续**写**
|
|
752
|
+
* 一份镜像(旧桥还能用),但新桥只认当前上游 authority 的那一份,foreign 一律跳过。
|
|
753
|
+
* 命名规则与桥半 `clients/dsh-remote/dsh-bridge.mjs` 同名,漂移由
|
|
754
|
+
* `clients/dsh-remote/test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
|
|
755
|
+
*/
|
|
756
|
+
function authoritySlug(authority) {
|
|
757
|
+
return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
758
|
+
}
|
|
759
|
+
/** 本宿主专属 Cookie 文件名(与桥半同规则)。 */
|
|
760
|
+
function harnessCookieFileFor(authority) {
|
|
761
|
+
return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
|
|
762
|
+
}
|
|
763
|
+
/** 本宿主专属「作废」标记名(与桥半同规则)。 */
|
|
764
|
+
function harnessCookieRevokedFileFor(authority) {
|
|
765
|
+
return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
|
|
766
|
+
}
|
|
767
|
+
/** 由本进程 webServer 端口推出 authority(与 bridge 的 Host 口径一致)。 */
|
|
768
|
+
function harnessAuthorityOfPort(port) {
|
|
769
|
+
return port ? `127.0.0.1:${port}` : "";
|
|
770
|
+
}
|
|
643
771
|
/**
|
|
644
772
|
* 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」的根因就在这里:
|
|
645
773
|
* 该 Cookie 由本插件在进程内经 ?token= 换取后交给 bridge 代持,**dsh web 每次重启都会换签名密钥**,
|
|
@@ -686,22 +814,47 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
686
814
|
if (UNINSTALLED_DIRS.has(relayDir)) return false;
|
|
687
815
|
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
688
816
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
689
|
-
|
|
690
|
-
|
|
817
|
+
// ① 本宿主专属文件(权威,两个宿主互不覆盖 —— 事故根治点)
|
|
818
|
+
const ownPath = join(relayDir, harnessCookieFileFor(out.authority));
|
|
819
|
+
writeFileSync(ownPath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
820
|
+
hardenFile(ownPath); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
|
|
821
|
+
// ② 旧版共享文件镜像:只为还跑着旧桥(<0.7.0-beta.2)的机器保留 —— 新桥优先读 ①,
|
|
822
|
+
// 且对 authority 不符的镜像**直接跳过**,所以这里写镜像不会再引发乒乓。
|
|
823
|
+
const legacyPath = join(relayDir, HARNESS_COOKIE_FILE);
|
|
824
|
+
writeFileSync(legacyPath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
825
|
+
hardenFile(legacyPath);
|
|
691
826
|
return true;
|
|
692
827
|
} catch {
|
|
693
828
|
return false;
|
|
694
829
|
}
|
|
695
830
|
}
|
|
696
831
|
|
|
697
|
-
/**
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
832
|
+
/**
|
|
833
|
+
* 读取当前 authority 可用的 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。
|
|
834
|
+
* ① 本宿主专属文件优先;
|
|
835
|
+
* ② 旧版共享文件仅在 authority 对得上、或旧插件没写 authority 字段(兼容)时采用;
|
|
836
|
+
* ③ 声明了别的 authority → 明确不用(那属于另一个宿主,用了必然 401)。
|
|
837
|
+
* @param {string} relayDir 配置目录
|
|
838
|
+
* @param {string} [authority] 本进程 authority(`127.0.0.1:<port>`);缺省只做兼容读取
|
|
839
|
+
*/
|
|
840
|
+
function readHarnessCookie(relayDir, authority) {
|
|
841
|
+
const read = (file) => {
|
|
842
|
+
try {
|
|
843
|
+
const j = JSON.parse(readFileSync(join(relayDir, file), "utf8"));
|
|
844
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
845
|
+
} catch {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
if (authority) {
|
|
850
|
+
const own = read(harnessCookieFileFor(authority));
|
|
851
|
+
if (own) return own;
|
|
704
852
|
}
|
|
853
|
+
const legacy = read(HARNESS_COOKIE_FILE);
|
|
854
|
+
if (!legacy) return null;
|
|
855
|
+
const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
|
|
856
|
+
if (claimed === "" || !authority || claimed === authority) return legacy;
|
|
857
|
+
return null; // foreign authority:另一个宿主的 Cookie,本宿主用不了
|
|
705
858
|
}
|
|
706
859
|
|
|
707
860
|
let harnessMintInflight = false;
|
|
@@ -719,9 +872,22 @@ let harnessMintLastAt = 0;
|
|
|
719
872
|
*/
|
|
720
873
|
async function ensureHarnessCookie(ctx, relayDir, opts) {
|
|
721
874
|
const force = !!(opts && opts.force);
|
|
722
|
-
const
|
|
875
|
+
const authority = harnessAuthorityOfPort(ctx.webServer?.port);
|
|
876
|
+
const ownRevokedPath = authority ? join(relayDir, harnessCookieRevokedFileFor(authority)) : "";
|
|
877
|
+
const legacyRevokedPath = join(relayDir, HARNESS_COOKIE_REVOKED_FILE);
|
|
878
|
+
const cur = readHarnessCookie(relayDir, authority);
|
|
879
|
+
// 作废标记两个来源:
|
|
880
|
+
// ① 本宿主专属标记(新桥 0.7.0-beta.2+ 写)—— 精确,不会被人家的 401 催着重换;
|
|
881
|
+
// ② 旧版共享标记(旧桥写)—— 仅当它**晚于**我们手上这份 Cookie 时才认:说明是它被拒了。
|
|
882
|
+
// 否则(比如另一个宿主刚被拒、标记比我们的 Cookie 还旧)不理会,避免两个宿主互相催。
|
|
883
|
+
let revoked = ownRevokedPath !== "" && existsSync(ownRevokedPath);
|
|
884
|
+
if (!revoked && existsSync(legacyRevokedPath)) {
|
|
885
|
+
try {
|
|
886
|
+
const revokedAt = Number(readFileSync(legacyRevokedPath, "utf8").trim()) || 0;
|
|
887
|
+
revoked = !cur || revokedAt > Number(cur.mintedAt || 0);
|
|
888
|
+
} catch { /* 标记读不到就当没有 */ }
|
|
889
|
+
}
|
|
723
890
|
if (!force && !revoked) {
|
|
724
|
-
const cur = readHarnessCookie(relayDir);
|
|
725
891
|
if (cur && Date.now() - Number(cur.mintedAt || 0) < HARNESS_COOKIE_STALE_MS) return true;
|
|
726
892
|
}
|
|
727
893
|
if (harnessMintInflight) return false; // 去重:同一时刻只换一次
|
|
@@ -731,7 +897,11 @@ async function ensureHarnessCookie(ctx, relayDir, opts) {
|
|
|
731
897
|
try {
|
|
732
898
|
const ok = await mintHarnessCookie(ctx, relayDir);
|
|
733
899
|
if (ok || revoked) {
|
|
734
|
-
|
|
900
|
+
// 清标记:① 本宿主专属标记(精确);② 旧版共享标记(旧桥写的,清了不影响新桥)。
|
|
901
|
+
// 代价:多宿主机器上可能清掉"另一个宿主"的旧标记 —— 那只让对方的**快速**自愈退化成
|
|
902
|
+
// 30 分钟周期刷新,不会造成错误状态,故不做更细的归属判定(复杂度不值)。
|
|
903
|
+
if (ownRevokedPath) { try { rmSync(ownRevokedPath, { force: true }); } catch { /* ignore */ } }
|
|
904
|
+
try { rmSync(legacyRevokedPath, { force: true }); } catch { /* ignore */ }
|
|
735
905
|
}
|
|
736
906
|
if (!ok && revoked) console.warn("[dsh-remote-web] 浏览器会话 Cookie 已被 dsh web 作废,重取失败,下次查状态再试");
|
|
737
907
|
return ok;
|
|
@@ -902,7 +1072,7 @@ ExecStart=${NODE_BIN} ${setup} run
|
|
|
902
1072
|
Restart=on-failure
|
|
903
1073
|
RestartSec=5
|
|
904
1074
|
Environment=PATH=${SERVICE_PATH}
|
|
905
|
-
Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
1075
|
+
${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
906
1076
|
Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
|
|
907
1077
|
Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
|
|
908
1078
|
|
|
@@ -1483,7 +1653,7 @@ function writeAutostartFile(relayDir) {
|
|
|
1483
1653
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
1484
1654
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1485
1655
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1486
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string
|
|
1656
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
|
|
1487
1657
|
</dict></plist>`;
|
|
1488
1658
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1489
1659
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -2127,6 +2297,22 @@ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
|
|
|
2127
2297
|
*/
|
|
2128
2298
|
async function restartHarness(relayDir, opts = {}) {
|
|
2129
2299
|
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实重启" };
|
|
2300
|
+
// 桌面端(Electron 宿主):**绝不重启 Host**。
|
|
2301
|
+
// · Host 是桌面应用(Electron)的子进程,把它杀掉会触发桌面端的**原生致命恢复弹窗**,
|
|
2302
|
+
// 用户的观感是"应用崩了",远差于"没重启";
|
|
2303
|
+
// · 而且桌面端重启用处很小:插件由共享插件管理器安装、激活走 profile 的 patch HMR
|
|
2304
|
+
// (存盘后约 1 秒重新 compose),日常更新刷新页面即可生效;
|
|
2305
|
+
// · 真要重启,正确动作是**退出并重开桌面应用**,那由用户/操作系统负责,不该由插件代劳。
|
|
2306
|
+
// 因此这里如实返回"做不到 + 该怎么做",而不是假装成功或把宿主打死。
|
|
2307
|
+
if (IS_ELECTRON_HOST) {
|
|
2308
|
+
return {
|
|
2309
|
+
ok: false,
|
|
2310
|
+
status: "desktop-managed",
|
|
2311
|
+
mode: "desktop",
|
|
2312
|
+
detail: "当前运行在 DeepSeek Harness 桌面端:Host 由桌面应用托管,插件不能单独重启它(强行退出会让应用弹致命错误)。"
|
|
2313
|
+
+ "插件更新一般刷新页面即可生效;确实需要重启时,请退出并重新打开 DeepSeek Harness —— 后台服务与手机端连接不受影响。",
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2130
2316
|
const cmd = selfCommand();
|
|
2131
2317
|
let mode = "relaunch";
|
|
2132
2318
|
let target = "";
|
|
@@ -3777,6 +3963,9 @@ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
|
|
|
3777
3963
|
writeFileSync(helperPath, buildRuntimeCleanupHelper(relayDir, protectedPath));
|
|
3778
3964
|
const { child, error } = safeSpawn(process.execPath, [helperPath], {
|
|
3779
3965
|
detached: true, cwd: homedir(), stdio: "ignore", windowsHide: true,
|
|
3966
|
+
// ⚠️ 必须带 spawnEnv:桌面端宿主下 process.execPath 是 Electron 二进制,
|
|
3967
|
+
// 不补 ELECTRON_RUN_AS_NODE=1 的话这里不是"跑清理脚本",而是**又开一个桌面端应用**。
|
|
3968
|
+
env: spawnEnv({}),
|
|
3780
3969
|
onError: (e) => console.warn(`[dsh-remote-web] 运行时清理子进程启动失败: ${e.message}`),
|
|
3781
3970
|
});
|
|
3782
3971
|
if (error || !child) {
|
|
@@ -4340,6 +4529,11 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
4340
4529
|
watcherRestarts: wedge.restarts,
|
|
4341
4530
|
upstreamUrl: upstreamUrl(),
|
|
4342
4531
|
upstreamSource: upstreamSourceLabel(),
|
|
4532
|
+
// 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
|
|
4533
|
+
// 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
|
|
4534
|
+
// 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
|
|
4535
|
+
desktopHost: IS_ELECTRON_HOST,
|
|
4536
|
+
restartSupported: !IS_ELECTRON_HOST,
|
|
4343
4537
|
upstreamReachable: upstream ? upstream.ok : null,
|
|
4344
4538
|
upstreamError: upstream && !upstream.ok ? upstream.error : "",
|
|
4345
4539
|
rateLimitedUntil,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-beta.2",
|
|
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",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-web#readme",
|
|
43
43
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
44
44
|
"engines": {
|
|
45
|
-
"dsh": ">=0.1.0-rc.6 <0.
|
|
45
|
+
"dsh": ">=0.1.0-rc.6 <0.3.0-0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/dsh": ">=0.1.0-rc.6 <0.3.0-0"
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -320,21 +320,100 @@ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
|
320
320
|
const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
|
|
321
321
|
/** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
|
|
322
322
|
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
|
|
323
|
-
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
|
|
326
|
+
*
|
|
327
|
+
* 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
|
|
328
|
+
* 随机端口)是**两个宿主、两个进程**,各自的插件都会往里写"自己 authority 的 Cookie" →
|
|
329
|
+
* 互相覆盖。桥撞 401 后重读磁盘,拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
|
|
330
|
+
* → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**。手机端持续白页。
|
|
331
|
+
* 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
|
|
332
|
+
* (`:` 换成 `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖;桥只认
|
|
333
|
+
* **当前上游 authority** 的那一份。旧版共享文件继续兼容读取,但**foreign authority 一律不用**
|
|
334
|
+
* —— 那正是乒乓的来源。命名规则与插件半 `packages/dsh-remote-web/lib/index.js` 同名,
|
|
335
|
+
* 漂移由 `test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
|
|
336
|
+
*/
|
|
337
|
+
function authoritySlug(authority) {
|
|
338
|
+
return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
339
|
+
}
|
|
340
|
+
/** 本宿主专属 Cookie 文件名(与插件半同规则)。 */
|
|
341
|
+
function harnessCookieFileFor(authority) {
|
|
342
|
+
return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
|
|
343
|
+
}
|
|
344
|
+
/** 本宿主专属「作废」标记名(与插件半同规则)。 */
|
|
345
|
+
function harnessCookieRevokedFileFor(authority) {
|
|
346
|
+
return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
|
|
347
|
+
}
|
|
348
|
+
/** 当前上游的 authority(`127.0.0.1:<port>`);取不到返回空串。 */
|
|
349
|
+
function upstreamAuthorityOf(upstream) {
|
|
324
350
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
351
|
+
return new URL(String(upstream || UPSTREAM)).host || "";
|
|
352
|
+
} catch {
|
|
353
|
+
return "";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** 已就"哪个 foreign authority 在抢"告警过的集合(每对只吵一次,不刷屏)。 */
|
|
357
|
+
const foreignCookieWarned = new Set();
|
|
358
|
+
function warnForeignCookieOnce(claimed, authority) {
|
|
359
|
+
const key = `${claimed}->${authority}`;
|
|
360
|
+
if (foreignCookieWarned.has(key)) return;
|
|
361
|
+
foreignCookieWarned.add(key);
|
|
362
|
+
console.warn(
|
|
363
|
+
`[bridge] 检测到另一宿主的会话 Cookie(${claimed}),而当前上游是 ${authority} —— ` +
|
|
364
|
+
`一台机器只能服务一个宿主,已跳过该 Cookie(不再乒乓重试)。` +
|
|
365
|
+
`若手机端仍 401,请确认 .dsh-upstream 指向正在运行的那个宿主,并让该宿主的插件重发 Cookie。`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
function readCookieDoc(dir, file) {
|
|
369
|
+
try {
|
|
370
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
371
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
328
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* 当前上游该用的浏览器会话 Cookie(空串 = 没有可用的)。
|
|
378
|
+
*
|
|
379
|
+
* 取值顺序(2026-10-09 起):
|
|
380
|
+
* ① 本宿主专属文件 `.harness-cookie.<authority>.json` —— 新插件写的,权威;
|
|
381
|
+
* ② 旧版共享 `.harness-cookie.json` —— 仅当它声明的 `authority` 与当前上游一致,
|
|
382
|
+
* 或旧插件根本没写 authority 字段(向后兼容)时才用;
|
|
383
|
+
* ③ 声明了**别的** authority → 明确不用,并告警一次(这就是乒乓的源头)。
|
|
384
|
+
*/
|
|
329
385
|
function harnessCookieOf() {
|
|
386
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
387
|
+
const authority = upstreamAuthorityOf();
|
|
388
|
+
if (authority) {
|
|
389
|
+
const own = readCookieDoc(dir, harnessCookieFileFor(authority));
|
|
390
|
+
if (own) return own.cookie;
|
|
391
|
+
}
|
|
392
|
+
const legacy = readCookieDoc(dir, HARNESS_COOKIE_FILE);
|
|
393
|
+
if (!legacy) return "";
|
|
394
|
+
const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
|
|
395
|
+
if (claimed === "" || authority === "" || claimed === authority) return legacy.cookie;
|
|
396
|
+
warnForeignCookieOnce(claimed, authority);
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
/** 本宿主 Cookie 是否处于「已作废、等插件重换」状态(诊断用,不参与取值)。 */
|
|
400
|
+
function harnessCookieRevokedFor(authority) {
|
|
330
401
|
try {
|
|
331
|
-
|
|
332
|
-
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
333
|
-
return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
|
|
402
|
+
return fs.existsSync(path.join(path.dirname(CONFIG_PATH), harnessCookieRevokedFileFor(authority)));
|
|
334
403
|
} catch {
|
|
335
|
-
return
|
|
404
|
+
return false;
|
|
336
405
|
}
|
|
337
406
|
}
|
|
407
|
+
function markHarnessCookieRevoked() {
|
|
408
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
409
|
+
const authority = upstreamAuthorityOf();
|
|
410
|
+
// 只写**本宿主**的标记:两个宿主各写各的,插件侧才不会被人家的 401 反复催着重换。
|
|
411
|
+
// (旧版共享标记 `.harness-cookie-revoked` 不再写:它是"两个写者抢一份文件"的同款隐患。)
|
|
412
|
+
if (!authority) return;
|
|
413
|
+
try {
|
|
414
|
+
fs.writeFileSync(path.join(dir, harnessCookieRevokedFileFor(authority)), String(Date.now()), { mode: 0o600 });
|
|
415
|
+
} catch { /* 忽略:标记只为加速自愈 */ }
|
|
416
|
+
}
|
|
338
417
|
|
|
339
418
|
/**
|
|
340
419
|
* 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
|
|
@@ -1568,6 +1647,18 @@ function wechatAppUrl() {
|
|
|
1568
1647
|
}
|
|
1569
1648
|
|
|
1570
1649
|
let wechatRuntime = null;
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* 微信事件通道要连的上游(**每次重连都会调一次**)。
|
|
1653
|
+
*
|
|
1654
|
+
* 行为:先顺手触发一次上游重发现(内部有 15s 冷却 + 单飞,反复调用不会打爆 lsof/探测),
|
|
1655
|
+
* 再返回**当下**的 UPSTREAM。这样宿主换端口时通道能自己跟上,而不必重启 bridge。
|
|
1656
|
+
*/
|
|
1657
|
+
function currentUpstreamForChannel() {
|
|
1658
|
+
void refreshUpstream("微信事件通道重连");
|
|
1659
|
+
return UPSTREAM;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1571
1662
|
function startWeChat() {
|
|
1572
1663
|
if (WECHAT_DISABLED) {
|
|
1573
1664
|
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
@@ -1578,10 +1669,15 @@ function startWeChat() {
|
|
|
1578
1669
|
const cfg = loadLocalConfig();
|
|
1579
1670
|
wechatRuntime = createWeChatRuntime({
|
|
1580
1671
|
relayDir,
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1672
|
+
// 传**取值器**而不是当次的值(0.7.0-beta.2 修复)。
|
|
1673
|
+
//
|
|
1674
|
+
// 【2026-10-09 真机事故】这里以前传的是 `UPSTREAM` 这个**当次快照**:bridge 起来时宿主还在
|
|
1675
|
+
// 3080,随后宿主换了端口(Desktop 每次启动随机),隧道转发侧因为每次读最新 UPSTREAM 而
|
|
1676
|
+
// 一切正常,**唯独微信事件通道抱着 3080 死循环重连** —— 现象极具迷惑性:手机能开页面、
|
|
1677
|
+
// bridge-status 显示 online,但任务跑完**一条推送都没有**,日志里只有刷屏的
|
|
1678
|
+
// `connect ECONNREFUSED 127.0.0.1:3080`。
|
|
1679
|
+
// 现在取值器每次重连都会重新求值,并在打到死端口时主动触发一次上游重发现。
|
|
1680
|
+
upstream: () => currentUpstreamForChannel(),
|
|
1585
1681
|
cookieOf: harnessCookieOf,
|
|
1586
1682
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1587
1683
|
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
@@ -457,6 +457,17 @@ class EventSubscriber extends EventEmitter {
|
|
|
457
457
|
*/
|
|
458
458
|
#cursors = new Map();
|
|
459
459
|
|
|
460
|
+
/**
|
|
461
|
+
* 上一次**连接尝试**用的上游 URL(不是"成功"的那一次)。
|
|
462
|
+
*
|
|
463
|
+
* 为什么记"尝试"而不是"成功":上游端口从 A 换到 B 时,第一轮打 B 必然是失败的
|
|
464
|
+
* (B 还没起来),若只在成功后才更新,就会每轮都判成"目标变了"而重置退避 ——
|
|
465
|
+
* 退避彻底失效、变成猛敲 B。记"上次尝试过的目标"才能做到**同一个目标只重置一次**。
|
|
466
|
+
*/
|
|
467
|
+
#lastConnectUrl = null;
|
|
468
|
+
/** 上一次成功求值到的上游(getter 抛错/返回空时的回退,保证不会去打空地址)。 */
|
|
469
|
+
#lastGoodUpstream = null;
|
|
470
|
+
|
|
460
471
|
#seq = 0;
|
|
461
472
|
#lastFrameAt = 0;
|
|
462
473
|
#authStatus = null;
|
|
@@ -465,11 +476,19 @@ class EventSubscriber extends EventEmitter {
|
|
|
465
476
|
|
|
466
477
|
constructor(options = {}) {
|
|
467
478
|
super();
|
|
468
|
-
|
|
479
|
+
const upstreamGiven = typeof options.upstream === "function"
|
|
480
|
+
? true
|
|
481
|
+
: typeof options.upstream === "string" && options.upstream.trim() !== "";
|
|
482
|
+
if (!upstreamGiven) {
|
|
469
483
|
throw new TypeError("dsh-events: options.upstream is required (e.g. http://127.0.0.1:3080)");
|
|
470
484
|
}
|
|
485
|
+
// upstream 允许是**函数**(每次连接时求值):宿主端口会变(`dsh web --port 0`、
|
|
486
|
+
// DSH Desktop 每次启动随机端口),写死字符串的话端口一变,本订阅器就会永久打旧端口。
|
|
487
|
+
const upstreamGetter = typeof options.upstream === "function"
|
|
488
|
+
? options.upstream
|
|
489
|
+
: () => options.upstream;
|
|
471
490
|
this.#opts = {
|
|
472
|
-
|
|
491
|
+
upstreamGetter,
|
|
473
492
|
cookie: options.cookie ?? "",
|
|
474
493
|
muxPath: options.muxPath ?? MUX_PATH,
|
|
475
494
|
/** HTTP 路由(默认 `/api/$events/result`)。 */
|
|
@@ -631,8 +650,27 @@ class EventSubscriber extends EventEmitter {
|
|
|
631
650
|
}
|
|
632
651
|
}
|
|
633
652
|
|
|
634
|
-
|
|
635
|
-
|
|
653
|
+
/**
|
|
654
|
+
* 当前上游地址(**每次调用都重新求值**,见构造函数里 upstream 可为函数的说明)。
|
|
655
|
+
*
|
|
656
|
+
* 求值失败(函数抛错 / 返回空 / 不是合法 URL)时回退到上一次成功用过的地址,
|
|
657
|
+
* 保证"取不到新地址"永远不会变成"打一个空 URL"。真的没得回退时返回空串,
|
|
658
|
+
* 由 `#connect()` 如实报错,而不是静默重连到垃圾地址。
|
|
659
|
+
*/
|
|
660
|
+
#upstream() {
|
|
661
|
+
let raw;
|
|
662
|
+
try {
|
|
663
|
+
raw = this.#opts.upstreamGetter();
|
|
664
|
+
} catch (error) {
|
|
665
|
+
this.#log("warn", "upstream getter threw", { error: String(error) });
|
|
666
|
+
return this.#lastGoodUpstream ?? "";
|
|
667
|
+
}
|
|
668
|
+
const value = typeof raw === "string" ? raw.replace(/\/+$/, "") : "";
|
|
669
|
+
return value || this.#lastGoodUpstream || "";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
#muxUrl(upstream) {
|
|
673
|
+
const url = new URL(upstream);
|
|
636
674
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
637
675
|
url.pathname = this.#opts.muxPath;
|
|
638
676
|
url.search = "";
|
|
@@ -643,16 +681,35 @@ class EventSubscriber extends EventEmitter {
|
|
|
643
681
|
#connect() {
|
|
644
682
|
if (this.#closed) return;
|
|
645
683
|
this.#state = "connecting";
|
|
684
|
+
const upstream = this.#upstream();
|
|
685
|
+
if (upstream === "") {
|
|
686
|
+
this.#state = "auth-failed";
|
|
687
|
+
this.#emitAuthError(0, "upstream 不可用(取不到宿主地址)", "ws");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// 目标变了 = 宿主换了端口(Desktop 每次启动随机 / `dsh web` 重启到新端口)。
|
|
691
|
+
// 把退避重置,否则旧目标上累积的长退避会让"换了端口反而更慢连上"(最长要等满 maxMs)。
|
|
692
|
+
if (upstream !== this.#lastConnectUrl) {
|
|
693
|
+
if (this.#lastConnectUrl !== null) {
|
|
694
|
+
this.#log("info", `上游切换 ${this.#lastConnectUrl} → ${upstream},重连退避已重置`, {
|
|
695
|
+
from: this.#lastConnectUrl,
|
|
696
|
+
to: upstream,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
this.#lastConnectUrl = upstream;
|
|
700
|
+
this.#attempt = 0;
|
|
701
|
+
}
|
|
702
|
+
this.#lastGoodUpstream = upstream;
|
|
646
703
|
let url;
|
|
647
704
|
try {
|
|
648
|
-
url = this.#muxUrl();
|
|
705
|
+
url = this.#muxUrl(upstream);
|
|
649
706
|
} catch (error) {
|
|
650
707
|
this.#state = "auth-failed";
|
|
651
708
|
this.#emitAuthError(0, `upstream 不是合法 URL: ${String(error)}`, "ws");
|
|
652
709
|
return;
|
|
653
710
|
}
|
|
654
711
|
// Host 显式写成上游 authority(loopback 围栏),Cookie 复用 bridge 已持有的浏览器会话。
|
|
655
|
-
const headers = { Host: new URL(
|
|
712
|
+
const headers = { Host: new URL(upstream).host };
|
|
656
713
|
const cookie = this.#cookieValue();
|
|
657
714
|
if (cookie !== "") headers.Cookie = cookie;
|
|
658
715
|
|
|
@@ -1411,7 +1468,8 @@ class EventSubscriber extends EventEmitter {
|
|
|
1411
1468
|
async #rpc(method, args, eventId) {
|
|
1412
1469
|
const rpcId = randomUUID();
|
|
1413
1470
|
const envelope = { type: "client-request", rpcId, method, payload: { args } };
|
|
1414
|
-
|
|
1471
|
+
// 回执/RPC 也走**当前**上游(端口变了就跟着走),不是一个启动时固化的字符串。
|
|
1472
|
+
const url = `${this.#upstream()}${API_CHANNEL}/${method}`;
|
|
1415
1473
|
|
|
1416
1474
|
const timeoutMs = Number(this.#opts.rpcTimeoutMs);
|
|
1417
1475
|
const budgeted = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
@@ -1984,7 +2042,7 @@ export function classifyTurnEnd(sessionId, event, at) {
|
|
|
1984
2042
|
/**
|
|
1985
2043
|
* 创建一个订阅器。
|
|
1986
2044
|
* @param {{
|
|
1987
|
-
* upstream: string,
|
|
2045
|
+
* upstream: string|(() => string), // 函数 = 每次连接/每次 RPC 重新求值(宿主端口会变)
|
|
1988
2046
|
* cookie?: string|(() => string),
|
|
1989
2047
|
* muxPath?: string,
|
|
1990
2048
|
* resultPath?: string,
|
|
@@ -340,7 +340,7 @@ export class WeChatRuntime {
|
|
|
340
340
|
/**
|
|
341
341
|
* @param {{
|
|
342
342
|
* relayDir: string,
|
|
343
|
-
* upstream?: string,
|
|
343
|
+
* upstream?: string|(() => string), // dsh web 地址,默认 http://127.0.0.1:3080;函数=每次重连重新求值
|
|
344
344
|
* cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
|
|
345
345
|
* secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
|
|
346
346
|
* logger?: any,
|
|
@@ -356,6 +356,16 @@ export class WeChatRuntime {
|
|
|
356
356
|
if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
|
|
357
357
|
this.relayDir = opts.relayDir;
|
|
358
358
|
this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
|
|
359
|
+
/**
|
|
360
|
+
* 上游地址的**取值器**:恰好是字符串时是常量函数。
|
|
361
|
+
*
|
|
362
|
+
* 为什么不让它是个固化字符串:宿主端口会变(`dsh web --port 0`、DSH Desktop 每次启动随机端口),
|
|
363
|
+
* 而事件订阅器是**长连接** —— 固化字符串的后果是端口一变就永久重连到旧端口。
|
|
364
|
+
* 【2026-10-09 真机实证】bridge 启动时宿主还是 3080,随后换成 19387;隧道转发侧
|
|
365
|
+
* (每次都读最新 UPSTREAM)一切正常,唯独微信事件通道死循环刷
|
|
366
|
+
* `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面,但**任务完成没有任何推送**。
|
|
367
|
+
*/
|
|
368
|
+
this.upstreamOf = typeof opts.upstream === "function" ? opts.upstream : () => this.upstream;
|
|
359
369
|
this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
|
|
360
370
|
this.secret = String(opts.secret || "");
|
|
361
371
|
this.clock = opts.clock || Date.now;
|
|
@@ -1597,7 +1607,8 @@ export class WeChatRuntime {
|
|
|
1597
1607
|
// 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
|
|
1598
1608
|
if (!this.subscriber) {
|
|
1599
1609
|
this.subscriber = createEventSubscriber({
|
|
1600
|
-
|
|
1610
|
+
// 传**取值器**而不是当次的值:见构造函数里 upstreamOf 的说明(端口变了要能跟上)。
|
|
1611
|
+
upstream: () => this.upstreamOf(),
|
|
1601
1612
|
cookie: () => this.cookieOf(),
|
|
1602
1613
|
follow: true,
|
|
1603
1614
|
log: (level, message, meta) => {
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -173,6 +173,12 @@ function printHelp() {
|
|
|
173
173
|
dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
|
|
174
174
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
175
175
|
|
|
176
|
+
目标 profile(默认 Web 版):
|
|
177
|
+
dsh-remote --profile desktop 装进 DeepSeek Harness **桌面端**的插件目录
|
|
178
|
+
(~/.dsh/profiles/desktop;装前请先完全退出桌面应用)
|
|
179
|
+
桌面端也可以在应用内「插件」页直接安装 dsh-remote-web —— 那是官方路径,无需退出。
|
|
180
|
+
⚠️ 桌面端与 Web 版共享 ~/.dsh(会话/设置/凭据),但**插件互相隔离**,两边要各自安装。
|
|
181
|
+
|
|
176
182
|
自建模式(可选):
|
|
177
183
|
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
178
184
|
|
|
@@ -239,19 +245,109 @@ function resolveExecutable(p) {
|
|
|
239
245
|
} catch { return null; }
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
/**
|
|
249
|
+
* 宿主是否跑在 **Electron 的 Node 模式**下(DeepSeek Harness 桌面端的 Host 就是)。
|
|
250
|
+
*
|
|
251
|
+
* 桌面端 `process.execPath` 是 **Electron 应用二进制**而不是 node:不带
|
|
252
|
+
* `ELECTRON_RUN_AS_NODE=1` 直接拿它跑脚本,得到的不是「跑脚本的 node」,而是
|
|
253
|
+
* **又启动一个桌面端应用实例**。自启动服务是系统在应用退出后拉起的,踩到这里
|
|
254
|
+
* 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口事故
|
|
255
|
+
* 一样难排查 —— 所以必须在**写服务定义时**就把变量带上。
|
|
256
|
+
*/
|
|
257
|
+
const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* DeepSeek Harness **桌面端自带 Node** 的稳定落点候选。
|
|
261
|
+
*
|
|
262
|
+
* 为什么需要:桌面端主打的正是**机器上没有系统 node** 的用户;而自启动服务要活过
|
|
263
|
+
* 桌面端的退出、甚至升级/重装(`.app` 路径会变、可能被换掉)。桌面端把自带运行时装在
|
|
264
|
+
* **用户目录** `~/.dsh/dsh-runtimes/…`,它不随应用包更新而移动 —— 所以只要它存在,
|
|
265
|
+
* 就该优先于任何指向 `.app` 内部的路径。
|
|
266
|
+
*
|
|
267
|
+
* 目录布局随桌面端版本演进(可能多套一层 runtime/node),故按多个候选探测;
|
|
268
|
+
* `resolveExecutable()` 会过滤掉不存在的路径,多列无害。
|
|
269
|
+
* @returns {string[]} 候选可执行文件绝对路径(可能都不存在)。
|
|
270
|
+
*/
|
|
271
|
+
function desktopRuntimeNodeCandidates() {
|
|
272
|
+
const isWin = process.platform === "win32";
|
|
273
|
+
const exe = isWin ? "node.exe" : "node";
|
|
274
|
+
const out = [];
|
|
275
|
+
// ① 稳定落点:装在**用户目录**,桌面端升级/重装都不动它。
|
|
276
|
+
// ⚠️ 真实布局是 `dependencies/node/bin/<exe>`(2026-10-07 拆官方 0.2.0-rc.2 DMG 实测:
|
|
277
|
+
// `…/Resources/runtime/primary-runtime/dependencies/node/bin/node` → Node v24.21.0)。
|
|
278
|
+
// 其余几个是兼容不同版本的兜底;resolveExecutable() 会过滤掉不存在的路径,多列无害。
|
|
279
|
+
const roots = [
|
|
280
|
+
process.env.DSH_DESKTOP_RUNTIME_DIR || "",
|
|
281
|
+
path.join(os.homedir(), ".dsh", "dsh-runtimes", "dsh-primary-runtime"),
|
|
282
|
+
path.join(os.homedir(), ".dsh", "dsh-runtimes", "primary-runtime"),
|
|
283
|
+
].filter(Boolean);
|
|
284
|
+
for (const root of roots) {
|
|
285
|
+
out.push(
|
|
286
|
+
path.join(root, "dependencies", "node", "bin", exe),
|
|
287
|
+
path.join(root, "bin", exe),
|
|
288
|
+
path.join(root, "node", "bin", exe),
|
|
289
|
+
path.join(root, "runtime", "bin", exe),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
// ② 应用自己把自带 node 的路径交给子进程(其 `runtime/bin/node` shim 就是靠这个变量)——
|
|
293
|
+
// 平台无关,且是"应用自己说的",比我们猜路径可靠。
|
|
294
|
+
if (process.env.DSH_DESKTOP_NODE_EXECUTABLE) out.push(process.env.DSH_DESKTOP_NODE_EXECUTABLE);
|
|
295
|
+
// ③ 最后才是指向应用包内部的绝对路径:它是**真实存在的 node**,但会随桌面端更新换路径,
|
|
296
|
+
// 所以排在稳定落点之后(自启动服务登记它属于"聊胜于无",好过登记 Electron 本体)。
|
|
297
|
+
if (isWin) {
|
|
298
|
+
const local = process.env.LOCALAPPDATA || "";
|
|
299
|
+
if (local) out.push(path.join(local, "Programs", "DeepSeek Harness", "resources", "runtime", "primary-runtime", "dependencies", "node", exe));
|
|
300
|
+
} else {
|
|
301
|
+
out.push(
|
|
302
|
+
"/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
|
|
303
|
+
"/Applications/DSH Desktop.app/Contents/Resources/runtime/primary-runtime/dependencies/node/bin/node",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
242
309
|
function preferredNode() {
|
|
310
|
+
// 先归一化"自己的可执行文件",用来判断最终选中的解释器是否就是 Electron 本体。
|
|
311
|
+
const selfReal = resolveExecutable(process.execPath) || process.execPath;
|
|
243
312
|
const candidates = [
|
|
244
313
|
process.env.DSH_SETUP_NODE20 || "",
|
|
245
314
|
process.env.DSH_SETUP_NODE || "",
|
|
315
|
+
// 桌面端(Electron 宿主):**真实 node 优先**。服务要活过桌面应用的退出与更新
|
|
316
|
+
//(.app 路径会变、可能被换掉),登记一个真实 node 才稳;找不到才回落 Electron 自身,
|
|
317
|
+
// 那时由 SERVICE_NODE_ENV 补上 ELECTRON_RUN_AS_NODE=1 把它变成 node。
|
|
318
|
+
...(IS_ELECTRON_HOST
|
|
319
|
+
? [
|
|
320
|
+
// 桌面端自带 Node 的**稳定**落点:装在用户目录(~/.dsh/dsh-runtimes/…),
|
|
321
|
+
// 桌面端升级/重装都不动它 —— 比 .app 内部路径更该登记进自启动服务。
|
|
322
|
+
...desktopRuntimeNodeCandidates(),
|
|
323
|
+
"/opt/homebrew/opt/node@20/bin/node", "/usr/local/opt/node@20/bin/node", "/opt/homebrew/bin/node", "/usr/local/bin/node",
|
|
324
|
+
]
|
|
325
|
+
: []),
|
|
246
326
|
process.execPath
|
|
247
327
|
].filter(Boolean);
|
|
248
328
|
for (const p of candidates) {
|
|
249
329
|
const real = resolveExecutable(p);
|
|
250
|
-
if (real) return real;
|
|
251
|
-
}
|
|
252
|
-
return process.execPath;
|
|
330
|
+
if (real) return { bin: real, electron: IS_ELECTRON_HOST && real === selfReal };
|
|
331
|
+
}
|
|
332
|
+
return { bin: process.execPath, electron: IS_ELECTRON_HOST };
|
|
333
|
+
}
|
|
334
|
+
const INTERPRETER = preferredNode();
|
|
335
|
+
const NODE_BIN = INTERPRETER.bin;
|
|
336
|
+
/** 解释器是 Electron 二进制时必须注入的变量(真实 node 时为空对象,行为与本改动前一致)。 */
|
|
337
|
+
const SERVICE_NODE_ENV = INTERPRETER.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {};
|
|
338
|
+
/** plist `EnvironmentVariables` 里追加的条目(真实 node 时为空串 → 模板逐字节不变)。 */
|
|
339
|
+
const SERVICE_NODE_ENV_PLIST = Object.entries(SERVICE_NODE_ENV)
|
|
340
|
+
.map(([k, v]) => `<key>${k}</key><string>${v}</string>`).join("");
|
|
341
|
+
/** systemd unit 里追加的 `Environment=` 行(真实 node 时为空串 → 模板逐字节不变)。 */
|
|
342
|
+
const SERVICE_NODE_ENV_UNIT = Object.entries(SERVICE_NODE_ENV)
|
|
343
|
+
.map(([k, v]) => `Environment=${k}=${v}\n`).join("");
|
|
344
|
+
/**
|
|
345
|
+
* 供**子进程 spawn** 用的环境(继承当前环境 + 解释器变量)。
|
|
346
|
+
* 本文件里凡是用 NODE_BIN 拉起子进程的地方都要带上它。
|
|
347
|
+
*/
|
|
348
|
+
function nodeEnv(extra) {
|
|
349
|
+
return { ...process.env, ...SERVICE_NODE_ENV, ...(extra || {}) };
|
|
253
350
|
}
|
|
254
|
-
const NODE_BIN = preferredNode();
|
|
255
351
|
|
|
256
352
|
/**
|
|
257
353
|
* 自启动服务里注入的 PATH。
|
|
@@ -516,10 +612,17 @@ function writeWinHiddenLauncher(node, setup) {
|
|
|
516
612
|
const vbsPath = winHiddenLauncherPath();
|
|
517
613
|
const commandLine = `"${node}" "${setup}" run`; // 真正要执行的命令行
|
|
518
614
|
const literal = commandLine.replace(/"/g, '""'); // 嵌进 VBS 字符串字面量后
|
|
615
|
+
// 解释器是 Electron(桌面端宿主且机器上没有真实 node)时,必须先设 ELECTRON_RUN_AS_NODE=1:
|
|
616
|
+
// 否则 wscript 拉起的不是 bridge 守护,而是**又开一个桌面端应用**。
|
|
617
|
+
// VBScript 侧的写法是往进程环境里塞,再 Run(不能像 shell 那样内联前缀)。
|
|
618
|
+
const envLines = Object.entries(SERVICE_NODE_ENV).map(([k, v]) => `sh.Environment("PROCESS")("${k}") = "${v}"`);
|
|
619
|
+
const runLines = envLines.length > 0
|
|
620
|
+
? [`Set sh = CreateObject("WScript.Shell")`, ...envLines, `sh.Run "${literal}", 0, False`]
|
|
621
|
+
: [`CreateObject("WScript.Shell").Run "${literal}", 0, False`];
|
|
519
622
|
const src = [
|
|
520
623
|
"' dsh-remote 自动生成(勿手改,重装会覆盖)",
|
|
521
624
|
"' 隐藏启动 bridge 守护:窗口样式 0 = 不显示窗口;False = 不等待,node 成为独立进程。",
|
|
522
|
-
|
|
625
|
+
...runLines,
|
|
523
626
|
"",
|
|
524
627
|
].join("\r\n");
|
|
525
628
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -532,6 +635,12 @@ function writeWindowsTask() {
|
|
|
532
635
|
const setup = runtimeSetupPath();
|
|
533
636
|
// 首选 wscript 包装(无黑窗);写不了 .vbs 就退回直接启动(有窗,但保底能自启)
|
|
534
637
|
let direct = `"${node}" "${setup}" run`;
|
|
638
|
+
// 解释器是 Electron 时,直接启动这条路没有 .vbs 可以设环境变量,只能借 cmd 的 `set`
|
|
639
|
+
// (schtasks 的 /TR 只接受一条命令行,不能内联 env)。
|
|
640
|
+
if (Object.keys(SERVICE_NODE_ENV).length > 0) {
|
|
641
|
+
const sets = Object.entries(SERVICE_NODE_ENV).map(([k, v]) => `set ${k}=${v}`).join(" && ");
|
|
642
|
+
direct = `cmd.exe /c ${sets} && "${node}" "${setup}" run`;
|
|
643
|
+
}
|
|
535
644
|
let tr = direct;
|
|
536
645
|
let hidden = false;
|
|
537
646
|
try {
|
|
@@ -599,7 +708,7 @@ function writeAutostartFile() {
|
|
|
599
708
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
600
709
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
601
710
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
602
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string
|
|
711
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
603
712
|
</dict></plist>`;
|
|
604
713
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
605
714
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -615,7 +724,7 @@ function writeAutostartFile() {
|
|
|
615
724
|
if (process.platform === "linux") {
|
|
616
725
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
617
726
|
fs.mkdirSync(dir, { recursive: true });
|
|
618
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\
|
|
727
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
619
728
|
const unitPath = autostartFilePath();
|
|
620
729
|
fs.writeFileSync(unitPath, unit);
|
|
621
730
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -673,7 +782,7 @@ function spawnDetachedBridge() {
|
|
|
673
782
|
const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
|
|
674
783
|
detached: true,
|
|
675
784
|
stdio: ["ignore", out, out],
|
|
676
|
-
env:
|
|
785
|
+
env: nodeEnv(),
|
|
677
786
|
// Windows 上 detached 即 DETACHED_PROCESS:子进程**不继承父控制台**,
|
|
678
787
|
// 控制台程序(node.exe)会因此拿到一个**全新的可见窗口**。
|
|
679
788
|
// 必须显式 windowsHide 才会带上 CREATE_NO_WINDOW(POSIX 上该选项被忽略)。
|
|
@@ -894,14 +1003,63 @@ async function isDshWebUp(timeoutMs = 1200) {
|
|
|
894
1003
|
return upstreamReachable(after, timeoutMs);
|
|
895
1004
|
}
|
|
896
1005
|
|
|
1006
|
+
/** 已知的 DSH profile 名(`--profile web` / `--profile desktop` 这种写法要能用)。 */
|
|
1007
|
+
const KNOWN_PROFILE_NAMES = ["web", "desktop"];
|
|
1008
|
+
/** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 口径一致)。 */
|
|
1009
|
+
function profileDirOfName(name) {
|
|
1010
|
+
return path.join(os.homedir(), ".dsh", "profiles", name);
|
|
1011
|
+
}
|
|
1012
|
+
/** 目标目录是否就是**桌面端**保留的 profile(由 Electron 应用独占管理)。 */
|
|
1013
|
+
function isDesktopProfileDir(dir) {
|
|
1014
|
+
return path.resolve(dir) === path.resolve(profileDirOfName("desktop"));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
897
1017
|
/**
|
|
898
|
-
*
|
|
1018
|
+
* 解析目标 profile 目录(安装与插件管理共用同一口径)。
|
|
1019
|
+
*
|
|
1020
|
+
* 三种写法都支持:
|
|
1021
|
+
* · `--profile web` / `--profile desktop` → `~/.dsh/profiles/<name>`
|
|
1022
|
+
* (新增:DeepSeek Harness 桌面端的 profile 就叫 `desktop`)
|
|
1023
|
+
* · `--profile <任意路径>` → 原样当目录用(老行为,保持不变)
|
|
1024
|
+
* · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同口径
|
|
1025
|
+
* 缺省仍是 Web 版 profile,行为与改动前完全一致。
|
|
899
1026
|
*/
|
|
900
1027
|
function resolveProfileDir(argv = []) {
|
|
901
1028
|
const i = argv.indexOf("--profile");
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1029
|
+
const explicit = i > -1 && argv[i + 1] ? argv[i + 1] : process.env.DSH_PROFILE_DIR;
|
|
1030
|
+
if (!explicit) return profileDirOfName("web");
|
|
1031
|
+
if (KNOWN_PROFILE_NAMES.includes(explicit)) return profileDirOfName(explicit);
|
|
1032
|
+
return explicit;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* 从任意 argv 里挑出 profile 选择参数。
|
|
1037
|
+
*
|
|
1038
|
+
* 为什么需要:安装主流程 `setup()` 的 argv 里可能带 `--profile desktop`,但它内部调用
|
|
1039
|
+
* `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
|
|
1040
|
+
* 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
|
|
1041
|
+
*/
|
|
1042
|
+
function profileArgvOf(argv = []) {
|
|
1043
|
+
const i = argv.indexOf("--profile");
|
|
1044
|
+
return i > -1 && argv[i + 1] ? ["--profile", argv[i + 1]] : [];
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* 目标 profile 是桌面端时给出的前置提醒(安装前调用)。
|
|
1049
|
+
*
|
|
1050
|
+
* 桌面端的 `profiles/desktop` 由 Electron 应用**独占**:应用运行时它会持有 profile 写锁,
|
|
1051
|
+
* 且运行中的进程不会重新装载我们刚写进去的插件。所以要么完全退出应用再装,要么
|
|
1052
|
+
* 直接在应用内的「插件」页安装(那是官方路径,无需退出)。
|
|
1053
|
+
* 这里只做提醒,不阻断 —— 用户可能正是在"应用已退出"的状态下跑安装器。
|
|
1054
|
+
*/
|
|
1055
|
+
function warnIfDesktopProfile(dir) {
|
|
1056
|
+
if (!isDesktopProfileDir(dir)) return;
|
|
1057
|
+
console.log("");
|
|
1058
|
+
console.log("ℹ️ 目标 profile 是 DeepSeek Harness **桌面端**的保留 profile(~/.dsh/profiles/desktop)。");
|
|
1059
|
+
console.log(" · 请先**完全退出** DeepSeek Harness 应用再安装(应用独占该 profile,运行中写入不会被装载);");
|
|
1060
|
+
console.log(" · 装完后重新打开应用即可。更省事的做法是在应用内「插件」页直接安装 dsh-remote-web;");
|
|
1061
|
+
console.log(" · 桌面端与 Web 版共用 ~/.dsh 下的会话/设置/凭据,但**插件互相隔离**,两边需要各自安装。");
|
|
1062
|
+
console.log("");
|
|
905
1063
|
}
|
|
906
1064
|
|
|
907
1065
|
/**
|
|
@@ -1032,7 +1190,7 @@ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
|
|
|
1032
1190
|
try {
|
|
1033
1191
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1034
1192
|
fs.writeFileSync(helper, src);
|
|
1035
|
-
const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true });
|
|
1193
|
+
const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true, env: nodeEnv() });
|
|
1036
1194
|
child.on("error", () => { /* 有 error 监听:绝不能把宿主/安装器打挂 */ });
|
|
1037
1195
|
child.unref();
|
|
1038
1196
|
return { ok: true, how: "Windows 重启助手(原命令行自拉起)" };
|
|
@@ -1357,7 +1515,7 @@ async function runBridge() {
|
|
|
1357
1515
|
// 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
|
|
1358
1516
|
bridgeProc = spawn(NODE_BIN,
|
|
1359
1517
|
[path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
|
|
1360
|
-
{ env: childEnv, stdio: ["inherit", "inherit", "inherit", "ipc"] });
|
|
1518
|
+
{ env: nodeEnv(childEnv), stdio: ["inherit", "inherit", "inherit", "ipc"] });
|
|
1361
1519
|
bridgeProc.on("exit", () => {
|
|
1362
1520
|
removePidFile(BRIDGE_PID_FILE);
|
|
1363
1521
|
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
@@ -1480,7 +1638,10 @@ async function setup(argv) {
|
|
|
1480
1638
|
let pluginResult = null;
|
|
1481
1639
|
if (!noPlugin) {
|
|
1482
1640
|
try {
|
|
1483
|
-
|
|
1641
|
+
// ⚠️ 必须把 `--profile` **透传下去**:否则 `--profile desktop`(桌面端)会被静默忽略,
|
|
1642
|
+
// 插件装进 Web 版 profile,而用户面对的是桌面端 —— 面板永远不出现。
|
|
1643
|
+
warnIfDesktopProfile(resolveProfileDir(argv));
|
|
1644
|
+
pluginResult = await pluginCmd(profileArgvOf(argv));
|
|
1484
1645
|
} catch (e) {
|
|
1485
1646
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
1486
1647
|
}
|
|
@@ -2178,7 +2339,7 @@ function probePluginResolve(profileDir) {
|
|
|
2178
2339
|
"}));",
|
|
2179
2340
|
].join("\n");
|
|
2180
2341
|
try {
|
|
2181
|
-
const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
|
|
2342
|
+
const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true, env: nodeEnv() });
|
|
2182
2343
|
if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
|
|
2183
2344
|
if (r.status !== 0) {
|
|
2184
2345
|
const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
|
|
@@ -2452,8 +2613,19 @@ async function pluginCmd(argv) {
|
|
|
2452
2613
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2453
2614
|
|
|
2454
2615
|
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2455
|
-
console.error(`❌ 未找到
|
|
2456
|
-
|
|
2616
|
+
console.error(`❌ 未找到 profile(${profileDir})。`);
|
|
2617
|
+
if (isDesktopProfileDir(profileDir)) {
|
|
2618
|
+
// 桌面端 profile 由 Electron 应用**自己初始化**:npm 版 `dsh` 明确拒绝管理它
|
|
2619
|
+
// (apps/cli 的 rejectElectronProfile),所以 Web 版那句 "npx @deepseek-ai/dsh web"
|
|
2620
|
+
// 在桌面端**不成立**,照抄只会让用户白折腾。这里必须给桌面端专属的三步。
|
|
2621
|
+
console.error(" 桌面端 profile 由 DeepSeek Harness 应用自己创建,不能像 Web 版那样用 npx 造出来:");
|
|
2622
|
+
console.error(" ① 先启动一次 DeepSeek Harness 应用,让它初始化该 profile;");
|
|
2623
|
+
console.error(" ② 完全退出应用(应用独占该 profile,运行中安装不会被装载);");
|
|
2624
|
+
console.error(" ③ 再执行本次安装:dsh-remote --profile desktop");
|
|
2625
|
+
console.error(" 更省事的官方路径:直接在应用内「插件」页安装 dsh-remote-web。");
|
|
2626
|
+
} else {
|
|
2627
|
+
console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
|
|
2628
|
+
}
|
|
2457
2629
|
process.exit(1);
|
|
2458
2630
|
}
|
|
2459
2631
|
if (!fs.existsSync(pluginDir)) {
|