@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
|
@@ -457,6 +457,17 @@ class EventSubscriber extends EventEmitter {
|
|
|
457
457
|
*/
|
|
458
458
|
#cursors = new Map();
|
|
459
459
|
|
|
460
|
+
/**
|
|
461
|
+
* 上一次**连接尝试**用的上游 URL(不是"成功"的那一次)。
|
|
462
|
+
*
|
|
463
|
+
* 为什么记"尝试"而不是"成功":上游端口从 A 换到 B 时,第一轮打 B 必然是失败的
|
|
464
|
+
* (B 还没起来),若只在成功后才更新,就会每轮都判成"目标变了"而重置退避 ——
|
|
465
|
+
* 退避彻底失效、变成猛敲 B。记"上次尝试过的目标"才能做到**同一个目标只重置一次**。
|
|
466
|
+
*/
|
|
467
|
+
#lastConnectUrl = null;
|
|
468
|
+
/** 上一次成功求值到的上游(getter 抛错/返回空时的回退,保证不会去打空地址)。 */
|
|
469
|
+
#lastGoodUpstream = null;
|
|
470
|
+
|
|
460
471
|
#seq = 0;
|
|
461
472
|
#lastFrameAt = 0;
|
|
462
473
|
#authStatus = null;
|
|
@@ -465,11 +476,19 @@ class EventSubscriber extends EventEmitter {
|
|
|
465
476
|
|
|
466
477
|
constructor(options = {}) {
|
|
467
478
|
super();
|
|
468
|
-
|
|
479
|
+
const upstreamGiven = typeof options.upstream === "function"
|
|
480
|
+
? true
|
|
481
|
+
: typeof options.upstream === "string" && options.upstream.trim() !== "";
|
|
482
|
+
if (!upstreamGiven) {
|
|
469
483
|
throw new TypeError("dsh-events: options.upstream is required (e.g. http://127.0.0.1:3080)");
|
|
470
484
|
}
|
|
485
|
+
// upstream 允许是**函数**(每次连接时求值):宿主端口会变(`dsh web --port 0`、
|
|
486
|
+
// DSH Desktop 每次启动随机端口),写死字符串的话端口一变,本订阅器就会永久打旧端口。
|
|
487
|
+
const upstreamGetter = typeof options.upstream === "function"
|
|
488
|
+
? options.upstream
|
|
489
|
+
: () => options.upstream;
|
|
471
490
|
this.#opts = {
|
|
472
|
-
|
|
491
|
+
upstreamGetter,
|
|
473
492
|
cookie: options.cookie ?? "",
|
|
474
493
|
muxPath: options.muxPath ?? MUX_PATH,
|
|
475
494
|
/** HTTP 路由(默认 `/api/$events/result`)。 */
|
|
@@ -631,8 +650,27 @@ class EventSubscriber extends EventEmitter {
|
|
|
631
650
|
}
|
|
632
651
|
}
|
|
633
652
|
|
|
634
|
-
|
|
635
|
-
|
|
653
|
+
/**
|
|
654
|
+
* 当前上游地址(**每次调用都重新求值**,见构造函数里 upstream 可为函数的说明)。
|
|
655
|
+
*
|
|
656
|
+
* 求值失败(函数抛错 / 返回空 / 不是合法 URL)时回退到上一次成功用过的地址,
|
|
657
|
+
* 保证"取不到新地址"永远不会变成"打一个空 URL"。真的没得回退时返回空串,
|
|
658
|
+
* 由 `#connect()` 如实报错,而不是静默重连到垃圾地址。
|
|
659
|
+
*/
|
|
660
|
+
#upstream() {
|
|
661
|
+
let raw;
|
|
662
|
+
try {
|
|
663
|
+
raw = this.#opts.upstreamGetter();
|
|
664
|
+
} catch (error) {
|
|
665
|
+
this.#log("warn", "upstream getter threw", { error: String(error) });
|
|
666
|
+
return this.#lastGoodUpstream ?? "";
|
|
667
|
+
}
|
|
668
|
+
const value = typeof raw === "string" ? raw.replace(/\/+$/, "") : "";
|
|
669
|
+
return value || this.#lastGoodUpstream || "";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
#muxUrl(upstream) {
|
|
673
|
+
const url = new URL(upstream);
|
|
636
674
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
637
675
|
url.pathname = this.#opts.muxPath;
|
|
638
676
|
url.search = "";
|
|
@@ -643,16 +681,35 @@ class EventSubscriber extends EventEmitter {
|
|
|
643
681
|
#connect() {
|
|
644
682
|
if (this.#closed) return;
|
|
645
683
|
this.#state = "connecting";
|
|
684
|
+
const upstream = this.#upstream();
|
|
685
|
+
if (upstream === "") {
|
|
686
|
+
this.#state = "auth-failed";
|
|
687
|
+
this.#emitAuthError(0, "upstream 不可用(取不到宿主地址)", "ws");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// 目标变了 = 宿主换了端口(Desktop 每次启动随机 / `dsh web` 重启到新端口)。
|
|
691
|
+
// 把退避重置,否则旧目标上累积的长退避会让"换了端口反而更慢连上"(最长要等满 maxMs)。
|
|
692
|
+
if (upstream !== this.#lastConnectUrl) {
|
|
693
|
+
if (this.#lastConnectUrl !== null) {
|
|
694
|
+
this.#log("info", `上游切换 ${this.#lastConnectUrl} → ${upstream},重连退避已重置`, {
|
|
695
|
+
from: this.#lastConnectUrl,
|
|
696
|
+
to: upstream,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
this.#lastConnectUrl = upstream;
|
|
700
|
+
this.#attempt = 0;
|
|
701
|
+
}
|
|
702
|
+
this.#lastGoodUpstream = upstream;
|
|
646
703
|
let url;
|
|
647
704
|
try {
|
|
648
|
-
url = this.#muxUrl();
|
|
705
|
+
url = this.#muxUrl(upstream);
|
|
649
706
|
} catch (error) {
|
|
650
707
|
this.#state = "auth-failed";
|
|
651
708
|
this.#emitAuthError(0, `upstream 不是合法 URL: ${String(error)}`, "ws");
|
|
652
709
|
return;
|
|
653
710
|
}
|
|
654
711
|
// Host 显式写成上游 authority(loopback 围栏),Cookie 复用 bridge 已持有的浏览器会话。
|
|
655
|
-
const headers = { Host: new URL(
|
|
712
|
+
const headers = { Host: new URL(upstream).host };
|
|
656
713
|
const cookie = this.#cookieValue();
|
|
657
714
|
if (cookie !== "") headers.Cookie = cookie;
|
|
658
715
|
|
|
@@ -1411,7 +1468,8 @@ class EventSubscriber extends EventEmitter {
|
|
|
1411
1468
|
async #rpc(method, args, eventId) {
|
|
1412
1469
|
const rpcId = randomUUID();
|
|
1413
1470
|
const envelope = { type: "client-request", rpcId, method, payload: { args } };
|
|
1414
|
-
|
|
1471
|
+
// 回执/RPC 也走**当前**上游(端口变了就跟着走),不是一个启动时固化的字符串。
|
|
1472
|
+
const url = `${this.#upstream()}${API_CHANNEL}/${method}`;
|
|
1415
1473
|
|
|
1416
1474
|
const timeoutMs = Number(this.#opts.rpcTimeoutMs);
|
|
1417
1475
|
const budgeted = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
@@ -1984,7 +2042,7 @@ export function classifyTurnEnd(sessionId, event, at) {
|
|
|
1984
2042
|
/**
|
|
1985
2043
|
* 创建一个订阅器。
|
|
1986
2044
|
* @param {{
|
|
1987
|
-
* upstream: string,
|
|
2045
|
+
* upstream: string|(() => string), // 函数 = 每次连接/每次 RPC 重新求值(宿主端口会变)
|
|
1988
2046
|
* cookie?: string|(() => string),
|
|
1989
2047
|
* muxPath?: string,
|
|
1990
2048
|
* resultPath?: string,
|
|
@@ -1244,7 +1244,12 @@ const SCRIPT = `(() => {
|
|
|
1244
1244
|
旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
|
|
1245
1245
|
反而让用户以为哪里坏了。 */
|
|
1246
1246
|
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1247
|
-
|
|
1247
|
+
/* 🔴 样本数组挂在 window 上,**不在 buildRemoteMenu 的闭包里**(2026-10-09 加固)。
|
|
1248
|
+
为什么:fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而菜单/悬浮按钮
|
|
1249
|
+
在官方 SPA 重渲染时可能被**重建**。若样本数组长在 buildRemoteMenu 里,包装就会一直喂给
|
|
1250
|
+
"上一代"渲染器(那个节点已经不在文档里),新界面读自己的空数组 → **永远停在「测速中…」**。
|
|
1251
|
+
挂在 window 上则任何一代渲染器都读同一份样本。 */
|
|
1252
|
+
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1248
1253
|
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1249
1254
|
const netEl = menu.querySelector('[data-role="net"]');
|
|
1250
1255
|
const rttValue = () => {
|
|
@@ -1262,11 +1267,13 @@ const SCRIPT = `(() => {
|
|
|
1262
1267
|
try {
|
|
1263
1268
|
const ms = rttValue();
|
|
1264
1269
|
const g = ms === null ? "" : rttGrade(ms);
|
|
1270
|
+
// 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
|
|
1271
|
+
const shown = ms === null ? null : Math.max(1, Math.round(ms));
|
|
1265
1272
|
if (rttEl) {
|
|
1266
|
-
if (
|
|
1273
|
+
if (shown === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
|
|
1267
1274
|
else {
|
|
1268
1275
|
rttEl.hidden = false;
|
|
1269
|
-
rttEl.textContent =
|
|
1276
|
+
rttEl.textContent = shown + "ms";
|
|
1270
1277
|
rttEl.setAttribute("data-rtt", g);
|
|
1271
1278
|
}
|
|
1272
1279
|
}
|
|
@@ -1274,17 +1281,22 @@ const SCRIPT = `(() => {
|
|
|
1274
1281
|
netEl.setAttribute("data-rtt", g || "");
|
|
1275
1282
|
const ico = netEl.querySelector(".ico");
|
|
1276
1283
|
const txt = netEl.querySelector(".txt");
|
|
1277
|
-
if (ico) ico.textContent =
|
|
1278
|
-
if (txt) txt.textContent =
|
|
1284
|
+
if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1285
|
+
if (txt) txt.textContent = shown === null ? "测速中…(等一次网络请求后显示)" : (shown + " ms · " + rttLabel(g));
|
|
1279
1286
|
}
|
|
1280
|
-
try { fab.title =
|
|
1287
|
+
try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1281
1288
|
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1282
1289
|
};
|
|
1283
1290
|
/** 记一个成功样本(由 fetch 包装在**响应到达**时调用;失败/超时不调用)。 */
|
|
1284
1291
|
const noteRtt = (ms) => {
|
|
1285
1292
|
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1286
1293
|
const r = Math.round(ms);
|
|
1287
|
-
if (r < 1 ||
|
|
1294
|
+
/* 🔴 旧写法是 if (r < 1 || ...) return; ,注释说"亚毫秒=同帧缓存命中"。这个假设是**错的**:
|
|
1295
|
+
本机/回环访问(桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms,
|
|
1296
|
+
于是**每一个**样本都被丢掉,界面永远停在「测速中…(等一次网络请求后显示)」——
|
|
1297
|
+
业主 2026-10-09 实测反馈的正是这个现象。只拒绝负数与超时;0 → 展示层夹到 1ms。
|
|
1298
|
+
(面板侧 chip 从一开始就是 v <= 0 + Math.max(1, round),两边口径由此对齐。) */
|
|
1299
|
+
if (r < 0 || r > RTT_MAX_MS) return;
|
|
1288
1300
|
rttSamples.push(r);
|
|
1289
1301
|
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1290
1302
|
renderRtt();
|
|
@@ -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
|
|
|
@@ -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
|
};
|
|
@@ -35,17 +35,27 @@ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"
|
|
|
35
35
|
/**
|
|
36
36
|
* 以给定的宿主形态构造一个真实的 spawnEnv 并调用它。
|
|
37
37
|
* 切片只含 spawnEnv 函数体,故它引用的模块级常量/工具必须显式注入。
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ 必须注入 **process 桩**(2026-10-09):`spawnEnv` 的第一件事是 `...process.env`,
|
|
40
|
+
* 而这个套件**是在 DSH 宿主里跑的** —— 从桌面端应用内跑 `npm run test:plugin` 时,
|
|
41
|
+
* 环境里天然带着 `ELECTRON_RUN_AS_NODE=1`(那正是"宿主是 Electron"的标志)。
|
|
42
|
+
* 于是"Web 版绝不得注入这个变量"那条断言会读到**宿主自己的**变量而必红 ——
|
|
43
|
+
* 可被测代码其实一个字节都没往 env 里加。注入干净环境后,这条用例检验的才是插件的行为
|
|
44
|
+
* (而不是"运行测试的这台机器是不是桌面端")。
|
|
45
|
+
*
|
|
38
46
|
* @param {object} selfNodeEnv 该宿主形态下 SELF_NODE_ENV 的值
|
|
39
47
|
* @param {object} [extra] 调用方业务变量
|
|
48
|
+
* @param {object} [sourceEnv] 宿主进程环境(默认干净:只含 PATH)
|
|
40
49
|
*/
|
|
41
|
-
function makeSpawnEnv(selfNodeEnv, extra) {
|
|
50
|
+
function makeSpawnEnv(selfNodeEnv, extra, sourceEnv) {
|
|
42
51
|
const start = INDEX_SRC.indexOf("function spawnEnv(extra)");
|
|
43
52
|
const end = INDEX_SRC.indexOf("function safeSpawn(");
|
|
44
53
|
assert.ok(start > 0 && end > start, "切片锚点失效:spawnEnv / safeSpawn 结构变了");
|
|
45
54
|
const src = INDEX_SRC.slice(start, end);
|
|
46
|
-
const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname",
|
|
55
|
+
const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname", "process", "path",
|
|
47
56
|
`${src}\nreturn spawnEnv;`);
|
|
48
|
-
|
|
57
|
+
const processStub = { env: sourceEnv ?? { PATH: "/usr/bin:/bin" }, execPath: "/usr/bin/node" };
|
|
58
|
+
return make(selfNodeEnv, () => false, path.posix.delimiter, path.posix.dirname, processStub, path.posix)(extra);
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
test("桌面端:Electron 宿主下 spawnEnv 必须注入 ELECTRON_RUN_AS_NODE=1", () => {
|
|
@@ -60,6 +70,19 @@ test("Web 版(真实 node):绝不得注入 ELECTRON_RUN_AS_NODE(不能
|
|
|
60
70
|
"非 Electron 宿主下注入该变量会污染子进程环境、并让 node 版本探测类工具困惑");
|
|
61
71
|
});
|
|
62
72
|
|
|
73
|
+
test("Web 版:宿主环境**已经带着** ELECTRON_RUN_AS_NODE 时,最终 env 与宿主逐键一致(插件没加料)", () => {
|
|
74
|
+
// 这条是上面那条的补充(2026-10-09):上面那条断言"结果里没有这个键",在 Electron 宿主里
|
|
75
|
+
// 跑测试时**天然不成立**(键来自宿主 process.env)。真正的判据是"插件有没有往 env 里加料",
|
|
76
|
+
// 故这里显式把宿主环境造成带这个变量,验证:最终 env **逐键等于宿主环境 + PATH 调整**,
|
|
77
|
+
// 一个多余的键都没有(`{}` 展开是恒等)。这样这条用例在任何宿主里都恒真且有意义。
|
|
78
|
+
const hostEnv = { PATH: "/usr/bin:/bin", ELECTRON_RUN_AS_NODE: "1", HOME: "/Users/tester" };
|
|
79
|
+
const env = makeSpawnEnv({}, undefined, hostEnv);
|
|
80
|
+
const expectedPath = ["/usr/bin", "/usr/bin:/bin", "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
|
|
81
|
+
assert.deepEqual(env, { ...hostEnv, PATH: expectedPath },
|
|
82
|
+
"除 PATH 外不得新增/删除/改动任何键(否则就是插件在污染子进程环境)");
|
|
83
|
+
assert.equal(env.ELECTRON_RUN_AS_NODE, "1", "宿主自己的值原样透传,不算插件注入");
|
|
84
|
+
});
|
|
85
|
+
|
|
63
86
|
test("spawnEnv:NODE_INTERPRETER 变量不得挤掉调用方的业务变量,PATH 仍按平台分隔符拼接", () => {
|
|
64
87
|
const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" }, { DSH_BRIDGE_INSTALL_SOURCE: "npx" });
|
|
65
88
|
assert.equal(env.DSH_BRIDGE_INSTALL_SOURCE, "npx", "业务变量必须仍能覆盖");
|
|
@@ -153,15 +176,25 @@ test("桌面端:面板文案改为引导'退出并重开应用',且不影响
|
|
|
153
176
|
|
|
154
177
|
// ─────────────── ④ 安装:--profile desktop 必须真的生效 ───────────────
|
|
155
178
|
|
|
156
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* 用安装器切片构造真实的 resolveProfileDir(注入 os / path / process,伪造 HOME 与空环境)。
|
|
181
|
+
*
|
|
182
|
+
* ⚠️ 必须注入 **process 桩**(2026-10-09):`resolveProfileDir` 的第二来源是
|
|
183
|
+
* `process.env.DSH_PROFILE_DIR`,而这个测试套件**是在 DSH 宿主里跑的** ——
|
|
184
|
+
* 从桌面端应用内跑 `npm run test:plugin` 时,环境里天然带着
|
|
185
|
+
* `DSH_PROFILE_DIR=/Users/<me>/.dsh/profiles/desktop`,于是"缺省仍是 Web 版"那条断言
|
|
186
|
+
* 会读到真实 HOME 下的 desktop,**在任何装了桌面端的机器上必红**(假红,且指向错误结论)。
|
|
187
|
+
* 注空环境 = 这条用例从此只验证被测逻辑,不受宿主环境影响。
|
|
188
|
+
*/
|
|
157
189
|
function makeResolveProfileDir(home) {
|
|
158
190
|
const start = SETUP_SRC.indexOf("const KNOWN_PROFILE_NAMES");
|
|
159
191
|
const end = SETUP_SRC.indexOf("function warnIfDesktopProfile");
|
|
160
192
|
assert.ok(start > 0 && end > start, "切片锚点失效:profile 解析函数结构变了");
|
|
161
193
|
const src = SETUP_SRC.slice(start, end);
|
|
162
194
|
const osStub = { homedir: () => home };
|
|
163
|
-
const
|
|
164
|
-
|
|
195
|
+
const processStub = { env: {} };
|
|
196
|
+
const make = new Function("os", "path", "process", `${src}\nreturn { resolveProfileDir, profileArgvOf, isDesktopProfileDir, profileDirOfName };`);
|
|
197
|
+
return make(osStub, path, processStub);
|
|
165
198
|
}
|
|
166
199
|
|
|
167
200
|
test("安装器:--profile desktop 解析到桌面端 profile 目录,不再是相对路径/被忽略", () => {
|