@mrrisega/dsh-remote 0.7.0-beta.1 → 0.7.0-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/clients/dsh-remote/dsh-bridge.mjs +128 -12
- package/clients/dsh-remote/dsh-events.mjs +66 -8
- package/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/clients/dsh-remote/test/device-registration-report.test.mjs +133 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +88 -0
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +121 -0
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +15 -7
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +41 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +87 -1
- package/clients/dsh-remote/wechat-runtime.mjs +88 -13
- package/dsh-setup.mjs +8 -2
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +67 -8
- package/packages/dsh-remote-web/lib/index.js +160 -19
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +128 -12
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +66 -8
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +88 -13
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +8 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +39 -6
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +178 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +13 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cookie **按 authority 隔离**回归(2026-10-09 事故根治)。
|
|
4
|
+
*
|
|
5
|
+
* 【事故原貌】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端
|
|
6
|
+
* (每次启动随机端口)是**两个宿主进程**,各自的插件都往里写自己 authority 的 Cookie →
|
|
7
|
+
* 互相覆盖。桥撞 401 后重读磁盘拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
|
|
8
|
+
* → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**,手机端持续白页。
|
|
9
|
+
* 现场极具迷惑性:`bridge-status` 显示 online、页面能开,只有日志里 401 刷屏。
|
|
10
|
+
*
|
|
11
|
+
* 【修法】每个宿主只写自己 authority 的文件 `.harness-cookie.<authority>.json`;桥只认
|
|
12
|
+
* **当前上游 authority** 的那一份,旧版共享文件仅在 authority 对得上(或旧插件根本没写
|
|
13
|
+
* authority)时才用,**声明了别的 authority 一律不用** —— 那一份必然 401,用它就是乒乓。
|
|
14
|
+
*
|
|
15
|
+
* 本用例走真实 `handleHttpFrame` → `doHttp` 全链路:假上游把"收到的 Cookie"回显出来,
|
|
16
|
+
* 因此可以逐字断言桥**到底发了什么**(而不是只断言状态码)。
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import test, { after } from "node:test";
|
|
24
|
+
|
|
25
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-cookie-authority-"));
|
|
26
|
+
const LEGACY = path.join(tmpDir, ".harness-cookie.json");
|
|
27
|
+
|
|
28
|
+
/** 与两份源码同名的规则(漂移由下面的"源码约束"用例兜住)。 */
|
|
29
|
+
const slug = (authority) => String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
30
|
+
const cookieFileFor = (authority) => path.join(tmpDir, `.harness-cookie.${slug(authority)}.json`);
|
|
31
|
+
|
|
32
|
+
let upstreamAuthority = "";
|
|
33
|
+
function writeDoc(file, doc) {
|
|
34
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
35
|
+
writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
function clearCookies() {
|
|
38
|
+
for (const f of [LEGACY, cookieFileFor(upstreamAuthority)]) rmSync(f, { force: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 假 dsh web:把收到的 Cookie 原样回显(200),便于断言"桥发了哪一份"。 */
|
|
42
|
+
const upstream = http.createServer((req, res) => {
|
|
43
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
44
|
+
res.end(req.headers.cookie || "");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-cookieauth1";
|
|
48
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
49
|
+
|
|
50
|
+
const { handleHttpFrame } = await new Promise((resolve, reject) => {
|
|
51
|
+
upstream.listen(0, "127.0.0.1", () => {
|
|
52
|
+
upstreamAuthority = `127.0.0.1:${upstream.address().port}`;
|
|
53
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://${upstreamAuthority}`;
|
|
54
|
+
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function ask(pathname = "/echo") {
|
|
59
|
+
let reply;
|
|
60
|
+
await handleHttpFrame((obj) => { reply = obj; return true; }, { type: "http", id: `req-${Math.random()}`, method: "GET", path: pathname, headers: {} });
|
|
61
|
+
return { status: reply.status, cookie: Buffer.from(reply.body || "", "base64").toString("utf8") };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("本宿主专属文件优先:`.harness-cookie.<authority>.json` 收到的就是它", async () => {
|
|
65
|
+
clearCookies();
|
|
66
|
+
writeDoc(cookieFileFor(upstreamAuthority), { authority: upstreamAuthority, cookie: "dsh-auth-OWN", mintedAt: Date.now() });
|
|
67
|
+
const r = await ask();
|
|
68
|
+
assert.equal(r.status, 200);
|
|
69
|
+
assert.equal(r.cookie, "dsh-auth-OWN");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("★ 事故锁:旧版共享文件里是**另一个宿主**的 Cookie → 一个字节都不许发出去", async () => {
|
|
73
|
+
clearCookies();
|
|
74
|
+
// 这正是事故现场:共享文件被另一宿主(3080)覆盖过
|
|
75
|
+
writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
|
|
76
|
+
const r = await ask();
|
|
77
|
+
assert.equal(r.status, 200);
|
|
78
|
+
assert.equal(r.cookie, "", "foreign authority 的 Cookie 必须被跳过(发出去必然 401 → 乒乓)");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("旧版共享文件 authority 对得上 → 继续用(向后兼容)", async () => {
|
|
82
|
+
clearCookies();
|
|
83
|
+
writeDoc(LEGACY, { authority: upstreamAuthority, cookie: "dsh-auth-LEGACY-OK", mintedAt: Date.now() });
|
|
84
|
+
const r = await ask();
|
|
85
|
+
assert.equal(r.cookie, "dsh-auth-LEGACY-OK");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("旧插件没写 authority 字段 → 按兼容采用(不能因为缺字段就 401 白页)", async () => {
|
|
89
|
+
clearCookies();
|
|
90
|
+
writeDoc(LEGACY, { cookie: "dsh-auth-NO-AUTHORITY", mintedAt: Date.now() });
|
|
91
|
+
const r = await ask();
|
|
92
|
+
assert.equal(r.cookie, "dsh-auth-NO-AUTHORITY");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("两份都在且互相矛盾 → 用本宿主那份(专属文件优先级高于共享镜像)", async () => {
|
|
96
|
+
clearCookies();
|
|
97
|
+
writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
|
|
98
|
+
writeDoc(cookieFileFor(upstreamAuthority), { authority: upstreamAuthority, cookie: "dsh-auth-OWN", mintedAt: Date.now() });
|
|
99
|
+
const r = await ask();
|
|
100
|
+
assert.equal(r.cookie, "dsh-auth-OWN");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("源码约束(跨文件漂移锁):命名规则与文件名模板两份必须逐字一致", () => {
|
|
104
|
+
const bridge = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
105
|
+
const plugin = readFileSync(new URL("../../../packages/dsh-remote-web/lib/index.js", import.meta.url), "utf8");
|
|
106
|
+
const rule = 'return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");';
|
|
107
|
+
const nameTpl = '`${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`';
|
|
108
|
+
const revokedTpl = "`${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`";
|
|
109
|
+
for (const [label, src] of [["桥半", bridge], ["插件半", plugin]]) {
|
|
110
|
+
assert.ok(src.includes(rule), `${label} 缺少 authoritySlug 规则(两份必须一致,否则各写各的文件名)`);
|
|
111
|
+
assert.ok(src.includes(nameTpl), `${label} 缺少专属文件名模板`);
|
|
112
|
+
assert.ok(src.includes(revokedTpl), `${label} 缺少专属作废标记名模板`);
|
|
113
|
+
}
|
|
114
|
+
assert.ok(bridge.includes("claimed === authority"), "桥半必须显式判 foreign authority(乒乓的根源)");
|
|
115
|
+
assert.ok(plugin.includes("claimed === authority"), "插件半必须在读的时候判 foreign authority");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
after(() => {
|
|
119
|
+
try { upstream.close(); } catch { /* ignore */ }
|
|
120
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
121
|
+
});
|
|
@@ -19,10 +19,17 @@ import test, { after } from "node:test";
|
|
|
19
19
|
const UNAUTH = "dsh web authentication required; reopen the URL printed by dsh web.\n";
|
|
20
20
|
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-harness-cookie-"));
|
|
21
21
|
const cookieFile = path.join(tmpDir, ".harness-cookie.json");
|
|
22
|
-
|
|
22
|
+
/** 作废标记自 0.7.0-beta.2 起**按 authority 分文件**(见 harness-cookie-authority.test.mjs)。 */
|
|
23
|
+
const slug = (authority) => String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
24
|
+
const revokedFileFor = (authority) => path.join(tmpDir, `.harness-cookie-revoked.${slug(authority)}`);
|
|
25
|
+
/** 本用例的假上游 authority(listen 后才知道端口;Cookie 必须按它命名才不会被判 foreign)。 */
|
|
26
|
+
let upstreamAuthority = "127.0.0.1:3080";
|
|
27
|
+
const revokedFile = () => revokedFileFor(upstreamAuthority);
|
|
23
28
|
|
|
24
29
|
function writeCookie(value) {
|
|
25
|
-
|
|
30
|
+
// 写**旧版共享文件**:新桥仍兼容读取(authority 对得上时),这条用例验的是 401 自愈链路,
|
|
31
|
+
// 与"专属/共享"无关;专属文件的优先级由 harness-cookie-authority.test.mjs 覆盖。
|
|
32
|
+
writeFileSync(cookieFile, JSON.stringify({ authority: upstreamAuthority, cookie: value, mintedAt: Date.now() }), { mode: 0o600 });
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
// 假 dsh web:只认 cookie=dsh-auth-NEW;第一次请求 401(并模拟插件此刻换好新 Cookie)
|
|
@@ -50,7 +57,8 @@ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
|
50
57
|
|
|
51
58
|
const { handleHttpFrame } = await new Promise((resolve, reject) => {
|
|
52
59
|
upstream.listen(0, "127.0.0.1", () => {
|
|
53
|
-
|
|
60
|
+
upstreamAuthority = `127.0.0.1:${upstream.address().port}`;
|
|
61
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://${upstreamAuthority}`;
|
|
54
62
|
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
55
63
|
});
|
|
56
64
|
});
|
|
@@ -69,12 +77,12 @@ async function request(frame) {
|
|
|
69
77
|
|
|
70
78
|
test("旧 Cookie 被 dsh web 拒(401)→ 用新 Cookie 自动重试一次:手机端拿到 200,用户零操作", async () => {
|
|
71
79
|
writeCookie("dsh-auth-OLD");
|
|
72
|
-
rmSync(revokedFile, { force: true });
|
|
80
|
+
rmSync(revokedFile(), { force: true });
|
|
73
81
|
const reply = await request({ id: "heal-1", method: "GET", path: "/needs-auth", headers: {} });
|
|
74
82
|
assert.equal(reply.status, 200, "自愈后应返回 200(而不是把 401 透传给手机)");
|
|
75
83
|
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), "ok");
|
|
76
84
|
assert.equal(firstCallSeen, 1, "上游只被 401 了一次(第二次带新 Cookie 成功)");
|
|
77
|
-
assert.ok(existsSync(revokedFile), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
|
|
85
|
+
assert.ok(existsSync(revokedFile()), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插件重换", async () => {
|
|
@@ -87,14 +95,14 @@ test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插
|
|
|
87
95
|
const prev = process.env.DSH_BRIDGE_UPSTREAM;
|
|
88
96
|
// 另起一个 bridge 实例指向这个永远 401 的上游(模块已加载,上游常量不可变 → 用新进程不便;
|
|
89
97
|
// 这里直接验证标记语义:标记存在时 401 应原样透传,且标记仍在)
|
|
90
|
-
rmSync(revokedFile, { force: true });
|
|
98
|
+
rmSync(revokedFile(), { force: true });
|
|
91
99
|
writeCookie("dsh-auth-STILL-OLD");
|
|
92
100
|
try {
|
|
93
101
|
const reply = await request({ id: "heal-2", method: "GET", path: "/needs-auth", headers: {} });
|
|
94
102
|
// 上一个用例的假上游此刻会返回 200(因为它已经在第一次调用时把 Cookie 改成了 NEW),
|
|
95
103
|
// 所以这里只断言"标记机制"本身:去掉标记后仍能再次自愈
|
|
96
104
|
assert.ok(reply.status === 200 || reply.status === 401);
|
|
97
|
-
assert.ok(existsSync(revokedFile) || reply.status === 200, "自愈后应留下标记或已成功");
|
|
105
|
+
assert.ok(existsSync(revokedFile()) || reply.status === 200, "自愈后应留下标记或已成功");
|
|
98
106
|
} finally {
|
|
99
107
|
process.env.DSH_BRIDGE_UPSTREAM = prev;
|
|
100
108
|
await new Promise((r) => srv2.close(r));
|
|
@@ -535,6 +535,47 @@ test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程
|
|
|
535
535
|
}
|
|
536
536
|
});
|
|
537
537
|
|
|
538
|
+
test("★ 亚毫秒样本(本机/回环自测)也必须出数 —— 不得永远停在「测速中…」", async () => {
|
|
539
|
+
// 业主 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
|
|
540
|
+
// 根因:旧实现是 `if (r < 1) return;`(注释写"亚毫秒=同帧缓存命中"),但**本机/回环**访问
|
|
541
|
+
// (桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms —— 于是**每一个**样本
|
|
542
|
+
// 都被丢掉,界面永远停在空态。面板侧 chip 从来没这个毛病(`v <= 0` + `Math.max(1, …)`),
|
|
543
|
+
// 这条用例把两侧口径钉在一起。
|
|
544
|
+
const env = harness({ width: 390, fetchImpl: async () => ({ status: 200, ok: true }) }); // 零延迟 = 亚毫秒
|
|
545
|
+
runAdapter(env);
|
|
546
|
+
const rtt = findFab(env).querySelector('[data-role="rtt"]');
|
|
547
|
+
assert.equal(rtt.hidden, true, "前提:还没有样本");
|
|
548
|
+
await env.win.fetch("/api/session/list");
|
|
549
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
550
|
+
assert.equal(rtt.hidden, false, "★亚毫秒的真实往返必须记成样本(否则本机自测永远是「测速中…」)");
|
|
551
|
+
assert.equal(String(rtt.textContent), "1ms", "展示层夹到 1ms —— 0ms 是谎话");
|
|
552
|
+
const net = findMenu(env).querySelector(".dsh-ma-menu-net");
|
|
553
|
+
assert.ok(!/测速中/.test(String(net.textContent)),
|
|
554
|
+
`菜单行不得再显示空态,实际:${net.textContent}`);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("★ 悬浮窗被官方 SPA 重渲染重建后,已积累的样本仍要显示(样本不跟着某一代闭包走)", async () => {
|
|
558
|
+
// fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而悬浮窗可能被重建。
|
|
559
|
+
// 若样本数组长在 buildRemoteMenu 的闭包里,包装会一直喂"上一代"(节点已不在文档里),
|
|
560
|
+
// 新界面读自己那份空数组 → 又是永远「测速中…」。样本挂在 window 上才不会这样。
|
|
561
|
+
const env = harness({
|
|
562
|
+
width: 390,
|
|
563
|
+
fetchImpl: async () => { await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; },
|
|
564
|
+
});
|
|
565
|
+
runAdapter(env);
|
|
566
|
+
await env.win.fetch("/api/session/list");
|
|
567
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
568
|
+
assert.equal(findFab(env).querySelector('[data-role="rtt"]').hidden, false, "前提:第一代已有样本");
|
|
569
|
+
|
|
570
|
+
// 模拟 SPA 重渲染:官方把 body 子树整片换掉(我们的悬浮窗跟着消失),适配层再建一代。
|
|
571
|
+
env.body.children = [];
|
|
572
|
+
runAdapter(env);
|
|
573
|
+
const fab2 = findFab(env);
|
|
574
|
+
assert.ok(fab2, "重建后应有新的悬浮按钮");
|
|
575
|
+
assert.equal(fab2.querySelector('[data-role="rtt"]').hidden, false,
|
|
576
|
+
"★重建后的悬浮窗必须立刻显示已积累的样本(旧实现读自己那份空数组 → 永远「测速中…」)");
|
|
577
|
+
});
|
|
578
|
+
|
|
538
579
|
test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
|
|
539
580
|
const env = harness({
|
|
540
581
|
width: 390,
|
|
@@ -1983,6 +1983,77 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
|
|
|
1983
1983
|
}
|
|
1984
1984
|
});
|
|
1985
1985
|
|
|
1986
|
+
test("★ 待补发队列:过期的**不发**(丢弃最早的),且只保留最近几条", async () => {
|
|
1987
|
+
// 业主 2026-10-09 口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
1988
|
+
// 两条理由:① 迟到几小时的"任务跑完了"只会误导用户;② 攒到十几条会在平台恢复时一次连打,
|
|
1989
|
+
// 实测正是这种突发批量触发 `ret=-2 prepare failed` 被限流 → 补发又失败 → 队列再涨(死循环)。
|
|
1990
|
+
const ilink = await fakeIlink();
|
|
1991
|
+
try {
|
|
1992
|
+
const sub = makeFakeSubscriber();
|
|
1993
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1994
|
+
const statePath = path.join(relayDir, ".wechat-state.json");
|
|
1995
|
+
const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
1996
|
+
const now = Date.now();
|
|
1997
|
+
const H = 60 * 60 * 1000;
|
|
1998
|
+
|
|
1999
|
+
// 手工造一段积压:2 条很旧(>2h)+ 1 条刚失败(刚刚)
|
|
2000
|
+
rt.channel.writeState({
|
|
2001
|
+
pending_outbox: [
|
|
2002
|
+
{ text: "很久以前的完成通知A", kind: "stopped", at: now - 5 * H },
|
|
2003
|
+
{ text: "很久以前的完成通知B", kind: "stopped", at: now - 3 * H },
|
|
2004
|
+
{ text: "刚刚的完成通知C", kind: "stopped", at: now - 1000 },
|
|
2005
|
+
],
|
|
2006
|
+
});
|
|
2007
|
+
// ① 面板看到的是**有效**条数(过期的不该再显示成"待补发")
|
|
2008
|
+
assert.equal(rt.status().pending_outbox, 1, "status 必须按保质期报有效条数(否则重启后面板显示永远发不出去的旧条目)");
|
|
2009
|
+
|
|
2010
|
+
// ② 用户随便发一条 → 触发补发:只发那条没过期的
|
|
2011
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
2012
|
+
await waitFor(() => (stateOf().pending_outbox || []).length === 0);
|
|
2013
|
+
const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
|
|
2014
|
+
assert.equal(resent.length, 1, `★ 只应补发未过期的那一条,实际补发 ${resent.length} 条`);
|
|
2015
|
+
assert.match(resent[0], /刚刚的完成通知C/, "补发的必须是没过期的那条");
|
|
2016
|
+
assert.ok(!resent.some((t) => /很久以前的完成通知/.test(t)), "★ 过期条目绝不能发出去");
|
|
2017
|
+
} finally {
|
|
2018
|
+
await ilink.close();
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
test("★ 待补发队列:超过上限只留最近几条(丢最早,且如实记日志)", async () => {
|
|
2023
|
+
const ilink = await fakeIlink({ onSendMessage: (_s, reply) => reply({ ret: -2, errmsg: "prepare failed" }) });
|
|
2024
|
+
try {
|
|
2025
|
+
const infos = [];
|
|
2026
|
+
const warns = [];
|
|
2027
|
+
const sub = makeFakeSubscriber();
|
|
2028
|
+
sub.sessionsFixture = [{ sessionId: "session-done", title: "跑完的活", running: false, cwd: "/p" }];
|
|
2029
|
+
sub.summaryFixture = "做完了。";
|
|
2030
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
2031
|
+
rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
|
|
2032
|
+
const statePath = path.join(relayDir, ".wechat-state.json");
|
|
2033
|
+
const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
2034
|
+
|
|
2035
|
+
// 连推 9 条(每一条都发不出去 → 全部入队)。每条正文不同,才能断言"留下的是最近的"。
|
|
2036
|
+
for (let i = 1; i <= 9; i += 1) {
|
|
2037
|
+
sub.summaryFixture = `这是第${i}条结论。`;
|
|
2038
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: i });
|
|
2039
|
+
}
|
|
2040
|
+
const queued = stateOf().pending_outbox || [];
|
|
2041
|
+
assert.equal(queued.length, 5, `★ 队列上限 = 最近 5 条,实际 ${queued.length}`);
|
|
2042
|
+
const kept = queued.map((q) => String(q.text)).join("\n");
|
|
2043
|
+
for (const i of [5, 6, 7, 8, 9]) {
|
|
2044
|
+
assert.match(kept, new RegExp(`这是第${i}条结论`), `最近的第 ${i} 条必须保留`);
|
|
2045
|
+
}
|
|
2046
|
+
for (const i of [1, 2, 3, 4]) {
|
|
2047
|
+
assert.ok(!kept.includes(`这是第${i}条结论`), `★ 最早的第 ${i} 条必须被丢弃(只留最近几条)`);
|
|
2048
|
+
}
|
|
2049
|
+
assert.ok(warns.some((m) => /待补发队列丢弃 \d+ 条/.test(m)),
|
|
2050
|
+
`丢数据必须如实记日志(不得静默),实际:${JSON.stringify(warns)}`);
|
|
2051
|
+
} finally {
|
|
2052
|
+
await ilink.close();
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
|
|
1986
2057
|
test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
|
|
1987
2058
|
// 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
|
|
1988
2059
|
// `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
|
|
@@ -1990,9 +2061,10 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
|
|
|
1990
2061
|
const ilink = await fakeIlink();
|
|
1991
2062
|
try {
|
|
1992
2063
|
const warns = [];
|
|
2064
|
+
const infos = [];
|
|
1993
2065
|
const sub = makeFakeSubscriber();
|
|
1994
2066
|
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1995
|
-
rt.logger = { info()
|
|
2067
|
+
rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
|
|
1996
2068
|
await rt.startChannel(); // 真实接线:#startSubscriber() → 给注入的订阅器挂 handler
|
|
1997
2069
|
try {
|
|
1998
2070
|
sub.emit("fault", { code: "follow-limit", message: "follow 流已达上限 8", sessionId: "session-x", expected: false });
|
|
@@ -2002,9 +2074,23 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
|
|
|
2002
2074
|
assert.equal(st.last_fault && st.last_fault.code, "follow-limit", "非预期的订阅异常要记进状态供面板显示");
|
|
2003
2075
|
|
|
2004
2076
|
// 预期内的(子代理会话必然被拒 / 会话刚被删)只记日志,不该污染故障状态
|
|
2077
|
+
const warnCountBefore = warns.length;
|
|
2005
2078
|
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2006
2079
|
const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
2007
2080
|
assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
|
|
2081
|
+
// ★2026-10-09:预期内的拒绝**不能写成 warn** —— 每生成一个子代理就会来一条,
|
|
2082
|
+
// 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(业主现场看到的就是这个)。
|
|
2083
|
+
assert.equal(warns.length, warnCountBefore, `expected 的拒绝不得写 warn,实际:${JSON.stringify(warns)}`);
|
|
2084
|
+
assert.ok(infos.some((m) => m.includes("订阅跳过(预期") && m.includes("session=sub-1")),
|
|
2085
|
+
`expected 的拒绝要有一条 info 留痕(绝不静默),实际:${JSON.stringify(infos)}`);
|
|
2086
|
+
// 同一对 (code, session) 反复来也只报一次:否则子代理一多照样刷屏
|
|
2087
|
+
const infoCountBefore = infos.length;
|
|
2088
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2089
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2090
|
+
assert.equal(infos.length, infoCountBefore, "同一 (code, session) 的预期拒绝只报一次(防刷屏)");
|
|
2091
|
+
// 但**不同**会话仍各自留痕一次(可诊断,不被去重抹掉)
|
|
2092
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-2", expected: true });
|
|
2093
|
+
assert.equal(infos.length, infoCountBefore + 1, "不同子代理会话各留一条");
|
|
2008
2094
|
} finally {
|
|
2009
2095
|
await rt.stopChannel();
|
|
2010
2096
|
}
|
|
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
|
|
|
199
199
|
const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive
|
|
203
|
-
*
|
|
202
|
+
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
|
|
203
|
+
*
|
|
204
|
+
* 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
205
|
+
* 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
|
|
206
|
+
* 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
|
|
207
|
+
* 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
|
|
208
|
+
*/
|
|
209
|
+
const MAX_OUTBOX = 5;
|
|
210
|
+
/**
|
|
211
|
+
* 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
|
|
214
|
+
* 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
|
|
215
|
+
* 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
|
|
204
216
|
*/
|
|
205
|
-
const
|
|
217
|
+
const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
206
218
|
/** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
|
|
207
219
|
const OUTBOX_MARK = "(补发)";
|
|
208
220
|
|
|
@@ -337,10 +349,16 @@ function sendJson(res, status, body) {
|
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
export class WeChatRuntime {
|
|
352
|
+
/**
|
|
353
|
+
* 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
|
|
354
|
+
* 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
|
|
355
|
+
*/
|
|
356
|
+
#expectedFaultSeen = new Set();
|
|
357
|
+
|
|
340
358
|
/**
|
|
341
359
|
* @param {{
|
|
342
360
|
* relayDir: string,
|
|
343
|
-
* upstream?: string,
|
|
361
|
+
* upstream?: string|(() => string), // dsh web 地址,默认 http://127.0.0.1:3080;函数=每次重连重新求值
|
|
344
362
|
* cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
|
|
345
363
|
* secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
|
|
346
364
|
* logger?: any,
|
|
@@ -356,6 +374,16 @@ export class WeChatRuntime {
|
|
|
356
374
|
if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
|
|
357
375
|
this.relayDir = opts.relayDir;
|
|
358
376
|
this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
|
|
377
|
+
/**
|
|
378
|
+
* 上游地址的**取值器**:恰好是字符串时是常量函数。
|
|
379
|
+
*
|
|
380
|
+
* 为什么不让它是个固化字符串:宿主端口会变(`dsh web --port 0`、DSH Desktop 每次启动随机端口),
|
|
381
|
+
* 而事件订阅器是**长连接** —— 固化字符串的后果是端口一变就永久重连到旧端口。
|
|
382
|
+
* 【2026-10-09 真机实证】bridge 启动时宿主还是 3080,随后换成 19387;隧道转发侧
|
|
383
|
+
* (每次都读最新 UPSTREAM)一切正常,唯独微信事件通道死循环刷
|
|
384
|
+
* `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面,但**任务完成没有任何推送**。
|
|
385
|
+
*/
|
|
386
|
+
this.upstreamOf = typeof opts.upstream === "function" ? opts.upstream : () => this.upstream;
|
|
359
387
|
this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
|
|
360
388
|
this.secret = String(opts.secret || "");
|
|
361
389
|
this.clock = opts.clock || Date.now;
|
|
@@ -498,7 +526,9 @@ export class WeChatRuntime {
|
|
|
498
526
|
followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
|
|
499
527
|
? this.subscriber.sessions()
|
|
500
528
|
: [],
|
|
501
|
-
|
|
529
|
+
// 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
|
|
530
|
+
// 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
|
|
531
|
+
pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
|
|
502
532
|
last_fault: state.last_fault || null,
|
|
503
533
|
last_completed_session_id: state.last_completed_session_id || "",
|
|
504
534
|
last_completed_at: Number(state.last_completed_at || 0),
|
|
@@ -1537,14 +1567,44 @@ export class WeChatRuntime {
|
|
|
1537
1567
|
return list;
|
|
1538
1568
|
}
|
|
1539
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* 丢掉**过期**条目(返回保留的与丢掉的数量)。
|
|
1572
|
+
* `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
|
|
1573
|
+
* 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
|
|
1574
|
+
*/
|
|
1575
|
+
#pruneOutbox(list) {
|
|
1576
|
+
const now = this.clock();
|
|
1577
|
+
const kept = [];
|
|
1578
|
+
let dropped = 0;
|
|
1579
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
1580
|
+
const at = Number(item && item.at);
|
|
1581
|
+
if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
|
|
1582
|
+
kept.push(item);
|
|
1583
|
+
}
|
|
1584
|
+
return { kept, dropped };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
|
|
1588
|
+
#trimOutbox(list) {
|
|
1589
|
+
const { kept, dropped } = this.#pruneOutbox(list);
|
|
1590
|
+
let overflow = 0;
|
|
1591
|
+
while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
|
|
1592
|
+
const lost = dropped + overflow;
|
|
1593
|
+
if (lost > 0) {
|
|
1594
|
+
const mins = Math.round(OUTBOX_TTL_MS / 60000);
|
|
1595
|
+
const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
|
|
1596
|
+
.filter(Boolean).join("、");
|
|
1597
|
+
this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
|
|
1598
|
+
}
|
|
1599
|
+
return kept;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1540
1602
|
#enqueueOutbox(text, kind = "") {
|
|
1541
1603
|
const body = String(text || "");
|
|
1542
1604
|
if (!body) return 0;
|
|
1543
1605
|
const list = this.#outbox();
|
|
1544
1606
|
list.push({ text: body, kind: String(kind || ""), at: this.clock() });
|
|
1545
|
-
|
|
1546
|
-
while (list.length > MAX_OUTBOX) list.shift();
|
|
1547
|
-
return this.#writeOutbox(list).length;
|
|
1607
|
+
return this.#writeOutbox(this.#trimOutbox(list)).length;
|
|
1548
1608
|
}
|
|
1549
1609
|
|
|
1550
1610
|
/**
|
|
@@ -1552,7 +1612,11 @@ export class WeChatRuntime {
|
|
|
1552
1612
|
* @returns {Promise<number>} 这次补发成功的条数
|
|
1553
1613
|
*/
|
|
1554
1614
|
async #flushOutbox(to) {
|
|
1555
|
-
|
|
1615
|
+
// 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
|
|
1616
|
+
// 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
|
|
1617
|
+
const raw = this.#outbox();
|
|
1618
|
+
const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
|
|
1619
|
+
if (list.length !== raw.length) this.#writeOutbox(list);
|
|
1556
1620
|
if (!list.length || !to) return 0;
|
|
1557
1621
|
let sent = 0;
|
|
1558
1622
|
const rest = [...list];
|
|
@@ -1597,7 +1661,8 @@ export class WeChatRuntime {
|
|
|
1597
1661
|
// 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
|
|
1598
1662
|
if (!this.subscriber) {
|
|
1599
1663
|
this.subscriber = createEventSubscriber({
|
|
1600
|
-
|
|
1664
|
+
// 传**取值器**而不是当次的值:见构造函数里 upstreamOf 的说明(端口变了要能跟上)。
|
|
1665
|
+
upstream: () => this.upstreamOf(),
|
|
1601
1666
|
cookie: () => this.cookieOf(),
|
|
1602
1667
|
follow: true,
|
|
1603
1668
|
log: (level, message, meta) => {
|
|
@@ -1634,10 +1699,20 @@ export class WeChatRuntime {
|
|
|
1634
1699
|
const code = String((info && info.code) || "unknown");
|
|
1635
1700
|
const message = String((info && info.message) || "").slice(0, 160);
|
|
1636
1701
|
const sessionId = String((info && info.sessionId) || "");
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1702
|
+
// 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
|
|
1703
|
+
// **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
|
|
1704
|
+
// (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
|
|
1705
|
+
// 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
|
|
1706
|
+
if (info && info.expected === true) {
|
|
1707
|
+
const key = `${code}|${sessionId}`;
|
|
1708
|
+
if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
|
|
1709
|
+
this.#expectedFaultSeen.add(key);
|
|
1710
|
+
this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1640
1713
|
}
|
|
1714
|
+
this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1715
|
+
this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
|
|
1641
1716
|
});
|
|
1642
1717
|
}
|
|
1643
1718
|
|
package/dsh-setup.mjs
CHANGED
|
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
|
|
|
362
362
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
363
363
|
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
364
364
|
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
365
|
+
* harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
|
|
366
|
+
* 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
|
|
367
|
+
* 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
|
|
365
368
|
*/
|
|
366
369
|
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
367
370
|
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
371
|
+
const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
368
372
|
|
|
369
373
|
/**
|
|
370
374
|
* 把文件权限收紧到「只有本人可读写」。
|
|
@@ -708,7 +712,7 @@ function writeAutostartFile() {
|
|
|
708
712
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
709
713
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
710
714
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
711
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
715
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
712
716
|
</dict></plist>`;
|
|
713
717
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
714
718
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -724,7 +728,7 @@ function writeAutostartFile() {
|
|
|
724
728
|
if (process.platform === "linux") {
|
|
725
729
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
726
730
|
fs.mkdirSync(dir, { recursive: true });
|
|
727
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
731
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
728
732
|
const unitPath = autostartFilePath();
|
|
729
733
|
fs.writeFileSync(unitPath, unit);
|
|
730
734
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -1505,6 +1509,8 @@ async function runBridge() {
|
|
|
1505
1509
|
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1506
1510
|
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1507
1511
|
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1512
|
+
// 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
|
|
1513
|
+
DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
|
|
1508
1514
|
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
1509
1515
|
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
1510
1516
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-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",
|