@mrrisega/dsh-remote 0.6.4-beta.9 → 0.6.4

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/dsh-setup.mjs CHANGED
@@ -921,10 +921,26 @@ function writePatchAtomic(patchFile, text) {
921
921
  /**
922
922
  * 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
923
923
  * 返回 {ok, changed, reason?, basePatch}
924
+ *
925
+ * ⚠️ 硬约束:**本函数绝不能在插件已在 dsh.profile.bundles 时写入 patch 行**。
926
+ * 两个激活点同时存在时,dsh web 启动即 `duplicate loader entry id: dsh-remote-web`
927
+ * 并导致「plugin tree failed to load」——整个插件树加载失败(实测日志)。
928
+ * 因此所有 return 之前都要保证:要么只有 patch 行、要么只有 bundles 条目。
924
929
  */
925
930
  function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
926
931
  const original = fs.readFileSync(patchFile, "utf8");
927
- // 幂等:已经是唯一激活点就什么都不做(但要确保 bundles 里没有重复激活)
932
+ const pkgBundles = () => {
933
+ try {
934
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
935
+ return (pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles))
936
+ ? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
937
+ } catch { return []; }
938
+ };
939
+ // 基座就是「插件已在 bundles」→ 保持 bundles 形态(不写 patch 行),避免两个激活点并存。
940
+ // 这一路等价于插件市场安装形态:需要重启 dsh web 才装载。
941
+ if (pkgBundles().length) {
942
+ return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
943
+ }
928
944
  const base = validatePatchBase(original);
929
945
  if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
930
946
  const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
@@ -972,6 +988,12 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
972
988
  return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
973
989
  }
974
990
  removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
991
+ // 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
992
+ // 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
993
+ if (pkgBundles().length) {
994
+ writePatchAtomic(patchFile, original);
995
+ return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
996
+ }
975
997
  return { ok: true, changed: true, basePatch: original };
976
998
  }
977
999
 
@@ -1137,10 +1159,14 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1137
1159
  console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
1138
1160
  return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1139
1161
  }
1140
- // patch 行写不进去(文件形态异常)→ 回退 bundles(需重启),保证功能可用
1141
- console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1142
- ensureBundleEntry(pkgFile);
1143
- console.log(`✅ 插件已就绪: ${pluginLocalDir}(bundles 形态)`);
1162
+ // patch 行不能写(文件形态异常,或插件已由 bundles 声明)→ 回退 bundles(需重启),保证功能可用。
1163
+ // 这条路的输出要如实说明"装完要重启一次",不能让人以为已经热加载了。
1164
+ if (r.bundleOnly) {
1165
+ console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1166
+ } else {
1167
+ console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1168
+ ensureBundleEntry(pkgFile); // 回退激活点:插件仍可用,代价是必须重启一次
1169
+ }
1144
1170
  return { hotPatch: false, changed: true, basePatch: patch };
1145
1171
  }
1146
1172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.4-beta.9",
3
+ "version": "0.6.4",
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",
@@ -887,7 +887,15 @@ function pluginInstalledAfterBoot() {
887
887
  const pkgFile = join(dirname(dirname(here)), "package.json");
888
888
  const mtimeMs = statSync(pkgFile).mtimeMs;
889
889
  const bootMs = Date.now() - process.uptime() * 1000;
890
- return mtimeMs > bootMs + 2000;
890
+ if (!(mtimeMs > bootMs + 2000)) return false;
891
+ // ⚠️ 只看时间戳会**误报**:安装器补装/在线更新会把同样的文件重写一遍(哪怕是同一个版本),
892
+ // 时间戳变新 → 被当成"有新插件要装载" → 弹出"需要重启/刷新"横幅(用户实测踩到:
893
+ // 自愈补装把 profile 副本重写了一次,运行中的旧代码就写下了"需要重启")。
894
+ // 真正该提示用户的只有一件事:**磁盘上的插件版本与本进程装载的版本不同**。
895
+ // 版本相同 → 运行中的代码就是目标版本,什么都不用做。
896
+ const diskVersion = String(JSON.parse(readFileSync(pkgFile, "utf8")).version || "");
897
+ const runningVersion = String(PLUGIN_VERSION || "");
898
+ return Boolean(diskVersion && runningVersion && diskVersion !== runningVersion);
891
899
  } catch { return false; }
892
900
  }
893
901
 
@@ -1745,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
1745
1753
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1746
1754
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1747
1755
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1748
- const PLUGIN_VERSION = "0.6.4-beta.9";
1756
+ const PLUGIN_VERSION = "0.6.4";
1749
1757
  const UPDATE_LOG = ".dsh-update.log";
1750
1758
  const UPDATE_MARKER = ".dsh-update-running";
1751
1759
 
@@ -1770,6 +1778,44 @@ async function npmLatestVersion() {
1770
1778
  return "";
1771
1779
  }
1772
1780
 
1781
+ /**
1782
+ * 语义化版本比较(只处理本仓库用到的形态:`X.Y.Z` 与 `X.Y.Z-<pre>.<n>`)。
1783
+ * 为什么需要它:面板判断"有没有新版本"不能只用 `latest !== current` ——
1784
+ * 预设版之间(beta.10 → beta.11)无法比较大小,而且预设版在 semver 里**低于**同号正式版
1785
+ * (0.6.4 < 0.6.4),朴素不等判断会把"已经装了预设版"误判成落后。
1786
+ * @returns {number} a>b → 1;a<b → -1;相等 → 0
1787
+ */
1788
+ function compareVersions(a, b) {
1789
+ const parse = (v) => {
1790
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
1791
+ if (!m) return null;
1792
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
1793
+ };
1794
+ const x = parse(a);
1795
+ const y = parse(b);
1796
+ if (!x || !y) return null; // 无法解析 → 交给调用方保守处理
1797
+ for (let i = 0; i < 3; i += 1) {
1798
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
1799
+ }
1800
+ if (!x.pre && !y.pre) return 0;
1801
+ if (!x.pre) return 1; // 有预设版 < 无预设版(semver §11)
1802
+ if (!y.pre) return -1;
1803
+ const len = Math.max(x.pre.length, y.pre.length);
1804
+ for (let i = 0; i < len; i += 1) {
1805
+ const p1 = x.pre[i];
1806
+ const p2 = y.pre[i];
1807
+ if (p1 === undefined) return -1;
1808
+ if (p2 === undefined) return 1;
1809
+ const n1 = /^\d+$/.test(p1) ? Number(p1) : null;
1810
+ const n2 = /^\d+$/.test(p2) ? Number(p2) : null;
1811
+ if (n1 !== null && n2 !== null) { if (n1 !== n2) return n1 > n2 ? 1 : -1; continue; }
1812
+ if (n1 !== null) return -1; // 数字标识符 < 字母标识符
1813
+ if (n2 !== null) return 1;
1814
+ if (p1 !== p2) return p1 > p2 ? 1 : -1;
1815
+ }
1816
+ return 0;
1817
+ }
1818
+
1773
1819
  /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
1774
1820
  function spawnUpdater(relayDir, extraEnv) {
1775
1821
  const log = join(relayDir, UPDATE_LOG);
@@ -2848,7 +2894,12 @@ function registerRoutes(ctx, relayDir) {
2848
2894
  handler: async (_req, res) => {
2849
2895
  const latest = await npmLatestVersion();
2850
2896
  const current = PLUGIN_VERSION;
2851
- sendJson(res, 200, { ok: true, current, latest, outdated: !!latest && latest !== current });
2897
+ // 正规比较:注册表版本**严格大于**本地版本才算有更新。
2898
+ // 这样 0.6.4 < 0.6.4(正式版发布后预设版会正确提示升级),
2899
+ // 而 0.6.4-beta.10 < 0.6.4 也能正确判断;解析不了时退回朴素不等(保守提示)。
2900
+ const cmp = compareVersions(latest, current);
2901
+ const outdated = cmp === null ? Boolean(latest && latest !== current) : cmp > 0;
2902
+ sendJson(res, 200, { ok: true, current, latest, outdated });
2852
2903
  },
2853
2904
  },
2854
2905
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.4-beta.9",
3
+ "version": "0.6.4",
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",
@@ -140,6 +140,17 @@ test("首次安装(运行环境在本进程内补齐)→ **不再**提示重
140
140
  } finally { await env.restore(); }
141
141
  });
142
142
 
143
+ test("补装把**同版本**插件重写一遍 → 不得提示(用户实测的误报)", async () => {
144
+ // 事故复盘:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
145
+ // 旧实现只看"插件文件 mtime 晚于进程启动"→ 误判成"有新插件要装载"→ 弹出需要重启/刷新的横幅,
146
+ // 而用户刷新后横幅还在(因为第二次补装又写了一遍)、最后靠重启才消失。
147
+ // 现在判的是「磁盘版本 != 运行版本」,版本相同就什么都不提示。
148
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
149
+ const fn = src.slice(src.indexOf("function pluginInstalledAfterBoot("), src.indexOf("/** 本进程的启动命令"));
150
+ assert.match(fn, /diskVersion !== runningVersion/, "必须比较版本,而不是只看时间戳");
151
+ assert.match(fn, /PLUGIN_VERSION/, "必须用本进程装载的版本号做比较");
152
+ });
153
+
143
154
  test("插件文件晚于本进程启动 → 提示「刷新页面」,且**不**自动重启", async () => {
144
155
  const env = await setup();
145
156
  try {
@@ -181,3 +181,30 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
181
181
  assert.match(src, /pluginResult = await pluginCmd\(\[\]\)/, "setup 应保存 pluginCmd 的返回值");
182
182
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
183
  });
184
+
185
+ test("硬约束:插件已在 bundles 时**绝不**写 patch 行(两个激活点 = duplicate id 崩溃)", () => {
186
+ // 事故复盘:patch 行与插件自带 bundle patch 同时生效时,dsh web 启动即
187
+ // TypeError: duplicate loader entry id: dsh-remote-web
188
+ // Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include)
189
+ // 整个插件树加载失败(实测生产日志)。所以这两条激活路径必须互斥。
190
+ const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
191
+ const relay = join(profile, "relay");
192
+ try {
193
+ const out = installPlugin(profile, relay);
194
+ assert.match(out, /已由 dsh\.profile\.bundles 声明/, "应识别并保持 bundles 形态");
195
+ assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 0,
196
+ "bundles 已声明时不得再写 patch 行(否则重复 ID)");
197
+ assert.ok(readBundles(profile).includes("dsh-remote-web"), "bundles 形态应保持");
198
+ } finally { cleanup(profile); }
199
+ });
200
+
201
+ test("硬约束:写入 patch 行后 bundles 必须被清空(单一激活点双向收口)", () => {
202
+ const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
203
+ const relay = join(profile, "relay");
204
+ try {
205
+ installPlugin(profile, relay);
206
+ const patchRows = (readPatch(profile).match(/- id: dsh-remote-web/g) || []).length;
207
+ const inBundles = readBundles(profile).filter((b) => b === "dsh-remote-web").length;
208
+ assert.equal(patchRows + inBundles, 1, `激活点必须恰好一个:patch=${patchRows} bundles=${inBundles}`);
209
+ } finally { cleanup(profile); }
210
+ });
@@ -109,3 +109,28 @@ test("安装输出:服务状态字符串仍是插件面板可解析的稳定契
109
109
  assert.ok(/\? `✅ 运行中/.test(src), "running 口径保留(带 pid)");
110
110
  assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "未运行口径保留(带原因)");
111
111
  });
112
+
113
+ test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提示不能误判)", () => {
114
+ // 事故背景:面板原本用 `latest !== current` 判断"有没有新版本",于是
115
+ // ① 0.6.4-beta.11 与 0.6.4-beta.10 无法比大小;② 正式版 0.6.4 发布后,
116
+ // 装了 0.6.4-beta.11 的人反而被当成"落后"(其实预设版 semver 低于正式版)。
117
+ const src = readFileSync(join(HERE, "..", "lib", "index.js"), "utf8");
118
+ const fnSrc = src.slice(src.indexOf("function compareVersions(a, b)"), src.indexOf("/** 以 detached 子进程执行"));
119
+ const compare = new Function(`${fnSrc}; return compareVersions;`)();
120
+ // semver §11:预设版低于同号正式版
121
+ assert.equal(compare("0.6.4-beta.11", "0.6.4"), -1, "预设版应低于同号正式版");
122
+ assert.equal(compare("0.6.4", "0.6.4-beta.11"), 1, "正式版应高于同号预设版");
123
+ // 预设版之间按数字标识符比较(不是字符串比较)
124
+ assert.equal(compare("0.6.4-beta.11", "0.6.4-beta.10"), 1, "beta.11 > beta.10");
125
+ assert.equal(compare("0.6.4-beta.10", "0.6.4-beta.9"), 1, "beta.10 > beta.9");
126
+ assert.equal(compare("0.6.4-beta.10", "0.6.4-beta.10"), 0);
127
+ // 正式版之间
128
+ assert.equal(compare("0.6.5", "0.6.4"), 1);
129
+ assert.equal(compare("0.7.0", "0.6.9"), 1);
130
+ assert.equal(compare("0.6.4", "0.6.4"), 0);
131
+ // 解析不了 → null(调用方退回保守判断)
132
+ assert.equal(compare("weird", "0.6.4"), null);
133
+ // 路由必须用严格大于判断 outdated
134
+ assert.match(src, /const cmp = compareVersions\(latest, current\)/, "update-check 应用 semver 比较");
135
+ assert.match(src, /cmp === null \? Boolean\(latest && latest !== current\) : cmp > 0/);
136
+ });