@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.
@@ -327,20 +327,113 @@ function resolveExecutable(p) {
327
327
  }
328
328
 
329
329
  /** 优先 node@20(node-datachannel 兼容性),回退当前 node。 */
330
- function preferredNode() {
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 NODE_BIN = preferredNode();
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
- return { ...process.env, PATH, ...(extra || {}) };
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
  /**
@@ -902,7 +1002,7 @@ ExecStart=${NODE_BIN} ${setup} run
902
1002
  Restart=on-failure
903
1003
  RestartSec=5
904
1004
  Environment=PATH=${SERVICE_PATH}
905
- Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
1005
+ ${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
906
1006
  Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
907
1007
  Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
908
1008
 
@@ -1483,7 +1583,7 @@ function writeAutostartFile(relayDir) {
1483
1583
  <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
1484
1584
  <key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1485
1585
  <key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1486
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
1586
+ <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
1587
  </dict></plist>`;
1488
1588
  mkdirSync(dirname(plistPath), { recursive: true });
1489
1589
  writeFileSync(plistPath, plist, { mode: 0o644 });
@@ -2127,6 +2227,22 @@ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
2127
2227
  */
2128
2228
  async function restartHarness(relayDir, opts = {}) {
2129
2229
  if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实重启" };
2230
+ // 桌面端(Electron 宿主):**绝不重启 Host**。
2231
+ // · Host 是桌面应用(Electron)的子进程,把它杀掉会触发桌面端的**原生致命恢复弹窗**,
2232
+ // 用户的观感是"应用崩了",远差于"没重启";
2233
+ // · 而且桌面端重启用处很小:插件由共享插件管理器安装、激活走 profile 的 patch HMR
2234
+ // (存盘后约 1 秒重新 compose),日常更新刷新页面即可生效;
2235
+ // · 真要重启,正确动作是**退出并重开桌面应用**,那由用户/操作系统负责,不该由插件代劳。
2236
+ // 因此这里如实返回"做不到 + 该怎么做",而不是假装成功或把宿主打死。
2237
+ if (IS_ELECTRON_HOST) {
2238
+ return {
2239
+ ok: false,
2240
+ status: "desktop-managed",
2241
+ mode: "desktop",
2242
+ detail: "当前运行在 DeepSeek Harness 桌面端:Host 由桌面应用托管,插件不能单独重启它(强行退出会让应用弹致命错误)。"
2243
+ + "插件更新一般刷新页面即可生效;确实需要重启时,请退出并重新打开 DeepSeek Harness —— 后台服务与手机端连接不受影响。",
2244
+ };
2245
+ }
2130
2246
  const cmd = selfCommand();
2131
2247
  let mode = "relaunch";
2132
2248
  let target = "";
@@ -3777,6 +3893,9 @@ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
3777
3893
  writeFileSync(helperPath, buildRuntimeCleanupHelper(relayDir, protectedPath));
3778
3894
  const { child, error } = safeSpawn(process.execPath, [helperPath], {
3779
3895
  detached: true, cwd: homedir(), stdio: "ignore", windowsHide: true,
3896
+ // ⚠️ 必须带 spawnEnv:桌面端宿主下 process.execPath 是 Electron 二进制,
3897
+ // 不补 ELECTRON_RUN_AS_NODE=1 的话这里不是"跑清理脚本",而是**又开一个桌面端应用**。
3898
+ env: spawnEnv({}),
3780
3899
  onError: (e) => console.warn(`[dsh-remote-web] 运行时清理子进程启动失败: ${e.message}`),
3781
3900
  });
3782
3901
  if (error || !child) {
@@ -4340,6 +4459,11 @@ async function composeConnect(relayDir, opts = {}) {
4340
4459
  watcherRestarts: wedge.restarts,
4341
4460
  upstreamUrl: upstreamUrl(),
4342
4461
  upstreamSource: upstreamSourceLabel(),
4462
+ // 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
4463
+ // 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
4464
+ // 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
4465
+ desktopHost: IS_ELECTRON_HOST,
4466
+ restartSupported: !IS_ELECTRON_HOST,
4343
4467
  upstreamReachable: upstream ? upstream.ok : null,
4344
4468
  upstreamError: upstream && !upstream.ok ? upstream.error : "",
4345
4469
  rateLimitedUntil,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.18",
3
+ "version": "0.7.0-beta.1",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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.2.0-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
  }
@@ -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)) {