@mrrisega/dsh-remote 0.4.3 → 0.4.5
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 +6 -0
- package/dsh-setup.mjs +151 -27
- package/package.json +1 -1
- package/packages/dsh-remote-ui/lib/index.js +111 -32
- package/packages/dsh-remote-ui/test/self-manage.test.mjs +22 -6
package/README.md
CHANGED
|
@@ -79,6 +79,12 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
79
79
|
其他命令:`settings`(设置页)、`status`(查看状态)、`run`(前台调试)、
|
|
80
80
|
`plugin`(重装/卸载 dsh web 插件)。运行 `npx @mrrisega/dsh-remote --help` 查看完整说明。
|
|
81
81
|
|
|
82
|
+
> **版本与更新 / 卸载**:dsh 官方插件市场目前不提供更新按钮,也不会改写用户补丁(因此市场
|
|
83
|
+
> 卸载会提示「仍通过 insert 引用 dsh-remote-ui」而拒绝)。插件设置面板里已内置管理入口
|
|
84
|
+
> (dsh web → 设置 → 「远程控制」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
|
|
85
|
+
> **一键在线更新**(后台补运行环境并重启 bridge,完成后重启 dsh web 生效)、以及**彻底卸载**
|
|
86
|
+
> (移除补丁 include / 依赖 / bundle 与本地文件,之后市场卸载或直接重启均可完成卸载)。
|
|
87
|
+
|
|
82
88
|
源码安装(开发 / 自建服务器):`git clone https://github.com/mrRisega/dsh-remote.git`
|
|
83
89
|
并 `npm install`,见下文各组件说明。
|
|
84
90
|
|
package/dsh-setup.mjs
CHANGED
|
@@ -41,6 +41,63 @@ const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
|
41
41
|
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
42
42
|
const REPO_URL = "https://github.com/mrRisega/dsh-remote";
|
|
43
43
|
|
|
44
|
+
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
45
|
+
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
46
|
+
// 就会像“找不到模块”一样崩溃。因此 npm 形态安装时把 dsh-setup.mjs + clients + 依赖(ws)
|
|
47
|
+
// 固化到 CONFIG_DIR(~/.dsh-remote),自启动服务只指向这个稳定路径。
|
|
48
|
+
// 插件(dsh-remote-ui)的“运行环境已就绪”判断同样以 CONFIG_DIR/dsh-setup.mjs 为准。
|
|
49
|
+
|
|
50
|
+
function pkgVersion() {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(THIS_DIR, "package.json"), "utf8")).version || "";
|
|
53
|
+
} catch { return ""; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 向上查找依赖树里的 ws(npx 布局通常提升到缓存根 node_modules,npm 布局则内嵌)。 */
|
|
57
|
+
function findDepWs(startDir) {
|
|
58
|
+
let d = startDir;
|
|
59
|
+
while (true) {
|
|
60
|
+
const cand = path.join(d, "node_modules", "ws");
|
|
61
|
+
if (fs.existsSync(cand)) return cand;
|
|
62
|
+
const parent = path.dirname(d);
|
|
63
|
+
if (parent === d) break;
|
|
64
|
+
d = parent;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureRuntimeCopy() {
|
|
70
|
+
if (!IS_NPM_INSTALL) return; // 仓库开发形态:原地使用
|
|
71
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
72
|
+
const ver = pkgVersion();
|
|
73
|
+
const setupTarget = path.join(CONFIG_DIR, "dsh-setup.mjs");
|
|
74
|
+
if (setupTarget === path.join(THIS_DIR, "dsh-setup.mjs")) return; // 已在配置目录内执行
|
|
75
|
+
const verFile = path.join(CONFIG_DIR, ".dsh-setup-version");
|
|
76
|
+
let cur = "";
|
|
77
|
+
try { cur = fs.readFileSync(verFile, "utf8").trim(); } catch { /* 首次 */ }
|
|
78
|
+
if (fs.existsSync(setupTarget) && cur === ver) return; // 同版本幂等跳过
|
|
79
|
+
fs.cpSync(path.join(THIS_DIR, "dsh-setup.mjs"), setupTarget);
|
|
80
|
+
fs.cpSync(path.join(THIS_DIR, "clients"), path.join(CONFIG_DIR, "clients"), { recursive: true, force: true });
|
|
81
|
+
const wsSrc = findDepWs(THIS_DIR);
|
|
82
|
+
if (wsSrc) {
|
|
83
|
+
fs.mkdirSync(path.join(CONFIG_DIR, "node_modules"), { recursive: true });
|
|
84
|
+
fs.cpSync(wsSrc, path.join(CONFIG_DIR, "node_modules", "ws"), { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
fs.writeFileSync(verFile, ver);
|
|
87
|
+
console.log(`✅ 运行时已固化到 ${CONFIG_DIR}(自启动指向稳定路径,不再依赖 npx 缓存)`);
|
|
88
|
+
}
|
|
89
|
+
try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
|
|
90
|
+
|
|
91
|
+
/** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
|
|
92
|
+
function runtimeSetupPath() {
|
|
93
|
+
const local = path.join(CONFIG_DIR, "dsh-setup.mjs");
|
|
94
|
+
try {
|
|
95
|
+
fs.accessSync(local, fs.constants.R_OK);
|
|
96
|
+
return local;
|
|
97
|
+
} catch { /* 未固化(如仓库开发)→ 用当前文件 */ }
|
|
98
|
+
return fileURLToPath(import.meta.url);
|
|
99
|
+
}
|
|
100
|
+
|
|
44
101
|
// ---------- 工具 ----------
|
|
45
102
|
|
|
46
103
|
function sh(cmd, timeoutMs = 15000, cwd = undefined) {
|
|
@@ -137,7 +194,7 @@ function autostartFilePath() {
|
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
function writeAutostartFile() {
|
|
140
|
-
const runCmd = `"${NODE_BIN}" "${
|
|
197
|
+
const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
|
|
141
198
|
if (process.platform === "darwin") {
|
|
142
199
|
const plistPath = autostartFilePath();
|
|
143
200
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -145,7 +202,7 @@ function writeAutostartFile() {
|
|
|
145
202
|
<plist version="1.0"><dict>
|
|
146
203
|
<key>Label</key><string>com.dshremote.bridge</string>
|
|
147
204
|
<key>ProgramArguments</key>
|
|
148
|
-
<array><string>${NODE_BIN}</string><string>${
|
|
205
|
+
<array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
|
|
149
206
|
<key>RunAtLoad</key><true/>
|
|
150
207
|
<key>KeepAlive</key><true/>
|
|
151
208
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
@@ -601,6 +658,91 @@ function declarePluginDep(pkgFile) {
|
|
|
601
658
|
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
602
659
|
}
|
|
603
660
|
|
|
661
|
+
/** 把 dsh-remote-ui 加入 dsh.profile.bundles(幂等)。返回是否发生变更。 */
|
|
662
|
+
function ensureBundleEntry(pkgFile) {
|
|
663
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
664
|
+
const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
665
|
+
? pkg.dsh.profile.bundles
|
|
666
|
+
: null;
|
|
667
|
+
if (bundles && bundles.includes("dsh-remote-ui")) return false;
|
|
668
|
+
pkg.dsh = pkg.dsh || {};
|
|
669
|
+
pkg.dsh.profile = pkg.dsh.profile || {};
|
|
670
|
+
pkg.dsh.profile.bundles = bundles || [];
|
|
671
|
+
pkg.dsh.profile.bundles.push("dsh-remote-ui");
|
|
672
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** 从 dsh.profile.bundles 移除 dsh-remote-ui(幂等)。返回是否发生变更。 */
|
|
677
|
+
function removeBundleEntry(pkgFile) {
|
|
678
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
679
|
+
const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
680
|
+
? pkg.dsh.profile.bundles
|
|
681
|
+
: null;
|
|
682
|
+
if (!bundles || !bundles.includes("dsh-remote-ui")) return false;
|
|
683
|
+
pkg.dsh.profile.bundles = bundles.filter((b) => b !== "dsh-remote-ui");
|
|
684
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治:
|
|
690
|
+
* dsh-remote-ui 是带 dsh.bundle.patch 的 bundle:package.json 的 dsh.profile.bundles
|
|
691
|
+
* 声明它后,加载器会自动应用插件自带的 cordis.patch.yml(节点半+浏览器半的唯一激活点)。
|
|
692
|
+
* 若用户级 cordis.patch.yml 再手工 insert 同一个 id,dsh web 启动即报“重复 ID”崩溃。
|
|
693
|
+
* 因此本命令【绝不写用户 include】,只负责:让插件以 bundle 形态可解析,
|
|
694
|
+
* 并清理历史遗留的 include 块。市场形态(github:/npm 依赖)则完全交由市场管理,只清理 include。
|
|
695
|
+
*/
|
|
696
|
+
function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
|
|
697
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
698
|
+
const dep = pkg.dependencies && pkg.dependencies["dsh-remote-ui"];
|
|
699
|
+
const inBundles = !!(pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
700
|
+
&& pkg.dsh.profile.bundles.includes("dsh-remote-ui"));
|
|
701
|
+
const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
|
|
702
|
+
const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
|
|
703
|
+
|
|
704
|
+
const stripInclude = (reason) => {
|
|
705
|
+
const newPatch = stripPluginEntries(patch);
|
|
706
|
+
if (newPatch !== patch) {
|
|
707
|
+
fs.writeFileSync(patchFile, newPatch);
|
|
708
|
+
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活统一走插件自带 bundle patch,避免重复 ID 崩溃)`);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
if (marketManaged && inBundles) {
|
|
713
|
+
// 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
|
|
714
|
+
stripInclude("插件市场安装形态无需用户 include");
|
|
715
|
+
console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (managedByUs) {
|
|
720
|
+
const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
|
|
721
|
+
const entryFile = path.join(pluginLocalDir, "lib", "index.js");
|
|
722
|
+
if (!fs.existsSync(entryFile)) {
|
|
723
|
+
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
console.log(`✅ 插件已拷贝到 ${pluginLocalDir}`);
|
|
727
|
+
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
728
|
+
const addedBundle = ensureBundleEntry(pkgFile); // bundles 声明 → 插件自带 patch 自动激活
|
|
729
|
+
const linked = ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
|
|
730
|
+
stripInclude("bundle patch 已是唯一激活点"); // 清历史 include
|
|
731
|
+
console.log(addedBundle
|
|
732
|
+
? "✅ 已加入 dsh.profile.bundles(dsh-remote-ui 自带 patch 自动生效)"
|
|
733
|
+
: "ℹ dsh.profile.bundles 已含 dsh-remote-ui");
|
|
734
|
+
console.log(linked
|
|
735
|
+
? `✅ 已建立 node_modules/dsh-remote-ui → ${pluginLocalDir}(免包管理器即可解析)`
|
|
736
|
+
: "✅ node_modules/dsh-remote-ui 已就绪");
|
|
737
|
+
console.log(`✅ 插件安装完成(bundle 形态,无用户 include)。配置目录: ${CONFIG_DIR}`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
|
|
742
|
+
console.log(`ℹ 检测到依赖 dsh-remote-ui(${dep}) 但未声明在 dsh.profile.bundles——插件不会激活。`);
|
|
743
|
+
console.log(" 请在 dsh 插件市场重新添加该插件,或先执行 `dsh-remote plugin --uninstall` 再一键安装。");
|
|
744
|
+
}
|
|
745
|
+
|
|
604
746
|
async function pluginCmd(argv) {
|
|
605
747
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
606
748
|
const profileIdx = argv.indexOf("--profile");
|
|
@@ -639,35 +781,17 @@ async function pluginCmd(argv) {
|
|
|
639
781
|
if (pkg.dependencies) delete pkg.dependencies["dsh-remote-ui"];
|
|
640
782
|
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
641
783
|
} catch { /* ignore */ }
|
|
784
|
+
// 同步移除 bundles 声明,避免“bundles 引用已删除包 → dsh web 启动报错”
|
|
785
|
+
try {
|
|
786
|
+
if (removeBundleEntry(pkgFile)) console.log("✅ 已从 dsh.profile.bundles 移除 dsh-remote-ui");
|
|
787
|
+
} catch { /* ignore */ }
|
|
642
788
|
console.log("✅ 卸载完成。重启 dsh web 生效。");
|
|
643
789
|
return;
|
|
644
790
|
}
|
|
645
791
|
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const entryFile = path.join(pluginLocalDir, "lib", "index.js");
|
|
650
|
-
if (!fs.existsSync(entryFile)) {
|
|
651
|
-
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
652
|
-
process.exit(1);
|
|
653
|
-
}
|
|
654
|
-
console.log(`✅ 插件已拷贝到 ${pluginLocalDir}`);
|
|
655
|
-
|
|
656
|
-
// 先清掉旧条目(含旧版无标记条目),再写入带标记的新块,保证不重复。
|
|
657
|
-
// 关键:先清除默认的 [] 空文档占位行,否则拼接出的 YAML 非法,dsh web 启动即崩。
|
|
658
|
-
const stripped = stripPluginEntries(patch);
|
|
659
|
-
const base = normalizePatchBase(stripped);
|
|
660
|
-
const block = pluginBlock(CONFIG_DIR);
|
|
661
|
-
fs.writeFileSync(patchFile, (base ? base + "\n" : "") + block + "\n");
|
|
662
|
-
console.log(`✅ 已写入 ${patchFile}(裸名 include:dsh-remote-ui → 节点半 + 浏览器半均生效)`);
|
|
663
|
-
|
|
664
|
-
declarePluginDep(pkgFile); // package.json 声明 file: 依赖(后端各类 install 不误删)
|
|
665
|
-
const linked = ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
|
|
666
|
-
console.log(linked
|
|
667
|
-
? `✅ 已建立 node_modules/dsh-remote-ui → ${pluginLocalDir}(免包管理器即可解析)`
|
|
668
|
-
: "✅ node_modules/dsh-remote-ui 已就绪");
|
|
669
|
-
|
|
670
|
-
console.log(`✅ 插件安装完成。配置目录: ${CONFIG_DIR}`);
|
|
792
|
+
// 安装:收敛到“恰好一处激活”(bundle patch 唯一激活点),绝不与市场/历史 include 并存
|
|
793
|
+
convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
|
|
794
|
+
|
|
671
795
|
console.log(" 打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可(无需任何命令)。");
|
|
672
796
|
console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
|
|
673
797
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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",
|
|
@@ -64,6 +64,89 @@ function preferredNode() {
|
|
|
64
64
|
|
|
65
65
|
const NODE_BIN = preferredNode();
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 解析 npx 绝对路径。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
|
|
69
|
+
* (没有 /opt/homebrew/bin 等),裸 `npx` 会 spawn ENOENT 而静默失败——必须按绝对路径找,
|
|
70
|
+
* 且子进程 env 的 PATH 要把当前 node 所在目录补在最前(npx 的 #!/usr/bin/env node 依赖它)。
|
|
71
|
+
*/
|
|
72
|
+
function npxCommand() {
|
|
73
|
+
const name = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
74
|
+
const dirs = [
|
|
75
|
+
dirname(process.execPath), // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
|
|
76
|
+
process.env.DSH_SETUP_NPX_DIR || "",
|
|
77
|
+
"/opt/homebrew/bin",
|
|
78
|
+
"/usr/local/bin",
|
|
79
|
+
"/opt/homebrew/opt/node@20/bin",
|
|
80
|
+
"/usr/local/opt/node@20/bin",
|
|
81
|
+
"/usr/bin",
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
for (const d of dirs) {
|
|
84
|
+
const real = resolveExecutable(join(d, name));
|
|
85
|
+
if (real) return real;
|
|
86
|
+
}
|
|
87
|
+
return name; // 全找不到 → 退回裸名(普通 shell 场景仍可用)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
|
|
91
|
+
function spawnEnv(extra) {
|
|
92
|
+
const nodeDir = dirname(process.execPath);
|
|
93
|
+
const base = process.env.PATH || "";
|
|
94
|
+
const PATH = [nodeDir, base, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"].filter(Boolean).join(":");
|
|
95
|
+
return { ...process.env, PATH, ...(extra || {}) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- 后台子进程标记(防重入 + 宿主重启自愈) ----------
|
|
99
|
+
// marker 内容 = JSON {pid, at}:pid 供“宿主重启后立即清理死进程残留”判断;
|
|
100
|
+
// 兼容旧格式(纯时间戳数字 → 只按超时清理)。
|
|
101
|
+
|
|
102
|
+
function readMarkerInfo(filePath) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = readFileSync(filePath, "utf8").trim();
|
|
105
|
+
const j = JSON.parse(raw);
|
|
106
|
+
if (Number.isInteger(j?.pid) || Number.isInteger(j?.at)) return j;
|
|
107
|
+
} catch { /* 非 JSON → 数字时间戳或空 */ }
|
|
108
|
+
const t = Number(raw || "0");
|
|
109
|
+
return Number.isFinite(t) && t > 0 ? { pid: null, at: t } : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeMarker(filePath, pid) {
|
|
113
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
114
|
+
writeFileSync(filePath, JSON.stringify({ pid: pid ?? null, at: Date.now() }), { mode: 0o600 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** pid 是否存活(ESRCH=已死)。 */
|
|
118
|
+
function pidAlive(pid) {
|
|
119
|
+
if (!Number.isInteger(pid) || pid <= 0) return null; // 未知 → 由超时规则兜底
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return e.code === "EPERM" ? true : false; // EPERM=存在但无权限
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 清理残留标记:宿主 dsh web 在后台安装/更新期间被重启/强杀时,子进程清理回调随之丢失,
|
|
130
|
+
* 若只按“30 分钟超时”清理,用户会在这半小时内反复遇到“已有更新进行中/正在安装”。
|
|
131
|
+
* 现在:记录 pid → 重启后立刻清掉已死进程的标记;pid 不可读的旧标记仍按超时兜底。
|
|
132
|
+
*/
|
|
133
|
+
function sweepStaleMarkers(relayDir) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
for (const name of [PROVISION_MARKER, UPDATE_MARKER]) {
|
|
136
|
+
const p = join(relayDir, name);
|
|
137
|
+
let info;
|
|
138
|
+
try { info = readMarkerInfo(p); } catch { continue; }
|
|
139
|
+
if (!info) continue;
|
|
140
|
+
const dead = pidAlive(info.pid);
|
|
141
|
+
const expired = now - info.at > STALE_MARKER_MS;
|
|
142
|
+
if (dead === false || (dead === null && expired)) {
|
|
143
|
+
try { rmSync(p, { force: true }); } catch { /* ignore */ }
|
|
144
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
145
|
+
`[dsh-remote-ui] 清理残留标记 ${name}(pid=${info.pid ?? "?"}, at=${new Date(info.at).toISOString()}${dead === false ? ", 进程已死" : ", 已超时"})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
67
150
|
/** 读取 JSON body。 */
|
|
68
151
|
async function readJsonBody(req) {
|
|
69
152
|
let raw = "";
|
|
@@ -274,23 +357,8 @@ function appendLogLine(relayDir, name, line) {
|
|
|
274
357
|
}
|
|
275
358
|
|
|
276
359
|
/**
|
|
277
|
-
*
|
|
278
|
-
* 子进程的清理回调会随宿主进程一起丢失,marker 会永久卡住后续安装/更新。
|
|
279
|
-
* 插件每次启动时把超时的 marker 清掉(内容=创建时间戳)。
|
|
360
|
+
* 清理“进程残留”标记的实现见上方小工具区 sweepStaleMarkers(pid 存活 + 超时双保险)。
|
|
280
361
|
*/
|
|
281
|
-
function sweepStaleMarkers(relayDir) {
|
|
282
|
-
const now = Date.now();
|
|
283
|
-
for (const name of [PROVISION_MARKER, UPDATE_MARKER]) {
|
|
284
|
-
const p = join(relayDir, name);
|
|
285
|
-
try {
|
|
286
|
-
const t = Number((readFileSync(p, "utf8") || "0").trim());
|
|
287
|
-
if (Number.isFinite(t) && t > 0 && now - t > STALE_MARKER_MS) {
|
|
288
|
-
rmSync(p, { force: true });
|
|
289
|
-
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-ui] 清理残留标记 ${name}(${new Date(t).toISOString()} 创建,已超时)`);
|
|
290
|
-
}
|
|
291
|
-
} catch { /* 无文件等 → 忽略 */ }
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
362
|
|
|
295
363
|
/** 插件市场只装了 UI 插件;若桌面缺 dsh-remote 运行环境(dsh-setup.mjs=bridge/自启动),
|
|
296
364
|
* 由插件在后台自动执行一次 `npx @mrrisega/dsh-remote` 补齐,用户无需手动跑命令。
|
|
@@ -301,18 +369,23 @@ function ensureRuntime(relayDir) {
|
|
|
301
369
|
if (existsSync(marker)) return false; // 正在安装中
|
|
302
370
|
try {
|
|
303
371
|
mkdirSync(relayDir, { recursive: true });
|
|
304
|
-
writeFileSync(marker, String(Date.now()), { mode: 0o600 });
|
|
305
372
|
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
306
|
-
const
|
|
307
|
-
const child = spawn(npx, ["--yes", "@mrrisega/dsh-remote"], {
|
|
373
|
+
const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
|
|
308
374
|
detached: true,
|
|
375
|
+
env: spawnEnv(),
|
|
309
376
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
310
377
|
});
|
|
378
|
+
writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
|
|
379
|
+
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
311
380
|
child.on("exit", (code) => {
|
|
312
|
-
|
|
313
|
-
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
381
|
+
clear();
|
|
314
382
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
|
|
315
383
|
});
|
|
384
|
+
child.on("error", (e) => {
|
|
385
|
+
clear();
|
|
386
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
387
|
+
console.warn(`[dsh-remote-ui] 自动安装子进程启动失败: ${e.message}`);
|
|
388
|
+
});
|
|
316
389
|
child.unref();
|
|
317
390
|
console.log(`[dsh-remote-ui] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
|
|
318
391
|
return false;
|
|
@@ -373,14 +446,15 @@ function scheduleRuntime(relayDir) {
|
|
|
373
446
|
const cfg = loadConfig(relayDir);
|
|
374
447
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
375
448
|
if (!hasAcct) return;
|
|
449
|
+
// 先看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
|
|
450
|
+
const st = launchdStatus();
|
|
451
|
+
if (st.running) { done = true; clearInterval(iv); return; }
|
|
376
452
|
const setupUrl = join(relayDir, "dsh-setup.mjs");
|
|
377
453
|
if (!existsSync(setupUrl)) {
|
|
378
|
-
ensureRuntime(relayDir);
|
|
454
|
+
ensureRuntime(relayDir); // 什么环境都没有 → 后台 npx 安装一次
|
|
379
455
|
return;
|
|
380
456
|
}
|
|
381
|
-
|
|
382
|
-
if (st.running) { done = true; clearInterval(iv); return; }
|
|
383
|
-
startBridge(relayDir);
|
|
457
|
+
startBridge(relayDir); // 环境在但服务没起 → 拉起
|
|
384
458
|
} catch { /* 下一轮再试 */ }
|
|
385
459
|
}, 12_000);
|
|
386
460
|
iv.unref?.();
|
|
@@ -637,7 +711,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
637
711
|
// ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
|
|
638
712
|
|
|
639
713
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
640
|
-
const PLUGIN_VERSION = "0.4.
|
|
714
|
+
const PLUGIN_VERSION = "0.4.5";
|
|
641
715
|
const UPDATE_LOG = ".dsh-update.log";
|
|
642
716
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
643
717
|
|
|
@@ -657,11 +731,10 @@ async function npmLatestVersion() {
|
|
|
657
731
|
/** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
|
|
658
732
|
function spawnUpdater(relayDir, extraEnv) {
|
|
659
733
|
const log = join(relayDir, UPDATE_LOG);
|
|
660
|
-
|
|
661
|
-
return spawn(npx, ["--yes", "@mrrisega/dsh-remote@latest"], {
|
|
734
|
+
return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
|
|
662
735
|
detached: true,
|
|
663
736
|
cwd: homedir(),
|
|
664
|
-
env:
|
|
737
|
+
env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
|
|
665
738
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
666
739
|
});
|
|
667
740
|
}
|
|
@@ -669,20 +742,22 @@ function spawnUpdater(relayDir, extraEnv) {
|
|
|
669
742
|
/**
|
|
670
743
|
* 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/重写 include)。
|
|
671
744
|
* 稳健性:
|
|
745
|
+
* - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
|
|
672
746
|
* - 默认 npx 源(国内常为 npmmirror)未同步到最新版导致失败时,自动用官方 npm 源重试一次;
|
|
673
|
-
* - marker
|
|
747
|
+
* - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
|
|
674
748
|
*/
|
|
675
749
|
function runOnlineUpdate(relayDir) {
|
|
676
750
|
try {
|
|
677
751
|
mkdirSync(relayDir, { recursive: true });
|
|
678
752
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
679
753
|
if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
|
|
680
|
-
writeFileSync(marker, String(Date.now()), { mode: 0o600 });
|
|
681
754
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
|
|
682
755
|
|
|
683
756
|
let retried = false;
|
|
757
|
+
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
684
758
|
const run = () => {
|
|
685
759
|
const child = spawnUpdater(relayDir, retried ? { npm_config_registry: "https://registry.npmjs.org" } : {});
|
|
760
|
+
writeMarker(marker, child.pid);
|
|
686
761
|
child.on("exit", (code) => {
|
|
687
762
|
if (!retried && code !== 0) {
|
|
688
763
|
retried = true;
|
|
@@ -691,7 +766,11 @@ function runOnlineUpdate(relayDir) {
|
|
|
691
766
|
return;
|
|
692
767
|
}
|
|
693
768
|
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(默认源${retried ? "/官方源" : ""})`);
|
|
694
|
-
|
|
769
|
+
clear();
|
|
770
|
+
});
|
|
771
|
+
child.on("error", (e) => {
|
|
772
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
773
|
+
clear();
|
|
695
774
|
});
|
|
696
775
|
child.unref();
|
|
697
776
|
return child;
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// 3) 版本可见 + 新版本检测 + 稳健更新(含残留 marker 清理兜底逻辑在 apply 时执行)。
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import http from "node:http";
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import test from "node:test";
|
|
@@ -66,24 +66,24 @@ test("self 路由:版本可见 + 运行环境状态(无 npx 环境时不谎
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("update-check 路由:从 npm 检测新版本(dist-tags.latest
|
|
69
|
+
test("update-check 路由:从 npm 检测新版本(dist-tags.latest 9.9.9 > 当前)", async () => {
|
|
70
70
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-upchk-"));
|
|
71
71
|
const origFetch = globalThis.fetch;
|
|
72
72
|
try {
|
|
73
73
|
const routes = boot(null, tempDir);
|
|
74
74
|
const { host, base } = await serve(routes);
|
|
75
|
-
// 假 npm 源:registry 请求回
|
|
75
|
+
// 假 npm 源:registry 请求回 9.9.9,其余请求(本测试自身的 HTTP 调用)走真实 fetch
|
|
76
76
|
globalThis.fetch = async (url, init) => {
|
|
77
77
|
if (String(url).startsWith("https://registry.")) {
|
|
78
|
-
return { ok: true, status: 200, json: async () => ({ "dist-tags": { latest: "
|
|
78
|
+
return { ok: true, status: 200, json: async () => ({ "dist-tags": { latest: "9.9.9" } }) };
|
|
79
79
|
}
|
|
80
80
|
return origFetch(url, init);
|
|
81
81
|
};
|
|
82
82
|
try {
|
|
83
83
|
const r = await (await fetch(`${base}/dsh-remote/self/update-check`)).json();
|
|
84
84
|
assert.equal(r.ok, true);
|
|
85
|
-
assert.equal(r.latest, "
|
|
86
|
-
assert.equal(r.outdated, true, "
|
|
85
|
+
assert.equal(r.latest, "9.9.9");
|
|
86
|
+
assert.equal(r.outdated, true, "9.9.9 > 当前版本 → outdated 应为 true");
|
|
87
87
|
assert.notEqual(r.current, r.latest);
|
|
88
88
|
} finally {
|
|
89
89
|
host.close();
|
|
@@ -139,6 +139,22 @@ test("update 路由:已有更新进行中时拒绝重复触发(防重入)"
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立即清理(不等 30 分钟超时)", () => {
|
|
143
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "dsh-ui-sweep-"));
|
|
144
|
+
try {
|
|
145
|
+
// 模拟“宿主在更新途中被重启”:更新子进程已死,但清理回调随旧宿主丢失
|
|
146
|
+
const deadPid = 2147483647; // 不可能存在的 pid
|
|
147
|
+
writeFileSync(path.join(tempDir, ".dsh-setup-installing"), JSON.stringify({ pid: deadPid, at: Date.now() }));
|
|
148
|
+
writeFileSync(path.join(tempDir, ".dsh-update-running"), JSON.stringify({ pid: deadPid, at: Date.now() }));
|
|
149
|
+
writeFileSync(path.join(tempDir, ".dsh-config.json"), "{}");
|
|
150
|
+
boot(null, tempDir); // apply() → sweepStaleMarkers
|
|
151
|
+
assert.equal(existsSync(path.join(tempDir, ".dsh-setup-installing")), false, "死进程的安装 marker 应被立即清理");
|
|
152
|
+
assert.equal(existsSync(path.join(tempDir, ".dsh-update-running")), false, "死进程的更新 marker 应被立即清理");
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
142
158
|
test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
|
|
143
159
|
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
|
|
144
160
|
const relayDir = path.join(tempHome, "relay");
|