@mrrisega/dsh-remote 0.6.6 → 0.6.7-beta.1
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 +30 -8
- package/clients/dsh-remote/dsh-bridge.mjs +1 -1
- package/clients/dsh-remote/e2ee-shim.mjs +4 -4
- package/clients/dsh-remote/mobile-adapter.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +4 -4
- package/docs/self-hosting.md +27 -9
- package/dsh-setup.mjs +342 -32
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +57 -6
- package/packages/dsh-remote-web/lib/index.js +578 -70
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/community-qr-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +3 -3
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +82 -0
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +3 -1
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +94 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +466 -0
package/dsh-setup.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import os from "node:os";
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import { spawn, execSync } from "node:child_process";
|
|
29
|
+
import { spawn, spawnSync, execSync } from "node:child_process";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
32
|
|
|
@@ -45,6 +45,22 @@ const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
|
45
45
|
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
46
46
|
const REPO_URL = "https://github.com/mrRisega/dsh-remote";
|
|
47
47
|
|
|
48
|
+
// ---------- 平台(0.6.7 起支持 Windows) ----------
|
|
49
|
+
const IS_WIN = process.platform === "win32";
|
|
50
|
+
/**
|
|
51
|
+
* Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
|
|
52
|
+
* 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
|
|
53
|
+
* 且**不需要管理员**就能为当前用户建 ONLOGON 任务(默认「仅在用户登录时运行」,不存密码)。
|
|
54
|
+
*/
|
|
55
|
+
const WIN_TASK_NAME = "dsh-remote-bridge";
|
|
56
|
+
/**
|
|
57
|
+
* watcher / bridge 的 pid 文件(Windows 必需)。
|
|
58
|
+
* Windows 没有 pgrep/ps,插件半(dsh-remote-web)与安装器都靠这两个文件发现进程:
|
|
59
|
+
* watcher = 本安装器 `run` 子命令的进程,bridge = 它拉起的 dsh-bridge.mjs 子进程。
|
|
60
|
+
*/
|
|
61
|
+
const WATCHER_PID_FILE = ".dsh-watcher.pid";
|
|
62
|
+
const BRIDGE_PID_FILE = ".dsh-bridge.pid";
|
|
63
|
+
|
|
48
64
|
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
49
65
|
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
50
66
|
// 就会像“找不到模块”一样崩溃。因此 npm 形态安装时把 dsh-setup.mjs + clients + 依赖(ws)
|
|
@@ -135,6 +151,48 @@ function ensureRuntimeCopy() {
|
|
|
135
151
|
fs.writeFileSync(verFile, ver);
|
|
136
152
|
console.log(`✅ 运行时: ${CONFIG_DIR}`);
|
|
137
153
|
}
|
|
154
|
+
/** 打印用法(唯一实现:提前退出与末尾分派共用,避免两处漂移)。 */
|
|
155
|
+
function printHelp() {
|
|
156
|
+
console.log(`dsh-remote — 手机远程控制 dsh web(隧道模式)
|
|
157
|
+
|
|
158
|
+
用法:
|
|
159
|
+
dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
|
|
160
|
+
dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
|
|
161
|
+
dsh-remote run 前台运行 bridge(调试)
|
|
162
|
+
dsh-remote status 查看配置与服务状态
|
|
163
|
+
dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
|
|
164
|
+
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
165
|
+
|
|
166
|
+
自建模式(可选):
|
|
167
|
+
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
168
|
+
|
|
169
|
+
登录/连接配置: 打开 dsh web → 设置 → 「远程控制」→ 注册或登录手机号即可
|
|
170
|
+
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
171
|
+
文档: ${REPO_URL}
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
|
|
176
|
+
const KNOWN_FLAGS = new Set([
|
|
177
|
+
"--api", "--server", "--key", "--profile",
|
|
178
|
+
"--no-autostart", "--no-plugin", "--no-restart", "--uninstall"
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
// ⚠️ 这一整块必须挡在 ensureRuntimeCopy() **之前**:它会往 ~/.dsh-remote 同步运行时脚本。
|
|
182
|
+
// 看用法、或参数拼错,都不该改动用户磁盘 —— 此前 `dsh-remote --help` 会一路走到 setup,
|
|
183
|
+
// 什么都不问就开始安装。
|
|
184
|
+
{
|
|
185
|
+
const first = process.argv[2];
|
|
186
|
+
if (first === "-h" || first === "--help" || first === "help") {
|
|
187
|
+
printHelp();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
if (first && first.startsWith("-") && !KNOWN_FLAGS.has(first)) {
|
|
191
|
+
console.error(`❌ 未知参数: ${first}`);
|
|
192
|
+
console.error(" 直接安装就用 dsh-remote(不带参数);查看用法用 dsh-remote --help。");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
138
196
|
try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
|
|
139
197
|
|
|
140
198
|
/** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
|
|
@@ -188,7 +246,7 @@ const NODE_BIN = preferredNode();
|
|
|
188
246
|
*/
|
|
189
247
|
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
190
248
|
|
|
191
|
-
// ---------- 安装口径(随 bridge
|
|
249
|
+
// ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
|
|
192
250
|
/**
|
|
193
251
|
* install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
|
|
194
252
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
@@ -268,6 +326,95 @@ function sleepSync(ms) {
|
|
|
268
326
|
catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
|
|
269
327
|
}
|
|
270
328
|
|
|
329
|
+
/**
|
|
330
|
+
* 运行 schtasks(Windows 任务计划程序)。
|
|
331
|
+
* **不走 sh()**:/TR 里必然带引号(node 路径与脚本路径都可能含空格),
|
|
332
|
+
* 经 cmd.exe 转一层会把嵌套引号绞碎;spawnSync 直接把 argv 交给 CreateProcess,
|
|
333
|
+
* 由 Node 负责转义,参数原样到达 schtasks。
|
|
334
|
+
*/
|
|
335
|
+
function schtasks(args, timeoutMs = 15000) {
|
|
336
|
+
try {
|
|
337
|
+
const r = spawnSync("schtasks", args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
|
|
338
|
+
return {
|
|
339
|
+
ok: r.status === 0,
|
|
340
|
+
code: r.status,
|
|
341
|
+
stdout: String(r.stdout || ""),
|
|
342
|
+
stderr: String(r.stderr || (r.error && r.error.message) || ""),
|
|
343
|
+
};
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return { ok: false, code: -1, stdout: "", stderr: e.message };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描)。 */
|
|
350
|
+
function writePidFile(name, pid) {
|
|
351
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
352
|
+
try {
|
|
353
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
354
|
+
fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
|
|
355
|
+
} catch { /* 非关键 */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function removePidFile(name) {
|
|
359
|
+
try { fs.rmSync(path.join(CONFIG_DIR, name), { force: true }); } catch { /* 非关键 */ }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readPidFile(name) {
|
|
363
|
+
try {
|
|
364
|
+
const n = Number(String(fs.readFileSync(path.join(CONFIG_DIR, name), "utf8")).trim());
|
|
365
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
366
|
+
} catch { return null; }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function pidAlive(pid) {
|
|
370
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
371
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 注册 Windows 登录任务(幂等:先删再建)。
|
|
376
|
+
* /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
|
|
377
|
+
* (watcher 本身就会等 dsh web 的 3080 端口,晚起没有任何副作用)。
|
|
378
|
+
* ⚠️ 已知限制(如实告知,不假装完美):ONLOGON 任务在用户会话里运行,登录时会有一个控制台窗口;
|
|
379
|
+
* 想彻底隐藏需要"不管用户是否登录都运行"(存密码/S4U) 或第三方隐藏器,都需要额外凭据或依赖,
|
|
380
|
+
* 故这里只做「能自启」,并在安装汇总里说明用户可在任务计划程序里勾「隐藏」。
|
|
381
|
+
*/
|
|
382
|
+
let winTaskCache = null;
|
|
383
|
+
|
|
384
|
+
function writeWindowsTask() {
|
|
385
|
+
const node = NODE_BIN;
|
|
386
|
+
const setup = runtimeSetupPath();
|
|
387
|
+
const tr = `"${node}" "${setup}" run`;
|
|
388
|
+
if (tr.length > 255) {
|
|
389
|
+
console.log(`⚠️ 自启动命令过长(${tr.length} 字符,schtasks 上限 261):${tr}`);
|
|
390
|
+
console.log(" 建议把 dsh-remote 装到更短的路径,或用 `dsh-remote run` 手动运行 bridge。");
|
|
391
|
+
}
|
|
392
|
+
schtasks(["/Delete", "/TN", WIN_TASK_NAME, "/F"]); // 幂等清理(不存在也只是非 0 退出,不影响后续
|
|
393
|
+
const created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
|
|
394
|
+
if (!created.ok) {
|
|
395
|
+
winTaskCache = false;
|
|
396
|
+
console.warn(`⚠️ 自启动任务注册失败: ${(created.stderr || created.stdout).trim() || `schtasks 退出码 ${created.code}`}`);
|
|
397
|
+
console.warn(" 可手动执行 `dsh-remote run` 运行 bridge(功能完全一样,只是不随登录自启)。");
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
winTaskCache = true;
|
|
401
|
+
return `任务计划程序 / ${WIN_TASK_NAME}`; // 汇总里展示的"路径"
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 查询 Windows 登录任务是否已注册(退出码 0 = 存在;不解析本地化输出)。 */
|
|
405
|
+
function windowsTaskInstalled() {
|
|
406
|
+
return schtasks(["/Query", "/TN", WIN_TASK_NAME]).ok;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 本次进程内查询一次登录任务是否已注册(汇总里多处要用,避免反复起 schtasks)。
|
|
411
|
+
* 注册/删除任务时同步刷新缓存,保证汇总看到的是最终状态。
|
|
412
|
+
*/
|
|
413
|
+
function winTaskOk() {
|
|
414
|
+
if (winTaskCache === null) winTaskCache = windowsTaskInstalled();
|
|
415
|
+
return winTaskCache;
|
|
416
|
+
}
|
|
417
|
+
|
|
271
418
|
function writeAutostartFile() {
|
|
272
419
|
const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
|
|
273
420
|
if (process.platform === "darwin") {
|
|
@@ -311,7 +458,8 @@ function writeAutostartFile() {
|
|
|
311
458
|
fs.writeFileSync(unitPath, unit);
|
|
312
459
|
return unitPath; // 路径在最终汇总里统一展示
|
|
313
460
|
}
|
|
314
|
-
|
|
461
|
+
if (IS_WIN) return writeWindowsTask();
|
|
462
|
+
console.log(`⚠️ 当前平台(${process.platform})暂不支持自启动,请手动运行 \`dsh-remote run\``);
|
|
315
463
|
return null;
|
|
316
464
|
}
|
|
317
465
|
|
|
@@ -429,6 +577,22 @@ function startBridgeDarwin(plistPath) {
|
|
|
429
577
|
}
|
|
430
578
|
|
|
431
579
|
function restartBridgeService() {
|
|
580
|
+
if (IS_WIN) {
|
|
581
|
+
if (!windowsTaskInstalled()) writeWindowsTask(); // 静默补生成(调用方会汇总展示)
|
|
582
|
+
// /Run 只是"现在跑一次";等 pid 文件出现才算真的起来了(schtasks 的退出码只说明任务被触发)
|
|
583
|
+
const r = schtasks(["/Run", "/TN", WIN_TASK_NAME]);
|
|
584
|
+
if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || `schtasks /Run 退出码 ${r.code}` };
|
|
585
|
+
for (let i = 0; i < 20; i += 1) {
|
|
586
|
+
const pid = readPidFile(WATCHER_PID_FILE);
|
|
587
|
+
if (pid && pidAlive(pid)) return { ok: true, status: "running", pid };
|
|
588
|
+
sleepSync(400);
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
status: "degraded-detached",
|
|
593
|
+
detail: "已触发自启动任务,但没等到 bridge 守护进程(dsh web 可能还没运行;打开 dsh web 后会自动接上)",
|
|
594
|
+
};
|
|
595
|
+
}
|
|
432
596
|
const svcFile = autostartFilePath();
|
|
433
597
|
if (svcFile && !fs.existsSync(svcFile)) {
|
|
434
598
|
writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
|
|
@@ -474,8 +638,21 @@ function resolveProfileDir(argv = []) {
|
|
|
474
638
|
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
475
639
|
}
|
|
476
640
|
|
|
477
|
-
/**
|
|
641
|
+
/**
|
|
642
|
+
* 监听 127.0.0.1:3080 的进程 pid(= dsh web)。
|
|
643
|
+
* macOS/Linux:lsof → pgrep;Windows:netstat -ano 取 LISTENING 行的最后一列
|
|
644
|
+
* (netstat 是系统自带,比每次都起 PowerShell 快得多)。
|
|
645
|
+
*/
|
|
478
646
|
function dshWebPid() {
|
|
647
|
+
if (IS_WIN) {
|
|
648
|
+
const r = sh('netstat -ano | findstr ":3080"');
|
|
649
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
650
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
651
|
+
const m = line.trim().match(/(\d+)\s*$/);
|
|
652
|
+
if (m) return Number(m[1]);
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
479
656
|
const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
|
|
480
657
|
const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
|
|
481
658
|
if (pid && /^\d+$/.test(pid)) return Number(pid);
|
|
@@ -512,8 +689,10 @@ function dshWebLaunchdJob() {
|
|
|
512
689
|
}
|
|
513
690
|
|
|
514
691
|
function restartDshWeb() {
|
|
515
|
-
|
|
692
|
+
// ⚠️ process.getuid 只在 macOS 分支里取:Windows 上**没有这个函数**,
|
|
693
|
+
// 旧写法把它放在函数首行无条件调用 → 安装流程在 Windows 直接 TypeError 中断。
|
|
516
694
|
if (process.platform === "darwin") {
|
|
695
|
+
const uid = process.getuid();
|
|
517
696
|
const job = dshWebLaunchdJob();
|
|
518
697
|
if (job) {
|
|
519
698
|
const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
|
|
@@ -524,6 +703,7 @@ function restartDshWeb() {
|
|
|
524
703
|
const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
|
|
525
704
|
if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
|
|
526
705
|
}
|
|
706
|
+
if (IS_WIN) return restartDshWebWindows();
|
|
527
707
|
// 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
|
|
528
708
|
const pid = dshWebPid();
|
|
529
709
|
if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
|
|
@@ -540,6 +720,95 @@ function restartDshWeb() {
|
|
|
540
720
|
}
|
|
541
721
|
}
|
|
542
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Windows:重启 dsh web(沿用原命令行自拉起)。
|
|
725
|
+
*
|
|
726
|
+
* 与插件半的重启助手同源思路:**先校验新进程拉得起来,再动旧进程**;用 node 跑一个 .mjs 助手,
|
|
727
|
+
* 不经过任何 shell(Windows 没有 /bin/sh,cmd.exe 的引号规则又极易把带空格的 node/入口路径绞碎)。
|
|
728
|
+
* 拿不到命令行(例如经 npx 启动)时如实拒绝,让用户手动重启,绝不"杀掉却起不来"。
|
|
729
|
+
*/
|
|
730
|
+
function restartDshWebWindows() {
|
|
731
|
+
const pid = dshWebPid();
|
|
732
|
+
if (!pid) return { ok: false, how: "", detail: "未找到监听 3080 的进程(dsh web 没在运行?)" };
|
|
733
|
+
const cmdline = windowsProcessCommandLine(pid);
|
|
734
|
+
if (!cmdline) return { ok: false, how: "", detail: "无法获取 dsh web 的启动命令行,请手动重启" };
|
|
735
|
+
if (/_npx|node_modules\\\.bin|node_modules\/\.bin/.test(cmdline)) {
|
|
736
|
+
return { ok: false, how: "", detail: "dsh web 是经 npx 启动的,无法安全复用命令行,请手动重启" };
|
|
737
|
+
}
|
|
738
|
+
const argv = parseWindowsCommandLine(cmdline);
|
|
739
|
+
if (!argv.length || !fs.existsSync(argv[0])) {
|
|
740
|
+
return { ok: false, how: "", detail: "dsh web 的启动命令无法解析为可执行程序,请手动重启" };
|
|
741
|
+
}
|
|
742
|
+
const helper = path.join(CONFIG_DIR, ".dsh-restart-dshweb.mjs");
|
|
743
|
+
const plan = JSON.stringify({ pid, argv, log: path.join(CONFIG_DIR, ".dsh-restart.log") });
|
|
744
|
+
const src = `import { spawn, spawnSync } from "node:child_process";
|
|
745
|
+
import { appendFileSync, existsSync, openSync, closeSync } from "node:fs";
|
|
746
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
747
|
+
const PLAN = ${plan};
|
|
748
|
+
function logLine(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) {} }
|
|
749
|
+
if (!existsSync(PLAN.argv[0])) { logLine("重启中止:可执行文件不存在 " + PLAN.argv[0]); process.exit(1); }
|
|
750
|
+
function alive(p) { try { process.kill(p, 0); return true; } catch (e) { return e.code === "EPERM"; } }
|
|
751
|
+
logLine("dsh web 重启助手启动:旧 pid=" + PLAN.pid);
|
|
752
|
+
await delay(1000);
|
|
753
|
+
try { process.kill(PLAN.pid); } catch (e) {}
|
|
754
|
+
const deadline = Date.now() + 30000;
|
|
755
|
+
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 });
|
|
757
|
+
let out = "ignore";
|
|
758
|
+
try { out = openSync(PLAN.log, "a"); } catch (e) {}
|
|
759
|
+
const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
|
|
760
|
+
child.on("error", (e) => logLine("重新拉起失败:" + e.message));
|
|
761
|
+
child.unref();
|
|
762
|
+
if (typeof out === "number") { try { closeSync(out); } catch (e) {} }
|
|
763
|
+
logLine("已重新拉起 dsh web:pid=" + String(child.pid));
|
|
764
|
+
`;
|
|
765
|
+
try {
|
|
766
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
767
|
+
fs.writeFileSync(helper, src);
|
|
768
|
+
const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true });
|
|
769
|
+
child.on("error", () => { /* 有 error 监听:绝不能把宿主/安装器打挂 */ });
|
|
770
|
+
child.unref();
|
|
771
|
+
return { ok: true, how: "Windows 重启助手(原命令行自拉起)" };
|
|
772
|
+
} catch (e) {
|
|
773
|
+
return { ok: false, how: "", detail: e.message };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** 读某进程的完整命令行(PowerShell CIM;取不到返回空串)。 */
|
|
778
|
+
function windowsProcessCommandLine(pid) {
|
|
779
|
+
const script = `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`;
|
|
780
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
781
|
+
try {
|
|
782
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
783
|
+
{ encoding: "utf8", timeout: 8000, windowsHide: true });
|
|
784
|
+
return String(r.stdout || "").trim();
|
|
785
|
+
} catch { return ""; }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* 把 Windows 命令行拆成 argv(按 CreateProcess/CRT 的引号规则)。
|
|
790
|
+
* 不引第三方依赖;处理 `"a b" c`、`a\b c`、`"a\"b"` 等常见形态即可。
|
|
791
|
+
*/
|
|
792
|
+
function parseWindowsCommandLine(cmd) {
|
|
793
|
+
const out = [];
|
|
794
|
+
let cur = "";
|
|
795
|
+
let inQuote = false;
|
|
796
|
+
let started = false;
|
|
797
|
+
for (let i = 0; i < cmd.length; i += 1) {
|
|
798
|
+
const ch = cmd[i];
|
|
799
|
+
if (ch === "\\" && cmd[i + 1] === '"') { cur += '"'; i += 1; started = true; continue; }
|
|
800
|
+
if (ch === '"') { inQuote = !inQuote; started = true; continue; }
|
|
801
|
+
if (!inQuote && /\s/.test(ch)) {
|
|
802
|
+
if (started) { out.push(cur); cur = ""; started = false; }
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
cur += ch;
|
|
806
|
+
started = true;
|
|
807
|
+
}
|
|
808
|
+
if (started) out.push(cur);
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
|
|
543
812
|
function installAutostart() {
|
|
544
813
|
const svcPath = writeAutostartFile();
|
|
545
814
|
const r = restartBridgeService();
|
|
@@ -565,6 +834,15 @@ dsh-remote:独立的本地设置页已移除。
|
|
|
565
834
|
|
|
566
835
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
567
836
|
async function runBridge() {
|
|
837
|
+
// Windows 去重:登录任务(Task Scheduler)与插件半的自愈都可能拉起 watcher,
|
|
838
|
+
// 两个 bridge 会抢同一个设备登记 → 这里以 pid 文件为准,已有守护在跑就直接退出。
|
|
839
|
+
if (IS_WIN) {
|
|
840
|
+
const other = readPidFile(WATCHER_PID_FILE);
|
|
841
|
+
if (other && other !== process.pid && pidAlive(other)) {
|
|
842
|
+
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
568
846
|
let cfg = loadConfig();
|
|
569
847
|
let warnedNoLogin = false;
|
|
570
848
|
|
|
@@ -629,7 +907,13 @@ async function runBridge() {
|
|
|
629
907
|
bridgeProc = spawn(NODE_BIN,
|
|
630
908
|
[path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
|
|
631
909
|
{ env: childEnv, stdio: "inherit" });
|
|
632
|
-
bridgeProc.on("exit", () => {
|
|
910
|
+
bridgeProc.on("exit", () => {
|
|
911
|
+
removePidFile(BRIDGE_PID_FILE);
|
|
912
|
+
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
913
|
+
});
|
|
914
|
+
// Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
|
|
915
|
+
// 插件半启动 bridge 走的就是本函数,所以这里写一次两边都覆盖到。
|
|
916
|
+
if (IS_WIN) writePidFile(BRIDGE_PID_FILE, bridgeProc.pid);
|
|
633
917
|
setTimeout(() => { starting = false; }, 5000);
|
|
634
918
|
} else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
|
|
635
919
|
console.log("[dsh-remote] dsh web 离线,停止 bridge...");
|
|
@@ -637,6 +921,16 @@ async function runBridge() {
|
|
|
637
921
|
}
|
|
638
922
|
};
|
|
639
923
|
|
|
924
|
+
// Windows:watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
|
|
925
|
+
if (IS_WIN) {
|
|
926
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
927
|
+
const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
|
|
928
|
+
process.on("exit", cleanup);
|
|
929
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
|
|
930
|
+
try { process.on(sig, () => { cleanup(); process.exit(0); }); } catch { /* 该信号在本平台不可注册 */ }
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
640
934
|
await ensureBridge();
|
|
641
935
|
setInterval(ensureBridge, 10000); // 每 10s 检查
|
|
642
936
|
console.log("[dsh-remote] 守护运行中(Ctrl-C 退出)");
|
|
@@ -792,13 +1086,23 @@ async function setup(argv) {
|
|
|
792
1086
|
const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
|
|
793
1087
|
// 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
|
|
794
1088
|
// 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
|
|
795
|
-
|
|
1089
|
+
// Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
|
|
1090
|
+
const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
|
|
796
1091
|
const svcDegraded = st.status === "degraded-detached";
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1092
|
+
// Windows:自启动 = 任务计划程序里的登录任务。口径与 macOS/Linux 不同
|
|
1093
|
+
// ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
|
|
1094
|
+
const winTask = IS_WIN && winTaskOk();
|
|
1095
|
+
const svcState = IS_WIN
|
|
1096
|
+
? (st.status === "skipped"
|
|
1097
|
+
? "未安装(本次显式跳过)"
|
|
1098
|
+
: (!winTask
|
|
1099
|
+
? "未注册(登录任务创建失败,可用 `dsh-remote run` 手动运行 bridge)"
|
|
1100
|
+
: (st.ok ? `✅ 已注册,守护进程在运行${st.pid ? ` (pid=${st.pid})` : ""}` : "✅ 已注册(登录后自动运行;dsh web 打开时会立刻接上)")))
|
|
1101
|
+
: (svcSkipped
|
|
1102
|
+
? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
|
|
1103
|
+
: (st.ok
|
|
1104
|
+
? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
|
|
1105
|
+
: (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`)));
|
|
802
1106
|
const L = [];
|
|
803
1107
|
L.push("✅ 安装完成");
|
|
804
1108
|
if (selfHosted) {
|
|
@@ -823,7 +1127,13 @@ async function setup(argv) {
|
|
|
823
1127
|
L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
|
|
824
1128
|
} else if (!st.ok) {
|
|
825
1129
|
L.push("");
|
|
826
|
-
if (
|
|
1130
|
+
if (IS_WIN) {
|
|
1131
|
+
// Windows:登录任务已注册,只是此刻 bridge 守护还没就绪(dsh web 刚起、或还没登录账号)
|
|
1132
|
+
L.push(`ℹ bridge 守护还没就绪: ${st.detail || st.status}`);
|
|
1133
|
+
L.push(` 已注册登录任务「${WIN_TASK_NAME}」,下次登录会自动运行;`);
|
|
1134
|
+
L.push(" 也可以现在执行 `dsh-remote run` 前台运行 bridge(Ctrl-C 退出)。");
|
|
1135
|
+
L.push(` 日志: ${CONFIG_DIR}\\.dsh-bridge.log`);
|
|
1136
|
+
} else if (svcDegraded) {
|
|
827
1137
|
// launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
|
|
828
1138
|
L.push(`⚠️ ${st.detail}`);
|
|
829
1139
|
L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
|
|
@@ -1178,7 +1488,17 @@ function ensurePluginLinked(profileDir, pluginLocalDir) {
|
|
|
1178
1488
|
try {
|
|
1179
1489
|
fs.symlinkSync(pluginLocalDir, nmPlugin, "dir");
|
|
1180
1490
|
} catch {
|
|
1181
|
-
|
|
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 { /* 退化到拷贝 */ }
|
|
1500
|
+
}
|
|
1501
|
+
if (!linked) fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true });
|
|
1182
1502
|
}
|
|
1183
1503
|
try { return fs.realpathSync(nmPlugin) === pluginLocalDir; } catch { return false; }
|
|
1184
1504
|
}
|
|
@@ -1415,7 +1735,12 @@ async function pluginCmd(argv) {
|
|
|
1415
1735
|
}
|
|
1416
1736
|
|
|
1417
1737
|
// ---------- main ----------
|
|
1418
|
-
// 无命令名(或首个参数以 - 开头)时默认执行 setup
|
|
1738
|
+
// 无命令名(或首个参数以 - 开头)时默认执行 setup —— 这样 `dsh-remote --no-autostart`
|
|
1739
|
+
// 这类「只给 flag」的用法才能装上去。
|
|
1740
|
+
//
|
|
1741
|
+
// 但有两个例外必须挡在前面:
|
|
1742
|
+
// ① -h/--help:用户想先看用法,绝不该顺手把东西装上(此前会走 setup,直接安装);
|
|
1743
|
+
// ② 不认识的 flag:拼错参数时静默安装比报错更糟,宁可停下来说清楚。
|
|
1419
1744
|
const raw = process.argv[2];
|
|
1420
1745
|
const cmd = raw && !raw.startsWith("-") ? raw : "setup";
|
|
1421
1746
|
const args = raw && !raw.startsWith("-") ? process.argv.slice(3) : process.argv.slice(2);
|
|
@@ -1431,21 +1756,6 @@ else if (cmd === "status") {
|
|
|
1431
1756
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
1432
1757
|
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程控制」查看与操作");
|
|
1433
1758
|
} else {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
用法:
|
|
1437
|
-
dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
|
|
1438
|
-
dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
|
|
1439
|
-
dsh-remote run 前台运行 bridge(调试)
|
|
1440
|
-
dsh-remote status 查看配置与服务状态
|
|
1441
|
-
dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
|
|
1442
|
-
|
|
1443
|
-
自建模式(可选):
|
|
1444
|
-
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
1445
|
-
|
|
1446
|
-
登录/连接配置: 打开 dsh web → 设置 → 「远程控制」→ 注册或登录手机号即可
|
|
1447
|
-
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
1448
|
-
文档: ${REPO_URL}
|
|
1449
|
-
`);
|
|
1450
|
-
process.exit(cmd === "help" ? 0 : 1);
|
|
1759
|
+
printHelp();
|
|
1760
|
+
process.exit(1);
|
|
1451
1761
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7-beta.1",
|
|
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",
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "npm run test:router && npm run test:plugin && npm run test:bridge",
|
|
53
|
-
"test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/
|
|
53
|
+
"test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
|
|
54
54
|
"_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
|
|
55
55
|
"test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
56
56
|
"test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
|
|
57
|
-
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh",
|
|
57
|
+
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
|
|
58
58
|
"release:tarballs": "bash scripts/release-tarballs.sh"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
@@ -359,7 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
359
359
|
return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
|
|
360
360
|
};
|
|
361
361
|
|
|
362
|
-
// ──
|
|
362
|
+
// ── 交流群(二维码由图层面板上传统一配置,node 半经公开配置下发) ─────────
|
|
363
363
|
// 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
|
|
364
364
|
var COMMUNITY_TTL_MS = 5 * 60 * 1000; // 面板/反馈卡共用缓存:5 分钟内不重复请求
|
|
365
365
|
var communityCache = { at: 0, data: null };
|
|
@@ -414,6 +414,14 @@ window.__ModuleLoader__.load({
|
|
|
414
414
|
var connInFlight = { v: false };
|
|
415
415
|
/** 最近一次连接阶段(供自调度定时器决定快/慢轮询节奏,避免依赖渲染闭包)。 */
|
|
416
416
|
var connPhaseRef = { v: "" };
|
|
417
|
+
/**
|
|
418
|
+
* 连接阶段轮询的**连续失败**计数(0.6.7 新增)。
|
|
419
|
+
* 背景(用户实测):轮询失败原来被 `.catch` 静默吞掉,而 node 半一旦 500
|
|
420
|
+
* (Windows 上 `process.getuid is not a function`),面板就永久停在「查询中…」转圈,
|
|
421
|
+
* 用户完全看不出发生了什么。现在连续失败到阈值就把失败原因摆到连接卡上。
|
|
422
|
+
*/
|
|
423
|
+
var connFailRef = { v: 0 };
|
|
424
|
+
var CONN_FAIL_VISIBLE = 3; // ≈ 2.5s × 3:既要够快让用户看见,又要避免一次抖动就报红
|
|
417
425
|
/** 连接阶段轮询节奏:未连通时 2.5s(自动推进),已连接后退避到 15s;页面隐藏时完全停下。 */
|
|
418
426
|
var CONN_POLL_FAST_MS = 2500;
|
|
419
427
|
var CONN_POLL_SLOW_MS = 15000;
|
|
@@ -1113,7 +1121,7 @@ window.__ModuleLoader__.load({
|
|
|
1113
1121
|
log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
|
|
1114
1122
|
selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
|
|
1115
1123
|
h("div", { className: "dru-hint", style: { marginTop: 8 } },
|
|
1116
|
-
armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(macOS com.dshremote.bridge / Linux dsh-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用请在插件市场重新安装。" :
|
|
1124
|
+
armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(macOS com.dshremote.bridge / Linux dsh-bridge / Windows 任务计划程序 dsh-remote-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用请在插件市场重新安装。" :
|
|
1117
1125
|
"检测新版、一键在线更新、彻底卸载都在本卡片完成。")
|
|
1118
1126
|
);
|
|
1119
1127
|
}
|
|
@@ -1621,6 +1629,7 @@ window.__ModuleLoader__.load({
|
|
|
1621
1629
|
connInFlight.v = true;
|
|
1622
1630
|
api("/dsh-remote/bridge-status").then(function (b) {
|
|
1623
1631
|
if (!b || !b.ok || !b.connect) return;
|
|
1632
|
+
connFailRef.v = 0; // 成功即清零:一次抖动不该把面板染红
|
|
1624
1633
|
var prev = connPhaseRef.v;
|
|
1625
1634
|
connPhaseRef.v = b.connect.phase || "";
|
|
1626
1635
|
setConn(b.connect);
|
|
@@ -1634,7 +1643,26 @@ window.__ModuleLoader__.load({
|
|
|
1634
1643
|
refreshDeviceList(); // 手机扫码后设备列表自动出现,无需刷新页面
|
|
1635
1644
|
}
|
|
1636
1645
|
}
|
|
1637
|
-
}).catch(function () {
|
|
1646
|
+
}).catch(function (e) {
|
|
1647
|
+
// 【0.6.7】原来这里是空 catch("轮询失败静默:下一轮自动重试")。在 Windows 上
|
|
1648
|
+
// node 半因 process.getuid 崩溃返回 500 时,这段静默让面板**永久**停在
|
|
1649
|
+
// 「查询中…」,用户完全无从判断(正是收到的那份诊断报告里的现象)。
|
|
1650
|
+
// 现在:连续失败到阈值就把原因摆到连接卡上,并提示可以直接重试。
|
|
1651
|
+
connFailRef.v += 1;
|
|
1652
|
+
if (connFailRef.v >= CONN_FAIL_VISIBLE) {
|
|
1653
|
+
setConn({
|
|
1654
|
+
phase: "error",
|
|
1655
|
+
online: false,
|
|
1656
|
+
text: "状态读取失败",
|
|
1657
|
+
detail: "本机状态接口连续 " + connFailRef.v + " 次请求失败:"
|
|
1658
|
+
+ ((e && e.message) ? e.message : String(e))
|
|
1659
|
+
+ "(面板读不到 bridge 状态,扫码/远程访问都不会推进)。可点「重试」,或刷新页面;若持续失败请把诊断信息发给客服。",
|
|
1660
|
+
error: { code: "status_unreachable", message: "无法读取本机 bridge 状态(/dsh-remote/bridge-status 请求失败)" },
|
|
1661
|
+
retryable: true,
|
|
1662
|
+
attempts: connFailRef.v,
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
})
|
|
1638
1666
|
.finally(function () { connInFlight.v = false; });
|
|
1639
1667
|
}
|
|
1640
1668
|
|
|
@@ -1970,7 +1998,20 @@ window.__ModuleLoader__.load({
|
|
|
1970
1998
|
var endsAt = a && (a.plan_ends_at || a.trial_expires_at) ? Number(a.plan_ends_at || a.trial_expires_at) : 0;
|
|
1971
1999
|
var quotaPct = quota && quota.limit_enabled ? quota.percent : null;
|
|
1972
2000
|
var planText;
|
|
1973
|
-
|
|
2001
|
+
// 免费档文案只依据**服务端实际下发的额度数据**,不猜具体数值:
|
|
2002
|
+
// · 中继压根不限制(自建)→ 只说“免费用户”,不显示任何限制
|
|
2003
|
+
// · 中继报告不限量 → 如实说明
|
|
2004
|
+
// · 中继报告限量 → 显示用量;带宽值服务端有下发才写
|
|
2005
|
+
if (!isMember) {
|
|
2006
|
+
if (quota && quota.limit_enabled) {
|
|
2007
|
+
var speedText = quota.max_mbps ? "带宽 ≈" + quota.max_mbps + "Mbps" : "已限速";
|
|
2008
|
+
planText = "免费额度: " + speedText + (quotaPct !== null ? " · 本月流量已用 " + quotaPct + "%" : "");
|
|
2009
|
+
} else if (quota) {
|
|
2010
|
+
planText = "免费用户 · 当前不限速、不限流量";
|
|
2011
|
+
} else {
|
|
2012
|
+
planText = "免费用户";
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1974
2015
|
else if (source === "trial") planText = "试用 PRO 会员 · 到期 " + fmtDate(a.trial_expires_at);
|
|
1975
2016
|
else if (endsAt) planText = plan === "pro_max" ? "Pro Max 会员 · 到期 " + fmtDate(endsAt) : "PRO 会员 · 到期 " + fmtDate(endsAt);
|
|
1976
2017
|
else planText = plan === "pro_max" ? "Pro Max 长期会员" : "PRO 长期会员";
|
|
@@ -2408,7 +2449,13 @@ window.__ModuleLoader__.load({
|
|
|
2408
2449
|
: h("button", { type: "button", className: "dru-btn dru-btn-danger", disabled: busy !== "", onClick: function () { toggleBridge(false); } }, busy === "stop" ? "停止中…" : "停止 bridge")
|
|
2409
2450
|
),
|
|
2410
2451
|
h("div", { className: "dru-meta" }, st && st.config && st.config.deviceId ? "设备 ID:" + st.config.deviceId : "设备 ID:生成中"),
|
|
2411
|
-
h("div", { className: "dru-meta" }, st ? (st.service && st.service.plistExists
|
|
2452
|
+
h("div", { className: "dru-meta" }, st ? (st.service && (st.service.plistExists || st.service.serviceManager === "detached")
|
|
2453
|
+
? (st.service.serviceManager === "detached"
|
|
2454
|
+
? (st.service.autostartTask
|
|
2455
|
+
? "自启动:已注册登录任务(任务计划程序 dsh-remote-bridge)"
|
|
2456
|
+
: "自启动:未注册登录任务(由面板在 dsh web 启动时自动拉起 bridge)")
|
|
2457
|
+
: "自启动服务已安装")
|
|
2458
|
+
: "自启动服务未安装(启动时自动创建)") : ""),
|
|
2412
2459
|
// 0.6.2:把「运行环境缺失 / launchd 崩溃循环」如实告诉用户,而不是显示「运行中」
|
|
2413
2460
|
st && !serviceRuntimeReady
|
|
2414
2461
|
? h("div", { className: "dru-hint", style: { marginTop: 8 } },
|
|
@@ -2431,7 +2478,7 @@ window.__ModuleLoader__.load({
|
|
|
2431
2478
|
card("📖 关于 dsh-remote", [
|
|
2432
2479
|
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。"),
|
|
2433
2480
|
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。"),
|
|
2434
|
-
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket
|
|
2481
|
+
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表。"),
|
|
2435
2482
|
h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
|
|
2436
2483
|
// 匿名装机统计(隐私披露):开源项目必须把「采什么 / 不采什么 / 怎么关」写在用户看得到的地方。
|
|
2437
2484
|
// 只保留结论式的说明句:不再单列「隐私说明: README… · docs/telemetry.md」那一行文档链接
|
|
@@ -2604,6 +2651,10 @@ window.__ModuleLoader__.load({
|
|
|
2604
2651
|
// 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」按钮共存不遮挡)
|
|
2605
2652
|
navEnsureStart();
|
|
2606
2653
|
|
|
2654
|
+
// 满意度弹窗已停用(不再调度);组件与调度代码保留,便于日后恢复。
|
|
2655
|
+
var FB_POPUP_RETIRED = true;
|
|
2656
|
+
if (FB_POPUP_RETIRED) return; // 改回 false 即恢复原行为
|
|
2657
|
+
|
|
2607
2658
|
// 满意度弹窗调度:首次观察到 bridge 运行(即“安装完成并体验”)后约 10 分钟弹出;
|
|
2608
2659
|
// 每 60 秒复查一次,避免 dsh web 启动晚于到点时间。
|
|
2609
2660
|
// 弹窗状态按「账号/设备」分作用域(见 fbPopScope);旧版无后缀键视为已完成并迁移,不再打扰老用户。
|