diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 3c451c55..fdca7c49 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -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, diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 01a01a56..4dcb97de 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -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": { diff --git a/cmd/gui/silent-start.test.mjs b/cmd/gui/silent-start.test.mjs new file mode 100644 index 00000000..c62f8cb9 --- /dev/null +++ b/cmd/gui/silent-start.test.mjs @@ -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("全部通过"); \ No newline at end of file