dsh-remote-web 0.7.0-beta.7 → 0.7.0

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.
@@ -194,7 +194,7 @@ function printHelp() {
194
194
  // 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
195
195
  const KNOWN_FLAGS = new Set([
196
196
  "--api", "--server", "--key", "--profile",
197
- "--no-autostart", "--no-plugin", "--no-restart", "--uninstall"
197
+ "--no-autostart", "--no-plugin", "--no-restart", "--uninstall", "--force"
198
198
  ]);
199
199
 
200
200
  // ⚠️ 这一整块必须挡在 ensureRuntimeCopy() **之前**:它会往 ~/.dsh-remote 同步运行时脚本。
@@ -254,7 +254,7 @@ function resolveExecutable(p) {
254
254
  * 桌面端 `process.execPath` 是 **Electron 应用二进制**而不是 node:不带
255
255
  * `ELECTRON_RUN_AS_NODE=1` 直接拿它跑脚本,得到的不是「跑脚本的 node」,而是
256
256
  * **又启动一个桌面端应用实例**。自启动服务是系统在应用退出后拉起的,踩到这里
257
- * 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口事故
257
+ * 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口故障
258
258
  * 一样难排查 —— 所以必须在**写服务定义时**就把变量带上。
259
259
  */
260
260
  const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
@@ -359,7 +359,7 @@ function nodeEnv(extra) {
359
359
  */
360
360
  const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
361
361
 
362
- // ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
362
+ // ---------- 安装约定(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
363
363
  /**
364
364
  * install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
365
365
  * 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
@@ -526,7 +526,7 @@ function readPidFile(name) {
526
526
  *
527
527
  * 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
528
528
  * 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
529
- * **主动结束**那个 pid —— 盲杀一个被回收的无关进程是事故。所以动它之前先认一下命令行。
529
+ * **主动结束**那个 pid —— 盲杀一个被回收的无关进程是故障。所以动它之前先认一下命令行。
530
530
  */
531
531
  function looksLikeWatcher(pid) {
532
532
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -838,7 +838,7 @@ function spawnDetachedBridge() {
838
838
  * ③ 后台进程 ← 连 launchd 都托管不了时保证可用
839
839
  */
840
840
  /**
841
- * 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
841
+ * 🔴 标签占用保护(2026-10-10 故障):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
842
842
  * 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
843
843
  * plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
844
844
  *
@@ -984,7 +984,7 @@ function restartBridgeService() {
984
984
  /**
985
985
  * 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
986
986
  *
987
- * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
987
+ * 【2026-09-23 用户实测故障】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
988
988
  * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
989
989
  * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
990
990
  * 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
@@ -1006,9 +1006,9 @@ let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().repla
1006
1006
  /**
1007
1007
  * 探测桌面端(Electron 应用)当前监听的**回环端口**。
1008
1008
  *
1009
- * 为什么需要(2026-10-11 实测事故):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
1009
+ * 为什么需要(2026-10-11 实测故障):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
1010
1010
  * 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
1011
- * 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而业主面对的是桌面端 19387,
1011
+ * 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而用户面对的是桌面端 19387,
1012
1012
  * 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
1013
1013
  * 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
1014
1014
  */
@@ -1105,7 +1105,7 @@ async function isDshWebUp(timeoutMs = 1200) {
1105
1105
 
1106
1106
  /** 已知的 DSH profile 名(`--profile web` / `--profile desktop` 这种写法要能用)。 */
1107
1107
  const KNOWN_PROFILE_NAMES = ["web", "desktop"];
1108
- /** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 口径一致)。 */
1108
+ /** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 约定一致)。 */
1109
1109
  function profileDirOfName(name) {
1110
1110
  return path.join(os.homedir(), ".dsh", "profiles", name);
1111
1111
  }
@@ -1115,13 +1115,13 @@ function isDesktopProfileDir(dir) {
1115
1115
  }
1116
1116
 
1117
1117
  /**
1118
- * 解析目标 profile 目录(安装与插件管理共用同一口径)。
1118
+ * 解析目标 profile 目录(安装与插件管理共用同一约定)。
1119
1119
  *
1120
1120
  * 三种写法都支持:
1121
1121
  * · `--profile web` / `--profile desktop` → `~/.dsh/profiles/<name>`
1122
1122
  * (新增:DeepSeek Harness 桌面端的 profile 就叫 `desktop`)
1123
1123
  * · `--profile <任意路径>` → 原样当目录用(老行为,保持不变)
1124
- * · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同口径
1124
+ * · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同约定
1125
1125
  * 缺省仍是 Web 版 profile,行为与改动前完全一致。
1126
1126
  */
1127
1127
  function resolveProfileDir(argv = []) {
@@ -1139,9 +1139,33 @@ function resolveProfileDir(argv = []) {
1139
1139
  * `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
1140
1140
  * 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
1141
1141
  */
1142
+ /**
1143
+ * DeepSeek Harness **桌面应用**是否正在运行。
1144
+ *
1145
+ * 🔴 2026-10-11 故障:我在应用运行中改写了 `~/.dsh/profiles/desktop/` 里的插件文件,
1146
+ * 应用随即**自己退出/重启**(用户:"怎么又触发了 dsh 的关闭")。安装器自己的提示早就写着
1147
+ * 「请先**完全退出** DeepSeek Harness 应用再安装(应用独占该 profile)」—— 这条必须由程序强制,
1148
+ * 不能靠"我记得"。
1149
+ * 检测方式:macOS 上桌面应用的进程名就是 `DeepSeek`(lsof/pgrep 实测),另加 App 包路径兜底。
1150
+ */
1151
+ function dshDesktopAppRunning() {
1152
+ // 显式覆盖(测试隔离 / 探测误报时的逃生口):DSH_DESKTOP_APP_RUNNING=0|1
1153
+ const override = String(process.env.DSH_DESKTOP_APP_RUNNING || "").trim();
1154
+ if (override === "0" || override === "1") return override === "1";
1155
+ if (process.platform !== "darwin") return false;
1156
+ try {
1157
+ // 实测(2026-10-11):`pgrep -x DeepSeek` 与 `pgrep -f "...app/Contents/MacOS"` 都匹配不到,
1158
+ // 必须用下面的写法(`Contents/MacOS/DeepSeek` 或短名 `DeepSeek Harness.app` 均可命中)。
1159
+ const a = sh("pgrep -f 'Contents/MacOS/DeepSeek'");
1160
+ if (a.ok && String(a.stdout || "").trim()) return true;
1161
+ const b = sh("lsof -nP -iTCP:19387 -sTCP:LISTEN");
1162
+ return Boolean(b.ok && /LISTEN/.test(String(b.stdout || "")));
1163
+ } catch { return false; }
1164
+ }
1165
+
1142
1166
  /**
1143
1167
  * 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
1144
- * desktop 排在前面:业主主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
1168
+ * desktop 排在前面:用户主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
1145
1169
  */
1146
1170
  function detectProfileDirs() {
1147
1171
  const root = path.join(os.homedir(), ".dsh", "profiles");
@@ -1159,7 +1183,7 @@ function detectProfileDirs() {
1159
1183
  /**
1160
1184
  * 本次要操作的 profile 列表。
1161
1185
  *
1162
- * 业主口径(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
1186
+ * 用户约定(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
1163
1187
  * 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
1164
1188
  * 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
1165
1189
  *
@@ -1175,7 +1199,7 @@ function targetProfileDirs(argv = []) {
1175
1199
  if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
1176
1200
  /* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
1177
1201
  往那些里面塞插件是越界。规则:
1178
- · 已知的面向用户的 profile(web / desktop):**有就装**(这是业主的核心诉求:默认也要给 desktop)
1202
+ · 已知的面向用户的 profile(web / desktop):**有就装**(这是用户的核心诉求:默认也要给 desktop)
1179
1203
  · 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
1180
1204
  · 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
1181
1205
  const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
@@ -1429,7 +1453,7 @@ function loginRateLimitUntil() {
1429
1453
 
1430
1454
  /* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
1431
1455
  *
1432
- * 🔴 2026-09-26 真机事故(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
1456
+ * 🔴 2026-09-26 真机故障(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
1433
1457
  * **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
1434
1458
  * 互相把对方顶掉 —— bridge 日志刷满
1435
1459
  * `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
@@ -1615,7 +1639,7 @@ async function runBridge() {
1615
1639
 
1616
1640
  // 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
1617
1641
  // 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
1618
- //(2026-09-19 实测事故)。
1642
+ //(2026-09-19 实测故障)。
1619
1643
  const fp = accountFingerprint(cfg);
1620
1644
  if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
1621
1645
  console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
@@ -1646,7 +1670,7 @@ async function runBridge() {
1646
1670
  DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
1647
1671
  // 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
1648
1672
  DSH_BRIDGE_UPSTREAM: upstreamUrl(),
1649
- // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1673
+ // 安装约定透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1650
1674
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1651
1675
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
1652
1676
  // 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
@@ -1879,7 +1903,7 @@ async function setup(argv) {
1879
1903
  }
1880
1904
 
1881
1905
  const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
1882
- // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
1906
+ // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改约定。
1883
1907
  // 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
1884
1908
  // 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
1885
1909
  const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
@@ -1888,7 +1912,7 @@ async function setup(argv) {
1888
1912
  // Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
1889
1913
  const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
1890
1914
  const svcDegraded = st.status === "degraded-detached";
1891
- // Windows:自启动 = 任务计划程序里的登录任务。口径与 macOS/Linux 不同
1915
+ // Windows:自启动 = 任务计划程序里的登录任务。约定与 macOS/Linux 不同
1892
1916
  // ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
1893
1917
  const winTask = IS_WIN && winTaskOk();
1894
1918
  const svcState = IS_WIN
@@ -2086,7 +2110,7 @@ function compareVersionStrings(a, b) {
2086
2110
  }
2087
2111
 
2088
2112
  /**
2089
- * 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
2113
+ * 解析 patch 文本里的 insert 行 id(与 dshmarket 同约定的极简解析,不引 YAML 依赖)。
2090
2114
  * 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
2091
2115
  * dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
2092
2116
  * @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
@@ -2195,7 +2219,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
2195
2219
  };
2196
2220
  // ★★ 稳定形态 = **只由 `dsh.profile.bundles` 激活**,我们**不再往 patch 里写激活行**。
2197
2221
  //
2198
- // 2026-09-22 实测事故:`duplicate loader entry id: dsh-remote-web` →
2222
+ // 2026-09-22 实测故障:`duplicate loader entry id: dsh-remote-web` →
2199
2223
  // `dsh: plugin tree failed to load` → **dsh web 启动即挂**(用真实启动复现)。
2200
2224
  // 根因是**两处激活同时存在**:
2201
2225
  // (a) 插件自带的 bundle 在 `package.json` 的 `dsh.profile.bundles` 里;
@@ -2298,7 +2322,7 @@ function normalizePatchBase(patch) {
2298
2322
  /**
2299
2323
  * 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
2300
2324
  *
2301
- * 🔴 为什么必须有这一步(2026-09-26 23:46 真实事故):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
2325
+ * 🔴 为什么必须有这一步(2026-09-26 23:46 真实故障):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
2302
2326
  * 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
2303
2327
  * → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
2304
2328
  *
@@ -2329,7 +2353,7 @@ function missingPluginFiles(dir) {
2329
2353
  * 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
2330
2354
  * include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
2331
2355
  *
2332
- * 🔴 **原子覆盖(2026-09-26 事故后的硬要求)**:
2356
+ * 🔴 **原子覆盖(2026-09-26 故障后的硬要求)**:
2333
2357
  * 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
2334
2358
  * 要么是完整的旧版、要么是完整的新版;
2335
2359
  * 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
@@ -2382,7 +2406,7 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
2382
2406
  throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
2383
2407
  }
2384
2408
 
2385
- // 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次事故的闸门)
2409
+ // 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次故障的闸门)
2386
2410
  const missing = missingPluginFiles(stage);
2387
2411
  if (missing.length > 0) {
2388
2412
  try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
@@ -2615,7 +2639,7 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
2615
2639
  console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
2616
2640
  process.exit(1);
2617
2641
  }
2618
- // ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
2642
+ // ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测故障):
2619
2643
  // 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
2620
2644
  // 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
2621
2645
  // 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
@@ -2787,14 +2811,27 @@ async function pluginCmd(argv) {
2787
2811
  }
2788
2812
  let last = null;
2789
2813
  let ok = 0;
2814
+ let skippedRunning = 0;
2815
+ const force = hasFlag(argv, "--force");
2790
2816
  for (const t of targets) {
2817
+ // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
2818
+ if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
2819
+ console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
2820
+ console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
2821
+ console.log(` · 完全退出应用后重跑本命令;或`);
2822
+ console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
2823
+ console.log(` (确实要强行写入:加 --force)`);
2824
+ skippedRunning += 1;
2825
+ continue;
2826
+ }
2791
2827
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
2792
2828
  const r = await pluginApplyTo(t.dir, uninstall, multi);
2793
2829
  if (r === "skip") continue;
2794
2830
  ok += 1;
2795
2831
  if (r !== undefined && r !== null) last = r;
2796
2832
  }
2797
- if (ok === 0) process.exit(1);
2833
+ if (ok === 0 && skippedRunning === 0) process.exit(1);
2834
+ if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
2798
2835
  return last;
2799
2836
  }
2800
2837