Files
HomeAgent/cmd/gui/device-gateway-discovery.test.mjs
JianFeeeee bca791a14b fix(gui): 设备桥 gateway 自动发现回写主进程
## 要判的是什么

2026-10-05 实测故障:GUI 面板里能看到设备通道地址,但设备桥连不上。
日志只有一行 `ws cookie len=0` + 401,没有任何「地址来自发现」的痕迹。

根因不是配置错,是**发现结果与实际连接配置从不联通**:

- 渲染进程 app.js 的 loadDiscoveredGateway() 调 /device/gateway,
  拿到的地址只写进 **state.discoveredGateway**(UI 展示用);
- 主进程 startDeviceBridge() 读的是 **gui-prefs.deviceBridge.gateway**;
- 两边从未交换过 —— state 是渲染进程内存,prefs 是主进程文件。

于是服务端明明已经通过 /device/gateway 告诉了客户端正确地址
(proxy.go:1078 生成的 url_portal),GUI 却仍在用用户几个月前手填的旧地址,
且永远 401/ECONNREFUSED、不重试。

## 修复

发现成功后回写 deviceBridge.set({gateway}),让主进程真正用上。

两个刻意的约束:

1. **仅当本地 gateway 为空才写**(!cur.gateway)—— 手填值优先。
   用户可能故意指向内网直连或灰度实例,发现只做缺省补全。
2. **只传 gateway,不传 token** —— /device/gateway 不返回设备令牌
   (ws_token),写了反而会写坏已有的正确 token。

deviceBridge.set 会 stop+start 桥,改完立即生效,无需重启进程。

## 顺带记下一个实测坑(已写进门禁)

设备桥 WS 的认证**不走 cookie**。registry.go ServeWS 只认三种来源:
?token= / X-API-Key / Sec-WebSocket-Protocol: homeagent.<token>,
cookie 完全不参与 acceptBind。

排查时曾误以为「发 cookie 就能过」—— 实测服务端仍 401。
cookie 那条路是错的,已还原 connections.json。

## 门禁(cmd/gui/device-gateway-discovery.test.mjs)

9 条判据,文本扫描(自动回写由 refreshAll 的 15s 定时器驱动,
构造那个时序需真 Electron + 真后端 + 会话 cookie,CI 里没有)。

覆盖:发现调用存在、回写接通、本地非空则不覆盖、只传 gateway、
preload 暴露 set、主进程注册 handler、get 返回明文 token、
set 会重启桥、connectDeviceWS 把 token 放进 query。

变异测试验证非假红:
- 删掉回写块(复现本次故障形态)⇒ 第 2、3、4 条转红;
- 去掉「本地为空才写」改成无条件覆盖 ⇒ 第 3 条转红。

排障过程中判据自身报过 3 次假红(正则窗口不足、字符串切片后仍按
match 数组用 setHandler[0]),均已修正并记在注释里。
2026-10-05 13:51:20 +08:00

169 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 设备桥 gateway 自动发现链路的门禁。
//
// ## 要判的是什么
//
// 2026-10-05 实测故障:GUI 面板里能看到设备通道地址,但设备桥连不上。
// 日志只有一行 `ws cookie len=0` + 401,没有任何「地址来自发现」的痕迹。
//
// 根因不是配置错,是**发现结果与实际连接配置从不联通**:
//
// · 渲染进程 app.js 的 loadDiscoveredGateway() 调 /device/gateway,
// 拿到的地址只写进 **state.discoveredGateway**(UI 展示用);
// · 主进程 startDeviceBridge() 读的是 **gui-prefs.deviceBridge.gateway**;
// · 两边从未交换过 —— state 是渲染进程内存,prefs 是主进程文件。
//
// 于是服务端明明已经通过 /device/gateway 告诉了客户端正确地址
// (proxy.go:1078 生成的 url_portal),GUI 却仍在用用户几个月前
// 手填的旧地址,且**永远 401/ECONNREFUSED,且不重试**。
//
// ## 判据形态:文本扫描而非运行时
//
// 与 endpoint-align / device-align 同路子。自动回写的触发点在
// loadDiscoveredGateway() 内,由 refreshAll 的 15s 定时器驱动 ——
// 构造那个时序需要真Electron + 真后端,而 /device/gateway 还要求
// 会话 cookie(CI 里没有)。故从源码文本判「链路是否接通」。
//
// 运行:node device-gateway-discovery.test.mjs
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const APP = join(here, "renderer", "app.js");
const MAIN = join(here, "main.js");
const PRELOAD = join(here, "preload.js");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
const appSrc = readFileSync(APP, "utf8");
const mainSrc = readFileSync(MAIN, "utf8");
const preloadSrc = readFileSync(PRELOAD, "utf8");
// ── 1) 发现函数存在且会调服务端权威端点 ────────────────────────────
check(
"存在 /device/gateway 发现调用",
appSrc.includes("/device/gateway"),
"找不到 —— 服务端发现端点没被调用",
);
// ── 2) 发现结果必须回写到主进程(本判据的核心)──────────────────────
//
// 这是本次修复的要点。若这段回写被删掉(重构时不慎),设备桥会退回
// 「只用用户手填地址」的行为,且没有任何报错——
// 这正是 2026-10-05 那次故障的形态。
check(
"发现结果回写到主进程 deviceBridge.set",
/discoveredGateway[\s\S]{0,600}?deviceBridge\s*\.\s*set\s*\(/.test(appSrc),
"loadDiscoveredGateway 里没有 deviceBridge.set 回写 —— " +
"state.discoveredGateway 只用于 UI,主进程仍在用旧的手填地址",
);
// ── 3) 回写不得覆盖用户手填值 ──────────────────────────────────────
//
// 发现只做**缺省补全**。若用户在设置页手填了内网直连地址(灰度实例、
// 绕过反代),自动回写把它冲掉就是新的回归。判据钉住「本地为空才写」。
// 注意不能用 /set\(\s*\{[\s\S]{0,300}?\}/ —— 调用点前后的 if 判断在花括号
// **外面**,而 300 字窗口会跨到下一个 deviceBridge.set(saveBridgeChannel
// 的手动保存),判据就误报了。改为直接取「发现函数体内」的那次调用。
const discoFn = appSrc.match(
/async function loadDiscoveredGateway\(\)[\s\S]*?\n}/,
);
const setCall = discoFn
? discoFn[0].match(/window\.homeagent\.deviceBridge\.set\([^)]*\)/)
: null;
check(
"回写前检查本地 gateway 为空",
discoFn ? /cur\s*&&\s*!cur\.gateway/.test(discoFn[0]) : false,
discoFn
? "回写处没看到「本地已配置则跳过」的判断 —— 会覆盖用户手填值"
: "找不到 loadDiscoveredGateway",
);
check(
"回写只传 gateway,不覆盖 token",
setCall ? !/token/.test(setCall[0]) : false,
setCall ? "回写里带了 token —— 发现端点不返回设备令牌,会写坏" : "找不到回写调用",
);
// ── 4) IPC 链路完整:preload 暴露 get/set,主进程处理 ──────────────
//
// 回写用的是 window.homeagent.deviceBridge.set(...);contextBridge
// 若没暴露或主进程没注册,这次调用是 undefined TypeError(被 catch
// 吞掉,于是又回到「静默不生效」)。
check(
"preload 暴露 deviceBridge.set",
/deviceBridge[\s\S]{0,120}?set\s*:/.test(preloadSrc),
"contextBridge 未暴露 set —— 渲染进程调不到",
);
check(
"主进程注册 device-bridge:set",
mainSrc.includes('ipcMain.handle("device-bridge:set"'),
"主进程没有该 handler",
);
check(
"主进程 device-bridge:get 返回明文 token",
/device-bridge:get[\s\S]{0,900}?token:\s*db\.token/.test(mainSrc),
"get 未返回 token 明文 —— 渲染侧拿不到设备令牌",
);
// ── 5) device-bridge:set 必须真正重启桥 ───────────────────────────
//
// 只存 prefs 不重启,配置不会立即生效(要等下次进程启动)。
// 这是同一类「存了但没生效」的静默失效。
// 不用大正则匹配整个 handler —— 返回对象里的嵌套 } 会让边界判断很脆。
// 改为取两个 handler 之间的**源码切片**,再在里面判 stop/start 是否存在。
const setStart = mainSrc.indexOf('ipcMain.handle("device-bridge:set"');
const setEnd = mainSrc.indexOf('ipcMain.handle("prefs:get"');
const setHandler =
setStart >= 0 && setEnd > setStart
? mainSrc.slice(setStart, setEnd)
: null;
check(
"device-bridge:set 会重启设备桥",
setHandler
? setHandler.includes("stopDeviceBridge") &&
setHandler.includes("startDeviceBridge")
: false,
setHandler
? "handler 里没有 stop+start —— 新配置要等重启进程才生效"
: "找不到 device-bridge:set handler",
);
// ── 6) 主进程连接必须带 token ─────────────────────────────────────
//
// 设备桥 WS 走 ?token=(registry.go ServeWS 只认 query/X-API-Key/
// Sec-WebSocket-Protocol 三种,**cookie 不参与** acceptBind)。
// 这是实测踩过的坑:曾误以为发 cookie 就能过,实际服务端仍 401。
const connectFn = mainSrc.match(
/function connectDeviceWS[\s\S]*?\n}\n/,
);
check(
"connectDeviceWS 把 token 放进 query",
connectFn ? /token=/.test(connectFn[0]) : false,
connectFn
? "query 里没带 token —— 服务端 acceptBind 会拒"
: "找不到 connectDeviceWS",
);
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
if (failures > 0) {
console.log(`失败 ${failures} 条`);
process.exit(1);
}
console.log("全部通过");