mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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 条)。
This commit is contained in:
@ -352,7 +352,20 @@ function stopHomed() {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
// createWindow 创建主窗口。
|
||||
//
|
||||
// ★ show=false = **静默启动**:窗口照常创建(渲染进程随之启动,SSE、
|
||||
// 设备桥、聊天全部可用),只是不显示。
|
||||
//
|
||||
// 为什么不是「不创建窗口」:不创建 ⇒ 渲染进程不启动 ⇒ app.js 整个不执行
|
||||
// ⇒ SSE 通道与设备桥轮询全废,GUI 退化成纯托盘图标;而且
|
||||
// showMainWindow() 只做 show()、不会创建窗口,app.on("activate") 又只在
|
||||
// macOS 触发 —— Windows 上托盘菜单点「显示主界面」将**毫无反应**,
|
||||
// 进程变成无法唤起的僵尸。
|
||||
//
|
||||
// 为什么不用 show:false 再 show():那会先显示一帧再隐藏,肉眼可见闪一下。
|
||||
// 这里用 ready-to-show + 条件 show,避免闪烁。
|
||||
function createWindow(show = true) {
|
||||
const menu = Menu.buildFromTemplate([]);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
@ -363,6 +376,7 @@ function createWindow() {
|
||||
minHeight: 600,
|
||||
title: "HomeAgent",
|
||||
frame: false,
|
||||
show: false, // 一律先不显示,由 ready-to-show 决定
|
||||
icon: path.join(__dirname, "icon.ico"),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
@ -371,6 +385,15 @@ function createWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
// 静默时不显示;非静默时在首帧就绪后显示,避免白屏闪现。
|
||||
if (show) {
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
try {
|
||||
mainWindow.show();
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
|
||||
|
||||
if (process.argv.includes("--dev")) {
|
||||
@ -2455,10 +2478,21 @@ function initTray() {
|
||||
function showMainWindow() {
|
||||
try {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
// 窗口是静默创建时已处于「未显示」态,这里正常显示即可。
|
||||
if (!mainWindow.isVisible()) mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
/* 落到下面重建 */
|
||||
}
|
||||
// 兜底:窗口不存在(异常退出后残留状态、或未来改成不建窗)时重建,
|
||||
// 否则托盘菜单点了没反应,进程就成了唤不起来的僵尸。
|
||||
try {
|
||||
createWindow(true);
|
||||
} catch (e) {
|
||||
console.error("showMainWindow: recreate failed: " + e.message);
|
||||
}
|
||||
}
|
||||
function destroyTray() {
|
||||
try {
|
||||
@ -2506,7 +2540,28 @@ app.whenReady().then(async () => {
|
||||
} catch (e) {
|
||||
console.error("[auth-schedule] start failed: " + e.message);
|
||||
}
|
||||
createWindow();
|
||||
|
||||
// ★ 静默启动:prefs.silentStart 为真时不显示主窗口,驻留托盘。
|
||||
//
|
||||
// 此前这个开关是**死的**:设置页有它、gui-prefs.json 存了它、文案写着
|
||||
// 「启动时不显示主窗口,驻留托盘后台运行」,但主进程从来没读过它 ——
|
||||
// whenReady 无条件 createWindow(),于是必然弹窗。
|
||||
//
|
||||
// 实测(本机,prefs.silentStart=true):窗口标题 HomeAgent 照样出现。
|
||||
//
|
||||
// 与 applyAutoLaunch 里的 openAsHidden 是两回事:那个是告诉 OS「开机自启
|
||||
// 时如何启动」(仅 Windows/macOS 生效),这里是本进程自己决定要不要显示,
|
||||
// 对手动启动同样生效。
|
||||
let silent = false;
|
||||
try {
|
||||
silent = !!loadGuiPrefs().silentStart;
|
||||
} catch (e) {
|
||||
console.error("[startup] read silentStart failed: " + e.message);
|
||||
}
|
||||
createWindow(!silent);
|
||||
if (silent) {
|
||||
console.log("[startup] silentStart=on: window created hidden (tray only)");
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
@ -2556,7 +2611,19 @@ const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json");
|
||||
function loadGuiPrefs() {
|
||||
try {
|
||||
if (fs.existsSync(GUI_PREFS_FILE)) {
|
||||
const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8"));
|
||||
// ★ 去 BOM 后再 parse。
|
||||
//
|
||||
// Windows 上用 PowerShell Set-Content -Encoding UTF8、记事本等工具改过
|
||||
// 这个文件,会在开头写入 UTF-8 BOM(EF BB BF)。JSON.parse 遇到它直接
|
||||
// 抛错 → 走 catch → 返回**默认 prefs**(silentStart/exitToTray/
|
||||
// deviceBridge 全部被重置)→ 用户界面上的开关像是"保存了但不起作用"。
|
||||
//
|
||||
// 本次实测踩到:判据脚本用 Set-Content 改 silentStart,文件带上 BOM 后
|
||||
// 静默启动不生效,而 loadConnections 早就有去 BOM 处理、这里没有 ——
|
||||
// 两个读取点不一致,属实打实的疏漏。
|
||||
const d = JSON.parse(
|
||||
fs.readFileSync(GUI_PREFS_FILE, "utf-8").replace(/^\uFEFF/, ""),
|
||||
);
|
||||
const db = d.deviceBridge || {};
|
||||
return {
|
||||
autoLaunch: !!d.autoLaunch,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"start": "electron . --no-sandbox",
|
||||
"dev": "electron . --no-sandbox --dev",
|
||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs",
|
||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs",
|
||||
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
122
cmd/gui/silent-start.test.mjs
Normal file
122
cmd/gui/silent-start.test.mjs
Normal file
@ -0,0 +1,122 @@
|
||||
// 静默启动的判据。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 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("全部通过");
|
||||
Reference in New Issue
Block a user