@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
package/README.md
CHANGED
|
@@ -30,6 +30,12 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
|
|
|
30
30
|
|
|
31
31
|
## 0.6.7 速览(Windows 可用版)
|
|
32
32
|
|
|
33
|
+
> **预发中**:先以 `0.6.7-beta.x` 发布在 npm `beta` 通道(`latest` 仍是 0.6.6),
|
|
34
|
+
> 待 Windows 真机验证通过后转正式版。安装预发版:
|
|
35
|
+
> `npx @mrrisega/dsh-remote@0.6.7-beta.3`;若 dsh web 已起不来,用
|
|
36
|
+
> `npx @mrrisega/dsh-remote@0.6.7-beta.3 repair`(只修 profile,不联网)。
|
|
37
|
+
|
|
38
|
+
|
|
33
39
|
0.6.6 及更早的插件半整套「服务状态 / 启停 / 重启」是按 macOS/Linux 写死的
|
|
34
40
|
(launchctl / systemd / pgrep / ps / /bin/sh),**在 Windows 上安装一切正常、运行期必死**:
|
|
35
41
|
面板红字「读取状态失败: process.getuid is not a function」、状态永远停在「查询中…」、
|
|
@@ -154,8 +160,16 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
154
160
|
校验值用于登录,与加密密钥域分离(详见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md))。
|
|
155
161
|
- 请**牢记账号密码**:修改/重置密码会使全部旧设备会话失效,且服务端不保存你的内容、
|
|
156
162
|
无法代为解密旧会话;改密后需在电脑端面板重新登录并重启 bridge,手机端重新解锁。
|
|
157
|
-
- 电脑端本机配置(`.dsh-config.json
|
|
163
|
+
- 电脑端本机配置(`.dsh-config.json`)会保存账号密码用于自动登录,等于该账号
|
|
158
164
|
内容的“解密权”,请妥善保护电脑;电脑被他人使用期间请退出登录。
|
|
165
|
+
- macOS / Linux:文件权限 **0600**(仅本人可读写)。
|
|
166
|
+
- Windows:**`0600` 在 Windows 上无效**(Windows 用 ACL,不是 POSIX 权限位)——
|
|
167
|
+
早期版本只写了 `mode: 0o600`,实测该文件拿到的仍是用户目录的**默认继承 ACL**,
|
|
168
|
+
等于没有这层保护。0.6.7-beta.3 起安装器/插件/bridge 都会显式收紧:`icacls` 断开继承、
|
|
169
|
+
只授予当前用户。若收紧失败(无 icacls / 权限异常),安装日志会明确告警,不会假装成功。
|
|
170
|
+
- 无论哪个平台:**不要把 `.dsh-remote` 目录交给备份/同步盘/他人排查**——
|
|
171
|
+
里面的 `.dsh-config.json`(明文账号密码)与 `.harness-cookie.json`(dsh web 会话 Cookie,
|
|
172
|
+
等于该会话的完整访问权)被复制走就等于把钥匙一起给了对方。发支持包前请先删除这两项。
|
|
159
173
|
|
|
160
174
|
**边界与建议(如实告知)**
|
|
161
175
|
|
|
@@ -65,7 +65,7 @@ import WebSocket from "ws";
|
|
|
65
65
|
import fs from "node:fs";
|
|
66
66
|
import os from "node:os";
|
|
67
67
|
import path from "node:path";
|
|
68
|
-
import { execSync } from "node:child_process";
|
|
68
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
69
69
|
import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
|
|
70
70
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
71
71
|
import { promisify } from "node:util";
|
|
@@ -200,10 +200,40 @@ function loadLocalConfig() {
|
|
|
200
200
|
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* 把文件权限收紧到「只有本人可读写」。
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
|
|
207
|
+
* 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
|
|
208
|
+
* 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
|
|
209
|
+
* 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
|
|
210
|
+
* (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
|
|
211
|
+
* 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
|
|
212
|
+
* 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
|
|
213
|
+
*/
|
|
214
|
+
let hardenWarned = false;
|
|
215
|
+
function hardenFile(file) {
|
|
216
|
+
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
217
|
+
if (process.platform !== "win32") return;
|
|
218
|
+
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
219
|
+
if (!who) return;
|
|
220
|
+
let r;
|
|
221
|
+
try {
|
|
222
|
+
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
223
|
+
{ windowsHide: true, encoding: "utf8", timeout: 8000 });
|
|
224
|
+
} catch (e) { r = { status: -1, stderr: e.message }; }
|
|
225
|
+
if (r.status !== 0 && !hardenWarned) {
|
|
226
|
+
hardenWarned = true;
|
|
227
|
+
console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
|
|
228
|
+
console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
203
232
|
function saveLocalConfig(cfg) {
|
|
204
233
|
try {
|
|
205
234
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
206
235
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
236
|
+
hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
|
|
207
237
|
} catch (e) {
|
|
208
238
|
console.warn(`[bridge] 无法写配置 ${CONFIG_PATH}: ${e.message}`);
|
|
209
239
|
}
|
package/dsh-setup.mjs
CHANGED
|
@@ -161,6 +161,7 @@ function printHelp() {
|
|
|
161
161
|
dsh-remote run 前台运行 bridge(调试)
|
|
162
162
|
dsh-remote status 查看配置与服务状态
|
|
163
163
|
dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
|
|
164
|
+
dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
|
|
164
165
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
165
166
|
|
|
166
167
|
自建模式(可选):
|
|
@@ -256,6 +257,35 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
|
|
|
256
257
|
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
257
258
|
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
258
259
|
|
|
260
|
+
/**
|
|
261
|
+
* 把文件权限收紧到「只有本人可读写」。
|
|
262
|
+
*
|
|
263
|
+
* ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
|
|
264
|
+
* 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
|
|
265
|
+
* 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
|
|
266
|
+
* 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
|
|
267
|
+
* (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
|
|
268
|
+
* 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
|
|
269
|
+
* 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
|
|
270
|
+
*/
|
|
271
|
+
let hardenWarned = false;
|
|
272
|
+
function hardenFile(file) {
|
|
273
|
+
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
274
|
+
if (process.platform !== "win32") return;
|
|
275
|
+
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
276
|
+
if (!who) return;
|
|
277
|
+
let r;
|
|
278
|
+
try {
|
|
279
|
+
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
280
|
+
{ windowsHide: true, encoding: "utf8", timeout: 8000 });
|
|
281
|
+
} catch (e) { r = { status: -1, stderr: e.message }; }
|
|
282
|
+
if (r.status !== 0 && !hardenWarned) {
|
|
283
|
+
hardenWarned = true;
|
|
284
|
+
console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
|
|
285
|
+
console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
// ---------- 配置读写 ----------
|
|
260
290
|
function loadConfig() {
|
|
261
291
|
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
|
|
@@ -264,6 +294,7 @@ function loadConfig() {
|
|
|
264
294
|
function saveConfig(cfg) {
|
|
265
295
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
266
296
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
297
|
+
hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
|
|
267
298
|
}
|
|
268
299
|
|
|
269
300
|
// ---------- 公共配置(从服务端取域名,服务商可随时更换) ----------
|
|
@@ -753,7 +784,7 @@ await delay(1000);
|
|
|
753
784
|
try { process.kill(PLAN.pid); } catch (e) {}
|
|
754
785
|
const deadline = Date.now() + 30000;
|
|
755
786
|
while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
|
|
756
|
-
if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
|
|
787
|
+
if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
|
|
757
788
|
let out = "ignore";
|
|
758
789
|
try { out = openSync(PLAN.log, "a"); } catch (e) {}
|
|
759
790
|
const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
|
|
@@ -1327,6 +1358,48 @@ function validatePatchBase(text) {
|
|
|
1327
1358
|
return { ok: true, cleaned, insertedIds: ids };
|
|
1328
1359
|
}
|
|
1329
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* patch 文本是否是「合法的顶层 YAML 数组文档」。
|
|
1363
|
+
*
|
|
1364
|
+
* 为什么必须判:dsh 侧解析 cordis.patch.yml 时**要求顶层是数组**,而
|
|
1365
|
+
* **纯注释文档的 YAML 解析结果是 `null`(不是空数组)**,于是 dsh 直接启动失败:
|
|
1366
|
+
* `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
|
|
1367
|
+
* 官方空 profile 模板(dsh-app-boot)正是「注释 + `[]`」——`[]` 这个占位符不能省。
|
|
1368
|
+
* 现场(2026-09-18 Windows 用户实测):卸载把插件条目摘掉后只剩顶部注释,dsh web 再也起不来。
|
|
1369
|
+
*/
|
|
1370
|
+
/** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
|
|
1371
|
+
function patchStructuralLines(text) {
|
|
1372
|
+
return String(text ?? "")
|
|
1373
|
+
.split(/\r?\n/)
|
|
1374
|
+
.map((l) => l.replace(/\s+$/, ""))
|
|
1375
|
+
.filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
|
|
1379
|
+
function isPatchArrayDocument(text) {
|
|
1380
|
+
const roots = patchStructuralLines(text);
|
|
1381
|
+
return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
|
|
1385
|
+
function needsArrayPlaceholder(text) {
|
|
1386
|
+
return patchStructuralLines(text).length === 0;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* 写回 patch 文档,并**保证结果仍是合法顶层数组**:条目被删光时补回 `[]` 占位。
|
|
1391
|
+
* 卸载、摘 include、回滚都必须走这里 —— 任何一条写路径漏了这步,用户就会拿到
|
|
1392
|
+
* 一个「dsh web 起不来且面板也进不去」的 profile(只能靠 CLI 自救)。
|
|
1393
|
+
*/
|
|
1394
|
+
function writePatchDocument(patchFile, text) {
|
|
1395
|
+
const body = String(text ?? "").replace(/\s+$/, "") + "\n";
|
|
1396
|
+
// ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
|
|
1397
|
+
// 那会把本来好的文件写坏(把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
|
|
1398
|
+
const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
|
|
1399
|
+
writePatchAtomic(patchFile, fixed);
|
|
1400
|
+
return fixed;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1330
1403
|
/** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
|
|
1331
1404
|
function writePatchAtomic(patchFile, text) {
|
|
1332
1405
|
const tmp = `${patchFile}.dsh-remote.tmp`;
|
|
@@ -1383,7 +1456,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
|
|
|
1383
1456
|
const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
|
|
1384
1457
|
const chk = parsePatchInsertIds(merged2);
|
|
1385
1458
|
if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
|
|
1386
|
-
|
|
1459
|
+
writePatchDocument(patchFile, merged2);
|
|
1387
1460
|
console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
|
|
1388
1461
|
return { ok: true, changed: true, basePatch: original };
|
|
1389
1462
|
}
|
|
@@ -1406,18 +1479,18 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
|
|
|
1406
1479
|
if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
1407
1480
|
return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
|
|
1408
1481
|
}
|
|
1409
|
-
|
|
1482
|
+
writePatchDocument(patchFile, merged);
|
|
1410
1483
|
const after = fs.readFileSync(patchFile, "utf8");
|
|
1411
1484
|
const verify = parsePatchInsertIds(after);
|
|
1412
1485
|
if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
1413
|
-
|
|
1486
|
+
writePatchDocument(patchFile, original); // 回滚
|
|
1414
1487
|
return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
|
|
1415
1488
|
}
|
|
1416
1489
|
removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
|
|
1417
1490
|
// 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
|
|
1418
1491
|
// 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
|
|
1419
1492
|
if (pkgBundles().length) {
|
|
1420
|
-
|
|
1493
|
+
writePatchDocument(patchFile, original);
|
|
1421
1494
|
return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
|
|
1422
1495
|
}
|
|
1423
1496
|
return { ok: true, changed: true, basePatch: original };
|
|
@@ -1427,7 +1500,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
|
|
|
1427
1500
|
function stripIncludeEntries(patchFile, patch, reason) {
|
|
1428
1501
|
const next = stripPluginEntries(patch);
|
|
1429
1502
|
if (next === patch) return false;
|
|
1430
|
-
|
|
1503
|
+
writePatchDocument(patchFile, next);
|
|
1431
1504
|
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
|
|
1432
1505
|
return true;
|
|
1433
1506
|
}
|
|
@@ -1473,34 +1546,143 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1473
1546
|
}
|
|
1474
1547
|
|
|
1475
1548
|
/** 把插件挂到 <profile>/node_modules/<PLUGIN_ID>(裸包名解析需要),指向拷贝目录;缺失/指错时重建。 */
|
|
1549
|
+
/**
|
|
1550
|
+
* 判定 `node_modules/<PLUGIN_ID>` 是否**真的可用**。
|
|
1551
|
+
*
|
|
1552
|
+
* 判据绝不是"函数有没有抛错" —— 这是 0.6.7-beta.1 在 Windows 上翻车的根因:
|
|
1553
|
+
* 非特权进程 + 开发者模式关闭时,`fs.symlinkSync(target, path, "dir")` 会**既不抛错也产不出可用链接**
|
|
1554
|
+
* (实测:用户目录下留下一个空目录;%TEMP% 下什么都不留;同一位置两次运行一次报成功一次抛 UNKNOWN)。
|
|
1555
|
+
* 于是"用 try/catch 探测能力"这个前提在 Windows 上根本不成立,写在其 catch 里的 junction 兜底永不执行。
|
|
1556
|
+
*
|
|
1557
|
+
* 这里只认 dsh 自己那条解析路径能不能走通:
|
|
1558
|
+
* ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
|
|
1559
|
+
* ② 读得到 lib/index.js(节点半入口);
|
|
1560
|
+
* ③ 要么 realpath 指回本地拷贝目录(链接形态),要么入口内容与本地拷贝逐字节一致(整目录拷贝形态)。
|
|
1561
|
+
*/
|
|
1562
|
+
function pluginLinkUsable(nmPlugin, pluginLocalDir) {
|
|
1563
|
+
try {
|
|
1564
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nmPlugin, "package.json"), "utf8"));
|
|
1565
|
+
if (pkg.name !== PLUGIN_ID) return false;
|
|
1566
|
+
if (!fs.existsSync(path.join(nmPlugin, "lib", "index.js"))) return false;
|
|
1567
|
+
} catch { return false; }
|
|
1568
|
+
|
|
1569
|
+
let real = "";
|
|
1570
|
+
try { real = fs.realpathSync(nmPlugin); } catch { /* 悬空链接 */ }
|
|
1571
|
+
if (real) {
|
|
1572
|
+
try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
return fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
|
|
1576
|
+
=== fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
|
|
1577
|
+
} catch { return false; }
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* 把插件挂到 `<profile>/node_modules/<PLUGIN_ID>`。
|
|
1582
|
+
*
|
|
1583
|
+
* 规格:已就绪返回 false(幂等)、重建成功返回 true、**任何情况下都没能挂好就抛错**。
|
|
1584
|
+
* 抛错是刻意的 —— 调用方必须据此中止安装:写进 patch 的激活行会让 dsh web 按裸包名解析这个目录,
|
|
1585
|
+
* 解析不到就 `Cannot find package '...\node_modules\dsh-remote-web\index.js'`,
|
|
1586
|
+
* 而 cordis 的插件树加载失败 = **整个 dsh web 起不来**(用户连面板都打不开,没有自助修复入口)。
|
|
1587
|
+
* 宁可不装,也不能写出一个起不来的 profile。
|
|
1588
|
+
*/
|
|
1476
1589
|
function ensurePluginLinked(profileDir, pluginLocalDir) {
|
|
1477
1590
|
const nmDir = path.join(profileDir, "node_modules");
|
|
1478
1591
|
const nmPlugin = path.join(nmDir, PLUGIN_ID);
|
|
1592
|
+
if (!fs.existsSync(path.join(pluginLocalDir, "package.json"))) {
|
|
1593
|
+
throw new Error(`本地插件拷贝缺失或不完整:${pluginLocalDir}(应含 package.json)`);
|
|
1594
|
+
}
|
|
1479
1595
|
fs.mkdirSync(nmDir, { recursive: true });
|
|
1480
1596
|
// 清理历史名链接(node_modules/dsh-remote-ui),避免解析到旧拷贝
|
|
1481
1597
|
for (const id of PLUGIN_LEGACY_IDS) {
|
|
1482
1598
|
try { fs.rmSync(path.join(nmDir, id), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1483
1599
|
}
|
|
1600
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return false; // 已就绪(拷贝形态也算就绪)
|
|
1601
|
+
|
|
1602
|
+
// ⚠️ Windows 上**不要用 symlinkSync(...,'dir')**:非特权进程下它产不出可用链接,
|
|
1603
|
+
// 而且抛不抛错不确定、失败时还会留下空目录残留 —— 兜底链会因此整条失效(实测)。
|
|
1604
|
+
// junction(目录联接)免特权、realpath 正确、require.resolve 与 ESM 裸包名解析都正常。
|
|
1605
|
+
const attempts = process.platform === "win32"
|
|
1606
|
+
? [
|
|
1607
|
+
["junction", () => fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction")], // junction 只接受绝对路径
|
|
1608
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1609
|
+
]
|
|
1610
|
+
: [
|
|
1611
|
+
["dir", () => fs.symlinkSync(pluginLocalDir, nmPlugin, "dir")],
|
|
1612
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1613
|
+
];
|
|
1614
|
+
|
|
1615
|
+
const failures = [];
|
|
1616
|
+
for (const [how, run] of attempts) {
|
|
1617
|
+
// 每支开始前先清残留:上一支"没抛错但留了个空目录"时,不清就会让下一支撞 EEXIST 而失败
|
|
1618
|
+
//(这正是原实现三级兜底退化成一级的原因)。
|
|
1619
|
+
try { fs.rmSync(nmPlugin, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1620
|
+
let err = null;
|
|
1621
|
+
try { run(); } catch (e) { err = e; }
|
|
1622
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return true;
|
|
1623
|
+
failures.push(`${how}: ${err ? err.message : "未抛错但落盘结果不可用(读不到 package.json / lib/index.js)"}`);
|
|
1624
|
+
}
|
|
1625
|
+
throw new Error(`插件挂载失败:${nmPlugin}\n ${failures.join("\n ")}`);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* 独立验收:复刻 dsh 客户端模块系统的真实动作 —— 从 profile 目录按**裸包名**解析到本插件。
|
|
1631
|
+
*
|
|
1632
|
+
* 与 pluginLinkUsable 是两道独立的闸门:前者看文件在不在、是不是真指向目标;
|
|
1633
|
+
* 这条走 Node 的解析器(exports 字段、symlink 跟随、pnpm 布局),是"面板能不能被注入"的前提
|
|
1634
|
+
* (@deepseek-ai/dsh-client-modules 就是 require.resolve('<name>/package.json') 读 dsh.client)。
|
|
1635
|
+
*/
|
|
1636
|
+
function probePluginResolve(profileDir) {
|
|
1637
|
+
const base = path.join(profileDir, "__dsh_resolve_probe.cjs");
|
|
1638
|
+
// 必须**另起一个 node 进程**做这次解析,不能在本进程里 require.resolve:
|
|
1639
|
+
// 实测(Node 25 / macOS):本进程里只要先失败过一次裸包名解析(例如"修复前诊断"那次),
|
|
1640
|
+
// 链接随后修好了,同一个进程里的裸包名解析**仍会持续失败**(子路径却正常)——
|
|
1641
|
+
// 进程内的解析缓存会把假阴性一直带下去,让"修复后验收"误判成没修好、拒绝恢复激活。
|
|
1642
|
+
// 另起进程同时也更忠实:dsh 启动时就是在一个全新进程里解析这些裸包名的。
|
|
1643
|
+
const script = [
|
|
1644
|
+
'const { createRequire } = require("node:module");',
|
|
1645
|
+
`const req = createRequire(${JSON.stringify(base)});`,
|
|
1646
|
+
"process.stdout.write(JSON.stringify({",
|
|
1647
|
+
` pkgPath: req.resolve(${JSON.stringify(`${PLUGIN_ID}/package.json`)}),`,
|
|
1648
|
+
` entry: req.resolve(${JSON.stringify(PLUGIN_ID)})`,
|
|
1649
|
+
"}));",
|
|
1650
|
+
].join("\n");
|
|
1484
1651
|
try {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
} catch {
|
|
1491
|
-
// Windows:普通目录符号链接需要管理员或"开发者模式",非管理员时直接 EPERM。
|
|
1492
|
-
// junction(目录联接)**不需要任何特权**,且 realpath 同样解析到目标,
|
|
1493
|
-
// 所以放在符号链接与整目录拷贝之间——能让 Windows 用户也拿到"链接"语义(改一处即生效)。
|
|
1494
|
-
let linked = false;
|
|
1495
|
-
if (process.platform === "win32") {
|
|
1496
|
-
try {
|
|
1497
|
-
fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction"); // junction 只接受绝对路径
|
|
1498
|
-
linked = true;
|
|
1499
|
-
} catch { /* 退化到拷贝 */ }
|
|
1652
|
+
const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
|
|
1653
|
+
if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
|
|
1654
|
+
if (r.status !== 0) {
|
|
1655
|
+
const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
|
|
1656
|
+
return { ok: false, reason: line.trim().slice(0, 200) };
|
|
1500
1657
|
}
|
|
1501
|
-
|
|
1658
|
+
const out = JSON.parse(String(r.stdout || "{}"));
|
|
1659
|
+
const pkg = JSON.parse(fs.readFileSync(out.pkgPath, "utf8"));
|
|
1660
|
+
return {
|
|
1661
|
+
ok: true,
|
|
1662
|
+
pkgPath: out.pkgPath,
|
|
1663
|
+
entry: out.entry,
|
|
1664
|
+
name: pkg.name,
|
|
1665
|
+
clientPlatform: (pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform) || "",
|
|
1666
|
+
};
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
return { ok: false, reason: `${e.code || e.name}: ${e.message}` };
|
|
1502
1669
|
}
|
|
1503
|
-
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* 描述插件在 profile 里的挂载形态(供 status / repair 输出)。
|
|
1674
|
+
* 用户报障时这三行就能区分"链接坏了"与"别的毛病"——不必再靠猜。
|
|
1675
|
+
* @returns {{localExists:boolean, exists:boolean, usable:boolean, kind:"missing"|"broken"|"linked"|"copy"|"no-local-copy"}}
|
|
1676
|
+
*/
|
|
1677
|
+
function describePluginLink(profileDir) {
|
|
1678
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
1679
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1680
|
+
const localExists = fs.existsSync(path.join(localDir, "package.json"));
|
|
1681
|
+
if (!fs.existsSync(nmPlugin)) return { localExists, exists: false, usable: false, kind: localExists ? "missing" : "no-local-copy" };
|
|
1682
|
+
const usable = pluginLinkUsable(nmPlugin, localDir);
|
|
1683
|
+
let isLink = false;
|
|
1684
|
+
try { isLink = fs.lstatSync(nmPlugin).isSymbolicLink(); } catch { /* ignore */ }
|
|
1685
|
+
return { localExists, exists: true, usable, kind: usable ? (isLink ? "linked" : "copy") : "broken" };
|
|
1504
1686
|
}
|
|
1505
1687
|
|
|
1506
1688
|
/** package.json 写入 file: 依赖(让后续任何 pnpm/npm install 也认可该插件,不会删链接)。 */
|
|
@@ -1564,8 +1746,26 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
|
|
|
1564
1746
|
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
1565
1747
|
process.exit(1);
|
|
1566
1748
|
}
|
|
1749
|
+
// ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
|
|
1750
|
+
// 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
|
|
1751
|
+
// 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
|
|
1752
|
+
// 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
|
|
1753
|
+
// 现在:先挂载 → 独立验收 → 才写依赖与激活行;任何一步不过就中止,绝不留下坏 profile。
|
|
1754
|
+
try {
|
|
1755
|
+
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
console.error(`❌ ${e.message}`);
|
|
1758
|
+
console.error(" 已中止安装:继续写 patch 会让 dsh web 因插件树加载失败而完全无法启动。");
|
|
1759
|
+
console.error(" 请把上面的错误整段反馈给作者(含平台 / 是否管理员 / 开发者模式是否开启)。");
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
const probe = probePluginResolve(profileDir);
|
|
1763
|
+
if (!probe.ok) {
|
|
1764
|
+
console.error(`❌ 验收失败:dsh 无法从 profile 按裸包名解析 ${PLUGIN_ID}(${probe.reason})`);
|
|
1765
|
+
console.error(" 已中止安装(未写 patch):否则 dsh web 会因插件树加载失败而完全无法启动。");
|
|
1766
|
+
process.exit(1);
|
|
1767
|
+
}
|
|
1567
1768
|
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
1568
|
-
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1569
1769
|
const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
|
|
1570
1770
|
if (r.ok) {
|
|
1571
1771
|
console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
|
|
@@ -1602,6 +1802,34 @@ function readInstalledPluginVersion(profileDir, name) {
|
|
|
1602
1802
|
} catch { return null; }
|
|
1603
1803
|
}
|
|
1604
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* 识别 profile 用哪个包管理器(市场装法「一键更新」要用它改源码)。
|
|
1807
|
+
*
|
|
1808
|
+
* ⚠️ 不能用 `sh("command -v pnpm && echo pnpm || echo npm")`:`sh()` 走 execSync,
|
|
1809
|
+
* Windows 下是 cmd.exe,而 `command` 是 POSIX 内建 → 永远命中 `||` 分支,
|
|
1810
|
+
* **Windows 上永远选 npm**。而 dsh profile 是 pnpm 管理的(pnpm-workspace.yaml + 虚拟 store),
|
|
1811
|
+
* 用 npm 去改同一个 node_modules 会写出与 pnpm 不同的布局(污染/冲突风险)。
|
|
1812
|
+
* 现在:先用**声明文件**判断(最可靠、零子进程),再用 where/which 探测可执行文件;
|
|
1813
|
+
* 兜底选 pnpm 而不是 npm —— dsh profile 默认就是 pnpm,选错方向的代价不对称。
|
|
1814
|
+
*/
|
|
1815
|
+
function detectPackageManager(profileDir) {
|
|
1816
|
+
try {
|
|
1817
|
+
if (fs.existsSync(path.join(profileDir, "pnpm-lock.yaml"))
|
|
1818
|
+
|| fs.existsSync(path.join(profileDir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
1819
|
+
if (fs.existsSync(path.join(profileDir, "package-lock.json"))) return "npm";
|
|
1820
|
+
} catch { /* 读不到就往下探测 */ }
|
|
1821
|
+
const has = (bin) => {
|
|
1822
|
+
try {
|
|
1823
|
+
return process.platform === "win32"
|
|
1824
|
+
? spawnSync("where", [bin], { stdio: "ignore", windowsHide: true }).status === 0
|
|
1825
|
+
: spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0;
|
|
1826
|
+
} catch { return false; }
|
|
1827
|
+
};
|
|
1828
|
+
if (has("pnpm")) return "pnpm";
|
|
1829
|
+
if (has("npm")) return "npm";
|
|
1830
|
+
return "pnpm";
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1605
1833
|
/**
|
|
1606
1834
|
* 市场装法下把插件包升到最新。
|
|
1607
1835
|
*
|
|
@@ -1615,7 +1843,7 @@ function readInstalledPluginVersion(profileDir, name) {
|
|
|
1615
1843
|
function upgradeMarketManagedPlugin(profileDir, name, dep) {
|
|
1616
1844
|
const spec = String(dep ?? "").trim();
|
|
1617
1845
|
const isRegistryRange = /^[\^~><=v\d]/.test(spec);
|
|
1618
|
-
const pm =
|
|
1846
|
+
const pm = detectPackageManager(profileDir);
|
|
1619
1847
|
const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
|
|
1620
1848
|
const cmd = `${pm} ${args}`;
|
|
1621
1849
|
const r = sh(cmd, 180000, profileDir);
|
|
@@ -1734,6 +1962,83 @@ async function pluginCmd(argv) {
|
|
|
1734
1962
|
// 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
|
|
1735
1963
|
}
|
|
1736
1964
|
|
|
1965
|
+
/**
|
|
1966
|
+
* 打印插件挂载诊断三行(供 status / repair 共用)。
|
|
1967
|
+
* 用户报障时这三行就能区分「链接坏了」与「别的毛病」,不必靠猜:
|
|
1968
|
+
* link = 有没有真挂上(junction / 符号链接 / 整目录拷贝 / BROKEN)
|
|
1969
|
+
* resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
|
|
1970
|
+
* client = dsh.client.platform 声明在不在
|
|
1971
|
+
*/
|
|
1972
|
+
function printPluginDiagnostics(profileDir) {
|
|
1973
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
1974
|
+
const link = describePluginLink(profileDir);
|
|
1975
|
+
const probe = probePluginResolve(profileDir);
|
|
1976
|
+
const kindText = { linked: "已挂载(链接)", copy: "已挂载(整目录拷贝)", broken: "BROKEN(存在但读不到内容)", missing: "缺失", "no-local-copy": "缺失(本地插件目录也没有)" }[link.kind] || link.kind;
|
|
1977
|
+
console.log(` [link] ${nmPlugin}\n → ${kindText}`);
|
|
1978
|
+
console.log(` [resolve] ${PLUGIN_ID}/package.json → ${probe.ok ? probe.pkgPath : `失败(${probe.reason})`}`);
|
|
1979
|
+
console.log(` [client] dsh.client.platform = ${probe.ok ? (probe.clientPlatform || "缺失") : "(解析失败,无法确认)"}`);
|
|
1980
|
+
return { link, probe };
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* `dsh-remote repair` —— 只修 profile 里的插件挂载,不碰运行环境 / 不联网 / 不碰自启动。
|
|
1985
|
+
*
|
|
1986
|
+
* 为什么需要这个独立入口:插件挂载坏掉时 dsh web **整个起不来**(cordis 插件树加载失败),
|
|
1987
|
+
* 而设置面板与插件市场都在那个进程里 —— 用户没有任何自助修复入口,只剩命令行。
|
|
1988
|
+
* 执行顺序刻意是"先保命、再修复、最后恢复激活":
|
|
1989
|
+
* ① patch 里引用了本插件但挂载不可用 → **先把 include 摘掉**(profile 立刻恢复可启动);
|
|
1990
|
+
* ② 再重建挂载(失败会抛错,但①的结果保留 → 用户至少能打开 dsh web);
|
|
1991
|
+
* ③ 只有①②都成功,才把激活行写回去。
|
|
1992
|
+
*/
|
|
1993
|
+
function repairProfile(argv = []) {
|
|
1994
|
+
const profileDir = resolveProfileDir(argv);
|
|
1995
|
+
const pkgFile = path.join(profileDir, "package.json");
|
|
1996
|
+
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
1997
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1998
|
+
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
1999
|
+
console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
|
|
2000
|
+
console.error(" 先确认 dsh web 装好并初始化过 profile,或用 --profile <目录> 指定。");
|
|
2001
|
+
process.exit(1);
|
|
2002
|
+
}
|
|
2003
|
+
console.log(`dsh-remote repair — ${profileDir}`);
|
|
2004
|
+
const before = printPluginDiagnostics(profileDir);
|
|
2005
|
+
if (!before.link.localExists) {
|
|
2006
|
+
console.error(`❌ 本地插件目录缺失(${localDir}):无法就地修复。`);
|
|
2007
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
2008
|
+
process.exit(1);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const patch = fs.readFileSync(patchFile, "utf8");
|
|
2012
|
+
const referenced = parsePatchInsertIds(patch).ids.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
2013
|
+
if (referenced && !before.link.usable) {
|
|
2014
|
+
// 保命动作:先摘掉激活行,让 dsh web 至少能启动(此时插件不可用,但用户进得去、能重装)
|
|
2015
|
+
stripIncludeEntries(patchFile, patch, "挂载不可用,先摘掉 include 保命");
|
|
2016
|
+
try { removeBundleEntry(pkgFile); } catch { /* ignore */ }
|
|
2017
|
+
console.log("🛟 挂载不可用 → 已先摘掉插件激活行(dsh web 现在应该能启动了)。");
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
try {
|
|
2021
|
+
ensurePluginLinked(profileDir, localDir);
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
console.error(`❌ 重建挂载失败:${e.message}`);
|
|
2024
|
+
console.error(" profile 已处于「能启动、插件未激活」状态;请把上面错误反馈给作者。");
|
|
2025
|
+
process.exit(1);
|
|
2026
|
+
}
|
|
2027
|
+
const after = printPluginDiagnostics(profileDir);
|
|
2028
|
+
if (!after.probe.ok) {
|
|
2029
|
+
console.error("❌ 挂载已重建,但 dsh 仍解析不到本插件 —— 请把上面的输出反馈给作者。");
|
|
2030
|
+
process.exit(1);
|
|
2031
|
+
}
|
|
2032
|
+
const srcPluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2033
|
+
if (!fs.existsSync(srcPluginDir)) {
|
|
2034
|
+
console.error(`❌ 本包缺少 packages/${PLUGIN_ID},无法就地恢复激活行。`);
|
|
2035
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
2036
|
+
process.exit(1);
|
|
2037
|
+
}
|
|
2038
|
+
convergePluginActivation(profileDir, pkgFile, patchFile, srcPluginDir, fs.readFileSync(patchFile, "utf8"));
|
|
2039
|
+
console.log("✅ 挂载与激活已修复 —— 重启一次 dsh web 生效。");
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1737
2042
|
// ---------- main ----------
|
|
1738
2043
|
// 无命令名(或首个参数以 - 开头)时默认执行 setup —— 这样 `dsh-remote --no-autostart`
|
|
1739
2044
|
// 这类「只给 flag」的用法才能装上去。
|
|
@@ -1748,6 +2053,7 @@ if (cmd === "setup" || cmd === "install") await setup(args);
|
|
|
1748
2053
|
else if (cmd === "settings") settingsHint();
|
|
1749
2054
|
else if (cmd === "run") await runBridge();
|
|
1750
2055
|
else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
|
|
2056
|
+
else if (cmd === "repair") repairProfile(process.argv.slice(3));
|
|
1751
2057
|
else if (cmd === "status") {
|
|
1752
2058
|
const cfg = loadConfig();
|
|
1753
2059
|
const local = Boolean(cfg.local_key);
|
|
@@ -1755,6 +2061,14 @@ else if (cmd === "status") {
|
|
|
1755
2061
|
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
1756
2062
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
1757
2063
|
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程控制」查看与操作");
|
|
2064
|
+
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
2065
|
+
const profileDir = resolveProfileDir([]);
|
|
2066
|
+
if (fs.existsSync(path.join(profileDir, "package.json"))) {
|
|
2067
|
+
console.log("插件挂载:");
|
|
2068
|
+
printPluginDiagnostics(profileDir);
|
|
2069
|
+
} else {
|
|
2070
|
+
console.log(`插件挂载: 未找到 profile(${profileDir})`);
|
|
2071
|
+
}
|
|
1758
2072
|
} else {
|
|
1759
2073
|
printHelp();
|
|
1760
2074
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.7-beta.
|
|
3
|
+
"version": "0.6.7-beta.3",
|
|
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",
|
|
@@ -340,8 +340,35 @@ window.__ModuleLoader__.load({
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
// ── 宿主 API ────────────────────────────────────────────────────────────
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
/**
|
|
344
|
+
* 默认请求超时(0.6.7-beta.3 新增)。
|
|
345
|
+
* 背景(用户实测):node 半一旦被同步子进程调用钉死(卸载时 spawnSync 卡在 schtasks 上),
|
|
346
|
+
* 请求**永远不会返回**,面板就对着一个永不结束的 spinner —— 用户只能猜。
|
|
347
|
+
* 现在超时后给出明确报错(并提示"这一步可能已经在后台完成了"),而不是无限转圈。
|
|
348
|
+
* 30s 足够覆盖最慢的正常路径(relay 6s + Windows 进程扫描 8s)。
|
|
349
|
+
*/
|
|
350
|
+
var API_TIMEOUT_MS = 30000;
|
|
351
|
+
function api(path, options, timeoutMs) {
|
|
352
|
+
var opts = options || {};
|
|
353
|
+
var timer = null;
|
|
354
|
+
try {
|
|
355
|
+
if (typeof AbortController === "function") {
|
|
356
|
+
var ctrl = new AbortController();
|
|
357
|
+
var ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : API_TIMEOUT_MS;
|
|
358
|
+
timer = setTimeout(function () { try { ctrl.abort(); } catch (e) { /* 忽略 */ } }, ms);
|
|
359
|
+
opts = Object.assign({}, opts, { signal: ctrl.signal });
|
|
360
|
+
}
|
|
361
|
+
} catch (e) { /* 无 AbortController 的旧环境:退化为无超时(不阻断使用) */ }
|
|
362
|
+
var done = function () { if (timer) { clearTimeout(timer); timer = null; } };
|
|
363
|
+
return fetch(path, opts).catch(function (e) {
|
|
364
|
+
// AbortError → 换成用户能看懂的话(并说明服务端可能已经做完了)
|
|
365
|
+
if (e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")))) {
|
|
366
|
+
var te = new Error("请求超时(面板等不到本机响应)。这一步可能已经在后台完成了 —— 请刷新页面查看;若 dsh web 无响应,重启一次 dsh web 即可。");
|
|
367
|
+
te.timeout = true;
|
|
368
|
+
throw te;
|
|
369
|
+
}
|
|
370
|
+
throw e;
|
|
371
|
+
}).finally(done).then(function (res) {
|
|
345
372
|
return res.text().then(function (text) {
|
|
346
373
|
var body = null;
|
|
347
374
|
try { body = JSON.parse(text); } catch (e) { body = null; }
|
|
@@ -358,6 +385,10 @@ window.__ModuleLoader__.load({
|
|
|
358
385
|
var post = function (path, data) {
|
|
359
386
|
return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
|
|
360
387
|
};
|
|
388
|
+
/** 带自定义超时的 POST(用于卸载这类"可能拖住"的操作)。 */
|
|
389
|
+
var postWithTimeout = function (path, data, timeoutMs) {
|
|
390
|
+
return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) }, timeoutMs);
|
|
391
|
+
};
|
|
361
392
|
|
|
362
393
|
// ── 交流群(二维码由图层面板上传统一配置,node 半经公开配置下发) ─────────
|
|
363
394
|
// 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
|
|
@@ -1066,7 +1097,9 @@ window.__ModuleLoader__.load({
|
|
|
1066
1097
|
if (!armed) { setArmed(true); return; }
|
|
1067
1098
|
setUnBusy(true);
|
|
1068
1099
|
setSelfMsg(null);
|
|
1069
|
-
|
|
1100
|
+
// 卸载:服务端现在"先卸插件、先回响应、慢活交子进程",正常应在 1 秒内返回;
|
|
1101
|
+
// 给 20s 上界,超时也要给用户一句能照做的话(而不是永久转圈)。
|
|
1102
|
+
postWithTimeout("/dsh-remote/self/uninstall", {}, 20000).then(function (b) {
|
|
1070
1103
|
if (b && b.ok) {
|
|
1071
1104
|
setArmed(false);
|
|
1072
1105
|
// 优先展示服务端 detail(含 bridge 自启动/配置目录的逐项清理结果与重启提示);
|
|
@@ -1079,7 +1112,7 @@ window.__ModuleLoader__.load({
|
|
|
1079
1112
|
}
|
|
1080
1113
|
}).catch(function (e) {
|
|
1081
1114
|
setArmed(false);
|
|
1082
|
-
setSelfMsg({ kind: "err", text: "卸载失败:" + e.message });
|
|
1115
|
+
setSelfMsg({ kind: e && e.timeout ? "warn" : "err", text: (e && e.timeout ? "" : "卸载失败:") + e.message });
|
|
1083
1116
|
}).finally(function () { setUnBusy(false); });
|
|
1084
1117
|
};
|
|
1085
1118
|
|