@mrrisega/dsh-remote 0.7.7 → 0.7.9
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 +32 -3
- package/clients/dsh-remote/test/wechat-channel.test.mjs +452 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +568 -29
- package/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/dsh-setup.mjs +200 -48
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -2
- package/packages/dsh-remote-web/lib/index.js +347 -53
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +200 -48
- package/packages/dsh-remote-web/test/setup-win-scan.test.mjs +455 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +50 -0
- package/packages/dsh-remote-web/test/windows-process-discovery.test.mjs +427 -0
package/dsh-setup.mjs
CHANGED
|
@@ -50,7 +50,16 @@ const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
|
50
50
|
const REPO_URL = "https://github.com/mrRisega/dsh-remote";
|
|
51
51
|
|
|
52
52
|
// ---------- 平台(0.6.7 起支持 Windows) ----------
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* 有效平台:`DSH_SETUP_PLATFORM` 可覆盖(**只用于测试**,让 win32 分支能在 macOS/Linux 上被
|
|
55
|
+
* 行为级跑一遍;生产不设置 → 与 `process.platform` 完全一致,行为不变)。
|
|
56
|
+
*
|
|
57
|
+
* 为什么值得开这个口子:Windows 的进程发现 / 自检扫描在开发机上根本跑不到,而它们恰恰是
|
|
58
|
+
* 最贵的一类 bug —— 2026-10 那次「活着的 bridge 被判成没运行 → 反复 taskkill → bridge_wedged」
|
|
59
|
+
* 里,win32 的两条扫描分支(`Name='node.exe'` 过滤 + doctor 的 tasklist 无命令行)**从来没被真正执行过**。
|
|
60
|
+
*/
|
|
61
|
+
const SETUP_PLATFORM = String(process.env.DSH_SETUP_PLATFORM || process.platform).toLowerCase();
|
|
62
|
+
const IS_WIN = SETUP_PLATFORM === "win32";
|
|
54
63
|
/**
|
|
55
64
|
* Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
|
|
56
65
|
* 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
|
|
@@ -587,17 +596,55 @@ function schtasks(args, timeoutMs = 15000) {
|
|
|
587
596
|
}
|
|
588
597
|
}
|
|
589
598
|
|
|
590
|
-
/**
|
|
599
|
+
/**
|
|
600
|
+
* 运行期留痕:写/删 pid 文件这类"非关键"动作失败时**必须留一行**(追加到安装日志)。
|
|
601
|
+
*
|
|
602
|
+
* 为什么不再静默 `catch {}`:pid 文件是插件半发现进程的唯一入口(Windows 无 pgrep/ps)。
|
|
603
|
+
* 它一旦"莫名消失",现场能看到的只有「bridge 未运行」——而**为什么**消失(EBUSY / 杀软 /
|
|
604
|
+
* 权限 / 并发截断)一点线索都没有。2026-10 那次排查里,这一层缺失直接把方向带偏。
|
|
605
|
+
* 日志本身写失败也不得影响业务(磁盘满/目录不可写 → 静默放弃)。
|
|
606
|
+
*/
|
|
607
|
+
function logSetupIssue(line) {
|
|
608
|
+
try {
|
|
609
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
610
|
+
fs.appendFileSync(path.join(CONFIG_DIR, ".dsh-setup-install.log"),
|
|
611
|
+
`\n[${new Date().toISOString()}] [dsh-setup] ${line}\n`);
|
|
612
|
+
} catch { /* 日志失败不得影响业务 */ }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描,但必须留痕)。 */
|
|
591
616
|
function writePidFile(name, pid) {
|
|
592
617
|
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
593
618
|
try {
|
|
594
619
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
595
620
|
fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
|
|
596
|
-
} catch {
|
|
621
|
+
} catch (e) {
|
|
622
|
+
logSetupIssue(`写 pid 文件失败(${name},pid=${pid}):${e && (e.code || e.message)}`);
|
|
623
|
+
}
|
|
597
624
|
}
|
|
598
625
|
|
|
599
|
-
|
|
600
|
-
|
|
626
|
+
/**
|
|
627
|
+
* 删除 pid 文件。
|
|
628
|
+
*
|
|
629
|
+
* `expectPid`:**只在文件内容等于它时才删**(不传 = 旧语义「无条件删」,仅留给确实要清理
|
|
630
|
+
* 整份残留的场景;本文件 `run()` 里的所有调用点都必须传)。
|
|
631
|
+
*
|
|
632
|
+
* 为什么必须校验归属:pid 文件是"谁持有谁负责"的:父进程无条件删会把**别的实例(或孤儿)**
|
|
633
|
+
* 的 pid 文件删掉,状态立刻变成「进程还活着、pid 文件没了」。2026-10 事故的根因链第 2 步
|
|
634
|
+
* 正是这个:新 bridge 撞本地实例锁秒退 → watcher 的 exit 钩子无条件删 `.dsh-bridge.pid` →
|
|
635
|
+
* 真正持有隧道的孤儿 bridge 从面板上"消失" → 被判成"没运行" → 反复重启 → `bridge_wedged`。
|
|
636
|
+
*/
|
|
637
|
+
function removePidFile(name, expectPid = null) {
|
|
638
|
+
const file = path.join(CONFIG_DIR, name);
|
|
639
|
+
try {
|
|
640
|
+
if (expectPid != null) {
|
|
641
|
+
const cur = readPidFile(name);
|
|
642
|
+
if (cur !== expectPid) return; // 不是我们的(或已被别人改写/删除)→ 一个字节都不动
|
|
643
|
+
}
|
|
644
|
+
fs.rmSync(file, { force: true });
|
|
645
|
+
} catch (e) {
|
|
646
|
+
logSetupIssue(`删除 pid 文件失败(${name}):${e && (e.code || e.message)}`);
|
|
647
|
+
}
|
|
601
648
|
}
|
|
602
649
|
|
|
603
650
|
function readPidFile(name) {
|
|
@@ -607,6 +654,22 @@ function readPidFile(name) {
|
|
|
607
654
|
} catch { return null; }
|
|
608
655
|
}
|
|
609
656
|
|
|
657
|
+
/**
|
|
658
|
+
* 取某 pid 的**完整命令行**(跨平台)。取不到 → 空串(调用方一律按"不认识这个进程"处理)。
|
|
659
|
+
*
|
|
660
|
+
* Windows 分支是本次修复的关键之一:旧实现只有 `ps -p <pid> -o command=`,而 Windows
|
|
661
|
+
* 没有 `ps`(`sh()` 走 cmd.exe)⇒ `looksLikeWatcher/looksLikeBridge` 在 Windows 上**恒 false**
|
|
662
|
+
* ⇒ 接管/回收上一任进程这两条安全回收路径在 Windows 上**从不生效**。这里改用
|
|
663
|
+
* `windowsProcessCommandLine()`(PowerShell CIM 按 ProcessId 点查,与 bridge 自己
|
|
664
|
+
* `instanceLockVerdict` 用的是同族手法)。
|
|
665
|
+
*/
|
|
666
|
+
function processCommandLine(pid) {
|
|
667
|
+
if (!Number.isInteger(pid) || pid <= 0) return "";
|
|
668
|
+
if (IS_WIN) return windowsProcessCommandLine(pid);
|
|
669
|
+
const r = sh(`ps -p ${pid} -o command=`);
|
|
670
|
+
return r.ok ? String(r.stdout || "") : "";
|
|
671
|
+
}
|
|
672
|
+
|
|
610
673
|
/**
|
|
611
674
|
* 这个 pid 看起来**真的**是 watcher 吗?
|
|
612
675
|
*
|
|
@@ -616,22 +679,21 @@ function readPidFile(name) {
|
|
|
616
679
|
*/
|
|
617
680
|
function looksLikeWatcher(pid) {
|
|
618
681
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
619
|
-
|
|
620
|
-
return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
|
|
682
|
+
return /dsh-setup\.mjs/.test(processCommandLine(pid));
|
|
621
683
|
}
|
|
622
684
|
|
|
623
685
|
/**
|
|
624
686
|
* 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
|
|
625
687
|
*
|
|
626
688
|
* 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
|
|
627
|
-
* 对无关进程发 SIGKILL
|
|
689
|
+
* 对无关进程发 SIGKILL 是不可接受的。所以对命令行做**双重**校验:
|
|
628
690
|
* 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
|
|
691
|
+
* (Windows 分支见 `processCommandLine`:Electron 宿主下拉起的 bridge 可能不叫 node.exe,
|
|
692
|
+
* 所以**不能**靠镜像名判断,只能靠命令行。)
|
|
629
693
|
*/
|
|
630
694
|
function looksLikeBridge(pid) {
|
|
631
695
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
632
|
-
const
|
|
633
|
-
if (!r.ok) return false;
|
|
634
|
-
const cmd = String(r.stdout || "");
|
|
696
|
+
const cmd = processCommandLine(pid);
|
|
635
697
|
return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
|
|
636
698
|
}
|
|
637
699
|
|
|
@@ -692,29 +754,60 @@ function isThisRuntimeBridgeCmd(cmd) {
|
|
|
692
754
|
return norm(c).includes(norm(needle));
|
|
693
755
|
}
|
|
694
756
|
|
|
695
|
-
/**
|
|
757
|
+
/**
|
|
758
|
+
* Windows:一次性 PowerShell CIM **全量枚举**进程的 {pid, name, cmd}。
|
|
759
|
+
*
|
|
760
|
+
* 返回值的三态是**契约**(doctor 的"假证据"就是被两态混用坑出来的):
|
|
761
|
+
* · `null` → **扫描不可用**(无 PowerShell / 超时 / 退出码非 0 / JSON 解析失败)
|
|
762
|
+
* · `[]` → 扫描成功,但确实一个进程都没有(或输出为空)
|
|
763
|
+
* · 数组 → 扫描成功,逐项 `{ pid, name, cmd }`
|
|
764
|
+
*
|
|
765
|
+
* 🔴 为什么不按镜像名过滤(旧实现是 `-Filter "Name='node.exe'"`):
|
|
766
|
+
* 桌面端(Electron)宿主起的 watcher 镜像名**一定不是** node.exe
|
|
767
|
+
*(`process.execPath` = 桌面端 exe),bridge 的镜像名则取决于本机有没有真 node
|
|
768
|
+
*(`preferredNode()` 回落 Electron 时为桌面端 exe)。按 node.exe 过滤 ⇒ 这类进程
|
|
769
|
+
* **永远扫不到** ⇒ 活着的孤儿 bridge 清不掉、doctor 恒报 0/0。
|
|
770
|
+
* 全量枚举 + 命令行匹配的代价是 PowerShell 多返回一些行(一次性 8s 上限),换来
|
|
771
|
+
* "与镜像名彻底解耦"——与 bridge 自己的单实例锁判定(按 ProcessId 查 CommandLine)同族手法。
|
|
772
|
+
*/
|
|
773
|
+
function windowsCimProcesses() {
|
|
774
|
+
if (!IS_WIN) return null;
|
|
775
|
+
const script = [
|
|
776
|
+
"try { $p = Get-CimInstance Win32_Process }",
|
|
777
|
+
"catch { $p = Get-WmiObject Win32_Process }",
|
|
778
|
+
"$p | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
|
|
779
|
+
].join("; ");
|
|
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, maxBuffer: 8 * 1024 * 1024 });
|
|
784
|
+
if (r && r.error) return null; // ENOENT(没 PowerShell)等
|
|
785
|
+
if (r && typeof r.status === "number" && r.status !== 0) return null; // 命令失败 ≠ 没有进程
|
|
786
|
+
const raw = String((r && r.stdout) || "").trim();
|
|
787
|
+
if (!raw) return [];
|
|
788
|
+
const parsed = JSON.parse(raw);
|
|
789
|
+
const out = [];
|
|
790
|
+
for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
791
|
+
const pid = Number(p && p.ProcessId);
|
|
792
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
793
|
+
out.push({ pid, name: String((p && p.Name) || ""), cmd: String((p && p.CommandLine) || "") });
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
} catch { return null; } // 无 PowerShell / 解析失败 → 调用方按"扫描不可用"处理,绝不输出 0
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;扫描不可用/取不到命令行 → 空数组)。 */
|
|
696
800
|
function listRuntimeBridgePids() {
|
|
697
801
|
const pids = [];
|
|
698
802
|
if (IS_WIN) {
|
|
699
|
-
//
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
708
|
-
{ encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
709
|
-
const raw = String(r.stdout || "").trim();
|
|
710
|
-
if (raw) {
|
|
711
|
-
const parsed = JSON.parse(raw);
|
|
712
|
-
for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
713
|
-
const pid = Number(p && p.ProcessId);
|
|
714
|
-
if (Number.isInteger(pid) && pid > 0 && isThisRuntimeBridgeCmd(p.CommandLine)) pids.push(pid);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
} catch { /* 无 PowerShell / 解析失败 → 不杀任何进程 */ }
|
|
803
|
+
// 全量 CIM 枚举(**不按镜像名过滤**:Electron 宿主拉起的进程可能不叫 node.exe)
|
|
804
|
+
const procs = windowsCimProcesses();
|
|
805
|
+
if (!procs) return pids; // 扫描不可用 → 一个都不杀(失败方向必须是漏杀,不能误杀)
|
|
806
|
+
for (const p of procs) {
|
|
807
|
+
// isThisRuntimeBridgeCmd 的严格性保持不变:命令行必须同时含 dsh-bridge.mjs 与
|
|
808
|
+
// **本运行时**的 clients/dsh-remote 路径(另一个运行时目录的进程不动,避免跨配置目录误杀)
|
|
809
|
+
if (isThisRuntimeBridgeCmd(p.cmd)) pids.push(p.pid);
|
|
810
|
+
}
|
|
718
811
|
return pids;
|
|
719
812
|
}
|
|
720
813
|
const r = sh("pgrep -fl 'dsh-bridge\\.mjs'");
|
|
@@ -1811,7 +1904,8 @@ async function runBridge() {
|
|
|
1811
1904
|
if (pidAlive(leftover) && looksLikeBridge(leftover)) {
|
|
1812
1905
|
try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
|
|
1813
1906
|
}
|
|
1814
|
-
|
|
1907
|
+
// 只删**刚才确认属于我们的**那个 pid(内容已核对过是 leftover);不无条件删整份文件
|
|
1908
|
+
removePidFile(BRIDGE_PID_FILE, leftover);
|
|
1815
1909
|
}
|
|
1816
1910
|
}
|
|
1817
1911
|
// ── C2:清扫「pid 文件之外的孤儿 bridge」(命令行校验,绝不误杀)──────────────
|
|
@@ -1999,8 +2093,16 @@ async function runBridge() {
|
|
|
1999
2093
|
// POSIX 上该选项被忽略。
|
|
2000
2094
|
windowsHide: true
|
|
2001
2095
|
});
|
|
2096
|
+
// 记下这个子进程的 pid:退出钩子删 pid 文件时必须核对归属(见 removePidFile 注释)。
|
|
2097
|
+
// 为什么不能直接用 bridgeProc.pid:bridgeProc 之后会被下一次 spawn 覆盖,而旧子进程的
|
|
2098
|
+
// exit 回调仍会跑 —— 那时若去删"当前"pid 文件,就会把新实例的 pid 文件删掉。
|
|
2099
|
+
const spawnedBridgePid = bridgeProc.pid;
|
|
2002
2100
|
bridgeProc.on("exit", (code) => {
|
|
2003
|
-
|
|
2101
|
+
// ⚠️ 只删**属于这个子进程**的 pid 文件(内容 == 它的 pid)。
|
|
2102
|
+
// 旧实现无条件删:新 bridge 撞本地实例锁秒退(code=4)是**常态事件**,每次都把
|
|
2103
|
+
// 真正活着的那条 bridge(孤儿)的 pid 文件删掉 → 面板「bridge 未运行」→ 反复重启
|
|
2104
|
+
// → bridge_wedged。这是 2026-10 事故根因链的第 2 步。
|
|
2105
|
+
removePidFile(BRIDGE_PID_FILE, spawnedBridgePid);
|
|
2004
2106
|
// C2 互踢根治的 watcher 半边:bridge 连续 2 次收到 code=4000(= 本机已有同 deviceId 的
|
|
2005
2107
|
// 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
|
|
2006
2108
|
// 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
|
|
@@ -2056,7 +2158,18 @@ async function runBridge() {
|
|
|
2056
2158
|
// watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
|
|
2057
2159
|
{
|
|
2058
2160
|
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
2059
|
-
|
|
2161
|
+
/**
|
|
2162
|
+
* 退出清理:**只删属于自己的 pid 文件**。
|
|
2163
|
+
* watcher 文件属于本进程;bridge 文件属于**本进程拉起的那个子进程**。
|
|
2164
|
+
* 无条件删(旧行为)会把孤儿/别的实例的 pid 文件删掉 → 状态变成「进程还活着、pid 文件没了」,
|
|
2165
|
+
* 正是本次事故根因链的第 2 步;失败方向必须是"残留一份过期文件"(插件按 pidAlive 会判掉),
|
|
2166
|
+
* 而不是"删掉别人活着的证据"。
|
|
2167
|
+
*/
|
|
2168
|
+
const cleanup = () => {
|
|
2169
|
+
removePidFile(WATCHER_PID_FILE, process.pid);
|
|
2170
|
+
const childPid = bridgeProc && bridgeProc.pid;
|
|
2171
|
+
if (Number.isInteger(childPid) && childPid > 0) removePidFile(BRIDGE_PID_FILE, childPid);
|
|
2172
|
+
};
|
|
2060
2173
|
process.on("exit", cleanup);
|
|
2061
2174
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
|
|
2062
2175
|
try {
|
|
@@ -3341,7 +3454,11 @@ async function doctorCmd(argv = []) {
|
|
|
3341
3454
|
};
|
|
3342
3455
|
|
|
3343
3456
|
console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
|
|
3344
|
-
|
|
3457
|
+
// 平台:若被 DSH_SETUP_PLATFORM 覆盖(仅测试用),如实标注 —— 否则"平台 darwin 却走了 win32 扫描"会非常难解释
|
|
3458
|
+
const platformLabel = SETUP_PLATFORM === process.platform
|
|
3459
|
+
? `${process.platform}/${process.arch}`
|
|
3460
|
+
: `${SETUP_PLATFORM}/${process.arch}(DSH_SETUP_PLATFORM 覆盖;实际 ${process.platform})`;
|
|
3461
|
+
console.log("时间:", new Date().toISOString(), "| 平台:", platformLabel, "| node:", process.version);
|
|
3345
3462
|
console.log("配置目录:", CONFIG_DIR);
|
|
3346
3463
|
|
|
3347
3464
|
// ── ① 插件与运行环境版本 ────────────────────────────────────────────────
|
|
@@ -3373,20 +3490,55 @@ async function doctorCmd(argv = []) {
|
|
|
3373
3490
|
const bridgePid = readPidFile(BRIDGE_PID_FILE);
|
|
3374
3491
|
console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
|
|
3375
3492
|
console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
|
|
3376
|
-
//
|
|
3493
|
+
// 进程扫描(平台各自的方式):只报**扫描拿到的真实数据**;扫不到就说"扫描不可用"。
|
|
3494
|
+
//
|
|
3495
|
+
// 🔴 旧实现是本次「假证据」的直接来源:Windows 侧用
|
|
3496
|
+
// `tasklist /FI "IMAGENAME eq node.exe" /FO CSV` 拿镜像名列表,再用
|
|
3497
|
+
// /dsh-setup\.mjs/、/dsh-bridge\.mjs/ 去过滤 **CSV 行** —— 而 tasklist 的输出里
|
|
3498
|
+
// **根本没有命令行**,于是两个计数在 Windows 上**恒为 0/0**,与真实情况毫无关系。
|
|
3499
|
+
// 2026-10 那次排查正是被这条"证据"带向「进程真的没起来」的错误方向。
|
|
3500
|
+
// 现在:Windows 复用 CIM 全量枚举(windowsCimProcesses,**带 CommandLine、不按镜像名过滤**),
|
|
3501
|
+
// 按命令行分别统计 watcher / bridge,并打出 pid + 解释器名 + 命令行摘要;
|
|
3502
|
+
// 扫描不可用(没 PowerShell / 超时 / 命令失败)时**显式打印「扫描不可用」**,
|
|
3503
|
+
// 绝不输出一个会被误读成"没有进程"的 0。
|
|
3377
3504
|
try {
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3505
|
+
if (IS_WIN) {
|
|
3506
|
+
const procs = windowsCimProcesses();
|
|
3507
|
+
if (!procs) {
|
|
3508
|
+
console.log(" 实测进程 : 扫描不可用(PowerShell/CIM 取不到进程列表:可能没装 PowerShell、被策略禁用或超时)");
|
|
3509
|
+
console.log(" ⚠️ 这条**不能**当作「进程没在跑」的证据 —— 只看上面的 pid 文件结论与日志尾部。");
|
|
3510
|
+
} else {
|
|
3511
|
+
const watchers = procs.filter((p) => /dsh-setup\.mjs/.test(p.cmd));
|
|
3512
|
+
const bridges = procs.filter((p) => /dsh-bridge\.mjs/.test(p.cmd)).filter((p) => !/dsh-setup/.test(p.cmd));
|
|
3513
|
+
console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个(CIM 全量扫描,按命令行识别)`);
|
|
3514
|
+
for (const [kind, list] of [["watcher", watchers], ["bridge", bridges]]) {
|
|
3515
|
+
for (const p of list) {
|
|
3516
|
+
const brief = p.cmd.length > 160 ? `${p.cmd.slice(0, 157)}...` : p.cmd;
|
|
3517
|
+
console.log(` · ${kind} pid=${p.pid} 解释器=${p.name || "?"} 命令行=${brief || "(取不到)"}`);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
|
|
3521
|
+
if (!watchers.length && !bridges.length) {
|
|
3522
|
+
console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
} else {
|
|
3526
|
+
const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
|
|
3527
|
+
if (!r.ok && r.code !== 1) {
|
|
3528
|
+
// pgrep 退出码 1 = "没有匹配的进程"(合法的 0),其它码 = 工具不可用(不能当 0 读)
|
|
3529
|
+
console.log(` 实测进程 : 扫描不可用(pgrep 退出码 ${r.code}:本机没有 pgrep 或被禁用)`);
|
|
3530
|
+
} else {
|
|
3531
|
+
const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
|
|
3532
|
+
const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
|
|
3533
|
+
const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
|
|
3534
|
+
console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
|
|
3535
|
+
if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
|
|
3536
|
+
if (!watchers.length && !bridges.length) {
|
|
3537
|
+
console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
} catch (e) { console.log(` 实测进程 : 扫描不可用(${e.message})`); }
|
|
3390
3542
|
if (process.platform === "linux") {
|
|
3391
3543
|
const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
|
|
3392
3544
|
console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
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",
|
|
@@ -5706,6 +5706,19 @@ window.__ModuleLoader__.load({
|
|
|
5706
5706
|
return "约 " + Math.round(n / 60_000) + " 分钟";
|
|
5707
5707
|
}
|
|
5708
5708
|
|
|
5709
|
+
/**
|
|
5710
|
+
* 宽松读一个「条数」字段:只有真正的数字(JSON number / 纯数字串)才算权威,
|
|
5711
|
+
* null / "" / undefined / true 一律视为**这个字段没有** → 用 fallback。
|
|
5712
|
+
* 为什么要较真:旧 bridge 不认这个键时若把它当 0,面板会**藏起**真实积压(用户以为通知丢了);
|
|
5713
|
+
* 反过来把字段缺失当"有积压"同样不行 —— 那会平白吓人。
|
|
5714
|
+
*/
|
|
5715
|
+
function wechatCountOf(value, fallback) {
|
|
5716
|
+
var numeric = typeof value === "number" || (typeof value === "string" && value.trim() !== "");
|
|
5717
|
+
var n = numeric ? Number(value) : NaN;
|
|
5718
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
5719
|
+
return Math.floor(n);
|
|
5720
|
+
}
|
|
5721
|
+
|
|
5709
5722
|
/**
|
|
5710
5723
|
* 🤖 微信机器人绑定面板。
|
|
5711
5724
|
* @param {{embedded?: boolean}} [props] embedded=true(远程访问面板里的「微信机器人通道」tab)
|
|
@@ -6085,6 +6098,16 @@ window.__ModuleLoader__.load({
|
|
|
6085
6098
|
// 规格 §9:**只有两态**。这一行是「已绑定 · 健康提示」,两种取值都明确带「已绑定」;
|
|
6086
6099
|
// 健康只由「最近一次推送是否失败」决定,**不参与绑定状态**(通道没在跑等情形另起一行说明,不冒充第三种状态)。
|
|
6087
6100
|
var healthy = !wx.last_error;
|
|
6101
|
+
// 🛑 推送静默(2026-10-29):微信只在「你最近发过消息」的很短窗口内允许机器人主动发言,
|
|
6102
|
+
// 窗口外平台没有续期接口(契约 §2.4/§3.4)→ bridge 识别出令牌过期后会**暂停主动推送**
|
|
6103
|
+
// (不再每小时猛打 60 次),并把没送出去的通知留在待补发队列,等你下次说话再补。
|
|
6104
|
+
// 面板这里只做两件事:① 如实说明「为什么暂时收不到推送」;② 给出唯一一条恢复动作(回一句话)。
|
|
6105
|
+
// ⚠️ 它只是**提示**:不参与两态绑定判定,也不隐藏绑定/解绑按钮(规格 §9)。
|
|
6106
|
+
// 字段缺失(旧 bridge 没有这个键)= 没开启静默 → 一律不渲染,绝不能把 undefined 当成"已静默"。
|
|
6107
|
+
var pushPaused = wx.push_paused === true;
|
|
6108
|
+
// 待补发条数:新 bridge 下发 pending_outbox_count(契约 §3.7),旧 bridge 只有 pending_outbox。
|
|
6109
|
+
// 两个字段都在时**只认优先的那一个**,避免同一件事在卡片上提示两遍。
|
|
6110
|
+
var outboxCount = wechatCountOf(wx.pending_outbox_count, wechatCountOf(wx.pending_outbox, 0));
|
|
6088
6111
|
body.push(h("div", { className: "dru-card" },
|
|
6089
6112
|
h("h3", null, "已绑定"),
|
|
6090
6113
|
h("div", { className: "dru-status-line" },
|
|
@@ -6098,6 +6121,16 @@ window.__ModuleLoader__.load({
|
|
|
6098
6121
|
wechatTimeText(wx.connected_at) ? h("div", { className: "dru-meta" }, "最近上线:" + wechatTimeText(wx.connected_at)) : null,
|
|
6099
6122
|
Number(wx.pending_replies) > 0 ? h("div", { className: "dru-meta" }, "等待你回复的通知:" + Number(wx.pending_replies) + " 条") : null,
|
|
6100
6123
|
wx.last_error ? h("div", { className: "dru-msg dru-msg-warn", role: "status", "aria-live": "polite" }, "⚠️ 最近一次推送失败:" + String(wx.last_error)) : null,
|
|
6124
|
+
// 静默提示紧跟 last_error:两者是**两回事**(上面是单次发送失败,这里是"平台侧窗口关了"),
|
|
6125
|
+
// 但都只是健康提示。文案刻意给出可执行的恢复动作,避免用户以为要重新绑定。
|
|
6126
|
+
pushPaused
|
|
6127
|
+
? h("div", { className: "dru-msg dru-msg-warn", role: "status", "aria-live": "polite" },
|
|
6128
|
+
"⚠️ 微信推送已静默:微信通道只在你最近发过消息后的一小段时间内允许机器人主动发消息。给机器人回任意一条消息即可恢复,未送达的通知会补发。")
|
|
6129
|
+
: null,
|
|
6130
|
+
// 静默期间给一个**简短**的待补发数字(详细说明已由上面的静默提示承担,不再重复一整段)。
|
|
6131
|
+
pushPaused && outboxCount > 0
|
|
6132
|
+
? h("div", { className: "dru-meta" }, "待补发 " + outboxCount + " 条:你回任意一条消息后会一起补给你。")
|
|
6133
|
+
: null,
|
|
6101
6134
|
Number(wx.cooldown_ms) > 0
|
|
6102
6135
|
? h("div", { className: "dru-msg dru-msg-warn" }, [
|
|
6103
6136
|
"⏳ 微信侧会话超时,后台已按官方做法退避(还剩 " + wechatCooldownText(wx.cooldown_ms) + ")。",
|
|
@@ -6122,9 +6155,11 @@ window.__ModuleLoader__.load({
|
|
|
6122
6155
|
? h("div", { className: "dru-msg dru-msg-warn" },
|
|
6123
6156
|
"提示:任务事件订阅未就绪(状态 " + String(wx.events_state) + "),这段时间任务跑完可能不会推给你。稍候会自动重连;持续如此请到「📱 远程访问」重启后台服务。")
|
|
6124
6157
|
: null,
|
|
6125
|
-
|
|
6158
|
+
// 静默态下这条长文案与上面的「静默提示 + 待补发 N 条」重复,故只在**未静默**时显示:
|
|
6159
|
+
// 条数统一走 outboxCount(新 bridge 用 pending_outbox_count,旧 bridge 回落 pending_outbox)。
|
|
6160
|
+
!pushPaused && outboxCount > 0
|
|
6126
6161
|
? h("div", { className: "dru-msg dru-msg-warn" },
|
|
6127
|
-
"有 " +
|
|
6162
|
+
"有 " + outboxCount + " 条通知刚才没能发出去(微信侧发送失败),已排在待补发队列里 —— 你下次在微信里任意发一句话,就会自动补给你。")
|
|
6128
6163
|
: null,
|
|
6129
6164
|
wx.last_fault && wx.last_fault.code
|
|
6130
6165
|
? h("div", { className: "dru-hint" },
|