@mrrisega/dsh-remote 0.6.4-beta.4 → 0.6.4-beta.6

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/README.md CHANGED
@@ -34,6 +34,7 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
34
34
  - **手机端自动等待电脑上线**:空设备列表不再让人干等或手动刷新,电脑一上线设备自己出现。
35
35
  - **安装引导改版**:有插件市场入口就搜索 `dsh-remote` 安装(不用敲命令);没有就复制一条命令;两条路都收敛到「登录后稍等,电脑会自己出现」。
36
36
  - **安装输出更干净**:不再重复打印同一段引导,结尾只给一份汇总(地址 / 自启动服务 / 下一步);装完当下 dsh web 没开着也会如实说明「打开 dsh web 后 bridge 会自动启动」,不再像报错。
37
+ - **macOS 26 自启动死角已修**:macOS 26 会把 `gui/<uid>` 会话域置为 on-demand-only,`RunAtLoad` / `KeepAlive` 失效(服务只登记不启动,`runs = 0`)。现在优先用 `user/<uid>` 域启动(该域仍支持开机自启与崩溃自愈),失败才回退 `gui/<uid>` + `kickstart`(只拉起这一次,会**明确告知**不支持崩溃自愈),再不行退化为后台进程(现在能用、无自启)。自启动服务的 PATH 也补了 `/usr/sbin`、`/sbin`(bridge 要调 `ioreg`)。
37
38
 
38
39
  **0.6.2 起已有**
39
40
 
package/dsh-setup.mjs CHANGED
@@ -131,6 +131,13 @@ function preferredNode() {
131
131
  }
132
132
  const NODE_BIN = preferredNode();
133
133
 
134
+ /**
135
+ * 自启动服务里注入的 PATH。
136
+ * 必须含 /usr/sbin 与 /sbin:bridge 会用 ioreg 之类系统命令探测本机信息,
137
+ * 缺少这两个目录时 launchd 服务日志里会刷 `ioreg: command not found`(用户反馈实测)。
138
+ */
139
+ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
140
+
134
141
  // ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,供运营统计) ----------
135
142
  /**
136
143
  * install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
@@ -194,37 +201,62 @@ function hasFlag(argv, name) {
194
201
  }
195
202
 
196
203
  // ---------- 自启动服务生成与热启动 ----------
204
+ /** 自启动服务 label(macOS LaunchAgent / Linux systemd 共用同一个名字)。 */
205
+ const BRIDGE_LABEL = "com.dshremote.bridge";
206
+
197
207
  function autostartFilePath() {
198
208
  if (process.platform === "darwin")
199
- return path.join(os.homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
209
+ return path.join(os.homedir(), `Library/LaunchAgents/${BRIDGE_LABEL}.plist`);
200
210
  if (process.platform === "linux")
201
211
  return path.join(os.homedir(), ".config/systemd/user/dsh-bridge.service");
202
212
  return null;
203
213
  }
204
214
 
215
+ /** 同步睡眠(生成 launchd 状态轮询用;不引第三方依赖)。 */
216
+ function sleepSync(ms) {
217
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
218
+ catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
219
+ }
220
+
205
221
  function writeAutostartFile() {
206
222
  const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
207
223
  if (process.platform === "darwin") {
208
224
  const plistPath = autostartFilePath();
225
+ // LimitLoadToSessionType 是**必需项**,不是可选优化:
226
+ // · macOS 26 上 gui/<uid> 会进入 on-demand-only 模式,RunAtLoad/KeepAlive 全部失效
227
+ // (只登记不派生,runs=0,日志报 "pending spawn, domain in on-demand-only mode");
228
+ // 迁到 user/<uid> domain 才能恢复派生与崩溃自愈。
229
+ // · 而不带该键时 `launchctl bootstrap user/<uid>` 直接失败(实测 rc=5 Input/output error)。
230
+ // · 带上 [Aqua, Background] 后 user/<uid> 与 gui/<uid> 都能正常装载(macOS 14/26 均实测)。
231
+ // 仍保留 RunAtLoad/KeepAlive:user domain 下它们是真生效的(实测 kill -9 后自动重建)。
209
232
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
210
233
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
211
234
  <plist version="1.0"><dict>
212
- <key>Label</key><string>com.dshremote.bridge</string>
235
+ <key>Label</key><string>${BRIDGE_LABEL}</string>
213
236
  <key>ProgramArguments</key>
214
237
  <array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
215
238
  <key>RunAtLoad</key><true/>
216
239
  <key>KeepAlive</key><true/>
240
+ <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
217
241
  <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
218
242
  <key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
219
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
243
+ <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>
220
244
  </dict></plist>`;
221
- fs.writeFileSync(plistPath, plist);
245
+ // ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
246
+ // 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
247
+ try {
248
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
249
+ fs.writeFileSync(plistPath, plist);
250
+ } catch (e) {
251
+ console.warn(`⚠️ 自启动服务写入失败: ${e.message}(可稍后用 \`dsh-remote install\` 重试)`);
252
+ return null;
253
+ }
222
254
  return plistPath; // 路径在最终汇总里统一展示(避免同一信息打印两遍)
223
255
  }
224
256
  if (process.platform === "linux") {
225
257
  const dir = path.join(os.homedir(), ".config/systemd/user");
226
258
  fs.mkdirSync(dir, { recursive: true });
227
- const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=/usr/local/bin:/usr/bin:/bin\nEnvironment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
259
+ 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`;
228
260
  const unitPath = autostartFilePath();
229
261
  fs.writeFileSync(unitPath, unit);
230
262
  return unitPath; // 路径在最终汇总里统一展示
@@ -233,37 +265,128 @@ function writeAutostartFile() {
233
265
  return null;
234
266
  }
235
267
 
236
- function restartBridgeService() {
237
- const svcFile = autostartFilePath();
238
- if (svcFile && !fs.existsSync(svcFile)) {
239
- writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
268
+ /**
269
+ * 读取 launchd job 状态。
270
+ * deferred = launchd 登记了但**从未派生**(macOS 26 的 on-demand-only 特征);
271
+ * 光看 "state = not running" 无法区分"被系统挂起"与"正常退出",必须单独识别才能给出正确建议。
272
+ */
273
+ function launchdState(target) {
274
+ const pr = sh(`launchctl print ${target}`);
275
+ if (!pr.ok) return { exists: false, running: false, pid: null, deferred: false, raw: "" };
276
+ const raw = pr.stdout;
277
+ const running = /state\s*=\s*running/.test(raw);
278
+ const m = raw.match(/pid\s*=\s*(\d+)/);
279
+ const runs = raw.match(/runs\s*=\s*(\d+)/);
280
+ return {
281
+ exists: true,
282
+ running,
283
+ pid: running && m ? Number(m[1]) : null,
284
+ runs: runs ? Number(runs[1]) : 0,
285
+ deferred: /pended nondemand spawn/.test(raw) || /on-demand-only/.test(raw),
286
+ raw
287
+ };
288
+ }
289
+
290
+ /**
291
+ * 轮询等待 job 起来。
292
+ * launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
293
+ * 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
294
+ */
295
+ function waitLaunchdRunning(target, tries = 20, gapMs = 400) {
296
+ let st = launchdState(target);
297
+ for (let i = 0; i < tries; i += 1) {
298
+ st = launchdState(target);
299
+ if (st.running) return st;
300
+ // 已登记、没在跑、又没被挂起(runs>0 说明派生过)→ 是启动后立刻退出,不必等满
301
+ if (st.exists && st.runs > 0 && !st.deferred) break;
302
+ if (i >= 2 && st.exists && !st.deferred && st.runs === 0) break; // 登记了却一次都没派生 → 被挂起
303
+ sleepSync(gapMs);
240
304
  }
241
- if (process.platform === "darwin") {
242
- const plistPath = autostartFilePath();
243
- if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
244
- const uid = process.getuid();
245
- const domain = `gui/${uid}`;
246
- const target = `${domain}/com.dshremote.bridge`;
247
- const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
305
+ return st;
306
+ }
307
+
308
+ /** 终极兜底:脱离 launchd 直接后台拉起 bridge(至少"现在能用",但没有开机自启/崩溃自愈)。 */
309
+ function spawnDetachedBridge() {
310
+ try {
311
+ const logPath = path.join(CONFIG_DIR, ".dsh-bridge.log");
312
+ const out = fs.openSync(logPath, "a");
313
+ const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
314
+ detached: true,
315
+ stdio: ["ignore", out, out],
316
+ env: process.env
317
+ });
318
+ child.unref();
319
+ fs.closeSync(out);
320
+ return { ok: Boolean(child.pid), pid: child.pid || null };
321
+ } catch (e) {
322
+ return { ok: false, pid: null, error: e.message };
323
+ }
324
+ }
325
+
326
+ /**
327
+ * macOS:按「可自愈优先」的阶梯启动 bridge。
328
+ * ① user/<uid> ← 首选。macOS 26 上唯一支持 RunAtLoad/KeepAlive 的 domain(可崩溃自愈)
329
+ * ② gui/<uid> + kickstart ← 兼容兜底。bootstrap 只登记不派生时用 kickstart 强拉一次;
330
+ * 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
331
+ * ③ 后台进程 ← 连 launchd 都托管不了时保证可用
332
+ */
333
+ function startBridgeDarwin(plistPath) {
334
+ const uid = process.getuid();
335
+ const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
336
+ let lastDetail = "";
337
+ for (const domain of [`user/${uid}`, `gui/${uid}`]) {
338
+ const target = `${domain}/${BRIDGE_LABEL}`;
339
+ // 清掉另一个 domain 的历史注册,避免两处并存导致重复实例
340
+ for (const d of [`user/${uid}`, `gui/${uid}`]) {
341
+ if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
342
+ }
248
343
  sh(`launchctl bootout ${target}`);
249
344
  let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
250
345
  if (!boot.ok) {
346
+ // 老写法兜底(部分系统上 load -w 仍可用)
251
347
  sh(`launchctl unload ${q(plistPath)}`);
252
348
  boot = sh(`launchctl load -w ${q(plistPath)}`);
253
349
  }
254
- if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
255
- const pr = sh(`launchctl print ${target}`);
256
- if (pr.ok && /state\s*=\s*running/.test(pr.stdout)) {
257
- const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
258
- return { ok: true, status: "running", pid: m ? Number(m[1]) : null };
350
+ if (!boot.ok) {
351
+ lastDetail = (boot.stderr || boot.stdout).trim() || "launchctl bootstrap 失败";
352
+ continue;
259
353
  }
260
- const ls = sh(`launchctl list | grep com.dshremote.bridge`);
261
- if (ls.ok) {
262
- const pidStr = ls.stdout.trim().split(/\s+/)[0];
263
- if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr))
264
- return { ok: true, status: "running", pid: Number(pidStr) };
354
+ // bootstrap 只是登记;立刻 kickstart 一次,确保"这一次"一定起来
355
+ // (on-demand-only 的 gui domain 下这是唯一能拉起的手段)
356
+ sh(`launchctl kickstart -k ${target}`);
357
+ const st = waitLaunchdRunning(target);
358
+ if (st.running) {
359
+ // 被挂起(deferred)过一次 = 本机 launchd 不保证崩溃自愈,必须如实报告,不能报成完全成功
360
+ const selfHealing = !st.deferred;
361
+ return { ok: true, status: "running", pid: st.pid, domain, selfHealing, detail: selfHealing ? "" : "本机 launchd 处于 on-demand-only,进程退出后不会自动重建" };
265
362
  }
266
- return { ok: false, status: "failed", detail: (pr.stderr || ls.stderr || "服务未在运行").trim() };
363
+ lastDetail = st.deferred
364
+ ? `launchd 拒绝派生(domain ${domain} 处于 on-demand-only 模式)`
365
+ : (st.exists ? `已登记但未运行(runs=${st.runs})` : "已登记但查不到状态");
366
+ }
367
+ const d = spawnDetachedBridge();
368
+ if (d.ok) {
369
+ return {
370
+ ok: false,
371
+ status: "degraded-detached",
372
+ pid: d.pid,
373
+ domain: null,
374
+ selfHealing: false,
375
+ detail: "launchd 无法托管本机 bridge,已改为后台进程运行(现在可用,但不会开机自启、崩溃后不自动恢复)"
376
+ };
377
+ }
378
+ return { ok: false, status: "failed", detail: `launchd 与后台进程均启动失败:${lastDetail}` };
379
+ }
380
+
381
+ function restartBridgeService() {
382
+ const svcFile = autostartFilePath();
383
+ if (svcFile && !fs.existsSync(svcFile)) {
384
+ writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
385
+ }
386
+ if (process.platform === "darwin") {
387
+ const plistPath = autostartFilePath();
388
+ if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
389
+ return startBridgeDarwin(plistPath);
267
390
  }
268
391
  if (process.platform === "linux") {
269
392
  const r = sh(`systemctl --user restart dsh-bridge`);
@@ -467,10 +590,19 @@ async function setup(argv) {
467
590
 
468
591
  const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
469
592
  const webUp = await isDshWebUp();
470
- // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径
471
- const svcState = st.ok
472
- ? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}`
473
- : `未运行 (${st.detail || st.status})`;
593
+ // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
594
+ // 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
595
+ // 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
596
+ const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
597
+ // 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
598
+ // 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
599
+ const svcFragile = !svcSkipped && st.ok && st.selfHealing === false;
600
+ const svcDegraded = st.status === "degraded-detached";
601
+ const svcState = svcSkipped
602
+ ? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
603
+ : (st.ok
604
+ ? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
605
+ : (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`));
474
606
  const L = [];
475
607
  L.push("✅ 安装完成");
476
608
  if (selfHosted) {
@@ -479,9 +611,15 @@ async function setup(argv) {
479
611
  } else {
480
612
  L.push(` 远程控制地址: ${pub.app_url || DEFAULT_APP_URL}`);
481
613
  }
482
- L.push(` 自启动服务: ${svc.path || "(当前平台不支持,可用 dsh-remote run 手动运行)"} — ${svcState}`);
614
+ L.push(svcSkipped
615
+ ? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`
616
+ : ` 自启动服务: ${svc.path} — ${svcState}`);
483
617
  // 唯一一处"下一步"引导(上面各阶段不再重复打印同样的话)
484
- if (!st.ok && !webUp) {
618
+ if (svcSkipped) {
619
+ // 没装自启动服务,就没有"服务状态"可谈,更不该给出"打开 dsh web 就会自动启动"的承诺
620
+ L.push("");
621
+ L.push("ℹ 未安装自启动服务:需要时执行 `dsh-remote run`(前台运行 bridge),或重新安装以启用自启动。");
622
+ } else if (!st.ok && !webUp) {
485
623
  // dsh web 没开时 bridge 起来即退,这是正常状态;要说清"什么时候会自己好",而不是甩一句"启动失败"
486
624
  L.push("");
487
625
  L.push("ℹ 检测到 dsh web 当前没有运行,所以 bridge 还没接上(正常,不是安装出错)。");
@@ -489,8 +627,21 @@ async function setup(argv) {
489
627
  L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
490
628
  } else if (!st.ok) {
491
629
  L.push("");
492
- L.push(`⚠️ bridge 未能启动: ${st.detail || st.status}`);
493
- L.push(` 可查看日志: ${CONFIG_DIR}/.dsh-bridge.log`);
630
+ if (svcDegraded) {
631
+ // launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
632
+ L.push(`⚠️ ${st.detail}`);
633
+ L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
634
+ L.push(" 但重启电脑或进程退出后不会自动恢复,需要时执行 `dsh-remote run`。");
635
+ } else {
636
+ L.push(`⚠️ bridge 未能启动: ${st.detail || st.status}`);
637
+ L.push(` 可查看日志: ${CONFIG_DIR}/.dsh-bridge.log`);
638
+ }
639
+ } else if (svcFragile) {
640
+ // 起来了但靠 kickstart 拉起的 → 明确告知"进程退出后不会自动重建"
641
+ L.push("");
642
+ L.push("ℹ 本机 launchd 处于 on-demand-only 模式(macOS 26 起会出现),系统不会自动派生自启动服务,");
643
+ L.push(" 安装脚本已手动把它拉起来一次,现在可用;但**进程退出后不会自动重建**。");
644
+ L.push(" 建议升级到最新版 dsh-remote(已针对该模式改用 user domain 启动,可恢复自动重建)。");
494
645
  }
495
646
  if (selfHosted) {
496
647
  L.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.4-beta.4",
3
+ "version": "0.6.4-beta.6",
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",
@@ -2423,25 +2423,12 @@ window.__ModuleLoader__.load({
2423
2423
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
2424
2424
  h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
2425
2425
  // 匿名装机统计(隐私披露):开源项目必须把「采什么 / 不采什么 / 怎么关」写在用户看得到的地方。
2426
+ // 只保留结论式的说明句:不再单列「隐私说明: README… · docs/telemetry.md」那一行文档链接
2427
+ // (面板里堆文档链接既占地方又不像产品文案;完整字段清单在仓库 docs/telemetry.md)。
2426
2428
  h("div", { className: "dru-hint", style: { marginTop: 6 } },
2427
2429
  "📊 匿名装机统计:只上报「装机/连接是否成功」这类事件(安装开始与失败原因、bridge 是否注册成功、是否首次远程打通)——"
2428
2430
  + "不含任何账号、手机号、会话或文件内容、主机名、路径、设备指纹与 IP;标识是本机随机 ID(重装即变)。"
2429
2431
  + "用环境变量 DSH_REMOTE_TELEMETRY=0 可完全关闭(不生成 ID、不发任何请求)。"),
2430
- h("div", { className: "dru-hint", style: { marginTop: 4 } },
2431
- "隐私说明:",
2432
- h("a", {
2433
- className: "dru-linkbtn",
2434
- href: "https://github.com/mrRisega/dsh-remote/blob/main/README.md#匿名装机统计与隐私",
2435
- target: "_blank",
2436
- rel: "noreferrer"
2437
- }, "README「匿名装机统计与隐私」"),
2438
- " · ",
2439
- h("a", {
2440
- className: "dru-linkbtn",
2441
- href: "https://github.com/mrRisega/dsh-remote/blob/main/docs/telemetry.md",
2442
- target: "_blank",
2443
- rel: "noreferrer"
2444
- }, "完整字段清单 docs/telemetry.md")),
2445
2432
  // 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
2446
2433
  community && community.qrcode
2447
2434
  ? h("div", { className: "dru-actions", style: { marginTop: 10 } },
@@ -1739,7 +1739,7 @@ const PLUGIN_ID = "dsh-remote-web";
1739
1739
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1740
1740
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1741
1741
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1742
- const PLUGIN_VERSION = "0.6.4-beta.4";
1742
+ const PLUGIN_VERSION = "0.6.4-beta.6";
1743
1743
  const UPDATE_LOG = ".dsh-update.log";
1744
1744
  const UPDATE_MARKER = ".dsh-update-running";
1745
1745
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.4-beta.4",
3
+ "version": "0.6.4-beta.6",
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",
@@ -52,8 +52,16 @@ async function setup({ list = "" } = {}) {
52
52
  await writeFile(path.join(fakeBin, "npx"), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, "npx"), 0o755);
53
53
 
54
54
  const saved = {};
55
- for (const k of ["PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
55
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
56
56
  "DSH_RELAY_RESTART_DRYRUN", "DSH_SETUP_NPX_DIR"]) saved[k] = process.env[k];
57
+ // HOME 必须一起伪造:本用例会 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
58
+ // 而插件的自启动 plist 路径写作 join(homedir(), "Library/LaunchAgents/…")。
59
+ // 不伪造 HOME 就会把**指向测试临时目录**的 plist 覆盖到开发者真实的
60
+ // ~/Library/LaunchAgents/com.dshremote.bridge.plist(实测污染,且临时目录随即被删 →
61
+ // 真实 bridge 自启动彻底失效)。这是测试对开发者机器的破坏,必须隔离。
62
+ const fakeHome = path.join(root, "home");
63
+ await mkdir(path.join(fakeHome, "Library", "LaunchAgents"), { recursive: true });
64
+ process.env.HOME = fakeHome;
57
65
  process.env.PATH = `${fakeBin}:${saved.PATH}`;
58
66
  process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
59
67
  process.env.DSH_TEST_LIST = list;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * macOS 自启动(launchd)契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:用户(macOS 26.1)反馈自启动服务登记后**从未被派生**(`runs = 0`),
5
+ * 安装脚本报「自启动服务启动失败:服务未在运行」。根因:
6
+ * ① macOS 26 会把 `gui/<uid>` domain 置于 on-demand-only 模式,`RunAtLoad` / `KeepAlive`
7
+ * 全部失效(只登记不派生);系统日志:`pending spawn, domain in on-demand-only mode`。
8
+ * 迁到 `user/<uid>` domain(并带 `LimitLoadToSessionType`)才能恢复派生与崩溃自愈。
9
+ * ② 旧实现 `bootstrap` 后**立刻** `print` 一次就判定失败 —— launchd 派生是异步的。
10
+ * ③ 从不调用 `launchctl kickstart`,所以被挂起时没有任何东西会把它拉起来。
11
+ * ④ plist 的 PATH 缺 `/usr/sbin`、`/sbin`,bridge 日志里刷 `ioreg: command not found`。
12
+ *
13
+ * 本机实测(macOS 14.4.1,同机验证):
14
+ * · `bootstrap user/501 <plist 无 LimitLoadToSessionType>` → rc=5 `Input/output error`
15
+ * · 带 `[Aqua, Background]` 后:user/501 与 gui/501 都装载成功且 `runs = 1`
16
+ * · user/501 下 `kill -9` 后 launchd 自动重建 → `runs = 2`(KeepAlive 真生效)
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, join } from "node:path";
24
+
25
+ const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
26
+ const src = readFileSync(SETUP, "utf8");
27
+
28
+ test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
29
+ assert.match(
30
+ src,
31
+ /<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
32
+ "缺该键时 user/<uid> bootstrap 直接 EIO(rc=5);macOS 26 上它也是恢复派生的前提"
33
+ );
34
+ // RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
35
+ assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
36
+ assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
37
+ });
38
+
39
+ test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
40
+ assert.ok(/function startBridgeDarwin/.test(src), "应有专门的 darwin 启动阶梯函数");
41
+ const fn = src.slice(src.indexOf("function startBridgeDarwin"), src.indexOf("function restartBridgeService"));
42
+ // 首选 user domain(macOS 26 上唯一支持自启动语义的 domain)
43
+ assert.ok(/for \(const domain of \[`user\/\$\{uid\}`, `gui\/\$\{uid\}`\]\)/.test(fn), "应为 user → gui 的阶梯顺序");
44
+ assert.ok(/launchctl kickstart -k/.test(fn), "bootstrap 只是登记,必须 kickstart 才能确保这次起来");
45
+ // 清掉另一个 domain 的残留,避免两处并存跑出两个 bridge
46
+ assert.ok(/bootout \$\{d\}\/\$\{BRIDGE_LABEL\}/.test(fn), "切换到某 domain 时应清掉另一 domain 的历史注册");
47
+ // 终极兜底:launchd 托管不了也要保证「现在能用」
48
+ assert.ok(/spawnDetachedBridge/.test(fn), "launchd 不可用时应退化为后台进程兜底");
49
+ assert.ok(/degraded-detached/.test(fn), "兜底状态要与完全失败区分开");
50
+ });
51
+
52
+ test("launchd 修复:不再只 print 一次就判定失败(异步派生必须轮询)", () => {
53
+ assert.ok(/function waitLaunchdRunning/.test(src), "应有轮询等待函数");
54
+ const fn = src.slice(src.indexOf("function launchdState"), src.indexOf("function spawnDetachedBridge"));
55
+ assert.ok(/for \(let i = 0; i < tries; i \+= 1\)/.test(fn), "应循环轮询而非单次检查");
56
+ assert.ok(/sleepSync\(gapMs\)/.test(fn), "轮询之间要真正等待");
57
+ assert.ok(/tries = 20/.test(fn), "总等待时长应足够 launchd 完成异步派生");
58
+ // 必须能识别「登记了但从未派生」这一 macOS 26 特征,才能给出正确建议而不是干等
59
+ assert.ok(/pended nondemand spawn/.test(fn) && /on-demand-only/.test(fn), "应识别 on-demand-only 挂起特征");
60
+ });
61
+
62
+ test("launchd 修复:被挂起后靠 kickstart 起来时,不得报成完全成功", () => {
63
+ assert.ok(/selfHealing/.test(src), "应回报是否具备崩溃自愈能力");
64
+ assert.ok(/本机 launchd 处于 on-demand-only/.test(src), "安装输出要如实说明不支持崩溃自愈");
65
+ const summary = src.slice(src.indexOf("const svcFragile"), src.indexOf("const L = [];"));
66
+ assert.ok(/svcFragile/.test(summary), "汇总里应区分「可自愈」与「仅拉起一次」");
67
+ });
68
+
69
+ test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg 找不到)", () => {
70
+ const m = src.match(/const SERVICE_PATH = "([^"]+)"/);
71
+ assert.ok(m, "应集中定义 SERVICE_PATH");
72
+ for (const dir of ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
73
+ assert.ok(m[1].split(":").includes(dir), `PATH 缺少 ${dir}`);
74
+ }
75
+ // plist 与 systemd unit 都必须用这个常量,不能各写各的
76
+ assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
77
+ assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
78
+ });
@@ -18,12 +18,31 @@ import assert from "node:assert/strict";
18
18
  import { readFileSync } from "node:fs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { dirname, join } from "node:path";
21
+ import { readdirSync } from "node:fs";
21
22
 
22
23
  const HERE = dirname(fileURLToPath(import.meta.url));
23
24
  const SETUP = join(HERE, "..", "..", "..", "dsh-setup.mjs");
24
25
 
25
26
  const src = readFileSync(SETUP, "utf8");
26
27
 
28
+ test("测试隔离:unset 系统操作开关的用例必须同时伪造 HOME(否则污染真实自启动)", () => {
29
+ // 事故复盘:harness-restart 用例 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
30
+ // 但没伪造 HOME → 插件写 plist 的路径是 join(homedir(), "Library/LaunchAgents/…"),
31
+ // 于是把**指向测试临时目录**的 plist 覆盖到开发者真实的 ~/Library/LaunchAgents,
32
+ // 临时目录随即被删 → 真实 bridge 自启动彻底失效(本机实测踩到,macOS 26 用户反馈排查时发现)。
33
+ // 静态护栏:凡 unset 该开关的用例,文件里必须出现 process.env.HOME =。
34
+ const rows = readdirSync(HERE).filter((f) => f.endsWith(".test.mjs"));
35
+ const offenders = [];
36
+ for (const f of rows) {
37
+ const text = readFileSync(join(HERE, f), "utf8");
38
+ if (/delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(text) && !/process\.env\.HOME\s*=/.test(text)) offenders.push(f);
39
+ }
40
+ assert.deepEqual(offenders, [], `这些用例会碰真实 HOME(必须伪造 HOME): ${offenders.join(", ")}`);
41
+ // 反向确认:护栏本身确有保护对象,否则是空断言
42
+ const guarded = rows.filter((f) => /delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(readFileSync(join(HERE, f), "utf8")));
43
+ assert.ok(guarded.length >= 3, `应至少有 3 个用例走真实分支(实际 ${guarded.length})`);
44
+ });
45
+
27
46
  test("安装输出:拼接汇总时「下一步」引导只有一处文案源(不再多处重复打印)", () => {
28
47
  // 引导语只由汇总的 L.push 产出:selfHosted 与非自建各一条(互斥分支,用户只会看到一条)
29
48
  const pushes = src.match(/L\.push\(" 下一步[::]/g) || [];
@@ -65,6 +84,20 @@ test("安装输出:真失败(dsh web 在运行但 bridge 没起来)必须给原
65
84
  assert.ok(/\.dsh-bridge\.log/.test(failBranch), "真失败分支要给出日志路径供排查");
66
85
  });
67
86
 
87
+ test("安装输出:跳过/不支持自启动时不得自相矛盾地显示「运行中」", () => {
88
+ const block = src.slice(src.indexOf("const webUp = await isDshWebUp();"));
89
+ // 三态判定必须存在:未安装 与 运行中/未运行 分开
90
+ assert.ok(/const svcSkipped = st\.status === "skipped"/.test(block), "应识别 skipped/unsupported/无路径三态");
91
+ assert.ok(/未安装(本次显式跳过)/.test(block), "显式跳过要说清是跳过");
92
+ assert.ok(/未安装(当前平台不支持自启动)/.test(block), "平台不支持要说清原因");
93
+ // 跳过分支的文案里不能出现「运行中」
94
+ const skippedBranch = block.slice(block.indexOf("if (svcSkipped) {"), block.indexOf("} else if (!st.ok && !webUp) {"));
95
+ const texts = (skippedBranch.match(/L\.push\((`[^`]*`|"[^"]*")/g) || []).join("\n");
96
+ assert.ok(!/运行中/.test(texts), `未安装自启动服务时不得声称「运行中」:\n${texts}`);
97
+ // 未安装时也不该承诺「打开 dsh web 就会自动启动」(那是自启动服务在管)
98
+ assert.ok(!/自动启动/.test(texts), "未安装自启动服务时不得承诺自动启动");
99
+ });
100
+
68
101
  test("安装输出:服务状态字符串仍是插件面板可解析的稳定契约", () => {
69
102
  // 插件半按 running / 未运行 解析,不能因为排版调整改口径
70
103
  assert.ok(/\? `✅ 运行中/.test(src), "running 口径保留(带 pid)");
@@ -450,10 +450,12 @@ test("失败退避:不阻塞主流程;退避窗口内不重试;累计 6
450
450
  const cost = Date.now() - t0;
451
451
  assert.equal(r.status, 200, "发送失败不得影响面板路由");
452
452
  assert.equal(body.ok, true);
453
- assert.ok(cost < 2000, "面板路由不得被遥测发送拖慢,实际 " + cost + "ms");
453
+ // 阈值只用于抓「同步重试风暴」这类病态问题(真同步会卡到几十秒),
454
+ // 不能设得太紧:CI/本机高负载时单次请求几秒钟是正常的,紧阈值只会制造假失败。
455
+ assert.ok(cost < 5000, "面板路由不得被遥测发送拖慢,实际 " + cost + "ms");
454
456
 
455
457
  // 累计 6 次仍失败 → 丢弃该批(不永久堆积)
456
- const ok = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout: 8000 });
458
+ const ok = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout: 15000 });
457
459
  assert.ok(ok, "6 次失败后必须丢弃该批(队列清零)");
458
460
  const total = relay.count("/api/telemetry/events");
459
461
  assert.ok(total >= 6, "必须真的重试到 6 次,实际 " + total);
@@ -579,16 +581,21 @@ test("不为遥测创建配置目录 / 测试隔离:隔离开关下零副作
579
581
  } finally { await env.restore(); relay.srv.close(); }
580
582
  });
581
583
 
582
- test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,并链到 README/docs/telemetry.md", () => {
584
+ test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,但不在面板里堆文档链接", () => {
583
585
  const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
584
586
  assert.match(client, /匿名装机统计/, "面板必须有匿名统计说明");
585
587
  assert.match(client, /DSH_REMOTE_TELEMETRY=0/, "面板必须写明关闭方法");
586
- assert.ok(client.includes("README.md#匿名装机统计与隐私"), "面板必须链到 README 对应段落");
587
- assert.ok(client.includes("docs/telemetry.md"), "面板必须链到完整字段清单");
588
588
  assert.match(client, /不含任何账号、手机号、会话或文件内容/, "面板必须写明不采集什么");
589
+ // 面板只讲结论,不放「隐私说明: README… · docs/telemetry.md」这类文档链接行(用户明确要求)
590
+ const about = client.slice(client.indexOf("关于 dsh-remote"), client.indexOf("加入交流群"));
591
+ assert.ok(!/隐私说明/.test(about), "关于卡片不应再出现「隐私说明:」链接行");
592
+ assert.ok(!/完整字段清单/.test(about), "关于卡片不应再出现「完整字段清单」链接");
593
+ assert.ok(!about.includes("README.md#匿名装机统计与隐私"), "关于卡片不应链 README 锚点");
594
+ assert.ok(!about.includes("docs/telemetry.md"), "关于卡片不应链 docs/telemetry.md");
589
595
  assert.ok(client.includes("/dsh-remote/telemetry/panel-opened"), "面板打开事件必须走本地路由(浏览器半不直连遥测端点)");
596
+ // 披露文档本身仍必须存在且可查(面板不再链,但 README 链,公开发布物包含)
590
597
  const readme = readFileSync(new URL("../../../README.md", import.meta.url), "utf8");
591
- assert.match(readme, /## 匿名装机统计与隐私/, "README 必须有对应小节(面板链接的锚点)");
598
+ assert.match(readme, /## 匿名装机统计与隐私/, "README 必须有对应小节");
592
599
  assert.ok(readme.includes("docs/telemetry.md"), "README 必须链到 docs/telemetry.md");
593
600
  assert.match(readme, /DSH_REMOTE_TELEMETRY=0/, "README 必须写明关闭方法");
594
601
  const doc = readFileSync(new URL("../../../docs/telemetry.md", import.meta.url), "utf8");