Files
HomeAgent/cmd/gui/silent-start.test.mjs
JianFeeeee 58ad6831f5 fix(gui): 静默启动开关此前是死设置,永远不生效
## 现象
设置页有「静默启动」开关,文案写着「启动时不显示主窗口,驻留托盘
后台运行」,勾选后启动 GUI 照样弹窗。

实测(本机,gui-prefs.json 里 silentStart=true):窗口标题 HomeAgent
照常出现。

## 根因
silentStart 从未被消费:
  · main.js 的 loadGuiPrefs() 把它读进对象
  · 渲染层设置页读写它
  · **但主进程没有任何地方读它做判断** —— whenReady 无条件 createWindow()
是个纯粹的界面开关,接线缺失。

## 改动
1) createWindow(show = true):支持「创建但不显示」。
   BrowserWindow 初始 show:false,非静默时经 ready-to-show 显示
   (避免先显示再隐藏的闪烁)。

   ★ 为什么静默时**仍然创建窗口**、而不是不创建:
     不创建 ⇒ 渲染进程不启动 ⇒ app.js 整个不执行 ⇒ SSE 通道、
     设备桥轮询、聊天全部失效,GUI 退化成纯托盘图标;且
     showMainWindow() 只做 show()、不会创建窗口,app.on("activate")
     又只在 macOS 触发 —— Windows 上托盘点「显示主界面」将毫无反应,
     进程变成唤不起来的僵尸。已按此结论实现(评审时确认为「创建后隐藏」)。

2) whenReady 读 silentStart 并据此 createWindow(!silent)。
   与 applyAutoLaunch 里的 openAsHidden 是两条不同路径:那个告诉 OS
   「开机自启时如何启动」,这里决定本进程要不要显示,对手动启动同样生效。

3) showMainWindow() 增加兜底:窗口不存在时重建;隐藏时先 show 再 focus。
   消除上述「唤不起来」的隐患。

4) loadGuiPrefs() 补去 UTF-8 BOM。
   ★ 这是排查中发现的**第二个真实缺陷**:Windows 工具
   (PowerShell Set-Content -Encoding UTF8、记事本)改过 gui-prefs.json
   会写入 BOM,而 JSON.parse 遇 BOM 直接抛错 → 走 catch → 返回**默认
   prefs** ⇒ silentStart/exitToTray/deviceBridge 全部被静默重置,
   表现为「开关保存了但不起作用」。loadConnections 早就有去 BOM 处理、
   这里没有,两个读取点不一致。
   (本次实测正是被自己的判据脚本用 Set-Content 改 prefs 后踩到的。)

## 判据
新增 cmd/gui/silent-start.test.mjs(进 npm test),14 条断言:
  · whenReady 确实读 silentStart 并决定 show
  · 静默路径仍创建窗口(不许退化成不建窗)
  · showMainWindow 能在窗口缺失时重建
  · loadGuiPrefs 去 BOM,且 loadConnections 的既有处理不回退
已做变异测试:还原成无条件 createWindow()、抽掉 BOM 处理,均判红。

## 实测(本机 Electron,真实 prefs)
  silentStart=true  → processes=8 visibleWindows=0,日志出现
                      [startup] silentStart=on: window created hidden
  silentStart=false → processes=4 visibleWindows=1(标题 HomeAgent)

判据:cmd/gui npm test 全绿(46 条)。
2026-10-02 09:47:11 +08:00

122 lines
5.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.

// 静默启动的判据。
//
// ## 要判的是什么
//
// prefs.silentStart 曾是**死设置**:设置页有开关、gui-prefs.json 存了它、
// 文案写着「启动时不显示主窗口,驻留托盘后台运行」,但主进程从来没读过它
// —— whenReady 无条件 createWindow(),于是必然弹窗。
//
// 实测(本机,prefs.silentStart=true):窗口标题 HomeAgent 照样出现。
//
// 本判据钉住三件事:
// 1. whenReady 真的读 silentStart 并据此决定 show
// 2. 静默时窗口**仍被创建**(不是"不建窗")—— 否则渲染进程不启动,
// SSE/设备桥/聊天全废,且 showMainWindow() 只做 show() 不会创建,
// 托盘菜单点了没反应,进程变成唤不起的僵尸
// 3. loadGuiPrefs 去掉 UTF-8 BOM —— 否则 JSON.parse 抛错 → 返回默认
// prefs → 所有偏好静默失效(Windows 工具改过文件就会踩到)
//
// 运行:node silent-start.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 src = readFileSync(join(here, "main.js"), "utf8");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// ── 1) whenReady 必须消费 silentStart ─────────────────────────────────
const readyIdx = src.indexOf("app.whenReady().then");
check("能定位 whenReady", readyIdx > 0);
const readyBlock = src.slice(readyIdx, src.indexOf("app.on(\"before-quit\"", readyIdx));
check(
"whenReady 读取 prefs.silentStart",
/loadGuiPrefs\(\)[\s\S]{0,40}silentStart|\.silentStart/.test(readyBlock),
"whenReady 里没有读 silentStart ⇒ 开关是死的",
);
check(
"whenReady 按 silent 决定是否显示",
/createWindow\(\s*!silent\s*\)/.test(readyBlock),
"没有 createWindow(!silent) ⇒ 无条件弹窗",
);
// ── 2) 静默必须「创建但隐藏」,不是不创建 ─────────────────────────────
const createIdx = src.indexOf("function createWindow(");
check("能定位 createWindow", createIdx > 0);
const createBlock = src.slice(createIdx, src.indexOf("\nfunction ", createIdx + 10));
check(
"createWindow 支持 show 参数",
/function createWindow\(\s*show\s*=\s*true\s*\)/.test(createBlock),
"签名不是 createWindow(show = true)",
);
check(
"BrowserWindow 初始 show:false(避免闪一下再隐藏)",
/show:\s*false/.test(createBlock),
"没用 show:false ⇒ 先显示再隐藏会闪",
);
check(
"非静默时经 ready-to-show 显示",
/ready-to-show[\s\S]{0,120}show\(\)/.test(createBlock),
"没有 ready-to-show → show(),非静默启动可能白屏闪现",
);
check(
"★ 静默路径仍然创建窗口(渲染进程要启动)",
/createWindow\(\s*!silent\s*\)/.test(readyBlock) &&
/function createWindow\(\s*show\s*=\s*true\s*\)/.test(createBlock),
"静默时若不建窗口,SSE/设备桥/聊天全废",
);
// ── 3) showMainWindow 必须能在窗口缺失时重建 ──────────────────────────
const showIdx = src.indexOf("function showMainWindow(");
check("能定位 showMainWindow", showIdx > 0);
const showBlock = src.slice(showIdx, src.indexOf("\nfunction ", showIdx + 10));
check(
"★ showMainWindow 在窗口不存在时会重建",
/createWindow\(\s*true\s*\)/.test(showBlock),
"只调 show() 不创建 ⇒ 托盘菜单点了没反应,进程成僵尸(activate 事件仅 macOS 触发)",
);
check(
"showMainWindow 对隐藏窗口调 show()",
/isVisible\(\)[\s\S]{0,60}show\(\)/.test(showBlock),
"未处理 isVisible() ⇒ 静默创建的窗口可能无法被唤起",
);
// ── 4) loadGuiPrefs 必须去 BOM ────────────────────────────────────────
const prefsIdx = src.indexOf("function loadGuiPrefs(");
check("能定位 loadGuiPrefs", prefsIdx > 0);
const prefsBlock = src.slice(prefsIdx, src.indexOf("\nfunction ", prefsIdx + 10));
check(
"★ loadGuiPrefs 读取时去 UTF-8 BOM",
/readFileSync\(GUI_PREFS_FILE[\s\S]{0,80}replace\(\s*\/\^\\uFEFF\//.test(prefsBlock),
"未去 BOM ⇒ Windows 工具(PowerShell Set-Content -Encoding UTF8/记事本)改过文件后," +
"JSON.parse 抛错 → 返回默认 prefs → 所有偏好静默失效",
);
// ── 5) loadConnections 的 BOM 处理不能被回退 ─────────────────────────
const connIdx = src.indexOf("function loadConnections(");
const connBlock = src.slice(connIdx, src.indexOf("\nfunction ", connIdx + 10));
check(
"loadConnections 仍去 BOM(既有行为不回退)",
/replace\(\s*\/\^\\uFEFF\//.test(connBlock),
"loadConnections 的 BOM 处理被移除了",
);
console.log("");
if (failures > 0) {
console.log(`全部失败:${failures} 条`);
process.exit(1);
}
console.log("全部通过");