@mrrisega/dsh-remote 0.4.6 → 0.4.8
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/clients/dsh-remote/dsh-bridge.mjs +56 -4
- package/package.json +1 -1
- package/packages/dsh-remote-ui/README.md +10 -1
- package/packages/dsh-remote-ui/lib/client.js +15 -3
- package/packages/dsh-remote-ui/lib/index.js +154 -6
- package/packages/dsh-remote-ui/package.json +1 -1
- package/packages/dsh-remote-ui/test/self-manage.test.mjs +13 -0
- package/packages/dsh-remote-ui/test/uninstall-runtime.test.mjs +317 -0
|
@@ -47,7 +47,8 @@ import WebSocket from "ws";
|
|
|
47
47
|
import fs from "node:fs";
|
|
48
48
|
import os from "node:os";
|
|
49
49
|
import path from "node:path";
|
|
50
|
-
import {
|
|
50
|
+
import { execSync } from "node:child_process";
|
|
51
|
+
import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
|
|
51
52
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
52
53
|
import { promisify } from "node:util";
|
|
53
54
|
import { gzip as gzipCb } from "node:zlib";
|
|
@@ -80,6 +81,46 @@ const PHONE = process.env.DSH_BRIDGE_PHONE || EMAIL;
|
|
|
80
81
|
const PASSWORD = process.env.DSH_BRIDGE_PASSWORD || "";
|
|
81
82
|
const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
|
|
82
83
|
|
|
84
|
+
// ---------- 本机稳定指纹(同机重装识别;供服务端自动顶替旧设备) ----------
|
|
85
|
+
|
|
86
|
+
/** 读取与安装无关的机器级唯一值:macOS IOPlatformUUID / Linux machine-id。 */
|
|
87
|
+
function machineUniqueId() {
|
|
88
|
+
if (process.env.DSH_BRIDGE_MACHINE_FP) return String(process.env.DSH_BRIDGE_MACHINE_FP).slice(0, 64);
|
|
89
|
+
if (process.platform === "darwin") {
|
|
90
|
+
try {
|
|
91
|
+
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf8", timeout: 5000 });
|
|
92
|
+
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
93
|
+
if (m && m[1]) return m[1];
|
|
94
|
+
} catch { /* 兜底 */ }
|
|
95
|
+
} else if (process.platform === "linux") {
|
|
96
|
+
for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
97
|
+
try {
|
|
98
|
+
const s = fs.readFileSync(f, "utf8").trim();
|
|
99
|
+
if (s) return s;
|
|
100
|
+
} catch { /* 继续 */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return ""; // 读不到(如容器) → 回退宿主名指纹
|
|
104
|
+
}
|
|
105
|
+
/** 稳定指纹:机器唯一值哈希;同机卸载重装后不变。 */
|
|
106
|
+
function machineFingerprint() {
|
|
107
|
+
const base = machineUniqueId() || `${os.hostname()}|${os.platform()}`;
|
|
108
|
+
return createHash("sha256").update(`dsh-remote/machine/v1:${base}`).digest("hex").slice(0, 32);
|
|
109
|
+
}
|
|
110
|
+
const MACHINE_FP = machineFingerprint();
|
|
111
|
+
|
|
112
|
+
/** 绑定/登录失败提示文件:供 dsh web 插件面板读取并展示(如“注册失败,已达到上限”)。 */
|
|
113
|
+
const BIND_ERROR_FILE = path.join(path.dirname(CONFIG_PATH), ".bind-error.json");
|
|
114
|
+
function persistBindError(payload) {
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(path.dirname(BIND_ERROR_FILE), { recursive: true });
|
|
117
|
+
fs.writeFileSync(BIND_ERROR_FILE, JSON.stringify({ ...payload, at: Date.now() }, null, 2), { mode: 0o600 });
|
|
118
|
+
} catch { /* 非关键 */ }
|
|
119
|
+
}
|
|
120
|
+
function clearBindError() {
|
|
121
|
+
try { fs.rmSync(BIND_ERROR_FILE, { force: true }); } catch { /* 非关键 */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
83
124
|
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
84
125
|
|
|
85
126
|
function loadLocalConfig() {
|
|
@@ -575,25 +616,36 @@ async function registerDeviceInAccount(token) {
|
|
|
575
616
|
const r = await fetch(API_BASE + "/api/devices", {
|
|
576
617
|
method: "POST",
|
|
577
618
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
578
|
-
body: JSON.stringify({
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
device_id: DEVICE_ID,
|
|
621
|
+
device_name: os.hostname() || "dsh-bridge",
|
|
622
|
+
pub_key: pubKey,
|
|
623
|
+
machine_fp: MACHINE_FP // v6 同机识别:服务端据此自动顶替旧设备(重装不再被设备数卡死)
|
|
624
|
+
})
|
|
579
625
|
});
|
|
580
626
|
const d = await r.json().catch(() => ({}));
|
|
581
627
|
if (r.status === 201 || r.status === 200) {
|
|
628
|
+
clearBindError();
|
|
582
629
|
console.log(`[bridge] ✅ 设备已登记到账号: ${DEVICE_ID}`);
|
|
583
630
|
return;
|
|
584
631
|
}
|
|
585
632
|
if (r.status === 409) {
|
|
586
633
|
const code = d.error?.code || "";
|
|
587
634
|
if (code === "device_limit_exceeded") {
|
|
588
|
-
|
|
589
|
-
|
|
635
|
+
const msg = d.error?.message || "当前套餐最多绑定 1 台设备";
|
|
636
|
+
persistBindError({ code, message: msg });
|
|
637
|
+
console.error(`[bridge] 设备数已达上限: ${msg}`);
|
|
638
|
+
console.error(" 同机重装会自动顶替旧设备;仍失败请到手机端设备管理解绑旧设备后重启(免费每月可解绑 3 次)。");
|
|
590
639
|
} else {
|
|
640
|
+
persistBindError({ code, status: 409, message: d.error?.message || "绑定冲突" });
|
|
591
641
|
console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${d.error?.message || "未知错误"}`);
|
|
592
642
|
}
|
|
593
643
|
process.exit(1);
|
|
594
644
|
}
|
|
645
|
+
persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败" });
|
|
595
646
|
console.warn(`[bridge] 设备登记失败(${r.status}): ${d.error?.message || "未知错误"}(手机端设备列表可能看不到本设备)`);
|
|
596
647
|
} catch (e) {
|
|
648
|
+
persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}` });
|
|
597
649
|
console.warn(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}(手机端设备列表可能看不到本设备)`);
|
|
598
650
|
}
|
|
599
651
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
# dsh-remote-ui — dsh web
|
|
1
|
+
# dsh-remote-ui — 公网远程控制 dsh web 的插件
|
|
2
|
+
|
|
3
|
+
> **给 DeepSeek Harness(dsh web)一个「随时随地」的公网入口。**
|
|
4
|
+
> 安装后在 dsh web 的设置页登录账号,即得到一个专属的**加密远程地址**:人在办公室外、
|
|
5
|
+
> 用手机流量或任意网络打开它,都能像坐在电脑前一样操作 dsh——继续对话、看工具执行、
|
|
6
|
+
> 审批授权、改设置。**不要求手机和电脑在同一 WiFi/局域网**,也不需要公网 IP、
|
|
7
|
+
> 路由器映射或自己搭服务器,开箱即用、全程加密。
|
|
8
|
+
> 懂技术的用户也可以把服务部署到自己的服务器上,数据与流量完全自控(普通用户无需关心)。
|
|
9
|
+
|
|
10
|
+
以下为面向开发者/维护者的实现说明:
|
|
2
11
|
|
|
3
12
|
Embeds the dsh-remote configuration UI into dsh web itself (the "everything is a
|
|
4
13
|
plugin" model):
|
|
@@ -713,7 +713,10 @@ window.__ModuleLoader__.load({
|
|
|
713
713
|
post("/dsh-remote/self/uninstall", {}).then(function (b) {
|
|
714
714
|
if (b && b.ok) {
|
|
715
715
|
setArmed(false);
|
|
716
|
-
|
|
716
|
+
// 优先展示服务端 detail(含 bridge 自启动/配置目录的逐项清理结果与重启提示);
|
|
717
|
+
// 兜底文案同样说明 bridge 自启动服务与本地配置目录会一并移除/清空
|
|
718
|
+
var detail = b && b.detail ? String(b.detail) : "";
|
|
719
|
+
setSelfMsg({ kind: "ok", text: detail || "已彻底卸载:插件引用、bridge 自启动服务与本地配置目录(账号/密钥/运行时等)已一并移除并清空。请重启 dsh web 后完全生效(本栏目将消失);如需再次使用,在插件市场重新安装即可。" });
|
|
717
720
|
} else {
|
|
718
721
|
setArmed(false);
|
|
719
722
|
setSelfMsg({ kind: "err", text: "卸载失败:" + ((b && (b.error || b.detail)) || "未知错误") });
|
|
@@ -762,7 +765,7 @@ window.__ModuleLoader__.load({
|
|
|
762
765
|
log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
|
|
763
766
|
selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
|
|
764
767
|
h("div", { className: "dru-hint", style: { marginTop: 8 } },
|
|
765
|
-
armed ? "
|
|
768
|
+
armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(macOS com.dshremote.bridge / Linux dsh-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用请在插件市场重新安装。" :
|
|
766
769
|
"插件市场没有更新/卸载按钮(dsh 官方市场暂不提供),本卡片即官方管理入口:检测新版、一键在线更新、彻底卸载都在这里完成。")
|
|
767
770
|
);
|
|
768
771
|
}
|
|
@@ -1174,7 +1177,16 @@ window.__ModuleLoader__.load({
|
|
|
1174
1177
|
: h("button", { type: "button", className: "dru-btn dru-btn-danger", disabled: busy !== "", onClick: function () { toggleBridge(false); } }, busy === "stop" ? "停止中…" : "停止 bridge")
|
|
1175
1178
|
),
|
|
1176
1179
|
h("div", { className: "dru-meta" }, st && st.config && st.config.deviceId ? "设备 ID:" + st.config.deviceId : "设备 ID:生成中"),
|
|
1177
|
-
h("div", { className: "dru-meta" }, st ? (st.service && st.service.plistExists ? "自启动服务已安装" : "自启动服务未安装(启动时自动创建)") : "")
|
|
1180
|
+
h("div", { className: "dru-meta" }, st ? (st.service && st.service.plistExists ? "自启动服务已安装" : "自启动服务未安装(启动时自动创建)") : ""),
|
|
1181
|
+
st && st.service && st.service.bindError
|
|
1182
|
+
? h("div", { className: "dru-msg dru-msg-err", style: { marginTop: 8 } },
|
|
1183
|
+
"⚠️ 设备注册失败:" + (st.service.bindError.message || "未说明原因"),
|
|
1184
|
+
h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
1185
|
+
st.service.bindError.code === "device_limit_exceeded"
|
|
1186
|
+
? "已达本套餐设备数上限。若是同一台电脑重装,稍等片刻会自动顶替旧设备;仍未恢复请在手机端「设备管理」解绑旧设备(免费用户每月可解绑 3 次)后,回到这里点「启动 bridge」。"
|
|
1187
|
+
: "请确认网络与账号状态后重试;仍未解决可点下方「彻底卸载」后重新安装。")
|
|
1188
|
+
)
|
|
1189
|
+
: null
|
|
1178
1190
|
),
|
|
1179
1191
|
// 关于 dsh-remote(开源项目说明卡片)
|
|
1180
1192
|
card("📖 关于 dsh-remote", [
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
|
|
6
6
|
// - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
|
|
7
7
|
// - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
|
|
8
|
-
// 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge
|
|
8
|
+
// 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge);
|
|
9
|
+
// 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
|
|
10
|
+
// 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
|
|
9
11
|
// - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
|
|
10
12
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
11
13
|
//
|
|
12
14
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
13
15
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
|
|
14
|
-
import { join, dirname } from "node:path";
|
|
16
|
+
import { join, dirname, sep } from "node:path";
|
|
15
17
|
import { execSync, spawn } from "node:child_process";
|
|
16
18
|
import { homedir, hostname, platform } from "node:os";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -231,6 +233,7 @@ function setCookieOf(res) {
|
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
async function mintHarnessCookie(ctx, relayDir) {
|
|
236
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再代持会话、不再重建配置目录
|
|
234
237
|
try {
|
|
235
238
|
const port = ctx.webServer?.port;
|
|
236
239
|
if (!port) return false;
|
|
@@ -248,6 +251,8 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
248
251
|
const res = await fetch(tokenUrl, { redirect: "manual", signal: AbortSignal.timeout(6000) });
|
|
249
252
|
const cookie = setCookieOf(res).split(";")[0].trim();
|
|
250
253
|
if (!cookie || !cookie.startsWith("dsh-auth-")) return false;
|
|
254
|
+
// 竞态兜底:fetch 期间用户点击了「彻底卸载」→ 不得重建已被清空的配置目录
|
|
255
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false;
|
|
251
256
|
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
252
257
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
253
258
|
writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
@@ -366,6 +371,7 @@ function appendLogLine(relayDir, name, line) {
|
|
|
366
371
|
* 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
|
|
367
372
|
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
368
373
|
function ensureRuntime(relayDir) {
|
|
374
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
369
375
|
if (existsSync(join(relayDir, "dsh-setup.mjs"))) return true;
|
|
370
376
|
const marker = join(relayDir, PROVISION_MARKER);
|
|
371
377
|
if (existsSync(marker)) return false; // 正在安装中
|
|
@@ -422,6 +428,10 @@ function writeAutostartFile(relayDir) {
|
|
|
422
428
|
|
|
423
429
|
/** 启动 bridge:确保 plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
424
430
|
function startBridge(relayDir) {
|
|
431
|
+
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
432
|
+
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
433
|
+
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
434
|
+
}
|
|
425
435
|
const plistPath = launchAgentPath();
|
|
426
436
|
if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
|
|
427
437
|
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
@@ -444,6 +454,7 @@ function scheduleRuntime(relayDir) {
|
|
|
444
454
|
let done = false;
|
|
445
455
|
const iv = setInterval(() => {
|
|
446
456
|
if (done) { clearInterval(iv); return; }
|
|
457
|
+
if (UNINSTALLED_DIRS.has(relayDir)) { done = true; clearInterval(iv); return; } // 已彻底卸载:自愈 watcher 停摆
|
|
447
458
|
try {
|
|
448
459
|
const cfg = loadConfig(relayDir);
|
|
449
460
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
@@ -471,6 +482,112 @@ function stopBridge() {
|
|
|
471
482
|
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
|
|
472
483
|
}
|
|
473
484
|
|
|
485
|
+
// ---------- 彻底卸载:bridge 自启动 / 残留进程 / 配置目录 ----------
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 已执行「彻底卸载」的 relayDir 集合。卸载动作本身不改代码,但 dsh web 重启前本插件仍在内存中:
|
|
489
|
+
* 自愈调度(scheduleRuntime/ensureRuntime/startBridge)与浏览器会话代持(mintHarnessCookie)
|
|
490
|
+
* 若继续执行,会把刚清空的配置目录 / 自启动服务重新拉起来——故卸载后本进程内一律停摆,
|
|
491
|
+
* 直到 dsh web 重启(profile 引用已移除,插件整体不再加载)或重新激活(apply 时清除)。
|
|
492
|
+
*/
|
|
493
|
+
const UNINSTALLED_DIRS = new Set();
|
|
494
|
+
|
|
495
|
+
/** 标记某 relayDir 已完成彻底卸载(其后续自愈/代持调度全部停摆)。 */
|
|
496
|
+
function markUninstalled(relayDir) {
|
|
497
|
+
if (relayDir) UNINSTALLED_DIRS.add(relayDir);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 彻底卸载 —— 「运行时/bridge」部分:把本机 dsh-remote 运行时回归到未安装状态。
|
|
502
|
+
* 执行顺序(每步独立 try/catch,单项失败不致命,不影响后续步骤;结果以标志位返回):
|
|
503
|
+
* 1) 停掉自启动服务并移除自启动文件:macOS launchctl bootout com.dshremote.bridge +
|
|
504
|
+
* 删 ~/Library/LaunchAgents/com.dshremote.bridge.plist;Linux systemctl --user
|
|
505
|
+
* stop/disable dsh-bridge(+ 删 ~/.config/systemd/user/dsh-bridge.service)。
|
|
506
|
+
* ⚠ 必须先停服务再删配置目录:否则 launchd KeepAlive / systemd Restart 会立刻
|
|
507
|
+
* 重启一个「指向已被删除文件」的进程;
|
|
508
|
+
* 2) 杀掉仍存活的手动 watcher/bridge 进程(launchd/systemd 托管的进程已随 bootout 结束,
|
|
509
|
+
* manualStatus() 本身也排除了本进程与 launchd 托管链);
|
|
510
|
+
* 3) rm -rf 配置目录 relayDir(账号/设备密钥/.dsh-config.json/.harness-cookie.json/
|
|
511
|
+
* 固化运行时 dsh-setup.mjs + clients 等全部残留)。
|
|
512
|
+
* 安全护栏:
|
|
513
|
+
* - DSH_RELAY_SKIP_SERVICE=1(测试隔离开关,生产勿设):跳过 1/2 的一切系统级操作,
|
|
514
|
+
* 只清理配置目录——避免测试真的去 launchctl / systemctl / kill 真实服务;
|
|
515
|
+
* - 自启动文件只处理「属于当前 HOME 的 plist/unit」,误配/测试环境不碰同名真实服务;
|
|
516
|
+
* - 配置目录删除前校验:不是 "/"、不是家目录、不是 dsh web profile 目录或其父级(防误删用户数据)。
|
|
517
|
+
*/
|
|
518
|
+
function uninstallRuntime(relayDir, protectedPath) {
|
|
519
|
+
const out = {
|
|
520
|
+
stoppedService: false, // 自启动服务原本在运行且已停止
|
|
521
|
+
removedPlist: false, // 自启动文件(plist / systemd unit)已删除
|
|
522
|
+
killedPids: [], // 额外结束的残留进程 pid 列表
|
|
523
|
+
removedDir: false, // 配置目录 relayDir 已整目录清空
|
|
524
|
+
servicePlatform: platform() === "darwin" ? "launchd" : platform() === "linux" ? "systemd" : "none",
|
|
525
|
+
};
|
|
526
|
+
const skipService = process.env.DSH_RELAY_SKIP_SERVICE === "1";
|
|
527
|
+
if (!skipService) {
|
|
528
|
+
// a) 停服务 + 移除自启动文件
|
|
529
|
+
try {
|
|
530
|
+
if (platform() === "darwin") {
|
|
531
|
+
// 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
|
|
532
|
+
const plistPath = launchAgentPath();
|
|
533
|
+
if (plistPath && existsSync(plistPath)) {
|
|
534
|
+
if (launchdStatus().running) {
|
|
535
|
+
const r = stopBridge(); // launchctl bootout → KeepAlive 一并失效
|
|
536
|
+
out.stoppedService = r.ok;
|
|
537
|
+
}
|
|
538
|
+
rmSync(plistPath, { force: true });
|
|
539
|
+
out.removedPlist = !existsSync(plistPath);
|
|
540
|
+
}
|
|
541
|
+
} else if (platform() === "linux") {
|
|
542
|
+
// systemd --user 用户态服务,与 dsh-setup.mjs 安装的 dsh-bridge 同名
|
|
543
|
+
const isActive = sh("systemctl --user is-active dsh-bridge");
|
|
544
|
+
if (isActive.ok && String(isActive.stdout).trim() === "active") {
|
|
545
|
+
sh("systemctl --user stop dsh-bridge");
|
|
546
|
+
const after = sh("systemctl --user is-active dsh-bridge");
|
|
547
|
+
out.stoppedService = !(after.ok && String(after.stdout).trim() === "active");
|
|
548
|
+
}
|
|
549
|
+
sh("systemctl --user disable dsh-bridge"); // 幂等;失败不致命
|
|
550
|
+
const unitPath = join(homedir(), ".config", "systemd", "user", "dsh-bridge.service");
|
|
551
|
+
if (existsSync(unitPath)) {
|
|
552
|
+
try { rmSync(unitPath, { force: true }); } catch { /* 非关键 */ }
|
|
553
|
+
sh("systemctl --user daemon-reload");
|
|
554
|
+
out.removedPlist = !existsSync(unitPath);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch { /* 服务清理失败不致命:目录照常清理,剩余残留可由用户手动处理 */ }
|
|
558
|
+
// b) 杀残留手动进程(launchd 托管的已随 bootout 结束;manualStatus 排除本进程)
|
|
559
|
+
try {
|
|
560
|
+
const manual = manualStatus();
|
|
561
|
+
const targets = [...manual.watcher, ...manual.bridge];
|
|
562
|
+
for (const pid of targets) {
|
|
563
|
+
try { process.kill(pid, "SIGTERM"); out.killedPids.push(pid); } catch { /* EPERM/ESRCH 忽略 */ }
|
|
564
|
+
}
|
|
565
|
+
if (targets.length) {
|
|
566
|
+
try { execSync("sleep 1", { timeout: 3000 }); } catch { /* 等待进程退出 */ }
|
|
567
|
+
for (const pid of targets) {
|
|
568
|
+
if (pidAlive(pid)) {
|
|
569
|
+
try { process.kill(pid, "SIGKILL"); } catch { /* 已退出 */ }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} catch { /* ignore */ }
|
|
574
|
+
}
|
|
575
|
+
// c) 清空配置目录(账号/密钥/会话 cookie/固化运行时等全部残留)
|
|
576
|
+
try {
|
|
577
|
+
const isRoot = dirname(relayDir) === relayDir; // "/" 或盘符根
|
|
578
|
+
const isHome = relayDir === homedir();
|
|
579
|
+
const hitsProfile = Boolean(protectedPath) && (
|
|
580
|
+
relayDir === protectedPath || relayDir.startsWith(protectedPath + sep)
|
|
581
|
+
|| protectedPath.startsWith(relayDir + sep)
|
|
582
|
+
); // 配置目录误指向 dsh web profile → 绝不整目录删除
|
|
583
|
+
if (relayDir && !isRoot && !isHome && !hitsProfile && existsSync(relayDir)) {
|
|
584
|
+
rmSync(relayDir, { recursive: true, force: true });
|
|
585
|
+
out.removedDir = !existsSync(relayDir);
|
|
586
|
+
}
|
|
587
|
+
} catch { /* 目录正被占用等:删除失败不致命(残留可由用户手动删除) */ }
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
|
|
474
591
|
// ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
|
|
475
592
|
|
|
476
593
|
async function relayFetch(relayDir, pathname, init) {
|
|
@@ -587,6 +704,12 @@ async function composeStatus(relayDir) {
|
|
|
587
704
|
const cfg = loadConfig(relayDir);
|
|
588
705
|
const launchd = launchdStatus();
|
|
589
706
|
const manual = manualStatus();
|
|
707
|
+
// 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
|
|
708
|
+
let bindError = null;
|
|
709
|
+
try {
|
|
710
|
+
const f = join(relayDir, ".bind-error.json");
|
|
711
|
+
if (existsSync(f)) bindError = JSON.parse(readFileSync(f, "utf8"));
|
|
712
|
+
} catch { /* 无/损坏忽略 */ }
|
|
590
713
|
// 远程地址(public-config 的 app_url,取不到用默认)
|
|
591
714
|
const pub = await relayFetch(relayDir, "/api/public-config");
|
|
592
715
|
const pubBody = pub.ok && pub.body && typeof pub.body === "object" ? pub.body : {};
|
|
@@ -610,6 +733,7 @@ async function composeStatus(relayDir) {
|
|
|
610
733
|
launchd,
|
|
611
734
|
manual,
|
|
612
735
|
running: launchd.running || manual.bridge.length > 0,
|
|
736
|
+
bindError,
|
|
613
737
|
},
|
|
614
738
|
host: hostname(),
|
|
615
739
|
};
|
|
@@ -713,7 +837,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
713
837
|
// ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
|
|
714
838
|
|
|
715
839
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
716
|
-
const PLUGIN_VERSION = "0.4.
|
|
840
|
+
const PLUGIN_VERSION = "0.4.8";
|
|
717
841
|
const UPDATE_LOG = ".dsh-update.log";
|
|
718
842
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
719
843
|
|
|
@@ -796,7 +920,7 @@ function tailOf(filePath, lines = 24) {
|
|
|
796
920
|
} catch { return ""; }
|
|
797
921
|
}
|
|
798
922
|
|
|
799
|
-
/**
|
|
923
|
+
/** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
|
|
800
924
|
function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
801
925
|
const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
|
|
802
926
|
try {
|
|
@@ -875,8 +999,30 @@ function registerRoutes(ctx, relayDir) {
|
|
|
875
999
|
method: "POST",
|
|
876
1000
|
path: "/dsh-remote/self/uninstall",
|
|
877
1001
|
handler: async (_req, res) => {
|
|
878
|
-
|
|
879
|
-
|
|
1002
|
+
// 彻底卸载 = ① 运行时/bridge 清理(停自启动 → 杀残留 → 清空配置目录,顺序防 KeepAlive 复活)
|
|
1003
|
+
// + ② 插件 profile 清理(include 块/依赖/bundle/本地目录与链接,解锁市场卸载)
|
|
1004
|
+
const rt = uninstallRuntime(relayDir, profileDir);
|
|
1005
|
+
const prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
|
|
1006
|
+
// 卸载后本进程内(直到重启)自愈/代持调度一律停摆,不再重建配置目录或拉起 bridge
|
|
1007
|
+
markUninstalled(relayDir);
|
|
1008
|
+
const bits = [];
|
|
1009
|
+
if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
|
|
1010
|
+
if (rt.stoppedService) bits.push("bridge 自启动服务已停止");
|
|
1011
|
+
if (rt.removedPlist) bits.push("自启动项已删除");
|
|
1012
|
+
if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
|
|
1013
|
+
if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
|
|
1014
|
+
bits.push("请重启 dsh web 后完全卸载生效(本插件与远程控制将消失);如需再次使用,在插件市场重新安装即可。");
|
|
1015
|
+
sendJson(res, 200, {
|
|
1016
|
+
ok: true,
|
|
1017
|
+
...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
|
|
1018
|
+
servicePlatform: rt.servicePlatform,
|
|
1019
|
+
stoppedService: rt.stoppedService,
|
|
1020
|
+
removedPlist: rt.removedPlist,
|
|
1021
|
+
killedPids: rt.killedPids,
|
|
1022
|
+
relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
|
|
1023
|
+
relayDir,
|
|
1024
|
+
detail: bits.join(";"),
|
|
1025
|
+
});
|
|
880
1026
|
},
|
|
881
1027
|
},
|
|
882
1028
|
{
|
|
@@ -1147,6 +1293,8 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1147
1293
|
*/
|
|
1148
1294
|
export function apply(ctx, config = {}) {
|
|
1149
1295
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
1296
|
+
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
1297
|
+
UNINSTALLED_DIRS.delete(relayDir);
|
|
1150
1298
|
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
1151
1299
|
sweepStaleMarkers(relayDir);
|
|
1152
1300
|
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-ui",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
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).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -175,6 +175,9 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
175
175
|
await writeFile(path.join(relayDir, ".dsh-config.json"), "{}");
|
|
176
176
|
|
|
177
177
|
try {
|
|
178
|
+
// 测试隔离开关:卸载路由跳过 launchctl / systemctl / 杀进程等系统级操作(本机真实 bridge 正由 launchd 运行,
|
|
179
|
+
// 不能被测试误停),只验证「插件 profile 清理 + 配置目录清空」逻辑。
|
|
180
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
178
181
|
// 关键:以 tempHome 为 HOME 启动,profileDir 默认推导才会落在 temp profile 而非真实 ~/.dsh
|
|
179
182
|
const routes = boot(tempHome, relayDir);
|
|
180
183
|
const { host, base } = await serve(routes);
|
|
@@ -186,6 +189,15 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
186
189
|
assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
|
|
187
190
|
assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
|
|
188
191
|
|
|
192
|
+
// 运行时/bridge 清理(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
|
|
193
|
+
assert.equal(r.relayDirRemoved, true, "配置目录 relayDir 应被整目录清空");
|
|
194
|
+
assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不做真实服务操作");
|
|
195
|
+
assert.equal(r.removedPlist, false, "DSH_RELAY_SKIP_SERVICE=1 → 不删自启动 plist");
|
|
196
|
+
assert.deepEqual(r.killedPids, [], "DSH_RELAY_SKIP_SERVICE=1 → 不杀进程");
|
|
197
|
+
assert.equal(existsSync(relayDir), false, "relayDir 物理上应已不存在");
|
|
198
|
+
assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
|
|
199
|
+
assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
|
|
200
|
+
|
|
189
201
|
const patch = await readFile(patchFile, "utf8");
|
|
190
202
|
assert.ok(!patch.includes("dsh-remote-ui"), "patch 不应再引用 dsh-remote-ui");
|
|
191
203
|
assert.ok(patch.includes("other-plugin"), "其他插件 include 不应被误伤");
|
|
@@ -198,6 +210,7 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
198
210
|
host.close();
|
|
199
211
|
}
|
|
200
212
|
} finally {
|
|
213
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
201
214
|
await rm(tempHome, { recursive: true, force: true });
|
|
202
215
|
}
|
|
203
216
|
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// 插件 node 半「彻底卸载(运行时/bridge 清理)」回归:/dsh-remote/self/uninstall 的新增行为。
|
|
2
|
+
// 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
|
|
3
|
+
// 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
|
|
4
|
+
// .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
|
|
5
|
+
// 卸载路由现在依次执行:profile 插件清理(uninstallSelf,原逻辑)+ 运行时清理(uninstallRuntime:
|
|
6
|
+
// 停自启动服务 → 删自启动文件 → 杀残留进程 → rm -rf 配置目录),全部幂等、单项失败不致命。
|
|
7
|
+
// 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
|
|
8
|
+
// - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
|
|
9
|
+
// - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { apply } from "../lib/index.js";
|
|
18
|
+
|
|
19
|
+
const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not edit)
|
|
20
|
+
- type: plugin
|
|
21
|
+
name: dsh-remote-ui
|
|
22
|
+
apply: dsh-remote-ui
|
|
23
|
+
# <<< dsh-remote-ui
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
|
|
27
|
+
function boot(relayDir) {
|
|
28
|
+
const routes = new Map();
|
|
29
|
+
apply({
|
|
30
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
31
|
+
effect(register) { return register(); },
|
|
32
|
+
logger: { info() {}, warn() {} }
|
|
33
|
+
}, { relayDir });
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 起一个指向 routes 的 http server,返回 base url。 */
|
|
38
|
+
async function serve(routes) {
|
|
39
|
+
const host = http.createServer((req, res) => {
|
|
40
|
+
const url = new URL(req.url, "http://x");
|
|
41
|
+
const handler = routes.get(url.pathname);
|
|
42
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 在 tempHome 下造一份「真实安装痕迹」:profile(patch+package.json+插件目录)+ relayDir(配置/密钥/运行时)。 */
|
|
49
|
+
async function plantInstallation(tempHome, opts = {}) {
|
|
50
|
+
const relayDir = opts.relayDir || path.join(tempHome, "relay");
|
|
51
|
+
const profile = path.join(tempHome, ".dsh", "profiles", "web");
|
|
52
|
+
await mkdir(profile, { recursive: true });
|
|
53
|
+
await writeFile(path.join(profile, "cordis.patch.yml"), `base: .\ninclude:\n${PLUGIN_BLOCK} - other-plugin\n`);
|
|
54
|
+
await writeFile(path.join(profile, "package.json"), JSON.stringify({
|
|
55
|
+
dependencies: { "dsh-remote-ui": "github:mrRisega/dsh-remote#path:/packages/dsh-remote-ui", "other": "^1.0.0" },
|
|
56
|
+
dsh: { profile: { bundles: ["dsh-remote-ui", "other-bundle"] } },
|
|
57
|
+
}));
|
|
58
|
+
if (!opts.skipPluginDirs) {
|
|
59
|
+
await mkdir(path.join(profile, "dsh-remote-ui-plugin"), { recursive: true });
|
|
60
|
+
await mkdir(path.join(profile, "node_modules", "dsh-remote-ui"), { recursive: true });
|
|
61
|
+
await writeFile(path.join(profile, "dsh-remote-ui-plugin", "index.js"), "// stub");
|
|
62
|
+
await writeFile(path.join(profile, "node_modules", "dsh-remote-ui", "index.js"), "// stub");
|
|
63
|
+
}
|
|
64
|
+
if (!opts.skipRelay) {
|
|
65
|
+
// 模拟固化运行时 + 账号/密钥/会话 cookie/安装标记等全部残留
|
|
66
|
+
await mkdir(path.join(relayDir, "clients", "dsh-remote"), { recursive: true });
|
|
67
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
68
|
+
phone: "13800000000", password: "pw", device_id: "dev", device_private_key: "pk", local_key: "lk",
|
|
69
|
+
}));
|
|
70
|
+
await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
71
|
+
await writeFile(path.join(relayDir, "clients", "dsh-remote", "dsh-bridge.mjs"), "// bridge stub");
|
|
72
|
+
await writeFile(path.join(relayDir, ".harness-cookie.json"), JSON.stringify({ authority: "127.0.0.1:3080", cookie: "dsh-auth-x=1" }));
|
|
73
|
+
await writeFile(path.join(relayDir, ".dsh-setup-installing"), JSON.stringify({ pid: 999999, at: Date.now() }));
|
|
74
|
+
await writeFile(path.join(relayDir, ".dsh-update-running"), JSON.stringify({ pid: 999999, at: Date.now() }));
|
|
75
|
+
await writeFile(path.join(relayDir, "unrelated-marker.txt"), "keep-me");
|
|
76
|
+
}
|
|
77
|
+
return { relayDir, profile };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行时),不误伤 profile 其它条目与家目录其它数据", async () => {
|
|
81
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-"));
|
|
82
|
+
const prevHome = process.env.HOME;
|
|
83
|
+
process.env.HOME = tempHome;
|
|
84
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 测试隔离:跳过 launchctl/systemctl/杀进程
|
|
85
|
+
try {
|
|
86
|
+
const { relayDir, profile } = await plantInstallation(tempHome);
|
|
87
|
+
// 家目录里放一份「用户自己的数据」:卸载绝不能误删
|
|
88
|
+
await writeFile(path.join(tempHome, "my-notes.txt"), "user data");
|
|
89
|
+
const routes = boot(relayDir);
|
|
90
|
+
const { host, base } = await serve(routes);
|
|
91
|
+
try {
|
|
92
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
93
|
+
assert.equal(r.ok, true);
|
|
94
|
+
// profile 清理(原 uninstallSelf 行为不变)
|
|
95
|
+
assert.equal(r.removedPatch, true);
|
|
96
|
+
assert.equal(r.removedDep, true);
|
|
97
|
+
assert.equal(r.removedBundle, true);
|
|
98
|
+
assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
|
|
99
|
+
// 运行时清理标志位
|
|
100
|
+
assert.equal(r.relayDirRemoved, true, "relayDir 应被整目录清空");
|
|
101
|
+
assert.equal(r.servicePlatform, "launchd", "macOS 下 servicePlatform=launchd");
|
|
102
|
+
assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不触碰真实 launchd 服务");
|
|
103
|
+
assert.equal(r.removedPlist, false);
|
|
104
|
+
assert.deepEqual(r.killedPids, []);
|
|
105
|
+
// 物理断言:relayDir 与其中全部残留都消失
|
|
106
|
+
assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
|
|
107
|
+
assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
|
|
108
|
+
assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
|
|
109
|
+
assert.equal(existsSync(path.join(relayDir, "clients")), false);
|
|
110
|
+
// 不误伤:profile 的其它 include/依赖仍在;家目录其它文件仍在
|
|
111
|
+
const patch = await readFile(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
112
|
+
assert.ok(!patch.includes("dsh-remote-ui") && patch.includes("other-plugin"), "patch 只移除插件条目");
|
|
113
|
+
const pkg = JSON.parse(await readFile(path.join(profile, "package.json"), "utf8"));
|
|
114
|
+
assert.equal(pkg.dependencies["other"], "^1.0.0");
|
|
115
|
+
assert.deepEqual(pkg.dsh.profile.bundles, ["other-bundle"]);
|
|
116
|
+
assert.equal(existsSync(path.join(tempHome, "my-notes.txt")), true, "家目录其它用户数据不得被删");
|
|
117
|
+
assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
|
|
118
|
+
// 人类可读 detail
|
|
119
|
+
assert.match(String(r.detail), /插件引用与本地文件已移除/);
|
|
120
|
+
assert.match(String(r.detail), /配置目录已清空/);
|
|
121
|
+
assert.match(String(r.detail), /请重启 dsh web/);
|
|
122
|
+
} finally {
|
|
123
|
+
host.close();
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
127
|
+
process.env.HOME = prevHome;
|
|
128
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir 不存在仍返回成功且不虚报「已清空」", async () => {
|
|
133
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-nodir-"));
|
|
134
|
+
const prevHome = process.env.HOME;
|
|
135
|
+
process.env.HOME = tempHome;
|
|
136
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
137
|
+
try {
|
|
138
|
+
// profile 有插件引用,但 relayDir(~/.dsh-remote)从未被创建
|
|
139
|
+
const relayDir = path.join(tempHome, "relay");
|
|
140
|
+
await plantInstallation(tempHome, { skipRelay: true });
|
|
141
|
+
assert.equal(existsSync(relayDir), false);
|
|
142
|
+
const routes = boot(relayDir);
|
|
143
|
+
const { host, base } = await serve(routes);
|
|
144
|
+
try {
|
|
145
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
146
|
+
assert.equal(r.ok, true);
|
|
147
|
+
assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
|
|
148
|
+
assert.equal(r.relayDirRemoved, false, "目录本就不存在 → 不声称已清空");
|
|
149
|
+
assert.equal(r.stoppedService, false);
|
|
150
|
+
assert.equal(r.removedPlist, false);
|
|
151
|
+
assert.deepEqual(r.killedPids, []);
|
|
152
|
+
const detail = String(r.detail);
|
|
153
|
+
assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
|
|
154
|
+
assert.match(detail, /请重启 dsh web/);
|
|
155
|
+
} finally {
|
|
156
|
+
host.close();
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
160
|
+
process.env.HOME = prevHome;
|
|
161
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,不抛错不崩溃", async () => {
|
|
166
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-empty-"));
|
|
167
|
+
const prevHome = process.env.HOME;
|
|
168
|
+
process.env.HOME = tempHome;
|
|
169
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
170
|
+
try {
|
|
171
|
+
const relayDir = path.join(tempHome, "relay"); // 不存在
|
|
172
|
+
const routes = boot(relayDir);
|
|
173
|
+
const { host, base } = await serve(routes);
|
|
174
|
+
try {
|
|
175
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
176
|
+
assert.equal(r.ok, true);
|
|
177
|
+
assert.equal(r.relayDirRemoved, false);
|
|
178
|
+
assert.equal(r.removedPatch, false);
|
|
179
|
+
assert.equal(r.removedDep, false);
|
|
180
|
+
assert.equal(r.removedBundle, false);
|
|
181
|
+
assert.deepEqual(r.killedPids, []);
|
|
182
|
+
assert.match(String(r.detail), /请重启 dsh web/);
|
|
183
|
+
} finally {
|
|
184
|
+
host.close();
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
188
|
+
process.env.HOME = prevHome;
|
|
189
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也不执行系统级清理,仅清空配置目录", async () => {
|
|
194
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-gate-"));
|
|
195
|
+
const prevHome = process.env.HOME;
|
|
196
|
+
process.env.HOME = tempHome;
|
|
197
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
198
|
+
try {
|
|
199
|
+
const { relayDir } = await plantInstallation(tempHome);
|
|
200
|
+
// 在「当前 HOME 的 LaunchAgents」放一个真实存在的 plist:开关必须挡下删除动作
|
|
201
|
+
const fakePlist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
202
|
+
await mkdir(path.dirname(fakePlist), { recursive: true });
|
|
203
|
+
await writeFile(fakePlist, "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
204
|
+
const routes = boot(relayDir);
|
|
205
|
+
const { host, base } = await serve(routes);
|
|
206
|
+
try {
|
|
207
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
208
|
+
assert.equal(r.ok, true);
|
|
209
|
+
assert.equal(r.removedPlist, false, "开关置位 → plist 不得被删");
|
|
210
|
+
assert.equal(existsSync(fakePlist), true, "plist 应原样保留(开关只放行配置目录清理)");
|
|
211
|
+
assert.equal(r.relayDirRemoved, true, "配置目录清理不受开关影响");
|
|
212
|
+
assert.equal(existsSync(relayDir), false);
|
|
213
|
+
} finally {
|
|
214
|
+
host.close();
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
218
|
+
process.env.HOME = prevHome;
|
|
219
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", async () => {
|
|
224
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
|
|
225
|
+
const prevHome = process.env.HOME;
|
|
226
|
+
const prevPath = process.env.PATH;
|
|
227
|
+
const prevLog = process.env.DSH_TEST_LAUNCH_LOG;
|
|
228
|
+
process.env.HOME = tempHome;
|
|
229
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 明确走无开关的真实分支
|
|
230
|
+
const launchLog = path.join(tempHome, "launchctl.log");
|
|
231
|
+
try {
|
|
232
|
+
// 假系统命令:launchctl 只记录参数、绝不报 running;pgrep/ps 一律空
|
|
233
|
+
const fakeBin = path.join(tempHome, "bin");
|
|
234
|
+
await mkdir(fakeBin, { recursive: true });
|
|
235
|
+
const fakeLaunchctl = `#!/bin/sh
|
|
236
|
+
printf '%s\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
|
|
237
|
+
exit 0
|
|
238
|
+
`;
|
|
239
|
+
const fakeNoMatch = "#!/bin/sh\nexit 1\n";
|
|
240
|
+
writeFileSync(path.join(fakeBin, "launchctl"), fakeLaunchctl); chmodSync(path.join(fakeBin, "launchctl"), 0o755);
|
|
241
|
+
writeFileSync(path.join(fakeBin, "pgrep"), fakeNoMatch); chmodSync(path.join(fakeBin, "pgrep"), 0o755);
|
|
242
|
+
writeFileSync(path.join(fakeBin, "ps"), fakeNoMatch); chmodSync(path.join(fakeBin, "ps"), 0o755);
|
|
243
|
+
process.env.PATH = `${fakeBin}:${prevPath}`;
|
|
244
|
+
process.env.DSH_TEST_LAUNCH_LOG = launchLog;
|
|
245
|
+
|
|
246
|
+
const { relayDir } = await plantInstallation(tempHome);
|
|
247
|
+
// 当前 HOME 的 LaunchAgents 里有 plist(服务此前装过,但现在未运行)
|
|
248
|
+
const plist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
249
|
+
await mkdir(path.dirname(plist), { recursive: true });
|
|
250
|
+
await writeFile(plist, "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
251
|
+
|
|
252
|
+
const routes = boot(relayDir);
|
|
253
|
+
const { host, base } = await serve(routes);
|
|
254
|
+
try {
|
|
255
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
256
|
+
assert.equal(r.ok, true);
|
|
257
|
+
assert.equal(r.removedPlist, true, "plist 存在于当前 HOME → 应被删除");
|
|
258
|
+
assert.equal(existsSync(plist), false);
|
|
259
|
+
assert.equal(r.stoppedService, false, "服务未运行 → 无需 bootout,不虚报已停止");
|
|
260
|
+
assert.deepEqual(r.killedPids, [], "无残留进程可杀");
|
|
261
|
+
assert.equal(r.relayDirRemoved, true);
|
|
262
|
+
assert.equal(existsSync(relayDir), false);
|
|
263
|
+
// 关键:全程不得发出 bootout(避免任何真实/假服务被停)
|
|
264
|
+
const log = readFileSync(launchLog, "utf8");
|
|
265
|
+
assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
|
|
266
|
+
} finally {
|
|
267
|
+
host.close();
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
if (prevLog === undefined) delete process.env.DSH_TEST_LAUNCH_LOG; else process.env.DSH_TEST_LAUNCH_LOG = prevLog;
|
|
271
|
+
process.env.PATH = prevPath;
|
|
272
|
+
process.env.HOME = prevHome;
|
|
273
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整目录删除(防误删 dsh 本体)", async () => {
|
|
278
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-protect-"));
|
|
279
|
+
const prevHome = process.env.HOME;
|
|
280
|
+
process.env.HOME = tempHome;
|
|
281
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
282
|
+
try {
|
|
283
|
+
// relayDir 与 profileDir 相同(异常配置):卸载只清插件引用/文件,绝不能 rm 整个 profile
|
|
284
|
+
const profile = path.join(tempHome, ".dsh", "profiles", "web");
|
|
285
|
+
const { relayDir } = await plantInstallation(tempHome, { relayDir: profile, skipRelay: true });
|
|
286
|
+
assert.equal(relayDir, profile);
|
|
287
|
+
const routes = boot(relayDir);
|
|
288
|
+
const { host, base } = await serve(routes);
|
|
289
|
+
try {
|
|
290
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
291
|
+
assert.equal(r.ok, true);
|
|
292
|
+
assert.equal(r.relayDirRemoved, false, "profile 目录受保护 → 不得整目录删除");
|
|
293
|
+
assert.equal(existsSync(profile), true, "dsh web profile 必须存活");
|
|
294
|
+
assert.equal(existsSync(path.join(profile, "package.json")), true);
|
|
295
|
+
assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
|
|
296
|
+
assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);
|
|
297
|
+
} finally {
|
|
298
|
+
host.close();
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
302
|
+
process.env.HOME = prevHome;
|
|
303
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("源码约束:浏览器半彻底卸载文案与行为说明 bridge 服务与本地配置一并移除/清空", () => {
|
|
308
|
+
const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
309
|
+
// 二次确认文案:说明会停 bridge 自启动、清配置目录(不再有旧的「bridge 与数据目录保留」误导)
|
|
310
|
+
assert.match(source, /停止并移除 bridge 自启动服务/);
|
|
311
|
+
assert.match(source, /清空本地配置目录/);
|
|
312
|
+
assert.ok(!source.includes("bridge 与数据目录保留"), "旧文案已移除:卸载不再保留 bridge 与数据目录");
|
|
313
|
+
// 成功提示:优先展示服务端 detail,兜底文案同样声明 bridge/本地配置已移除
|
|
314
|
+
assert.match(source, /b\.detail/);
|
|
315
|
+
assert.match(source, /bridge 自启动服务与本地配置目录/);
|
|
316
|
+
assert.match(source, /请重启 dsh web/);
|
|
317
|
+
});
|