@mrrisega/dsh-remote 0.6.7-beta.1 → 0.6.7-beta.3
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 +15 -1
- package/clients/dsh-remote/dsh-bridge.mjs +31 -1
- package/dsh-setup.mjs +339 -25
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -4
- package/packages/dsh-remote-web/lib/index.js +259 -24
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +400 -0
- package/packages/dsh-remote-web/test/self-manage.test.mjs +16 -7
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +44 -33
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +379 -0
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
21
21
|
//
|
|
22
22
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
23
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, constants as fsConstants } from "node:fs";
|
|
24
24
|
import { join, dirname, sep, delimiter } from "node:path";
|
|
25
25
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
27
|
-
import { homedir } from "node:os";
|
|
27
|
+
import { homedir, tmpdir } from "node:os";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
29
|
|
|
30
30
|
/** 本插件在 host 侧的服务依赖。 */
|
|
@@ -86,6 +86,35 @@ const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-rem
|
|
|
86
86
|
const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
87
87
|
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* 把文件权限收紧到「只有本人可读写」。
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
|
|
93
|
+
* 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
|
|
94
|
+
* 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
|
|
95
|
+
* 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
|
|
96
|
+
* (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
|
|
97
|
+
* 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
|
|
98
|
+
* 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
|
|
99
|
+
*/
|
|
100
|
+
let hardenWarned = false;
|
|
101
|
+
function hardenFile(file) {
|
|
102
|
+
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
103
|
+
if (process.platform !== "win32") return;
|
|
104
|
+
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
105
|
+
if (!who) return;
|
|
106
|
+
let r;
|
|
107
|
+
try {
|
|
108
|
+
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
109
|
+
{ windowsHide: true, encoding: "utf8", timeout: 8000 });
|
|
110
|
+
} catch (e) { r = { status: -1, stderr: e.message }; }
|
|
111
|
+
if (r.status !== 0 && !hardenWarned) {
|
|
112
|
+
hardenWarned = true;
|
|
113
|
+
console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
|
|
114
|
+
console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
// ---------- 小工具 ----------
|
|
90
119
|
|
|
91
120
|
/** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
|
|
@@ -334,6 +363,7 @@ function saveConfig(relayDir, cfg) {
|
|
|
334
363
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
335
364
|
// mode 0o600:与 dsh-setup.mjs 一致(文件已存在时 writeFileSync 不改权限,显式 chmod 兜底)
|
|
336
365
|
writeFileSync(configPathOf(relayDir), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
366
|
+
hardenFile(configPathOf(relayDir)); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
|
|
337
367
|
try {
|
|
338
368
|
chmodSync(configPathOf(relayDir), 0o600);
|
|
339
369
|
} catch {
|
|
@@ -412,6 +442,7 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
412
442
|
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
413
443
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
414
444
|
writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
445
|
+
hardenFile(join(relayDir, HARNESS_COOKIE_FILE)); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
|
|
415
446
|
return true;
|
|
416
447
|
} catch {
|
|
417
448
|
return false;
|
|
@@ -1459,7 +1490,7 @@ const deadline = Date.now() + 30000;
|
|
|
1459
1490
|
while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
|
|
1460
1491
|
if (alive(PLAN.pid)) {
|
|
1461
1492
|
logLine("旧进程 30s 未退出,改用 taskkill /T /F");
|
|
1462
|
-
spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
|
|
1493
|
+
spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
|
|
1463
1494
|
await delay(500);
|
|
1464
1495
|
}
|
|
1465
1496
|
|
|
@@ -2302,7 +2333,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
2302
2333
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
2303
2334
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
2304
2335
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
2305
|
-
const PLUGIN_VERSION = "0.6.7-beta.
|
|
2336
|
+
const PLUGIN_VERSION = "0.6.7-beta.3";
|
|
2306
2337
|
const UPDATE_LOG = ".dsh-update.log";
|
|
2307
2338
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
2308
2339
|
|
|
@@ -2507,17 +2538,204 @@ function tailOf(filePath, lines = 24) {
|
|
|
2507
2538
|
} catch { return ""; }
|
|
2508
2539
|
}
|
|
2509
2540
|
|
|
2541
|
+
/**
|
|
2542
|
+
* 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
|
|
2543
|
+
*
|
|
2544
|
+
* 为什么必须判(2026-09-18 Windows 用户实测的致命事故):dsh 解析 cordis.patch.yml 时
|
|
2545
|
+
* **要求顶层是数组**,而**纯注释文档的 YAML 解析结果是 `null` 而不是空数组**,于是 dsh 直接:
|
|
2546
|
+
* `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
|
|
2547
|
+
* 官方空 profile 模板是「注释 + `[]`」——`[]` 这个占位符不能省。
|
|
2548
|
+
* 卸载把插件条目摘掉后若只剩注释,用户就再也起不来 dsh web(面板也进不去,只能靠 CLI 自救)。
|
|
2549
|
+
*/
|
|
2550
|
+
/** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
|
|
2551
|
+
function patchStructuralLines(text) {
|
|
2552
|
+
return String(text ?? "")
|
|
2553
|
+
.split(/\r?\n/)
|
|
2554
|
+
.map((l) => l.replace(/\s+$/, ""))
|
|
2555
|
+
.filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
/** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
|
|
2559
|
+
function isPatchArrayDocument(text) {
|
|
2560
|
+
const roots = patchStructuralLines(text);
|
|
2561
|
+
return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
|
|
2565
|
+
function needsArrayPlaceholder(text) {
|
|
2566
|
+
return patchStructuralLines(text).length === 0;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/** 摘掉本插件的 patch 条目:管理标记块 + 无标记的 `- insert:` 块(当前名与历史名都清)。 */
|
|
2570
|
+
function stripOwnEntriesFromPatch(patch) {
|
|
2571
|
+
// ① 管理标记块(dsh-setup 写入的形式)
|
|
2572
|
+
// 结尾标记后面可能还有残留文字(历史变体),用 [^\n]* 吃掉整行,避免留下半截内容
|
|
2573
|
+
const out = String(patch ?? "").replace(/\n?# >>> dsh-remote-(?:web|ui)[^\n]*\n[\s\S]*?# <<< dsh-remote-(?:web|ui)[^\n]*\n?/g, "\n");
|
|
2574
|
+
// ② 无标记的 insert 块(旧版 / 其它工具写入的形式,历史事故里出现过)。
|
|
2575
|
+
// 只摘标记块是不够的:目录已删而 patch 仍引用 → dsh 插件树加载失败 → 整个 dsh web 起不来。
|
|
2576
|
+
const lines = out.split("\n");
|
|
2577
|
+
const kept = [];
|
|
2578
|
+
let i = 0;
|
|
2579
|
+
while (i < lines.length) {
|
|
2580
|
+
if (/^- insert:\s*$/.test(lines[i])) {
|
|
2581
|
+
const block = [lines[i]];
|
|
2582
|
+
let j = i + 1;
|
|
2583
|
+
while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t"))) { block.push(lines[j]); j += 1; }
|
|
2584
|
+
if (PLUGIN_ALL_IDS.some((id) => new RegExp(`\\b${id}\\b`).test(block.join("\n")))) {
|
|
2585
|
+
while (kept.length && /^\s*#/.test(kept[kept.length - 1])) kept.pop(); // 连带条目上方的说明注释
|
|
2586
|
+
i = j;
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
kept.push(...block);
|
|
2590
|
+
i = j;
|
|
2591
|
+
continue;
|
|
2592
|
+
}
|
|
2593
|
+
kept.push(lines[i]);
|
|
2594
|
+
i += 1;
|
|
2595
|
+
}
|
|
2596
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
/**
|
|
2600
|
+
* 原子写回 patch,并保证结果仍是合法顶层数组:条目被删光时补回 `[]` 占位。
|
|
2601
|
+
* ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
|
|
2602
|
+
* 那会把本来好的文件写坏(例如把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
|
|
2603
|
+
*/
|
|
2604
|
+
function writePatchDocument(patchFile, text) {
|
|
2605
|
+
const body = String(text ?? "").replace(/\s+$/, "") + "\n";
|
|
2606
|
+
const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
|
|
2607
|
+
const tmp = `${patchFile}.dsh-remote.tmp`;
|
|
2608
|
+
writeFileSync(tmp, fixed);
|
|
2609
|
+
renameSync(tmp, patchFile);
|
|
2610
|
+
return fixed;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* 「运行时清理助手」源码(卸载用;由插件生成、detached 执行)。
|
|
2615
|
+
*
|
|
2616
|
+
* 为什么必须是独立进程(2026-09-18 用户实测的僵死事故):
|
|
2617
|
+
* uninstallRuntime() 里有 spawnSync("schtasks"/"powershell"/"taskkill"),同步子进程调用会把
|
|
2618
|
+
* **dsh web 的事件循环整个钉死** —— 不只是这一个请求不返回,手机端隧道、其它页面、所有 API
|
|
2619
|
+
* 一起停摆,表现为「端口在听、连接建立、但永不响应」。而 spawnSync 的 timeout 在 Windows 上
|
|
2620
|
+
* **并不可靠**(实测被策略拦截的进程 18s+ 仍未返回),所以不能把 timeout 当设计依据:
|
|
2621
|
+
* 真正的解法是不要把同步子进程调用留在宿主进程里。
|
|
2622
|
+
*
|
|
2623
|
+
* 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
|
|
2624
|
+
*/
|
|
2625
|
+
function buildRuntimeCleanupHelper(relayDir, protectedPath) {
|
|
2626
|
+
const plan = JSON.stringify({
|
|
2627
|
+
relayDir,
|
|
2628
|
+
protectedPath: protectedPath || "",
|
|
2629
|
+
platform: osPlatform(),
|
|
2630
|
+
taskName: WIN_TASK_NAME,
|
|
2631
|
+
pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
|
|
2632
|
+
plistPath: join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
|
|
2633
|
+
unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
|
|
2634
|
+
log: join(tmpdir(), "dsh-remote-uninstall.log"),
|
|
2635
|
+
});
|
|
2636
|
+
return `// dsh-remote 自动生成:彻底卸载的「运行时清理」部分(独立进程执行,避免阻塞 dsh web)
|
|
2637
|
+
import { spawnSync } from "node:child_process";
|
|
2638
|
+
import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
2639
|
+
import { homedir } from "node:os";
|
|
2640
|
+
import { dirname, join, sep } from "node:path";
|
|
2641
|
+
|
|
2642
|
+
const PLAN = ${plan};
|
|
2643
|
+
// 与主进程一致的测试隔离开关:置位时**绝不做任何真实系统操作**(不碰 launchctl/systemctl/schtasks/taskkill),
|
|
2644
|
+
// 但仍清空配置目录 —— 否则用例会去动开发者本机真实的自启动服务(历史事故,见 setup-output 用例的护栏)。
|
|
2645
|
+
const SKIP_SYSTEM_OPS = process.env.DSH_RELAY_SKIP_SERVICE === "1";
|
|
2646
|
+
function log(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) { /* 日志失败不阻断清理 */ } }
|
|
2647
|
+
function run(cmd, args, timeout) {
|
|
2648
|
+
try { const r = spawnSync(cmd, args, { windowsHide: true, encoding: "utf8", timeout }); return r.status === 0; }
|
|
2649
|
+
catch (e) { log("命令失败 " + cmd + ": " + e.message); return false; }
|
|
2650
|
+
}
|
|
2651
|
+
function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
|
|
2652
|
+
function readPid(name) {
|
|
2653
|
+
try { const n = Number(String(readFileSync(join(PLAN.relayDir, name), "utf8")).trim()); return Number.isInteger(n) && n > 0 ? n : null; }
|
|
2654
|
+
catch (e) { return null; }
|
|
2655
|
+
}
|
|
2656
|
+
log("运行时清理开始(platform=" + PLAN.platform + ",relayDir=" + PLAN.relayDir + ")");
|
|
2657
|
+
if (SKIP_SYSTEM_OPS) log("测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过一切真实系统操作");
|
|
2658
|
+
|
|
2659
|
+
// ① 结束 watcher / bridge 残留进程
|
|
2660
|
+
for (const name of (SKIP_SYSTEM_OPS ? [] : PLAN.pidFiles)) {
|
|
2661
|
+
const pid = readPid(name);
|
|
2662
|
+
if (!pid || pid === process.pid) continue;
|
|
2663
|
+
if (!alive(pid)) { log(name + " 里的 pid " + pid + " 已不在"); continue; }
|
|
2664
|
+
if (PLAN.platform === "win32") run("taskkill", ["/PID", String(pid), "/T", "/F"], 8000);
|
|
2665
|
+
else { try { process.kill(pid, "SIGTERM"); } catch (e) { /* 已退出 */ } }
|
|
2666
|
+
log("已结束 " + name + " 的进程 pid=" + pid);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// ② 移除自启动(按平台)
|
|
2670
|
+
if (SKIP_SYSTEM_OPS) {
|
|
2671
|
+
log("跳过自启动清理(测试隔离)");
|
|
2672
|
+
} else if (PLAN.platform === "win32") {
|
|
2673
|
+
run("schtasks", ["/Delete", "/TN", PLAN.taskName, "/F"], 15000);
|
|
2674
|
+
log("已请求删除登录任务 " + PLAN.taskName);
|
|
2675
|
+
} else if (PLAN.platform === "darwin") {
|
|
2676
|
+
if (existsSync(PLAN.plistPath)) {
|
|
2677
|
+
run("launchctl", ["bootout", "gui/" + (typeof process.getuid === "function" ? process.getuid() : 0) + "/com.dshremote.bridge"], 8000);
|
|
2678
|
+
try { rmSync(PLAN.plistPath, { force: true }); } catch (e) { /* 非关键 */ }
|
|
2679
|
+
log("已移除自启动 plist");
|
|
2680
|
+
}
|
|
2681
|
+
} else if (PLAN.platform === "linux") {
|
|
2682
|
+
run("systemctl", ["--user", "stop", "dsh-bridge"], 8000);
|
|
2683
|
+
run("systemctl", ["--user", "disable", "dsh-bridge"], 8000);
|
|
2684
|
+
try { if (existsSync(PLAN.unitPath)) rmSync(PLAN.unitPath, { force: true }); } catch (e) { /* 非关键 */ }
|
|
2685
|
+
log("已移除 systemd 用户服务");
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// ③ 清空配置目录(账号/密钥/会话 cookie/固化运行时)——安全护栏与主进程一致
|
|
2689
|
+
try {
|
|
2690
|
+
const isRoot = dirname(PLAN.relayDir) === PLAN.relayDir;
|
|
2691
|
+
const isHome = PLAN.relayDir === homedir();
|
|
2692
|
+
const hitsProfile = Boolean(PLAN.protectedPath) && (
|
|
2693
|
+
PLAN.relayDir === PLAN.protectedPath
|
|
2694
|
+
|| PLAN.relayDir.startsWith(PLAN.protectedPath + sep)
|
|
2695
|
+
|| PLAN.protectedPath.startsWith(PLAN.relayDir + sep));
|
|
2696
|
+
if (PLAN.relayDir && !isRoot && !isHome && !hitsProfile && existsSync(PLAN.relayDir)) {
|
|
2697
|
+
rmSync(PLAN.relayDir, { recursive: true, force: true });
|
|
2698
|
+
log("已清空配置目录");
|
|
2699
|
+
} else {
|
|
2700
|
+
log("跳过配置目录清理(护栏命中或目录不存在)");
|
|
2701
|
+
}
|
|
2702
|
+
} catch (e) { log("配置目录清理失败: " + e.message); }
|
|
2703
|
+
log("运行时清理结束");
|
|
2704
|
+
`;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* 把「运行时清理」交给一个 detached 子进程(不阻塞宿主事件循环)。
|
|
2709
|
+
* @returns {{ok:boolean, pid:number|null, log:string, detail:string}}
|
|
2710
|
+
*/
|
|
2711
|
+
function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
|
|
2712
|
+
const logPath = join(tmpdir(), "dsh-remote-uninstall.log");
|
|
2713
|
+
try {
|
|
2714
|
+
// 助手放**系统临时目录**而不是 relayDir:它自己就要删 relayDir,
|
|
2715
|
+
// 把正在运行的脚本放在待删目录里既别扭又可能在 Windows 上因文件占用而失败。
|
|
2716
|
+
const helperPath = join(tmpdir(), `dsh-remote-uninstall-${process.pid}-${Date.now()}.mjs`);
|
|
2717
|
+
writeFileSync(helperPath, buildRuntimeCleanupHelper(relayDir, protectedPath));
|
|
2718
|
+
const { child, error } = safeSpawn(process.execPath, [helperPath], {
|
|
2719
|
+
detached: true, cwd: homedir(), stdio: "ignore", windowsHide: true,
|
|
2720
|
+
onError: (e) => console.warn(`[dsh-remote-web] 运行时清理子进程启动失败: ${e.message}`),
|
|
2721
|
+
});
|
|
2722
|
+
if (error || !child) {
|
|
2723
|
+
return { ok: false, pid: null, log: logPath, detail: (error || new Error("spawn 未返回子进程")).message };
|
|
2724
|
+
}
|
|
2725
|
+
child.unref();
|
|
2726
|
+
return { ok: true, pid: child.pid, log: logPath, detail: "已在后台清理" };
|
|
2727
|
+
} catch (e) {
|
|
2728
|
+
return { ok: false, pid: null, log: logPath, detail: e.message };
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2510
2732
|
/** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
|
|
2511
2733
|
function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
2512
2734
|
const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
|
|
2513
2735
|
try {
|
|
2514
2736
|
const patch = readFileSync(patchFile, "utf8");
|
|
2515
|
-
// 兼容当前与历史(dsh-remote-ui
|
|
2516
|
-
|
|
2517
|
-
.replace(/\n?# >>> dsh-remote-(?:web|ui) .*?# <<< dsh-remote-(?:web|ui)\s*/s, "\n")
|
|
2518
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
2519
|
-
.trimEnd() + "\n";
|
|
2520
|
-
if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
|
|
2737
|
+
const cleaned = stripOwnEntriesFromPatch(patch); // 兼容当前与历史(dsh-remote-ui)两种名
|
|
2738
|
+
if (cleaned !== patch) { writePatchDocument(patchFile, cleaned); out.removedPatch = true; }
|
|
2521
2739
|
} catch { /* 无 patch 忽略 */ }
|
|
2522
2740
|
try {
|
|
2523
2741
|
const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
|
|
@@ -3571,30 +3789,47 @@ function registerRoutes(ctx, relayDir) {
|
|
|
3571
3789
|
method: "POST",
|
|
3572
3790
|
path: "/dsh-remote/self/uninstall",
|
|
3573
3791
|
handler: async (_req, res) => {
|
|
3574
|
-
// 彻底卸载 = ①
|
|
3575
|
-
//
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
//
|
|
3792
|
+
// 彻底卸载 = ① 插件 profile 清理(快、纯 fs、可预期)② 运行时/bridge 清理(慢、要调外部命令)
|
|
3793
|
+
//
|
|
3794
|
+
// ⚠️ 顺序是刻意的,别改回去(2026-09-18 用户实测的僵死事故):
|
|
3795
|
+
// 旧实现把**慢的** uninstallRuntime 排在最前、uninstallSelf 排在其后,于是
|
|
3796
|
+
// uninstallRuntime 里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
|
|
3797
|
+
// timeout 在 Windows 上并不可靠,实测 18s+ 未返回),uninstallSelf 就**永远不执行**
|
|
3798
|
+
// → 用户看到「点了卸载、插件纹丝不动」,而且整个 dsh web 僵死(端口在听、无响应)。
|
|
3799
|
+
// 现在:先做真正"卸载掉插件"的部分 → 立刻关掉自愈 → 立刻回响应 → 慢步骤交独立子进程。
|
|
3800
|
+
let prof = { removedPatch: false, removedDep: false, removedBundle: false, removedDir: false };
|
|
3801
|
+
try {
|
|
3802
|
+
prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
|
|
3803
|
+
} catch (e) {
|
|
3804
|
+
console.warn(`[dsh-remote-web] 卸载插件引用失败: ${e.message}`);
|
|
3805
|
+
}
|
|
3806
|
+
// 必须在慢步骤之前置位:否则慢步骤期间自愈有机会把 bridge / 配置目录再拉回来
|
|
3579
3807
|
markUninstalled(relayDir);
|
|
3808
|
+
// 运行时清理交给独立进程:宿主事件循环绝不做同步子进程调用
|
|
3809
|
+
const cleanup = spawnRuntimeCleanupDetached(relayDir, profileDir);
|
|
3810
|
+
const cleanupMode = cleanup.ok ? "deferred" : "in-process";
|
|
3580
3811
|
const bits = [];
|
|
3581
3812
|
if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
|
|
3813
|
+
bits.push(cleanup.ok
|
|
3814
|
+
? "bridge 自启动项与本地配置目录(账号/密钥/固化运行时等)正在后台清理"
|
|
3815
|
+
: `bridge 自启动项与配置目录的后台清理未能启动(${cleanup.detail}),已改在本进程内清理`);
|
|
3586
3816
|
bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
|
|
3587
3817
|
sendJson(res, 200, {
|
|
3588
3818
|
ok: true,
|
|
3589
3819
|
...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
removedPlist: rt.removedPlist,
|
|
3593
|
-
killedPids: rt.killedPids,
|
|
3594
|
-
relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
|
|
3820
|
+
runtimeCleanup: cleanupMode,
|
|
3821
|
+
cleanupLog: cleanup.log,
|
|
3595
3822
|
relayDir,
|
|
3596
3823
|
detail: bits.join(";"),
|
|
3597
3824
|
});
|
|
3825
|
+
// 兜底:助手起不来(relayDir 不可写 / node 起不来)时才在进程内清理。
|
|
3826
|
+
// **必须放在响应之后**:这条路径会阻塞事件循环,绝不能让用户先等它。
|
|
3827
|
+
if (!cleanup.ok) {
|
|
3828
|
+
setTimeout(() => {
|
|
3829
|
+
try { uninstallRuntime(relayDir, profileDir); }
|
|
3830
|
+
catch { /* 非致命:残留可由用户手动清理 */ }
|
|
3831
|
+
}, 50).unref?.();
|
|
3832
|
+
}
|
|
3598
3833
|
},
|
|
3599
3834
|
},
|
|
3600
3835
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.7-beta.
|
|
3
|
+
"version": "0.6.7-beta.3",
|
|
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",
|