dsh-remote-web 0.6.5 → 0.6.6-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.
package/lib/client.js CHANGED
@@ -1010,7 +1010,15 @@ window.__ModuleLoader__.load({
1010
1010
  if (!b.running) {
1011
1011
  clearInterval(iv);
1012
1012
  setUpdating(false);
1013
- setUpdated(true);
1013
+ // 关键:进程结束 ≠ 更新成功。以前这里无条件显示"已更新完成",
1014
+ // 于是 spawn 失败(例如 npx 不可用)时界面只报成功、真实原因躺在日志里 ——
1015
+ // 用户看到的就是"点了一键修复没反应"。现在按失败信息与版本是否变化如实反馈。
1016
+ if (b.failure && b.failure.detail) {
1017
+ setUpdated(false);
1018
+ setSelfMsg({ kind: "err", text: "更新失败:" + b.failure.detail + "(可点「复制诊断信息」,或查看下方日志)" });
1019
+ } else {
1020
+ setUpdated(true);
1021
+ }
1014
1022
  loadVer();
1015
1023
  doCheck();
1016
1024
  }
package/lib/index.js CHANGED
@@ -499,10 +499,56 @@ function appendLogLine(relayDir, name, line) {
499
499
  * 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
500
500
  * 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
501
501
  * 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
502
+ /**
503
+ * 补装失败退避(2026-09-15 生产事故)。
504
+ *
505
+ * 背景:Windows 用户补装 `npx @mrrisega/dsh-remote` 持续失败时,自愈调度每轮都重试,
506
+ * 生产遥测里看到单机 12 秒一次、累计 606 次的失败风暴(1659 次 install_failed 全在 Windows)。
507
+ * 这既刷屏日志、也让真实失败原因淹没在噪声里。
508
+ *
509
+ * 策略:连续失败后按 30s → 2m → 10m 退避(封顶 10 分钟);用户点「一键修复 / 一键更新」时
510
+ * 显式放行(resetProvisionRetry),保证人工操作永远能立即重试。
511
+ */
512
+ const PROVISION_RETRY_BACKOFF_MS = [30_000, 120_000, 600_000];
513
+
514
+ function provisionRetryGate(relayDir) {
515
+ const key = String(relayDir);
516
+ let st = provisionRetry.get(key);
517
+ if (!st) { st = { fails: 0, nextAt: 0 }; provisionRetry.set(key, st); }
518
+ if (Date.now() < st.nextAt) {
519
+ return { ok: false, waitMs: st.nextAt - Date.now(), fails: st.fails };
520
+ }
521
+ return { ok: true, st };
522
+ }
523
+
524
+ function noteProvisionFailure(relayDir) {
525
+ const key = String(relayDir);
526
+ const st = provisionRetry.get(key) || { fails: 0, nextAt: 0 };
527
+ st.fails += 1;
528
+ const wait = PROVISION_RETRY_BACKOFF_MS[Math.min(st.fails, PROVISION_RETRY_BACKOFF_MS.length) - 1];
529
+ st.nextAt = Date.now() + wait;
530
+ provisionRetry.set(key, st);
531
+ return { fails: st.fails, waitMs: wait };
532
+ }
533
+
534
+ function noteProvisionSuccess(relayDir) {
535
+ provisionRetry.delete(String(relayDir));
536
+ }
537
+
538
+ /** 用户显式要求重试(一键修复/一键更新)→ 清掉退避,让这次一定真的执行。 */
539
+ function resetProvisionRetry(relayDir) {
540
+ provisionRetry.delete(String(relayDir));
541
+ }
542
+
502
543
  function ensureRuntime(relayDir) {
503
544
  if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
504
545
  if (runtimeReady(relayDir)) return true;
505
546
  if (skipsSystemOps()) return false; // 测试隔离:绝不在用例里 spawn 真实 npx 安装
547
+ const gate = provisionRetryGate(relayDir);
548
+ if (!gate.ok) {
549
+ // 退避中:不重复 spawn(避免失败风暴)。面板会据 provisionRetryInfo() 显示"上次失败、X 后可重试"。
550
+ return false;
551
+ }
506
552
  const marker = join(relayDir, PROVISION_MARKER);
507
553
  if (existsSync(marker)) return false; // 正在安装中
508
554
  try {
@@ -531,13 +577,23 @@ function ensureRuntime(relayDir) {
531
577
  child.on("exit", (code) => {
532
578
  clear();
533
579
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
534
- if (code === 0 && runtimeReady(relayDir)) telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
535
- else if (code !== 0) telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromText(readTail(log)) });
580
+ if (code === 0 && runtimeReady(relayDir)) {
581
+ noteProvisionSuccess(relayDir);
582
+ telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
583
+ } else {
584
+ // 退出码非 0,或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
585
+ const code2 = code === 0 ? "install_script_missing" : telemetryFailCodeFromText(readTail(log));
586
+ const back = noteProvisionFailure(relayDir);
587
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
588
+ `[auto-install] 第 ${back.fails} 次失败(归因 ${code2}),${Math.round(back.waitMs / 1000)}s 后才会重试`);
589
+ telemetryRecord(relayDir, "install_failed", { fail_code: code2 });
590
+ }
536
591
  });
537
592
  child.on("error", (e) => {
538
593
  clear();
539
594
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
540
595
  console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
596
+ noteProvisionFailure(relayDir);
541
597
  telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
542
598
  });
543
599
  child.unref();
@@ -546,6 +602,7 @@ function ensureRuntime(relayDir) {
546
602
  } catch (e) {
547
603
  console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
548
604
  try { rmSync(marker, { force: true }); } catch { /* ignore */ }
605
+ noteProvisionFailure(relayDir);
549
606
  telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
550
607
  return false;
551
608
  }
@@ -713,6 +770,8 @@ function stopBridge() {
713
770
  * 直到 dsh web 重启(profile 引用已移除,插件整体不再加载)或重新激活(apply 时清除)。
714
771
  */
715
772
  const UNINSTALLED_DIRS = new Set();
773
+ /** 补装失败退避状态(relayDir → {fails, nextAt});见 provisionRetryGate。 */
774
+ const provisionRetry = new Map();
716
775
 
717
776
  /** 标记某 relayDir 已完成彻底卸载(其后续自愈/代持调度全部停摆)。 */
718
777
  function markUninstalled(relayDir) {
@@ -1753,7 +1812,7 @@ const PLUGIN_ID = "dsh-remote-web";
1753
1812
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1754
1813
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1755
1814
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1756
- const PLUGIN_VERSION = "0.6.5";
1815
+ const PLUGIN_VERSION = "0.6.6-beta.1";
1757
1816
  const UPDATE_LOG = ".dsh-update.log";
1758
1817
  const UPDATE_MARKER = ".dsh-update-running";
1759
1818
 
@@ -1816,6 +1875,29 @@ function compareVersions(a, b) {
1816
1875
  return 0;
1817
1876
  }
1818
1877
 
1878
+ /**
1879
+ * 最近一次在线更新的失败信息(内存态,TTL 10 分钟)。
1880
+ *
1881
+ * 为什么需要它:此前"一键更新/一键修复"点了没反应 —— spawn 是异步的,npx 解析失败时
1882
+ * 同步分支仍然返回 `{ok:true}`,而错误只写进 `.dsh-update.log`;前端看到 ok:true 就显示
1883
+ * 「更新已开始」,2 秒后轮询到 marker 被清(running:false)便显示「已更新完成」——
1884
+ * 版本没变、运行环境仍缺失,界面上没有任何失败提示(用户实测:点了没反应)。
1885
+ * 现在失败原因进内存态并可查询,前端在"没成功"时能如实报错。
1886
+ */
1887
+ const updateFailures = new Map(); // relayDir → { at, detail, failCode }
1888
+ const UPDATE_FAILURE_TTL_MS = 10 * 60 * 1000;
1889
+
1890
+ function noteUpdateFailure(relayDir, detail, failCode) {
1891
+ updateFailures.set(String(relayDir), { at: Date.now(), detail: String(detail || "未知原因"), failCode: failCode || "unknown" });
1892
+ }
1893
+
1894
+ function recentUpdateFailure(relayDir) {
1895
+ const rec = updateFailures.get(String(relayDir));
1896
+ if (!rec) return null;
1897
+ if (Date.now() - rec.at > UPDATE_FAILURE_TTL_MS) { updateFailures.delete(String(relayDir)); return null; }
1898
+ return rec;
1899
+ }
1900
+
1819
1901
  /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
1820
1902
  function spawnUpdater(relayDir, extraEnv) {
1821
1903
  const log = join(relayDir, UPDATE_LOG);
@@ -1842,7 +1924,18 @@ function runOnlineUpdate(relayDir) {
1842
1924
  try {
1843
1925
  mkdirSync(relayDir, { recursive: true });
1844
1926
  const marker = join(relayDir, UPDATE_MARKER);
1845
- if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
1927
+ if (existsSync(marker)) {
1928
+ // 残留 marker(进程被强杀/上次异常退出)会让点击**永久**被拒,前端还会把它当成
1929
+ // "正在跟踪进度"而只转圈不报错。超过 10 分钟一律视为陈旧并清理。
1930
+ let stale = false;
1931
+ try { stale = Date.now() - statSync(marker).mtimeMs > 10 * 60 * 1000; } catch { /* ignore */ }
1932
+ if (stale) {
1933
+ try { rmSync(marker, { force: true }); } catch { /* ignore */ }
1934
+ appendLogLine(relayDir, UPDATE_LOG, "[update] 清理超过 10 分钟的陈旧 in-progress 标记");
1935
+ } else {
1936
+ return { ok: false, detail: "已有更新在进行中,请稍候(若长时间无进展,最多 10 分钟后可重试)" };
1937
+ }
1938
+ }
1846
1939
  appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
1847
1940
  telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
1848
1941
 
@@ -1866,17 +1959,34 @@ function runOnlineUpdate(relayDir) {
1866
1959
  // 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
1867
1960
  // 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
1868
1961
  // 硬发只会被服务端静默丢弃)。
1869
- if (code !== 0) telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromText(readTail(join(relayDir, UPDATE_LOG))) });
1962
+ if (code !== 0) {
1963
+ const tail = readTail(join(relayDir, UPDATE_LOG));
1964
+ const fc = telemetryFailCodeFromText(tail);
1965
+ // 只把最后的可读片段给用户看(不含完整路径/用户名);分类码仍按白名单上报
1966
+ const lastLine = String(tail || "").split("\n").map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
1967
+ noteUpdateFailure(relayDir, lastLine || `安装进程退出码 ${code}`, fc);
1968
+ telemetryRecord(relayDir, "update_failed", { fail_code: fc });
1969
+ } else {
1970
+ updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
1971
+ }
1870
1972
  });
1871
1973
  child.on("error", (e) => {
1872
1974
  appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
1873
1975
  clear();
1874
- telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
1976
+ const fc = telemetryFailCodeFromError(e);
1977
+ noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
1978
+ telemetryRecord(relayDir, "update_failed", { fail_code: fc });
1875
1979
  });
1876
1980
  child.unref();
1877
1981
  return child;
1878
1982
  };
1879
1983
  const child = run();
1984
+ // spawn 是异步的:解析失败时 pid 可能是 undefined。此时**不能**同步返回 ok:true,
1985
+ // 否则前端会显示"更新已开始"、随后看到 running:false 便报"已完成"(实测的静默失败)。
1986
+ if (!child || !child.pid) {
1987
+ const rec = recentUpdateFailure(relayDir);
1988
+ return { ok: false, detail: (rec && rec.detail) || "无法启动更新进程(npx 不可用?)", failCode: rec ? rec.failCode : "unknown" };
1989
+ }
1880
1990
  return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
1881
1991
  } catch (e) {
1882
1992
  try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
@@ -2517,14 +2627,29 @@ function telemetryEventOf(name, extra = {}) {
2517
2627
  /** 从既有日志/错误文本归类 fail_code(白名单内);原始文本绝不外发(可能含路径/主机名)。 */
2518
2628
  function telemetryFailCodeFromText(text) {
2519
2629
  const t = String(text || "");
2520
- if (/EACCES|EPERM|permission denied|权限不足/i.test(t)) return "npm_eacces";
2630
+ // 顺序有意义:从"最具体"到"最泛"。
2631
+ // 2026-09-15:Windows 装机失败此前**全部**落到 unknown(1659 次 install_failed 无法定位),
2632
+ // 所以把真实会遇到的形态拆开。仍然只上报枚举,原始文本不出机器(见下方注释与隐私测试)。
2633
+ // ① Windows 上 `npx.cmd` 根本调不起来(不是内部命令 / 找不到 cmd / EINVAL 等)
2634
+ if (/npx(\.cmd)?\s+(is not recognized|不是内部或外部命令)|不是内部或外部命令|无法将.*识别为/i.test(t)) return "npx_cmd_unavailable";
2635
+ if (/EINVAL|spawn .*\.cmd/i.test(t)) return "npx_cmd_unavailable";
2636
+ // ② 拿到的包不对:404 / 版本被 deprecate / 装完仍没有安装脚本
2637
+ if (/404|Not Found - GET|ETARGET|No matching version/i.test(t)) return "install_script_missing";
2638
+ // ③ 纯网络层问题(含 npm ERR! network / 超时 / TLS)
2639
+ if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|ERR_SOCKET_TIMEOUT|npm ERR! network|registry\.npmjs\.org.*(timeout|timed out)|超时/i.test(t)) {
2640
+ return "registry_timeout";
2641
+ }
2642
+ if (/network|registry|EAI|socket hang up/i.test(t)) return "npm_unreachable";
2643
+ // ④ 权限(含只读目录 / 沙箱拦截)
2644
+ if (/EACCES|EPERM|permission denied|权限不足|read-only file system/i.test(t)) return "npm_eacces";
2521
2645
  if (/platform|不支持的平台/i.test(t)) return "platform_unsupported";
2522
- if (/too old|版本过低|engine/i.test(t)) return "node_too_old";
2523
- if (/ENOENT|not found|No such file/i.test(t)) return "node_missing";
2524
- if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|network|registry|超时/i.test(t)) {
2525
- return "npm_unreachable";
2526
- }
2527
- return "unknown";
2646
+ if (/too old|版本过低|engine|Unsupported engine/i.test(t)) return "node_too_old";
2647
+ if (/ENOENT|not found|No such file|Cannot find module/i.test(t)) return "node_missing";
2648
+ // ⑤ 输出是乱码(Windows GBK 代码页下 npm 输出可能整段不可读)→ 谁都没法归因,单独标出来
2649
+ if (/[\uFFFD]/.test(t)) return "npx_output_encoding";
2650
+ // ⑥ 兜底:进程确实退出了非零码,但日志里没有可识别的特征 —— 标成 npx_exit_nonzero,
2651
+ // 与"启动都没起来"(npx_cmd_unavailable / node_missing)区分开,便于下轮定位。
2652
+ return "npx_exit_nonzero";
2528
2653
  }
2529
2654
  /** 子进程启动失败(spawn error)→ fail_code。 */
2530
2655
  function telemetryFailCodeFromError(e) {
@@ -2777,6 +2902,9 @@ export const __telemetryInternals = {
2777
2902
  installId: (relayDir) => telemetryInstallId(relayDir),
2778
2903
  queueOf: (relayDir) => telemetryLoadQueue(relayDir, telemetryBook(relayDir)).slice(),
2779
2904
  eventOf: (name, extra) => telemetryEventOf(name, extra),
2905
+ // 归因函数也暴露出来:Windows 装机失败此前全落到 unknown,需要能被用例逐条锁住
2906
+ failCodeFromText: (text) => telemetryFailCodeFromText(text),
2907
+ failCodeFromError: (e) => telemetryFailCodeFromError(e),
2780
2908
  eventNames: [...TELEMETRY_EVENT_NAMES],
2781
2909
  failCodes: [...TELEMETRY_FAIL_CODES],
2782
2910
  queueMax: TELEMETRY_QUEUE_MAX,
@@ -2913,7 +3041,15 @@ function registerRoutes(ctx, relayDir) {
2913
3041
  method: "GET",
2914
3042
  path: "/dsh-remote/self/update-log",
2915
3043
  handler: async (_req, res) => {
2916
- sendJson(res, 200, { ok: true, running: existsSync(join(relayDir, UPDATE_MARKER)), log: tailOf(join(relayDir, UPDATE_LOG)) });
3044
+ const failure = recentUpdateFailure(relayDir);
3045
+ sendJson(res, 200, {
3046
+ ok: true,
3047
+ running: existsSync(join(relayDir, UPDATE_MARKER)),
3048
+ log: tailOf(join(relayDir, UPDATE_LOG)),
3049
+ // 供前端在"没成功"时如实报错(此前失败只写日志,界面显示"已完成")
3050
+ failure: failure ? { detail: failure.detail, failCode: failure.failCode, at: failure.at } : null,
3051
+ version: PLUGIN_VERSION
3052
+ });
2917
3053
  },
2918
3054
  },
2919
3055
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.5",
3
+ "version": "0.6.6-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",
@@ -24,10 +24,7 @@
24
24
  "patch": "./cordis.patch.yml"
25
25
  },
26
26
  "client": {
27
- "platform": "web",
28
- "inject": [
29
- "@deepseek-ai/dsh-client-runtime"
30
- ]
27
+ "platform": "web"
31
28
  }
32
29
  },
33
30
  "repository": {