mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(gui): 设备桥补心跳与断线重连,兑现 liveness 门禁
## 背景
`device-bridge-liveness.test.mjs`(判据)已在 main 上,但它要求的实现
一直缺席,跑起来是:
Error: cannot find function sendDeviceControlFrame in main.js
判据注释描述了两个洞,合起来的效果是「一次失联,终身失联」:
1. 不发 ping —— waiter 侧有 pingLoop,GUI 侧一次都没发过;
2. 不重连 —— startDeviceBridge 失败就 console.error 结束。
我先前只补了 ping 且命名为 sendDevicePing,判据仍红;本次按判据契约补全。
## 本次实现
**通用控制帧** `sendDeviceControlFrame(sock, opcode, payload)`
控制帧有 ping(0x9)/pong(0xA)/close(0x8) 三种,签名写死一种就换不来另外两种。
**心跳** 每 30s 一个 0x9 帧(与 waiter 的 pingLoop 对齐)。
起在 doConnectDeviceBridge 而非 connectDeviceWS —— 跟着「一条实际连上的
桥」而不是「某次调用」,后者在重连路径上会漏。
**重连** `scheduleDeviceReconnect`:线性退避 base×n、封顶 60s,
成功即归零(否则「昨天断十次、今天断一次」要等 10×base 才有第一次重试)。
退避先递增后算:head 用 max(1,attempt) 会让 attempt 0 与 1 都算出 base,
出现「退避头两次一样」的假象。
三条排程路径,缺一条就终身失联:
- socket close(已连上后被关)
- connectDeviceWS reject(**没有** close 事件,最易漏)
- deviceWanted闸:用户主动停用后不得再排程,否则把已关的桥偷偷拉起来
## 顺带修掉的隐性故障
`connectDeviceBridgeOnce` 里 host/cpus/process/app 等推导原先裸调。
它们在 ws.send 的实参里,抛错会被上层 catch 吞成「连接失败」—— 而实际
连接是好的,表现为「反复重连但永远连不上」。现已逐项 try 包裹。
## 判据分工(避免两个门禁互相打架)
删掉 device-gateway-discovery.test.mjs 里 6 条 keepalive 文本判据
(grep 0x89、找 setInterval 间隔…)。它们只能证明「代码里出现过这些
字符」,证明不了「心跳真发出去了」。liveness 那份用 node:vm 跑真实函数,
是强判据;弱的那份会在实现重构时先红、掩盖真信号。
## 验证
device-bridge-liveness 14 条全绿(改前是 0 条可跑)。
另外 7 个门禁全绿:device-gateway-discovery / device-align /
endpoint-align / connections-io / retry-guard / sse-backoff /
package-config。
对齐过程中判据自身报过 3 次假红(process 未注入、doConnectDeviceBridge
无 return、ws 为 undefined),根因都已写进代码注释。
This commit is contained in:
@ -159,63 +159,13 @@ check(
|
|||||||
: "找不到 connectDeviceWS",
|
: "找不到 connectDeviceWS",
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 6) keepalive:设备桥必须有主动 ping ──────────────────────────────
|
// ★ keepalive 与重连的判据**不在本文件**,在同目录的
|
||||||
//
|
// device-bridge-liveness.test.mjs —— 那份用 node:vm 把真实函数抽出来跑
|
||||||
// 2026-10-05 实测故障:GUI bind 成功、设备进了 devices 表(面板可见),
|
// 「连接 → 断线 → 重连 → 再断线」,数真实的 ping 帧与重连次数。
|
||||||
// 但几秒后能力调用全失败:
|
// 本文件早期版本曾用文本判据盯keepalive(grep 0x89、找 setInterval 的
|
||||||
// POST /api/v1/device/push {"device_id":"gui-JianF"}
|
// 间隔),已删:它只能证明「代码里出现过这些字符」,证明不了「心跳真的
|
||||||
// → {"error":"device gui-JianF not online"}
|
// 发出去了」「重连真的发生了」。两份盯同一件事时,弱的那份会在实现重构
|
||||||
// 同时 waiter/NAS 完全正常(last_seen 持续刷新)。
|
// 时先红,掩盖真信号。
|
||||||
//
|
|
||||||
// 根因:GUI **从不发 ping**。链路是 frp + TLS 反代,空闲超时(30~60s)
|
|
||||||
// 静默关连接 → 服务端 handleWS 的 defer 走 markOffline → conns 删除
|
|
||||||
// ⇒ 「设备可见但命令下发不到」。
|
|
||||||
//
|
|
||||||
// 服务端 registry.go:735 那段 ping/pong 注释修的是「**回** pong 不能因
|
|
||||||
// 未bind 而失败」,不是「客户端有没有发」—— 那是另一个方向的坑。
|
|
||||||
|
|
||||||
const pingFn = mainSrc.match(/function sendDevicePing[\s\S]*?\n}\n/);
|
|
||||||
check(
|
|
||||||
"存在 sendDevicePing",
|
|
||||||
!!pingFn,
|
|
||||||
"找不到 sendDevicePing —— 设备桥没有 keepalive 能力",
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
"ping 用 WebSocket 控制帧 0x89",
|
|
||||||
pingFn ? /0x89/.test(pingFn[0]) : false,
|
|
||||||
pingFn ? "ping 帧构造不对(应为 0x89 = FIN|ping 且置掩码位)" : "",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ping 必须被定时器驱动,且间隔要压在常见空闲阈值之下。
|
|
||||||
const openBlock = mainSrc.slice(
|
|
||||||
mainSrc.indexOf("opened = true"),
|
|
||||||
mainSrc.indexOf("opened = true") + 2000,
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
"连接建立后启动 ping 定时器",
|
|
||||||
/setInterval/.test(openBlock) && /sendDevicePing/.test(openBlock),
|
|
||||||
"opened 之后没有启动 keepalive 定时器 —— 连接仍会因空闲超时断开",
|
|
||||||
);
|
|
||||||
const intervalMatch = openBlock.match(/setInterval\([^,]*,\s*(\d+)\)/);
|
|
||||||
check(
|
|
||||||
"keepalive 间隔 ≤ 30s",
|
|
||||||
intervalMatch ? parseInt(intervalMatch[1], 10) <= 30000 : false,
|
|
||||||
intervalMatch
|
|
||||||
? `间隔 ${intervalMatch[1]}ms 太长,压不住常见 30s 空闲超时`
|
|
||||||
: "找不到 setInterval 的间隔参数",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 关闭路径必须清定时器,否则进程不退出 / 往死 socket 写。
|
|
||||||
check(
|
|
||||||
"close 时清理 ping 定时器",
|
|
||||||
/close:[\s\S]{0,200}?clearInterval/.test(mainSrc),
|
|
||||||
"close() 里没有 clearInterval —— 定时器泄漏且会向已销毁 socket 写入",
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
"socket close 事件也清理定时器",
|
|
||||||
/on\("close"[\s\S]{0,300}?clearInterval/.test(mainSrc),
|
|
||||||
'sock.on("close") 里没有 clearInterval —— 异常断开后定时器泄漏',
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 汇总 ────────────────────────────────────────────────────────────
|
// ── 汇总 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
359
cmd/gui/main.js
359
cmd/gui/main.js
@ -922,6 +922,18 @@ let deviceBridgeAddr = ""; // 设备桥网关地址
|
|||||||
// 连接成功但 bind 被拒时设备是失联的,只看 connected 会给出假阳性。
|
// 连接成功但 bind 被拒时设备是失联的,只看 connected 会给出假阳性。
|
||||||
let deviceBridgeBound = false;
|
let deviceBridgeBound = false;
|
||||||
let deviceBridgeBindError = "";
|
let deviceBridgeBindError = "";
|
||||||
|
// 设备桥心跳与重连的状态(心跳 + 线性退避重连)。
|
||||||
|
let devicePingTimer = null;
|
||||||
|
let deviceReconnectTimer = null;
|
||||||
|
let deviceReconnectAttempt = 0;
|
||||||
|
// 拉起意图:用户主动停用后置 false,作为 scheduleDeviceReconnect 的闸。
|
||||||
|
let deviceWanted = false;
|
||||||
|
// 当前生效的连接配置,重连时复用(连接断开后局部变量就没了)。
|
||||||
|
//
|
||||||
|
// 缓存而非每次读 gui-prefs:重连是定时器里的异步路径,重读偏好会引入
|
||||||
|
// 「读到半写文件」与「用户刚改完就按新值连」两种意外;且重连必须用
|
||||||
|
// **当初启动时那份**配置,否则退避语义就乱了。
|
||||||
|
let deviceBridgeCfg = null;
|
||||||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||||||
let speechAccum = null;
|
let speechAccum = null;
|
||||||
|
|
||||||
@ -1137,26 +1149,13 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
opened = true;
|
opened = true;
|
||||||
// ★ keepalive:绑定成功后必须周期性发 ping,否则链路空闲超时会
|
// 心跳不在这里起:统一由 connectDeviceBridgeOnce 在拿到 ws.__sock
|
||||||
// 静默断连,导致「设备可见但命令下发不到」(详见 sendDevicePing
|
// 后调 startDevicePing。放这里的话心跳会跟着「每次 connectDeviceWS
|
||||||
// 上方注释)。间隔 25s:压在常见的 30s 空闲阈值之下。
|
// 调用」起,而不是跟着「一条实际连上的桥」起——重连路径上会漏。
|
||||||
const pingTimer = setInterval(() => {
|
|
||||||
try {
|
|
||||||
sendDevicePing(sock);
|
|
||||||
} catch (e) {
|
|
||||||
/* ping 失败不致命,下一轮或 close 事件会处理 */
|
|
||||||
}
|
|
||||||
}, 25000);
|
|
||||||
sock.__bridgePingTimer = pingTimer;
|
|
||||||
resolve({
|
resolve({
|
||||||
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
||||||
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
||||||
close: () => {
|
close: () => sock.destroy(),
|
||||||
// 关闭前必须清掉 ping,否则定时器会继续往已销毁的 socket 写,
|
|
||||||
// 抛 unhandled error(且进程不会退出)。
|
|
||||||
clearInterval(sock.__bridgePingTimer);
|
|
||||||
sock.destroy();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
while (buf.length >= 2) {
|
while (buf.length >= 2) {
|
||||||
@ -1225,50 +1224,241 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
if (!opened) reject(e);
|
if (!opened) reject(e);
|
||||||
});
|
});
|
||||||
sock.on("close", () => {
|
sock.on("close", () => {
|
||||||
// 断开时停掉 keepalive,否则定时器会一直持有已死的 socket。
|
// 断开时停掉心跳,否则定时器会一直持有已死的 socket。
|
||||||
if (sock.__bridgePingTimer) {
|
stopDevicePing();
|
||||||
clearInterval(sock.__bridgePingTimer);
|
|
||||||
sock.__bridgePingTimer = null;
|
|
||||||
}
|
|
||||||
deviceBridge = null;
|
deviceBridge = null;
|
||||||
|
// 已建立过的连接被对端/中间链路关掉 ⇒ 排程重连。
|
||||||
|
// scheduleDeviceReconnect 内部有 deviceWanted 闸,用户主动停用
|
||||||
|
// 时不会把桥拉起来。
|
||||||
|
if (opened) scheduleDeviceReconnect();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 WS ping 控制帧(客户端必须加掩码)。
|
// ============ 设备桥心跳 + 断线重连 ============
|
||||||
//
|
//
|
||||||
// ★ 为什么必须主动 ping(2026-10-05 实测故障):
|
// ★ 这里修的是 2026-10-05 实测到的两个洞,合起来的效果是
|
||||||
|
// 「一次失联,终身失联」:
|
||||||
//
|
//
|
||||||
// GUI 的 bind 能成功(日志有 bind_ok),设备也确实进了服务端 devices 表
|
// 1. **不发 ping**。waiter 侧有 pingLoop(internal/devicebridge/client/
|
||||||
// (webui 面板里能看到),但**几秒后所有设备能力调用全部失败**:
|
// bridge.go,每 30s 一个 0x9 帧),GUI 侧全文件一次 ping 都没发过。
|
||||||
|
// 而 remotedevice 服务端**没有设备侧读超时**(registry.go 里没有
|
||||||
|
// SetReadDeadline)—— 不发心跳就没人能发现设备已死,网关会把一台
|
||||||
|
// 半死的设备长期显示为 online。
|
||||||
//
|
//
|
||||||
// POST /api/v1/device/push {"device_id":"gui-JianF",...}
|
// 实测症状:bind 能成功(bind_ok)、设备进了 devices 表(面板可见),
|
||||||
// → {"error":"device gui-JianF not online"}
|
// 但几秒后 push 就报
|
||||||
|
// {"error":"device gui-JianF not online"}
|
||||||
|
// —— conns 表已被 defer 里的 markOffline 清空,而 devices 表还在。
|
||||||
|
// 「可见但不可下发」就是这个割裂的表象。
|
||||||
//
|
//
|
||||||
// 而同一时刻 NAS 上的 waiter 完全正常(last_seen 持续刷新)。
|
// 2. **不重连**。原来 startDeviceBridge 只有启动/配置变更两个调用点,
|
||||||
|
// 失败就 console.error 结束。GUI 是最容易被休眠/切网/服务端重启打断的
|
||||||
|
// 形态(合盖一次、WiFi 抖一下、服务端重启),设备桥就再也回不来,
|
||||||
|
// 直到用户重启 GUI 或手动改一次配置。
|
||||||
//
|
//
|
||||||
// 根因:GUI **没有任何 keepalive**。全文件搜不到发ping 的地方,而
|
// 判据:cmd/gui/device-bridge-liveness.test.mjs(用 node:vm 抽真实函数
|
||||||
// waiter / 嵌入式客户端都有。链路中间是 frp + TLS 反代,它们的空闲超
|
// 在沙箱里跑「连接→ 断线 → 重连 → 再断线」,数真实的 ping 帧与重连次数)。
|
||||||
// 时(常见 30~60s)会静默关掉这条连接。
|
|
||||||
//
|
|
||||||
// 服务端 handleWS 虽有 ping/pong 处理(registry.go:735,且那段注释
|
|
||||||
// 记录了「回 pong 不能因未 bind 而失败」的修复),但那解决的是**服务端
|
|
||||||
// 回** pong;连接能不能活下去,取决于**客户端有没有主动发**。
|
|
||||||
// 连接一断,handleWS 的 defer 走 markOffline(curID) → conns 里删除,
|
|
||||||
// 于是「设备可见(devices 表)但不可下发(conns 表)」——
|
|
||||||
// 正是本故障的表象。
|
|
||||||
//
|
|
||||||
// 对照组说明这不是服务端能力缺失:同一 registry 下waiter 一直好好的。
|
|
||||||
const deviceBridgePingTimerKey = "__bridgePingTimer";
|
|
||||||
|
|
||||||
function sendDevicePing(sock) {
|
// 与 waiter 的 pingLoop 对齐(bridge.go:636 每 30s 一个 0x9 帧)。
|
||||||
try {
|
const devicePingIntervalMs = 30000;
|
||||||
// 控制帧:FIN + opcode 0x9 (ping),payload 为空;客户端帧必须掩码。
|
// 重连退避:线性增长、封顶 60s。首个等待 = base。
|
||||||
const mask = crypto.randomBytes(4);
|
const deviceReconnectBaseMs = 2000;
|
||||||
sock.write(Buffer.concat([Buffer.from([0x89, 0x80]), mask]));
|
const deviceReconnectMaxMs = 60000;
|
||||||
} catch (e) {
|
|
||||||
console.log("[device-bridge] ping failed: " + e.message);
|
// 发送 WS 控制帧(客户端帧必须带掩码位,RFC6455 §5.3)。
|
||||||
|
//
|
||||||
|
// 通用签名而非写死 ping:控制帧有 ping(0x9)/pong(0xA)/close(0x8) 三种,
|
||||||
|
// 写死一种就换不来另外两种。payload 为 null 表示空载荷。
|
||||||
|
function sendDeviceControlFrame(sock, opcode, payload) {
|
||||||
|
const body = payload ? Buffer.from(payload) : Buffer.alloc(0);
|
||||||
|
const mask = crypto.randomBytes(4);
|
||||||
|
const masked = Buffer.alloc(body.length);
|
||||||
|
for (let i = 0; i < body.length; i++) masked[i] = body[i] ^ mask[i % 4];
|
||||||
|
const len = masked.length;
|
||||||
|
let hdr;
|
||||||
|
if (len < 126) {
|
||||||
|
hdr = Buffer.from([0x80 | opcode, 0x80 | len]);
|
||||||
|
} else if (len < 65536) {
|
||||||
|
hdr = Buffer.alloc(4);
|
||||||
|
hdr[0] = 0x80 | opcode;
|
||||||
|
hdr[1] = 0x80 | 126;
|
||||||
|
hdr.writeUInt16BE(len, 2);
|
||||||
|
} else {
|
||||||
|
hdr = Buffer.alloc(10);
|
||||||
|
hdr[0] = 0x80 | opcode;
|
||||||
|
hdr[1] = 0x80 | 127;
|
||||||
|
hdr.writeBigUInt64BE(BigInt(len), 2);
|
||||||
}
|
}
|
||||||
|
sock.write(Buffer.concat([hdr, mask, masked]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳(先清旧的,避免重复 interval 叠加)。
|
||||||
|
function startDevicePing(sock) {
|
||||||
|
if (devicePingTimer) clearInterval(devicePingTimer);
|
||||||
|
devicePingTimer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
if (!sock || sock.destroyed) return;
|
||||||
|
sendDeviceControlFrame(sock, 0x9, null);
|
||||||
|
} catch (e) {
|
||||||
|
/* 心跳失败不致命:close 事件会触发重连 */
|
||||||
|
}
|
||||||
|
}, devicePingIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDevicePing() {
|
||||||
|
if (devicePingTimer) {
|
||||||
|
clearInterval(devicePingTimer);
|
||||||
|
devicePingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排程一次重连。退避随失败次数线性增长并封顶。
|
||||||
|
//
|
||||||
|
// ★ deviceWanted 是闸:用户主动 stopDeviceBridge(或改配置)之后
|
||||||
|
// 再排程,就会把已停用的桥偷偷拉起来。GUI 是常驻托盘程序,
|
||||||
|
// 这个「拉起来」没人看得见,表现为「我明明关了怎么又在连」。
|
||||||
|
function scheduleDeviceReconnect() {
|
||||||
|
if (!deviceWanted) return;
|
||||||
|
if (deviceReconnectTimer) return; // 已有排程,不叠加
|
||||||
|
// 先递增后算:首次排程(含连接失败那一回)拿到的就是 base,
|
||||||
|
// 之后 2×base、3×base… 线性增长。用 max(1, attempt) 会在
|
||||||
|
// attempt=0 与 1 时都算出 base,出现「退避头两次一样」的假象。
|
||||||
|
deviceReconnectAttempt++;
|
||||||
|
const delay = Math.min(
|
||||||
|
deviceReconnectBaseMs * deviceReconnectAttempt,
|
||||||
|
deviceReconnectMaxMs,
|
||||||
|
);
|
||||||
|
deviceReconnectTimer = setTimeout(async () => {
|
||||||
|
deviceReconnectTimer = null;
|
||||||
|
if (!deviceWanted) return;
|
||||||
|
const cfg = deviceBridgeCfg;
|
||||||
|
// 配置被清空(用户改了偏好)⇒ 无处可连,停在这里等下一次 start。
|
||||||
|
if (!cfg) {
|
||||||
|
deviceReconnectAttempt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connectDeviceBridgeOnce(cfg);
|
||||||
|
// 连上了:退避归零。下次若再断,重新从 base 开始 ——
|
||||||
|
// 否则「昨天断十次、今天又断一次」要等 10×base 才有第一次重试。
|
||||||
|
deviceReconnectAttempt = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[device-bridge] reconnect failed: " + e.message);
|
||||||
|
scheduleDeviceReconnect();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDeviceBridge 拆出的「只做一次连接」部分:重连走这里。
|
||||||
|
async function connectDeviceBridgeOnce(cfg) {
|
||||||
|
const url = cfg.url || cfg.gateway || "";
|
||||||
|
const token = cfg.apiKey || cfg.token || "";
|
||||||
|
deviceBridgeAddr = url;
|
||||||
|
deviceBridgeBound = false;
|
||||||
|
// doConnectDeviceBridge 内部会建 ws、起心跳、并存进 deviceBridge。
|
||||||
|
// 不用它的返回值:那层把结果写全局而非return,两边约定不一致时
|
||||||
|
// 「读返回值」就得到 undefined,随后 ws.send 直接TypeError。
|
||||||
|
await doConnectDeviceBridge(cfg);
|
||||||
|
const ws = deviceBridge;
|
||||||
|
if (!ws) {
|
||||||
|
throw new Error("connect produced no bridge");
|
||||||
|
}
|
||||||
|
// host/cpus/mem 的推导包在try 里:它们在 ws.send 的实参里,而 send 之前
|
||||||
|
// 抛错会被 startDeviceBridge 的 catch 吞成「连接失败」—— 实际连接是好的,
|
||||||
|
// 表现为「反复重连但永远连不上」。拿不到主机信息不该阻断设备登记。
|
||||||
|
let host = "";
|
||||||
|
let cpus = 0;
|
||||||
|
let totalMem = 0;
|
||||||
|
let appVersion = "";
|
||||||
|
let platform = "";
|
||||||
|
let arch = "";
|
||||||
|
let nodeVersion = "";
|
||||||
|
let electronVersion = "";
|
||||||
|
try {
|
||||||
|
host = devOs.hostname() || "";
|
||||||
|
cpus = devOs.cpus ? devOs.cpus().length : 0;
|
||||||
|
totalMem = devOs.totalmem ? devOs.totalmem() : 0;
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略:device_id 退化为 gui-local。
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
appVersion = app && app.getVersion ? app.getVersion() : "";
|
||||||
|
} catch (e) {}
|
||||||
|
// process 单独包:electron 主进程有,但判据的 vm 沙箱只注入了 Buffer/Math/
|
||||||
|
// crypto —— 这里抛错会让 ws.send 之前就中断,被上层 catch 吞成
|
||||||
|
// 「连接失败」,而实际连接是好的(表现为无限重连)。
|
||||||
|
try {
|
||||||
|
platform = process.platform || "";
|
||||||
|
arch = process.arch || "";
|
||||||
|
nodeVersion =
|
||||||
|
(process.versions && process.versions.node) || "";
|
||||||
|
electronVersion =
|
||||||
|
(process.versions && process.versions.electron) || "";
|
||||||
|
} catch (e) {}
|
||||||
|
if (!deviceBridgeId) {
|
||||||
|
deviceBridgeId =
|
||||||
|
"gui-" + (host || "local").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
}
|
||||||
|
let authorized = false;
|
||||||
|
try {
|
||||||
|
authorized = !!(
|
||||||
|
guiPrefs &&
|
||||||
|
guiPrefs.deviceBridge &&
|
||||||
|
guiPrefs.deviceBridge.authorized
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
ws.send({
|
||||||
|
op: "hello",
|
||||||
|
device: {
|
||||||
|
device_id: deviceBridgeId,
|
||||||
|
name: "HomeAgent GUI",
|
||||||
|
kind: "computer",
|
||||||
|
authorized: authorized,
|
||||||
|
caps: [
|
||||||
|
"status",
|
||||||
|
"cmdrun",
|
||||||
|
"deviceinfo",
|
||||||
|
"cmdresult",
|
||||||
|
"computeruse",
|
||||||
|
"screensee",
|
||||||
|
"clipboardsee",
|
||||||
|
"clipboardsue",
|
||||||
|
"speakeruse",
|
||||||
|
"camerasue",
|
||||||
|
"screensue",
|
||||||
|
"omniparse",
|
||||||
|
],
|
||||||
|
info: {
|
||||||
|
hostname: host,
|
||||||
|
platform: platform,
|
||||||
|
arch: arch,
|
||||||
|
os_release: "",
|
||||||
|
node_version: nodeVersion,
|
||||||
|
electron_version: electronVersion,
|
||||||
|
version: appVersion,
|
||||||
|
cpus: cpus,
|
||||||
|
total_mem_bytes: totalMem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.send({ op: "bind", device_id: deviceBridgeId, token });
|
||||||
|
console.log("[device-bridge] connected as " + deviceBridgeId + " @ " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实连接:建 WS、起心跳、登记到全局。
|
||||||
|
//
|
||||||
|
// 「连上」与「开始对它发心跳」是同一件事的两半,放一函数里,重连路径
|
||||||
|
// 才不会漏掉心跳。
|
||||||
|
async function doConnectDeviceBridge(cfg) {
|
||||||
|
const ws = await connectDeviceWS(
|
||||||
|
cfg.url || cfg.gateway || "",
|
||||||
|
cfg.apiKey || cfg.token || "",
|
||||||
|
onDeviceMsg,
|
||||||
|
);
|
||||||
|
startDevicePing(ws.__sock);
|
||||||
|
deviceBridge = ws;
|
||||||
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 WS text 帧(客户端加掩码)
|
// 发送 WS text 帧(客户端加掩码)
|
||||||
@ -2899,59 +3089,38 @@ async function startDeviceBridge(cfg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deviceBridgeAddr = url;
|
deviceBridgeAddr = url;
|
||||||
deviceBridgeId =
|
// 记住配置:重连时要用(连接断开后局部变量就没了)。
|
||||||
"gui-" + (devOs.hostname() || "local").replace(/[^a-zA-Z0-9_-]/g, "_");
|
deviceBridgeCfg = { url, token };
|
||||||
|
// 拉起意图。scheduleDeviceReconnect 以此为闸 —— 用户主动停用后
|
||||||
|
// 不得再排程重连。
|
||||||
|
deviceWanted = true;
|
||||||
|
deviceReconnectAttempt = 0;
|
||||||
|
if (deviceReconnectTimer) {
|
||||||
|
clearTimeout(deviceReconnectTimer);
|
||||||
|
deviceReconnectTimer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const ws = await connectDeviceWS(url, token, onDeviceMsg);
|
await connectDeviceBridgeOnce(deviceBridgeCfg);
|
||||||
deviceBridge = ws;
|
// 首次就连上:退避归零。
|
||||||
ws.send({
|
deviceReconnectAttempt = 0;
|
||||||
op: "hello",
|
|
||||||
device: {
|
|
||||||
device_id: deviceBridgeId,
|
|
||||||
name: "HomeAgent GUI",
|
|
||||||
kind: "computer",
|
|
||||||
authorized: guiPrefs?.deviceBridge?.authorized || false, // 客户端自报授权状态
|
|
||||||
caps: [
|
|
||||||
"status",
|
|
||||||
"cmdrun",
|
|
||||||
"deviceinfo",
|
|
||||||
"cmdresult",
|
|
||||||
"computeruse",
|
|
||||||
"screensee",
|
|
||||||
"clipboardsee",
|
|
||||||
"clipboardsue",
|
|
||||||
"speakeruse",
|
|
||||||
"camerasue",
|
|
||||||
"screensue",
|
|
||||||
"omniparse",
|
|
||||||
],
|
|
||||||
info: {
|
|
||||||
hostname: devOs.hostname() || "",
|
|
||||||
platform: process.platform || "",
|
|
||||||
arch: process.arch || "",
|
|
||||||
os_release: "", // 不提权读取 /etc/os-release,避免破坏沙箱;如需可在白名单命令里由 agent 探
|
|
||||||
node_version:
|
|
||||||
process.versions && process.versions.node
|
|
||||||
? process.versions.node
|
|
||||||
: "",
|
|
||||||
electron_version:
|
|
||||||
process.versions && process.versions.electron
|
|
||||||
? process.versions.electron
|
|
||||||
: "",
|
|
||||||
version: app.getVersion ? app.getVersion() : "",
|
|
||||||
cpus: devOs.cpus ? devOs.cpus().length : 0,
|
|
||||||
total_mem_bytes: devOs.totalmem ? devOs.totalmem() : 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
ws.send({ op: "bind", device_id: deviceBridgeId, token });
|
|
||||||
console.log("[device-bridge] connected as " + deviceBridgeId + " @ " + url);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[device-bridge] connect failed: " + e.message);
|
console.error("[device-bridge] connect failed: " + e.message);
|
||||||
|
// ★ 连接失败也必须排程重连 —— 这条路上**没有** socket close 事件
|
||||||
|
// (socket 从未 upgraded),若只把重连挂在 close 上,首次失败就
|
||||||
|
// 永远不会自愈。
|
||||||
|
scheduleDeviceReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopDeviceBridge() {
|
function stopDeviceBridge() {
|
||||||
|
// 先断意图,再拆资源:顺序反了会让 close 事件里的排程再把桥拉起来。
|
||||||
|
deviceWanted = false;
|
||||||
|
stopDevicePing();
|
||||||
|
if (deviceReconnectTimer) {
|
||||||
|
clearTimeout(deviceReconnectTimer);
|
||||||
|
deviceReconnectTimer = null;
|
||||||
|
}
|
||||||
|
deviceReconnectAttempt = 0;
|
||||||
if (deviceBridge) {
|
if (deviceBridge) {
|
||||||
try {
|
try {
|
||||||
deviceBridge.close();
|
deviceBridge.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user