@mrrisega/dsh-remote 0.6.4-beta.8 → 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 +31 -5
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +25 -9
- package/packages/dsh-remote-web/lib/index.js +75 -11
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/auto-restart.test.mjs +10 -4
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +97 -39
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +27 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +25 -0
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
|
-
|
|
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
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
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",
|
|
@@ -1243,6 +1243,9 @@ window.__ModuleLoader__.load({
|
|
|
1243
1243
|
// 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
|
|
1244
1244
|
var restartInfo = (st && st.restart) || {};
|
|
1245
1245
|
var restartPending = !!restartInfo.pending;
|
|
1246
|
+
// kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
|
|
1247
|
+
// 其余情况(极为罕见)才提重启 dsh web。
|
|
1248
|
+
var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
|
|
1246
1249
|
// 连接阶段(node 半下发):轮询拿到的新鲜结果优先,其次用 /status 里带的那一份;
|
|
1247
1250
|
// 旧版 host(响应里没有 connect 字段)→ 回退到原来的 service.running 文案(行为不变)。
|
|
1248
1251
|
var connInfo = conn || (st && st.connect) || null;
|
|
@@ -2446,9 +2449,11 @@ window.__ModuleLoader__.load({
|
|
|
2446
2449
|
);
|
|
2447
2450
|
}
|
|
2448
2451
|
|
|
2449
|
-
// 【自动化】待重启时自动倒计时重启(见 autoRestartCancelled 说明)
|
|
2452
|
+
// 【自动化】待重启时自动倒计时重启(见 autoRestartCancelled 说明);倒计时只在面板可见且无其他操作时推进。
|
|
2453
|
+
// ⚠️ isRefreshOnly(插件更新后只需刷新)时**不**自动重启:刷新是零风险动作,重启 dsh web 会打断
|
|
2454
|
+
// 用户正在进行的会话,不能替他做这个决定 —— 让他点「刷新页面」即可。
|
|
2450
2455
|
useEffect(function () {
|
|
2451
|
-
if (!restartPending || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
2456
|
+
if (!restartPending || isRefreshOnly || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
2452
2457
|
var at = restartInfo.at || 0;
|
|
2453
2458
|
if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
|
|
2454
2459
|
var left = Math.round(AUTO_RESTART_DELAY_MS / 1000);
|
|
@@ -2462,7 +2467,7 @@ window.__ModuleLoader__.load({
|
|
|
2462
2467
|
restartHarness(); // 已有实现:重启 + 轮询等它回来 + 自动刷新页面
|
|
2463
2468
|
}, 1000);
|
|
2464
2469
|
return function () { clearInterval(iv); };
|
|
2465
|
-
}, [restartPending, restartInfo.at, busy, restartCancelled]);
|
|
2470
|
+
}, [restartPending, isRefreshOnly, restartInfo.at, busy, restartCancelled]);
|
|
2466
2471
|
|
|
2467
2472
|
/** 取消本次自动重启(按事件持久化:同一 pending 事件不再自动重启)。 */
|
|
2468
2473
|
var cancelAutoRestart = function () {
|
|
@@ -2508,9 +2513,13 @@ window.__ModuleLoader__.load({
|
|
|
2508
2513
|
h("span", { className: "dru-restart-alert-icon" }, "🔔"),
|
|
2509
2514
|
h("div", { style: { flex: "1" } },
|
|
2510
2515
|
h("div", { className: "dru-restart-alert-title" },
|
|
2511
|
-
|
|
2516
|
+
// 插件已改为 patch 热加载:装插件/在线更新都不再需要重启 harness,
|
|
2517
|
+
// 唯一要用户动一下的情形是"插件文件在运行中被改写"→ 刷新页面即可。
|
|
2518
|
+
isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"),
|
|
2512
2519
|
h("div", { className: "dru-restart-alert-sub" },
|
|
2513
|
-
|
|
2520
|
+
isRefreshOnly
|
|
2521
|
+
? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
|
|
2522
|
+
: "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"),
|
|
2514
2523
|
// 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
|
|
2515
2524
|
restartCountdown !== null && restartCountdown > 0
|
|
2516
2525
|
? h("div", { className: "dru-restart-auto", role: "status" },
|
|
@@ -2524,12 +2533,19 @@ window.__ModuleLoader__.load({
|
|
|
2524
2533
|
? h("div", { className: "dru-restart-auto muted" }, "已取消自动重启 —— 你也可以随时点下面的按钮完成安装")
|
|
2525
2534
|
: null,
|
|
2526
2535
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
2536
|
+
isRefreshOnly
|
|
2537
|
+
? h("button", {
|
|
2538
|
+
type: "button",
|
|
2539
|
+
className: "dru-btn dru-btn-primary",
|
|
2540
|
+
onClick: function () { try { location.reload(); } catch (e) { /* 忽略 */ } }
|
|
2541
|
+
}, "刷新页面")
|
|
2542
|
+
: null,
|
|
2527
2543
|
h("button", {
|
|
2528
2544
|
type: "button",
|
|
2529
|
-
className: "dru-btn dru-btn-primary",
|
|
2545
|
+
className: "dru-btn " + (isRefreshOnly ? "dru-btn-ghost" : "dru-btn-primary"),
|
|
2530
2546
|
disabled: busy !== "",
|
|
2531
2547
|
onClick: function () { restartHarness(); }
|
|
2532
|
-
}, busy === "restart" ? "重启中…" : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启
|
|
2548
|
+
}, busy === "restart" ? "重启中…" : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启 dsh web")),
|
|
2533
2549
|
restartCountdown !== null && restartCountdown > 0
|
|
2534
2550
|
? h("button", {
|
|
2535
2551
|
type: "button",
|
|
@@ -2545,13 +2561,13 @@ window.__ModuleLoader__.load({
|
|
|
2545
2561
|
),
|
|
2546
2562
|
// 常驻入口最底部:「重启 DeepSeek harness」按钮始终可达(首次安装/更新/排查都用它)
|
|
2547
2563
|
h("div", { className: "dru-restart-foot" },
|
|
2548
|
-
h("div", { className: "dru-hint" }, "重启
|
|
2564
|
+
h("div", { className: "dru-hint" }, "重启 dsh web:仅在「刷新页面后仍看不到面板」时才需要。日常安装/更新插件已自动热加载,装完刷新页面即可;重启只影响 dsh web 本身,不影响 bridge 与手机端连接。"),
|
|
2549
2565
|
h("button", {
|
|
2550
2566
|
type: "button",
|
|
2551
2567
|
className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
|
|
2552
2568
|
disabled: busy !== "",
|
|
2553
2569
|
onClick: function () { restartHarness(); }
|
|
2554
|
-
}, busy === "restart" ? "重启中…" : "🔄 重启
|
|
2570
|
+
}, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
|
|
2555
2571
|
),
|
|
2556
2572
|
renderCommunityModal()
|
|
2557
2573
|
);
|
|
@@ -677,8 +677,11 @@ function scheduleRuntime(relayDir) {
|
|
|
677
677
|
telemetryRuntimeProbe(relayDir); // 匿名遥测:环境刚补齐 → runtime_ready(每进程一次)
|
|
678
678
|
if (awaitingRestartHint) {
|
|
679
679
|
awaitingRestartHint = false;
|
|
680
|
-
//
|
|
681
|
-
|
|
680
|
+
// 运行环境刚在本进程内补齐 —— **不需要重启 harness**:
|
|
681
|
+
// · bridge 是独立 launchd 进程,补齐后自愈调度会直接把它拉起;
|
|
682
|
+
// · 插件本体走 patch 热加载,装完即生效。
|
|
683
|
+
// 以前这里会 markRestartPending("first-install") 弹出"需要重启 DeepSeek harness"横幅,
|
|
684
|
+
// 在热加载落地后已经变成纯粹的误导(用户实测反馈),故移除。
|
|
682
685
|
}
|
|
683
686
|
const cfg = loadConfig(relayDir);
|
|
684
687
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
@@ -849,7 +852,10 @@ function writeRestartState(relayDir, state) {
|
|
|
849
852
|
} catch { /* 写盘失败不致命:面板退化为不提示 */ }
|
|
850
853
|
}
|
|
851
854
|
|
|
852
|
-
/**
|
|
855
|
+
/**
|
|
856
|
+
* 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
|
|
857
|
+
* 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
|
|
858
|
+
*/
|
|
853
859
|
function markRestartPending(relayDir, kind, reason) {
|
|
854
860
|
const cur = readRestartState(relayDir);
|
|
855
861
|
if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
|
|
@@ -881,7 +887,15 @@ function pluginInstalledAfterBoot() {
|
|
|
881
887
|
const pkgFile = join(dirname(dirname(here)), "package.json");
|
|
882
888
|
const mtimeMs = statSync(pkgFile).mtimeMs;
|
|
883
889
|
const bootMs = Date.now() - process.uptime() * 1000;
|
|
884
|
-
|
|
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);
|
|
885
899
|
} catch { return false; }
|
|
886
900
|
}
|
|
887
901
|
|
|
@@ -1739,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1739
1753
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1740
1754
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1741
1755
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1742
|
-
const PLUGIN_VERSION = "0.6.4
|
|
1756
|
+
const PLUGIN_VERSION = "0.6.4";
|
|
1743
1757
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1744
1758
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1745
1759
|
|
|
@@ -1764,6 +1778,44 @@ async function npmLatestVersion() {
|
|
|
1764
1778
|
return "";
|
|
1765
1779
|
}
|
|
1766
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
|
+
|
|
1767
1819
|
/** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
|
|
1768
1820
|
function spawnUpdater(relayDir, extraEnv) {
|
|
1769
1821
|
const log = join(relayDir, UPDATE_LOG);
|
|
@@ -1809,9 +1861,12 @@ function runOnlineUpdate(relayDir) {
|
|
|
1809
1861
|
}
|
|
1810
1862
|
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
1811
1863
|
clear();
|
|
1812
|
-
// 在线更新改写了插件/运行环境文件 →
|
|
1813
|
-
|
|
1814
|
-
|
|
1864
|
+
// 在线更新改写了插件/运行环境文件 → 版本对不上时由 pluginInstalledAfterBoot() 自动识别,
|
|
1865
|
+
// 面板会提示"刷新页面"。这里**不再**写"需要重启 DeepSeek harness"的待重启标记:
|
|
1866
|
+
// 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
|
|
1867
|
+
// 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
|
|
1868
|
+
// 硬发只会被服务端静默丢弃)。
|
|
1869
|
+
if (code !== 0) telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromText(readTail(join(relayDir, UPDATE_LOG))) });
|
|
1815
1870
|
});
|
|
1816
1871
|
child.on("error", (e) => {
|
|
1817
1872
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
@@ -2839,7 +2894,12 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2839
2894
|
handler: async (_req, res) => {
|
|
2840
2895
|
const latest = await npmLatestVersion();
|
|
2841
2896
|
const current = PLUGIN_VERSION;
|
|
2842
|
-
|
|
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 });
|
|
2843
2903
|
},
|
|
2844
2904
|
},
|
|
2845
2905
|
{
|
|
@@ -3325,8 +3385,12 @@ export function apply(ctx, config = {}) {
|
|
|
3325
3385
|
ctx.logger?.info?.("dsh-remote-web: 检测到 DeepSeek harness 已重启,撤下「需要重启」提示");
|
|
3326
3386
|
}
|
|
3327
3387
|
if (!readRestartState(relayDir).pending && pluginInstalledAfterBoot()) {
|
|
3328
|
-
|
|
3329
|
-
|
|
3388
|
+
// 唯一还需要用户动一下的情形:插件文件晚于本进程启动才落盘(= 运行中被市场安装/在线更新改写),
|
|
3389
|
+
// 此时本进程里没有本插件的路由与面板。**先让用户刷新页面**(客户端半随之更新);
|
|
3390
|
+
// 只有刷新后仍无面板才需要重启(那时多半是 profile patch 行没被 HMR 读到)。
|
|
3391
|
+
// 不再对"首次安装/在线更新完成"提示重启:插件走 patch 热加载、bridge 是独立进程,重启 harness 无必要。
|
|
3392
|
+
markRestartPending(relayDir, "refresh", "插件已更新,刷新页面即可生效");
|
|
3393
|
+
ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 提示刷新页面");
|
|
3330
3394
|
}
|
|
3331
3395
|
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
|
|
3332
3396
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.4
|
|
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",
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 【自动化】「自动重启 dsh web」的安全回归(浏览器半,0.6.4)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 目标:**确实需要重启时**,用户不必自己点按钮 —— 面板发现待重启后自己倒计时重启,重启后自动恢复页面
|
|
4
4
|
// (复用既有 restartHarness + waitHarnessBack:轮询到 harness 回来即自动刷新)。
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ 0.6.4-beta.9 起:kind="refresh"(插件被运行时改写 → 只需刷新页面)**不再自动重启**,
|
|
7
|
+
// 因为刷新是零风险动作、重启会打断用户正在进行的会话。因此本用例用 kind="update"
|
|
8
|
+
// (非 refresh)来构造"确实需要重启"的场景,以覆盖倒计时链路。
|
|
5
9
|
// 但「自动重启进程」是危险动作,本用例锁死四条安全阀:
|
|
6
10
|
// ① 只在面板可见时计时(用户没在看就不动);
|
|
7
11
|
// ② 15 秒倒计时内可一键取消,取消标记按本次事件(restart.at)持久化,同一事件不再自动重启;
|
|
@@ -40,7 +44,9 @@ const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode:
|
|
|
40
44
|
const KEY_URL = "https://app.test/a/K1";
|
|
41
45
|
const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
|
|
42
46
|
function statusWithRestart(pending, at) {
|
|
43
|
-
|
|
47
|
+
// kind 用 "update"(非 refresh):只有这一路才走自动重启倒计时;
|
|
48
|
+
// kind="refresh" 的面板只提示刷新页面(见 harness-restart.test.mjs)。
|
|
49
|
+
return Object.assign({}, LOGGED, { restart: { pending: !!pending, kind: "update", reason: "需要重启 dsh web 才能载入新插件", at: at || 0 } });
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
function loadPlugin(opts = {}) {
|
|
@@ -238,7 +244,7 @@ function loadPlugin(opts = {}) {
|
|
|
238
244
|
test("待重启 + 面板可见 → 15 秒后自动重启(用户零操作),重启后 pending 结清即停止", async () => {
|
|
239
245
|
const plugin = loadPlugin({ status: statusWithRestart(true, 1111) });
|
|
240
246
|
let tree = await plugin.settle();
|
|
241
|
-
assert.ok(textHas(tree, "
|
|
247
|
+
assert.ok(textHas(tree, "需要重启 dsh web 才能载入新插件"), "应显示待重启横幅");
|
|
242
248
|
assert.ok(textHas(tree, "秒后自动重启"), "应显示自动重启倒计时文案");
|
|
243
249
|
assert.equal(plugin.counts().restartCalls, 0, "倒计时期间不得提前重启");
|
|
244
250
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 「插件装载 / 刷新 / 重启」回归(0.6.2 新增,0.6.4-beta.9 语义修订)。
|
|
2
2
|
//
|
|
3
3
|
// 产品诉求(用户原话):
|
|
4
|
-
// 1. 安装完成后桌面端 UI
|
|
5
|
-
// 2. 常驻入口最底下要有「重启
|
|
6
|
-
// 3.
|
|
4
|
+
// 1. 安装完成后桌面端 UI 直接可用;
|
|
5
|
+
// 2. 常驻入口最底下要有「重启 dsh web」按钮(排查用,始终可达);
|
|
6
|
+
// 3. 需要用户动一下时,该提示要出现在上方显眼位置。
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ 0.6.4-beta.9 语义修订(用户反馈「能热加载了,这个设置窗口就不需要了」):
|
|
9
|
+
// 插件已改为 profile patch **热加载**装载(HMR 监听 cordis.patch.yml,存盘约 1 秒生效),
|
|
10
|
+
// 运行环境(bridge)又是独立 launchd 进程 —— 所以「装插件 / 在线更新完成」都**不再需要重启
|
|
11
|
+
// DeepSeek harness**,那条横幅确实是误导。现在只在一种情形提示用户:
|
|
12
|
+
// **磁盘上的插件文件比当前进程新**(= 运行中被市场安装/在线更新改写),文案是
|
|
13
|
+
// 「插件已更新,刷新页面即可生效」,并且**不自动重启**(刷新零风险,重启会打断用户会话)。
|
|
7
14
|
//
|
|
8
15
|
// 机制:dsh web 的插件(宿主半 + 浏览器半)都在**进程启动时**装载,市场安装/在线更新只是把文件
|
|
9
16
|
// 写进 profile,当前进程里既没有 /dsh-remote/* 路由也没有面板入口 → 必须重启才生效。
|
|
@@ -12,7 +19,7 @@
|
|
|
12
19
|
import assert from "node:assert/strict";
|
|
13
20
|
import http from "node:http";
|
|
14
21
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
|
16
23
|
import os from "node:os";
|
|
17
24
|
import path from "node:path";
|
|
18
25
|
import test from "node:test";
|
|
@@ -107,7 +114,7 @@ async function serve(routes) {
|
|
|
107
114
|
|
|
108
115
|
// ─────────────────────────── 宿主半:状态机 ───────────────────────────
|
|
109
116
|
|
|
110
|
-
test("首次安装(运行环境在本进程内补齐)→
|
|
117
|
+
test("首次安装(运行环境在本进程内补齐)→ **不再**提示重启 harness(热加载后已无必要)", async () => {
|
|
111
118
|
const env = await setup();
|
|
112
119
|
try {
|
|
113
120
|
await writeConfig(env.relayDir);
|
|
@@ -118,16 +125,52 @@ test("首次安装(运行环境在本进程内补齐)→ 自动标记「需
|
|
|
118
125
|
const { host, base } = await serve(routes);
|
|
119
126
|
try {
|
|
120
127
|
const before = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
121
|
-
assert.equal(before.restart.pending, false, "
|
|
128
|
+
assert.equal(before.restart.pending, false, "运行环境补齐前不该有提示");
|
|
122
129
|
|
|
123
130
|
// 模拟后台 npx 安装完成:固化运行时落盘
|
|
124
131
|
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
125
|
-
await
|
|
132
|
+
await sleep(400); // 给自愈调度一轮时间(以前这一轮会 markRestartPending)
|
|
126
133
|
|
|
127
134
|
const after = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
assert.
|
|
135
|
+
// bridge 是独立 launchd 进程、插件走 patch 热加载 → 补齐运行环境不需要重启 harness。
|
|
136
|
+
// 旧行为(提示"首次安装需要重启 DeepSeek harness")已被用户实测判定为误导,故断言必须为 false。
|
|
137
|
+
assert.equal(after.restart.pending, false,
|
|
138
|
+
"运行环境补齐**不该**再提示重启 harness(热加载 + 独立 bridge 进程)");
|
|
139
|
+
} finally { host.close(); routes.dispose(); }
|
|
140
|
+
} finally { await env.restore(); }
|
|
141
|
+
});
|
|
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
|
+
|
|
154
|
+
test("插件文件晚于本进程启动 → 提示「刷新页面」,且**不**自动重启", async () => {
|
|
155
|
+
const env = await setup();
|
|
156
|
+
try {
|
|
157
|
+
await writeConfig(env.relayDir);
|
|
158
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
159
|
+
const routes = boot(env.relayDir);
|
|
160
|
+
const { host, base } = await serve(routes);
|
|
161
|
+
try {
|
|
162
|
+
const cur = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
163
|
+
// 用**真实的 bootId** 写回状态:否则 settleRestartState 会判定"这是上个进程写的 = 已重启过"
|
|
164
|
+
// 而把提示结清(那样就测不到"同进程内保留提示"这条语义了)。
|
|
165
|
+
await writeFile(env.stateFile, JSON.stringify({
|
|
166
|
+
pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
|
|
167
|
+
at: Date.now() - 3000, bootId: cur.restart.bootId
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
171
|
+
assert.equal(st.restart.pending, true, "插件被运行时改写 → 应提示");
|
|
172
|
+
assert.equal(st.restart.kind, "refresh", "kind 必须是 refresh(刷新语义,不是重启)");
|
|
173
|
+
assert.match(String(st.restart.reason), /刷新页面/);
|
|
131
174
|
} finally { host.close(); routes.dispose(); }
|
|
132
175
|
} finally { await env.restore(); }
|
|
133
176
|
});
|
|
@@ -148,18 +191,31 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
|
|
|
148
191
|
} finally { host.close(); routes.dispose(); }
|
|
149
192
|
} finally { await a.restore(); }
|
|
150
193
|
|
|
151
|
-
// 场景 B
|
|
152
|
-
//
|
|
194
|
+
// 场景 B:状态由**当前进程**写下(用户还没处理,只是刷新了页面 / 插件重新装载)→ 必须保留提示。
|
|
195
|
+
// 注:0.6.4-beta.9 起"运行环境在本进程内补齐"不再写待重启(热加载后无必要),
|
|
196
|
+
// 所以这里直接按**真实 bootId** 写一份 kind=refresh 状态来构造该场景。
|
|
153
197
|
const b = await setup();
|
|
154
198
|
try {
|
|
155
199
|
await writeConfig(b.relayDir);
|
|
200
|
+
await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
156
201
|
process.env.DSH_RELAY_SELFHEAL_MS = "200";
|
|
202
|
+
// 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
|
|
203
|
+
// 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
|
|
204
|
+
// 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
|
|
205
|
+
const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
|
|
157
206
|
const routes1 = boot(b.relayDir);
|
|
158
207
|
const { host: host1, base: base1 } = await serve(routes1);
|
|
159
|
-
await writeFile(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
await writeFile(b.stateFile, JSON.stringify({
|
|
209
|
+
pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
|
|
210
|
+
at: Date.now() - 3000, bootId: sameProcessBootId
|
|
211
|
+
}));
|
|
212
|
+
// 把状态文件的 mtime 置为"刚写过":pluginInstalledAfterBoot() 判的是"插件文件晚于本进程启动"。
|
|
213
|
+
// 不这么做,第二次 apply() 的条件 `!pending && pluginInstalledAfterBoot()` 会为真,
|
|
214
|
+
// 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
|
|
215
|
+
const nowMs = Date.now() / 1000;
|
|
216
|
+
await utimes(b.stateFile, nowMs, nowMs);
|
|
217
|
+
const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
|
|
218
|
+
assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
|
|
163
219
|
host1.close();
|
|
164
220
|
routes1.dispose();
|
|
165
221
|
|
|
@@ -169,7 +225,7 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
|
|
|
169
225
|
try {
|
|
170
226
|
const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
|
|
171
227
|
assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
|
|
172
|
-
assert.equal(st2.restart.bootId,
|
|
228
|
+
assert.equal(st2.restart.bootId, sameProcessBootId, "bootId 应在同一进程内稳定不变");
|
|
173
229
|
} finally { host2.close(); routes2.dispose(); }
|
|
174
230
|
} finally { await b.restore(); }
|
|
175
231
|
});
|
|
@@ -299,15 +355,11 @@ function loadPlugin(statusBody) {
|
|
|
299
355
|
return { requests, states, render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); } };
|
|
300
356
|
}
|
|
301
357
|
|
|
302
|
-
|
|
358
|
+
// 现在唯一会提示用户的状态是 kind=refresh(插件文件被运行时改写 → 刷新页面即可)
|
|
359
|
+
const STATUS_REFRESH = {
|
|
303
360
|
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
304
361
|
service: { running: true, runtimeReady: true, launchd: {} },
|
|
305
|
-
restart: { pending: true, kind: "
|
|
306
|
-
};
|
|
307
|
-
const STATUS_UPDATE = {
|
|
308
|
-
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
309
|
-
service: { running: true, runtimeReady: true, launchd: {} },
|
|
310
|
-
restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
|
|
362
|
+
restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效", at: Date.now() },
|
|
311
363
|
};
|
|
312
364
|
const STATUS_IDLE = {
|
|
313
365
|
ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
|
|
@@ -315,21 +367,26 @@ const STATUS_IDLE = {
|
|
|
315
367
|
restart: { pending: false },
|
|
316
368
|
};
|
|
317
369
|
|
|
318
|
-
test("UI
|
|
370
|
+
test("UI:插件更新后 → 顶部提示「刷新页面即可生效」+ 刷新按钮(不再喊重启 harness)", () => {
|
|
319
371
|
const plugin = loadPlugin({
|
|
320
372
|
ok: true,
|
|
321
373
|
config: { phone: "13800000000", deviceId: "dev-x" },
|
|
322
374
|
service: { running: true, runtimeReady: true, launchd: {} },
|
|
323
|
-
restart: { pending: true, kind: "
|
|
375
|
+
restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
|
|
324
376
|
});
|
|
325
|
-
plugin.states[0] =
|
|
377
|
+
plugin.states[0] = STATUS_REFRESH; // mock 的 useEffect 不执行 → 直接注入面板状态
|
|
326
378
|
const tree = plugin.render();
|
|
327
|
-
assert.ok(textHas(tree, "
|
|
379
|
+
assert.ok(textHas(tree, "插件已更新,刷新页面即可生效"), "顶部应出现刷新提示");
|
|
380
|
+
// 不能再说"需要重启 DeepSeek harness" —— 热加载后那是误导(用户实测反馈)
|
|
381
|
+
assert.ok(!textHas(tree, "需要重启 DeepSeek harness"), "不应再出现「需要重启 DeepSeek harness」");
|
|
328
382
|
const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
|
|
329
383
|
assert.ok(alert, "应有醒目提示块(dru-restart-alert)");
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
384
|
+
// 主按钮应是「刷新页面」
|
|
385
|
+
const refreshBtn = find(tree, (n) => n.props && String(n.props.className || "").includes("dru-btn-primary") &&
|
|
386
|
+
(n.children || []).some((c) => typeof c === "string" && c.includes("刷新页面")));
|
|
387
|
+
assert.ok(refreshBtn, "提示块内主按钮应是「刷新页面」");
|
|
388
|
+
// 重启按钮仍可达(兜底),但不再是主按钮
|
|
389
|
+
assert.ok(textHas(tree, "重启 dsh web"), "应保留「重启 dsh web」作为兜底入口");
|
|
333
390
|
});
|
|
334
391
|
|
|
335
392
|
test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;不需要重启时也不消失", () => {
|
|
@@ -341,28 +398,29 @@ test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;
|
|
|
341
398
|
});
|
|
342
399
|
plugin.states[0] = STATUS_IDLE;
|
|
343
400
|
const tree = plugin.render();
|
|
344
|
-
assert.ok(!textHas(tree, "
|
|
401
|
+
assert.ok(!textHas(tree, "刷新页面即可生效"), "不需要动作时不该出现顶部提示");
|
|
345
402
|
const foot = find(tree, (n) => n.props && n.props.className === "dru-restart-foot");
|
|
346
403
|
assert.ok(foot, "底部应常驻重启按钮区(dru-restart-foot)");
|
|
347
|
-
assert.ok(textHas(foot, "重启
|
|
404
|
+
assert.ok(textHas(foot, "重启 dsh web"), "常驻区应含重启按钮文案(排查兜底,始终可达)");
|
|
348
405
|
});
|
|
349
406
|
|
|
350
|
-
test("UI
|
|
407
|
+
test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async () => {
|
|
351
408
|
const plugin = loadPlugin({
|
|
352
409
|
ok: true,
|
|
353
410
|
config: { phone: "13800000000", deviceId: "dev-x" },
|
|
354
411
|
service: { running: true, runtimeReady: true, launchd: {} },
|
|
355
|
-
restart: { pending: true, kind: "
|
|
412
|
+
restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
|
|
356
413
|
});
|
|
357
|
-
plugin.states[0] =
|
|
414
|
+
plugin.states[0] = STATUS_REFRESH;
|
|
358
415
|
const tree = plugin.render();
|
|
359
|
-
//
|
|
416
|
+
// 顶部提示块内的兜底重启按钮(常驻脚注里也有一个)
|
|
360
417
|
const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
|
|
361
418
|
const btn = find(alert, (n) => n.props && String(n.props.className || "").includes("dru-btn") &&
|
|
362
|
-
(n.children || []).some((c) => typeof c === "string" && c.includes("重启
|
|
419
|
+
(n.children || []).some((c) => typeof c === "string" && c.includes("重启 dsh web")));
|
|
363
420
|
assert.ok(btn, "顶部提示块内应能定位到重启按钮");
|
|
364
421
|
btn.props.onClick();
|
|
365
422
|
await new Promise((r) => setImmediate(r));
|
|
366
423
|
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/harness/restart" && r.method === "POST"),
|
|
367
424
|
"点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
|
|
368
425
|
});
|
|
426
|
+
|
|
@@ -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
|
+
});
|