@mrrisega/dsh-remote 0.6.18 → 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,7 @@ import {
31
31
  probeDshWeb,
32
32
  resolveUpstreamHint,
33
33
  DESKTOP_DEFAULT_PORT,
34
+ DESKTOP_PORT_SCAN,
34
35
  FALLBACK_UPSTREAM,
35
36
  UPSTREAM_FILE
36
37
  } from "../upstream-discovery.mjs";
@@ -52,14 +53,26 @@ const close = (x) => new Promise((r) => x.srv.close(r));
52
53
 
53
54
  const writeHint = (url) => writeFileSync(path.join(DIR, UPSTREAM_FILE), url + "\n");
54
55
  /**
55
- * 把 3080 封掉的 fetch 包装。
56
- * 为什么需要:开发机上真实存在一个 dsh web 监听 3080,候选扫描会命中它 ——
57
- * 那是**宿主**而不是测试夹具,断言就变成了"看运气"。封掉它,测试才只依赖自己起的假服务。
56
+ * 把**宿主真实占用的端口**整段封掉的 fetch 包装。
57
+ *
58
+ * 为什么需要:候选扫描会命中开发机上真实存在的宿主,而那是**宿主**不是测试夹具,
59
+ * 断言就变成"看运气"。最初这里只封了 `3080`(Web 版 dsh web);但装了 **DeepSeek Harness
60
+ * 桌面端**的机器上,Desktop Host 常年监听 `43120` 起的一段端口,而这段端口正是
61
+ * `candidatePorts()` 的静态候选 —— 于是两条用例在**代码没改**的情况下变红
62
+ * (2026-10-07 实测:桌面端在 43120 上跑着,`什么都找不到 → 回退 3080` 得到 43120)。
63
+ * 因此这里按"生产代码认定的已知宿主端口集合"整体封掉,测试才只依赖自己起的假服务。
58
64
  */
59
65
  const realFetch = globalThis.fetch;
66
+ const AMBIENT_HOST_PORTS = new Set([
67
+ 3080, // Web 版 dsh web 的历史默认端口
68
+ ...Array.from({ length: DESKTOP_PORT_SCAN }, (_, i) => DESKTOP_DEFAULT_PORT + i), // 桌面端 Host 候选区间
69
+ ]);
60
70
  const blockedFetch = (input, init) => {
61
71
  const url = String(typeof input === "string" ? input : (input && input.url) || "");
62
- if (/:3080(\/|$)/.test(url)) return Promise.reject(new Error("blocked: host 3080"));
72
+ const m = /:(\d+)(?:\/|$)/.exec(url);
73
+ if (m && AMBIENT_HOST_PORTS.has(Number(m[1]))) {
74
+ return Promise.reject(new Error(`blocked: ambient host port ${m[1]}`));
75
+ }
63
76
  return realFetch(input, init);
64
77
  };
65
78
 
package/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
- `CreateObject("WScript.Shell").Run "${literal}", 0, False`,
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><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
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}\nEnvironment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
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: process.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
- * 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
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
- return i > -1 && argv[i + 1]
903
- ? argv[i + 1]
904
- : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
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
- pluginResult = await pluginCmd([]);
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(`❌ 未找到 dsh web profile(${profileDir})。`);
2456
- console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.18",
3
+ "version": "0.7.0-beta.1",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -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
- if (!restartPending || isRefreshOnly || isRestartHard || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
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, isRestartHard ? "🔄 插件已更新:重启 dsh web 后生效" : "🔄 插件已更新:刷新页面即可生效"),
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
- isRestartHard ? "插件已更新,重启 dsh web 后生效"
4976
+ // 桌面端:需要换 Host 进程的那一类改为引导「退出并重开应用」(插件无权重启 Host)。
4977
+ isRestartHard
4978
+ ? (desktopHost ? "插件已更新,退出并重新打开 DeepSeek Harness 后生效"
4979
+ : "插件已更新,重启 dsh web 后生效")
4949
4980
  : isRefreshOnly ? "插件已更新,刷新页面即可生效"
4950
- : "需要重启 dsh web 才能载入新插件"),
4981
+ : (desktopHost ? "需要重新打开 DeepSeek Harness 才能载入新插件"
4982
+ : "需要重启 dsh web 才能载入新插件")),
4951
4983
  h("div", { className: "dru-restart-alert-sub" },
4952
4984
  isRestartHard
4953
- ? "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。"
4985
+ ? (desktopHost
4986
+ ? "新版本已经装到这台电脑上,但当前运行的 DeepSeek Harness 还是旧代码(新的后台路由与面板要等它重新载入)。桌面端的运行环境由应用本身托管,插件不能单独重启它 —— 请退出并重新打开 DeepSeek Harness 应用。手机端连接与已登录状态都不受影响。"
4987
+ : "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。")
4954
4988
  : isRefreshOnly
4955
4989
  ? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
4956
- : "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"),
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
- h("button", {
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" }, "重启 dsh web:仅在「刷新页面后仍看不到面板」时才需要。日常安装/更新插件已自动热加载,装完刷新页面即可;重启只影响 dsh web 本身,不影响后台服务与手机端连接。"),
5009
- h("button", {
5010
- type: "button",
5011
- className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
5012
- disabled: busy !== "",
5013
- onClick: function () { restartHarness(); }
5014
- }, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
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
  // 📣 公告详情弹窗(与交流群弹窗同一层:都挂在面板根上,点遮罩即收起)