@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
|
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
|
|
|
254
254
|
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
|
|
255
255
|
} catch { return ""; }
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
|
|
259
|
+
*
|
|
260
|
+
* 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
|
|
261
|
+
* **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
|
|
262
|
+
* 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
|
|
263
|
+
* harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
|
|
264
|
+
*
|
|
265
|
+
* 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
|
|
266
|
+
* install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
|
|
267
|
+
* harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
|
|
268
|
+
* 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
|
|
269
|
+
*/
|
|
270
|
+
function resolveHarnessVersion() {
|
|
271
|
+
return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
272
|
+
}
|
|
257
273
|
const INSTALL_SOURCE = resolveInstallSource();
|
|
258
274
|
const INSTALL_VERSION = resolveInstallVersion();
|
|
275
|
+
const HARNESS_VERSION = resolveHarnessVersion();
|
|
259
276
|
|
|
260
277
|
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
261
278
|
|
|
@@ -320,21 +337,100 @@ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
|
320
337
|
const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
|
|
321
338
|
/** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
|
|
322
339
|
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
|
|
323
|
-
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
|
|
343
|
+
*
|
|
344
|
+
* 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
|
|
345
|
+
* 随机端口)是**两个宿主、两个进程**,各自的插件都会往里写"自己 authority 的 Cookie" →
|
|
346
|
+
* 互相覆盖。桥撞 401 后重读磁盘,拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
|
|
347
|
+
* → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**。手机端持续白页。
|
|
348
|
+
* 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
|
|
349
|
+
* (`:` 换成 `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖;桥只认
|
|
350
|
+
* **当前上游 authority** 的那一份。旧版共享文件继续兼容读取,但**foreign authority 一律不用**
|
|
351
|
+
* —— 那正是乒乓的来源。命名规则与插件半 `packages/dsh-remote-web/lib/index.js` 同名,
|
|
352
|
+
* 漂移由 `test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
|
|
353
|
+
*/
|
|
354
|
+
function authoritySlug(authority) {
|
|
355
|
+
return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
356
|
+
}
|
|
357
|
+
/** 本宿主专属 Cookie 文件名(与插件半同规则)。 */
|
|
358
|
+
function harnessCookieFileFor(authority) {
|
|
359
|
+
return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
|
|
360
|
+
}
|
|
361
|
+
/** 本宿主专属「作废」标记名(与插件半同规则)。 */
|
|
362
|
+
function harnessCookieRevokedFileFor(authority) {
|
|
363
|
+
return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
|
|
364
|
+
}
|
|
365
|
+
/** 当前上游的 authority(`127.0.0.1:<port>`);取不到返回空串。 */
|
|
366
|
+
function upstreamAuthorityOf(upstream) {
|
|
324
367
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
368
|
+
return new URL(String(upstream || UPSTREAM)).host || "";
|
|
369
|
+
} catch {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** 已就"哪个 foreign authority 在抢"告警过的集合(每对只吵一次,不刷屏)。 */
|
|
374
|
+
const foreignCookieWarned = new Set();
|
|
375
|
+
function warnForeignCookieOnce(claimed, authority) {
|
|
376
|
+
const key = `${claimed}->${authority}`;
|
|
377
|
+
if (foreignCookieWarned.has(key)) return;
|
|
378
|
+
foreignCookieWarned.add(key);
|
|
379
|
+
console.warn(
|
|
380
|
+
`[bridge] 检测到另一宿主的会话 Cookie(${claimed}),而当前上游是 ${authority} —— ` +
|
|
381
|
+
`一台机器只能服务一个宿主,已跳过该 Cookie(不再乒乓重试)。` +
|
|
382
|
+
`若手机端仍 401,请确认 .dsh-upstream 指向正在运行的那个宿主,并让该宿主的插件重发 Cookie。`
|
|
383
|
+
);
|
|
328
384
|
}
|
|
385
|
+
function readCookieDoc(dir, file) {
|
|
386
|
+
try {
|
|
387
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
388
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 当前上游该用的浏览器会话 Cookie(空串 = 没有可用的)。
|
|
395
|
+
*
|
|
396
|
+
* 取值顺序(2026-10-09 起):
|
|
397
|
+
* ① 本宿主专属文件 `.harness-cookie.<authority>.json` —— 新插件写的,权威;
|
|
398
|
+
* ② 旧版共享 `.harness-cookie.json` —— 仅当它声明的 `authority` 与当前上游一致,
|
|
399
|
+
* 或旧插件根本没写 authority 字段(向后兼容)时才用;
|
|
400
|
+
* ③ 声明了**别的** authority → 明确不用,并告警一次(这就是乒乓的源头)。
|
|
401
|
+
*/
|
|
329
402
|
function harnessCookieOf() {
|
|
403
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
404
|
+
const authority = upstreamAuthorityOf();
|
|
405
|
+
if (authority) {
|
|
406
|
+
const own = readCookieDoc(dir, harnessCookieFileFor(authority));
|
|
407
|
+
if (own) return own.cookie;
|
|
408
|
+
}
|
|
409
|
+
const legacy = readCookieDoc(dir, HARNESS_COOKIE_FILE);
|
|
410
|
+
if (!legacy) return "";
|
|
411
|
+
const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
|
|
412
|
+
if (claimed === "" || authority === "" || claimed === authority) return legacy.cookie;
|
|
413
|
+
warnForeignCookieOnce(claimed, authority);
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
/** 本宿主 Cookie 是否处于「已作废、等插件重换」状态(诊断用,不参与取值)。 */
|
|
417
|
+
function harnessCookieRevokedFor(authority) {
|
|
330
418
|
try {
|
|
331
|
-
|
|
332
|
-
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
333
|
-
return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
|
|
419
|
+
return fs.existsSync(path.join(path.dirname(CONFIG_PATH), harnessCookieRevokedFileFor(authority)));
|
|
334
420
|
} catch {
|
|
335
|
-
return
|
|
421
|
+
return false;
|
|
336
422
|
}
|
|
337
423
|
}
|
|
424
|
+
function markHarnessCookieRevoked() {
|
|
425
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
426
|
+
const authority = upstreamAuthorityOf();
|
|
427
|
+
// 只写**本宿主**的标记:两个宿主各写各的,插件侧才不会被人家的 401 反复催着重换。
|
|
428
|
+
// (旧版共享标记 `.harness-cookie-revoked` 不再写:它是"两个写者抢一份文件"的同款隐患。)
|
|
429
|
+
if (!authority) return;
|
|
430
|
+
try {
|
|
431
|
+
fs.writeFileSync(path.join(dir, harnessCookieRevokedFileFor(authority)), String(Date.now()), { mode: 0o600 });
|
|
432
|
+
} catch { /* 忽略:标记只为加速自愈 */ }
|
|
433
|
+
}
|
|
338
434
|
|
|
339
435
|
/**
|
|
340
436
|
* 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
|
|
@@ -1320,6 +1416,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1320
1416
|
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1321
1417
|
install_source: INSTALL_SOURCE,
|
|
1322
1418
|
install_version: INSTALL_VERSION,
|
|
1419
|
+
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
1420
|
+
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
1421
|
+
harness_version: HARNESS_VERSION,
|
|
1323
1422
|
host_os: process.platform,
|
|
1324
1423
|
host_arch: process.arch
|
|
1325
1424
|
})
|
|
@@ -1568,6 +1667,18 @@ function wechatAppUrl() {
|
|
|
1568
1667
|
}
|
|
1569
1668
|
|
|
1570
1669
|
let wechatRuntime = null;
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 微信事件通道要连的上游(**每次重连都会调一次**)。
|
|
1673
|
+
*
|
|
1674
|
+
* 行为:先顺手触发一次上游重发现(内部有 15s 冷却 + 单飞,反复调用不会打爆 lsof/探测),
|
|
1675
|
+
* 再返回**当下**的 UPSTREAM。这样宿主换端口时通道能自己跟上,而不必重启 bridge。
|
|
1676
|
+
*/
|
|
1677
|
+
function currentUpstreamForChannel() {
|
|
1678
|
+
void refreshUpstream("微信事件通道重连");
|
|
1679
|
+
return UPSTREAM;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1571
1682
|
function startWeChat() {
|
|
1572
1683
|
if (WECHAT_DISABLED) {
|
|
1573
1684
|
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
@@ -1578,10 +1689,15 @@ function startWeChat() {
|
|
|
1578
1689
|
const cfg = loadLocalConfig();
|
|
1579
1690
|
wechatRuntime = createWeChatRuntime({
|
|
1580
1691
|
relayDir,
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1692
|
+
// 传**取值器**而不是当次的值(0.7.0-beta.2 修复)。
|
|
1693
|
+
//
|
|
1694
|
+
// 【2026-10-09 真机事故】这里以前传的是 `UPSTREAM` 这个**当次快照**:bridge 起来时宿主还在
|
|
1695
|
+
// 3080,随后宿主换了端口(Desktop 每次启动随机),隧道转发侧因为每次读最新 UPSTREAM 而
|
|
1696
|
+
// 一切正常,**唯独微信事件通道抱着 3080 死循环重连** —— 现象极具迷惑性:手机能开页面、
|
|
1697
|
+
// bridge-status 显示 online,但任务跑完**一条推送都没有**,日志里只有刷屏的
|
|
1698
|
+
// `connect ECONNREFUSED 127.0.0.1:3080`。
|
|
1699
|
+
// 现在取值器每次重连都会重新求值,并在打到死端口时主动触发一次上游重发现。
|
|
1700
|
+
upstream: () => currentUpstreamForChannel(),
|
|
1585
1701
|
cookieOf: harnessCookieOf,
|
|
1586
1702
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1587
1703
|
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
@@ -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();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 设备登记上报口径回归(2026-10 版本分析需求)。
|
|
2
|
+
//
|
|
3
|
+
// 【为什么必须有】生产反馈只能看到「插件版本」(install_version),从来拿不到
|
|
4
|
+
// 「宿主 DeepSeek Harness 版本」—— 于是「是不是 DSH 升级导致的兼容性故障」这个问题
|
|
5
|
+
// 在后台**答不出来**(数据不能回填,只能从新版开始积累)。
|
|
6
|
+
// 宿主版本由插件在**宿主进程里**问出来(harnessVersion()),经 DSH_BRIDGE_HARNESS_VERSION
|
|
7
|
+
// 传给 bridge;bridge 自己(独立进程)推不出来。本用例走真实 bridge 进程 + 假账号 API,
|
|
8
|
+
// 逐字断言**桥到底往 /api/devices 发了什么**。
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { WebSocketServer } from "ws";
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
|
|
22
|
+
|
|
23
|
+
test("设备登记:harness_version(宿主版本)与 install_version(插件版本)必须分别如实上报", async () => {
|
|
24
|
+
let seen = null;
|
|
25
|
+
const api = http.createServer((req, res) => {
|
|
26
|
+
if (req.url === "/api/device-login") {
|
|
27
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
28
|
+
res.end(JSON.stringify({ token: "token-1" }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (req.url === "/api/devices") {
|
|
32
|
+
let raw = "";
|
|
33
|
+
req.on("data", (c) => { raw += c; });
|
|
34
|
+
req.on("end", () => {
|
|
35
|
+
try { seen = JSON.parse(raw); } catch { seen = { parseError: raw }; }
|
|
36
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
37
|
+
res.end(JSON.stringify({ device: { id: "dev-000000000002" } }));
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
42
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
|
|
46
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
47
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
48
|
+
|
|
49
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
|
|
50
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
54
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
55
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
56
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
57
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000002",
|
|
58
|
+
DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
|
|
59
|
+
// 插件在宿主进程里探测到的宿主版本 → 经环境变量注入 bridge
|
|
60
|
+
DSH_BRIDGE_HARNESS_VERSION: "0.2.0-rc.2",
|
|
61
|
+
DSH_BRIDGE_INSTALL_VERSION: "0.7.0-beta.2",
|
|
62
|
+
DSH_BRIDGE_INSTALL_SOURCE: "plugin_market",
|
|
63
|
+
},
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
|
|
69
|
+
assert.ok(seen, "bridge 应在 6 秒内完成设备登记(POST /api/devices)");
|
|
70
|
+
assert.equal(seen.harness_version, "0.2.0-rc.2", "★宿主版本必须进登记请求(这是本次需求的核心)");
|
|
71
|
+
assert.equal(seen.install_version, "0.7.0-beta.2", "插件版本照旧上报,两个数都要有");
|
|
72
|
+
assert.equal(seen.install_source, "plugin_market");
|
|
73
|
+
assert.equal(seen.device_id, "dev-000000000002");
|
|
74
|
+
assert.ok(seen.host_os && seen.host_arch, "平台/架构照旧");
|
|
75
|
+
} finally {
|
|
76
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
77
|
+
await new Promise((r) => wss.close(r));
|
|
78
|
+
await new Promise((r) => api.close(r));
|
|
79
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("设备登记:老插件不注入 DSH_BRIDGE_HARNESS_VERSION 时,字段必须是空串(不是缺字段、更不是猜)", async () => {
|
|
84
|
+
let seen = null;
|
|
85
|
+
const api = http.createServer((req, res) => {
|
|
86
|
+
if (req.url === "/api/device-login") {
|
|
87
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
88
|
+
res.end(JSON.stringify({ token: "token-1" }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (req.url === "/api/devices") {
|
|
92
|
+
let raw = "";
|
|
93
|
+
req.on("data", (c) => { raw += c; });
|
|
94
|
+
req.on("end", () => {
|
|
95
|
+
try { seen = JSON.parse(raw); } catch { seen = null; }
|
|
96
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ device: { id: "dev-000000000003" } }));
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
102
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
103
|
+
});
|
|
104
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
105
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
106
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
107
|
+
|
|
108
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
|
|
109
|
+
const env = { ...process.env };
|
|
110
|
+
delete env.DSH_BRIDGE_HARNESS_VERSION; // 模拟老插件
|
|
111
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
112
|
+
env: {
|
|
113
|
+
...env,
|
|
114
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
115
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
116
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
117
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
118
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000003",
|
|
119
|
+
DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
|
|
120
|
+
},
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
|
|
125
|
+
assert.ok(seen, "bridge 仍应完成设备登记");
|
|
126
|
+
assert.equal(seen.harness_version, "", "取不到就上报空串(= 未上报),绝不猜一个错的版本");
|
|
127
|
+
} finally {
|
|
128
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
129
|
+
await new Promise((r) => wss.close(r));
|
|
130
|
+
await new Promise((r) => api.close(r));
|
|
131
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -2085,3 +2085,91 @@ test("★ 一次 follow 失败不该把这条会话**永久**钉死:下一轮
|
|
|
2085
2085
|
await api.close();
|
|
2086
2086
|
}
|
|
2087
2087
|
});
|
|
2088
|
+
|
|
2089
|
+
// ============================================================
|
|
2090
|
+
// 上游是**函数**:宿主换端口要能自己跟上(2026-10-09 真机事故回归)
|
|
2091
|
+
// ============================================================
|
|
2092
|
+
|
|
2093
|
+
/**
|
|
2094
|
+
* 【事故原貌】bridge 起来时宿主在 3080,随后宿主换端口(DSH Desktop 每次启动都是随机端口)。
|
|
2095
|
+
* 隧道转发侧每次都读最新 UPSTREAM,所以一切正常;**唯独事件订阅器抱着启动那一刻的字符串**,
|
|
2096
|
+
* 于是永久 `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面、面板显示 online,
|
|
2097
|
+
* 但任务跑完**一条微信推送都没有**。迷惑性极强,最后靠 bridge 日志里刷屏的那一行才定位。
|
|
2098
|
+
*
|
|
2099
|
+
* 修法:`upstream` 允许传函数,每次连接/RPC 重新求值。这条用例就是那件事的锁。
|
|
2100
|
+
*/
|
|
2101
|
+
test("★ 事故锁:upstream 传函数时,宿主换端口后订阅器必须自己跟过去(不得永久打旧端口)", async () => {
|
|
2102
|
+
const a = await startFakeDsh();
|
|
2103
|
+
const b = await startFakeDsh();
|
|
2104
|
+
let current = a.base;
|
|
2105
|
+
const logs = [];
|
|
2106
|
+
const sub = createEventSubscriber({
|
|
2107
|
+
upstream: () => current,
|
|
2108
|
+
cookie: () => a.cookie,
|
|
2109
|
+
followSettleMs: 20,
|
|
2110
|
+
reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
|
|
2111
|
+
log: (level, message) => logs.push(`${level}:${message}`),
|
|
2112
|
+
});
|
|
2113
|
+
try {
|
|
2114
|
+
const first = await start(sub);
|
|
2115
|
+
assert.ok(first.clientId, "先连上 A(模拟宿主最初的端口)");
|
|
2116
|
+
|
|
2117
|
+
// 宿主换端口:A 下线、B 上线,取值器开始返回 B。
|
|
2118
|
+
await a.close();
|
|
2119
|
+
current = b.base;
|
|
2120
|
+
|
|
2121
|
+
const second = await new Promise((resolve, reject) => {
|
|
2122
|
+
const timer = setTimeout(() => reject(new Error("换端口后没能自行连上 B(会永久打旧端口)")), 3_000);
|
|
2123
|
+
timer.unref?.();
|
|
2124
|
+
sub.on("ready", (info) => {
|
|
2125
|
+
clearTimeout(timer);
|
|
2126
|
+
resolve(info);
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
assert.ok(second.clientId, "必须自己连上 B 并拿到新的 clientId");
|
|
2130
|
+
// clientId 是**每个服务端各自从 client-1 开始编号**的,不能拿它证明"换了连接";
|
|
2131
|
+
// 真正的证据是:B(新端口)确实收到了这条 mux 连接。
|
|
2132
|
+
assert.equal(b.state.connections.length, 1, "新上游 B 必须真的收到了连接");
|
|
2133
|
+
assert.ok(
|
|
2134
|
+
logs.some((l) => l.includes("上游切换") && l.includes(b.base)),
|
|
2135
|
+
`切换上游时必须留下可排查的日志(实际:${JSON.stringify(logs)})`,
|
|
2136
|
+
);
|
|
2137
|
+
} finally {
|
|
2138
|
+
sub.close();
|
|
2139
|
+
await a.close().catch(() => {});
|
|
2140
|
+
await b.close();
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
test("upstream 传函数但取值器抛错/返回空:绝不退化成打空地址(回退上一次可用地址)", async () => {
|
|
2145
|
+
const a = await startFakeDsh();
|
|
2146
|
+
let mode = "ok";
|
|
2147
|
+
const logs = [];
|
|
2148
|
+
const sub = createEventSubscriber({
|
|
2149
|
+
upstream: () => {
|
|
2150
|
+
if (mode === "throw") throw new Error("取值器炸了");
|
|
2151
|
+
if (mode === "empty") return "";
|
|
2152
|
+
return a.base;
|
|
2153
|
+
},
|
|
2154
|
+
cookie: () => a.cookie,
|
|
2155
|
+
followSettleMs: 20,
|
|
2156
|
+
reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
|
|
2157
|
+
log: (level, message) => logs.push(`${level}:${message}`),
|
|
2158
|
+
});
|
|
2159
|
+
try {
|
|
2160
|
+
await start(sub);
|
|
2161
|
+
mode = "empty";
|
|
2162
|
+
// 断掉当前连接以触发一次退避重连:必须仍然打到 A(回退上次可用地址),而不是某个空 URL。
|
|
2163
|
+
const before = a.state.connections.length;
|
|
2164
|
+
a.currentConn().ws.terminate();
|
|
2165
|
+
await waitFor(() => a.state.connections.length > before, { label: "退避重连(回退上次可用地址)" });
|
|
2166
|
+
mode = "throw";
|
|
2167
|
+
const before2 = a.state.connections.length;
|
|
2168
|
+
a.currentConn().ws.terminate();
|
|
2169
|
+
await waitFor(() => a.state.connections.length > before2, { label: "取值器抛错后仍能重连" });
|
|
2170
|
+
assert.ok(!logs.some((l) => l.includes("upstream 不可用")), "有可回退的地址时不该报『upstream 不可用』");
|
|
2171
|
+
} finally {
|
|
2172
|
+
sub.close();
|
|
2173
|
+
await a.close();
|
|
2174
|
+
}
|
|
2175
|
+
});
|