diff --git a/desktop/src/icon.ico b/desktop/src/icon.ico new file mode 100644 index 0000000..9bc5880 Binary files /dev/null and b/desktop/src/icon.ico differ diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 753601e..a2321bc 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -2,6 +2,7 @@ const { app, BrowserWindow, Menu, + Tray, ipcMain, globalShortcut, dialog, @@ -19,6 +20,10 @@ const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`; let backendProc = null; let backendStdioStream = null; let resolvedDataDir = null; +let mainWindow = null; +let tray = null; +let isQuitting = false; +let desktopSettings = null; function nowIso() { return new Date().toISOString(); @@ -109,6 +114,163 @@ function logMain(line) { } catch {} } +function getDesktopSettingsPath() { + const dir = getUserDataDir(); + if (!dir) return null; + return path.join(dir, "desktop-settings.json"); +} + +function loadDesktopSettings() { + if (desktopSettings) return desktopSettings; + + const defaults = { + // 'tray' (default): closing the window hides it to the system tray. + // 'exit': closing the window quits the app. + closeBehavior: "tray", + }; + + const p = getDesktopSettingsPath(); + if (!p) { + desktopSettings = { ...defaults }; + return desktopSettings; + } + + try { + if (!fs.existsSync(p)) { + desktopSettings = { ...defaults }; + return desktopSettings; + } + const raw = fs.readFileSync(p, { encoding: "utf8" }); + const parsed = JSON.parse(raw || "{}"); + desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) }; + } catch (err) { + desktopSettings = { ...defaults }; + logMain(`[main] failed to load settings: ${err?.message || err}`); + } + + return desktopSettings; +} + +function persistDesktopSettings() { + const p = getDesktopSettingsPath(); + if (!p) return; + if (!desktopSettings) return; + + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" }); + } catch (err) { + logMain(`[main] failed to persist settings: ${err?.message || err}`); + } +} + +function getCloseBehavior() { + const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase(); + return v === "exit" ? "exit" : "tray"; +} + +function setCloseBehavior(next) { + const v = String(next || "").trim().toLowerCase(); + loadDesktopSettings(); + desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray"; + persistDesktopSettings(); + return desktopSettings.closeBehavior; +} + +function getTrayIconPath() { + // Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds. + const shipped = path.join(__dirname, "icon.ico"); + try { + if (fs.existsSync(shipped)) return shipped; + } catch {} + + // Dev fallback (not available in packaged builds). + const dev = path.resolve(__dirname, "..", "build", "icon.ico"); + try { + if (fs.existsSync(dev)) return dev; + } catch {} + + return null; +} + +function showMainWindow() { + if (!mainWindow) return; + try { + mainWindow.setSkipTaskbar(false); + } catch {} + try { + if (mainWindow.isMinimized()) mainWindow.restore(); + } catch {} + try { + mainWindow.show(); + } catch {} + try { + mainWindow.focus(); + } catch {} +} + +function createTray() { + if (tray) return tray; + if (!app.isPackaged) return null; + + const iconPath = getTrayIconPath(); + if (!iconPath) { + logMain("[main] tray icon not found; disabling tray behavior"); + return null; + } + + try { + tray = new Tray(iconPath); + } catch (err) { + tray = null; + logMain(`[main] failed to create tray: ${err?.message || err}`); + return null; + } + + try { + tray.setToolTip("WeChatDataAnalysis"); + } catch {} + + try { + tray.setContextMenu( + Menu.buildFromTemplate([ + { + label: "显示", + click: () => showMainWindow(), + }, + { + label: "退出", + click: () => { + isQuitting = true; + app.quit(); + }, + }, + ]) + ); + } catch {} + + try { + tray.on("click", () => showMainWindow()); + tray.on("double-click", () => showMainWindow()); + } catch {} + + return tray; +} + +function destroyTray() { + if (!tray) return; + try { + tray.destroy(); + } catch {} + tray = null; +} + +function ensureTrayForCloseBehavior() { + const behavior = getCloseBehavior(); + if (behavior === "tray") createTray(); + else destroyTray(); +} + function getBackendStdioLogPath(dataDir) { return path.join(dataDir, "backend-stdio.log"); } @@ -335,6 +497,26 @@ function createMainWindow() { }, }); + win.on("close", (event) => { + // In packaged builds, we default to "close -> minimize to tray" unless the user opts out. + if (!app.isPackaged) return; + if (isQuitting) return; + if (getCloseBehavior() !== "tray") return; + if (!tray) return; + + try { + event.preventDefault(); + win.setSkipTaskbar(true); + win.hide(); + try { + tray.displayBalloon({ + title: "WeChatDataAnalysis", + content: "已最小化到托盘,可从托盘图标再次打开。", + }); + } catch {} + } catch {} + }); + win.on("closed", () => { stopBackend(); }); @@ -409,6 +591,26 @@ function registerWindowIpc() { return on; } }); + + ipcMain.handle("app:getCloseBehavior", () => { + try { + return getCloseBehavior(); + } catch (err) { + logMain(`[main] getCloseBehavior failed: ${err?.message || err}`); + return "tray"; + } + }); + + ipcMain.handle("app:setCloseBehavior", (_event, behavior) => { + try { + const next = setCloseBehavior(behavior); + ensureTrayForCloseBehavior(); + return next; + } catch (err) { + logMain(`[main] setCloseBehavior failed: ${err?.message || err}`); + return getCloseBehavior(); + } + }); } async function main() { @@ -428,6 +630,8 @@ async function main() { await waitForBackend({ timeoutMs: 30_000 }); const win = createMainWindow(); + mainWindow = win; + ensureTrayForCloseBehavior(); const startUrl = process.env.ELECTRON_START_URL || @@ -455,6 +659,8 @@ app.on("will-quit", () => { }); app.on("before-quit", () => { + isQuitting = true; + destroyTray(); stopBackend(); }); diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 39681c4..1f1eea2 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -8,4 +8,7 @@ contextBridge.exposeInMainWorld("wechatDesktop", { getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"), setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled), + + getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"), + setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")), }); diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 07ada82..22e7755 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -1600,6 +1600,25 @@ {{ desktopAutoLaunchError }} +
+
+
关闭窗口行为
+
点击关闭按钮时:默认最小化到托盘
+
+ +
+
+ {{ desktopCloseBehaviorError }} +
+
启动后自动开启实时获取
@@ -1717,6 +1736,10 @@ const desktopAutoLaunch = ref(false) const desktopAutoLaunchLoading = ref(false) const desktopAutoLaunchError = ref('') +const desktopCloseBehavior = ref('tray') // tray | exit +const desktopCloseBehaviorLoading = ref(false) +const desktopCloseBehaviorError = ref('') + const readLocalBool = (key) => { if (!process.client) return false try { @@ -1768,9 +1791,42 @@ const setDesktopAutoLaunch = async (enabled) => { } } +const refreshDesktopCloseBehavior = async () => { + if (!process.client || typeof window === 'undefined') return + if (!window.wechatDesktop?.getCloseBehavior) return + desktopCloseBehaviorLoading.value = true + desktopCloseBehaviorError.value = '' + try { + const v = await window.wechatDesktop.getCloseBehavior() + desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray' + } catch (e) { + desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败' + } finally { + desktopCloseBehaviorLoading.value = false + } +} + +const setDesktopCloseBehavior = async (behavior) => { + if (!process.client || typeof window === 'undefined') return + if (!window.wechatDesktop?.setCloseBehavior) return + const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray' + desktopCloseBehaviorLoading.value = true + desktopCloseBehaviorError.value = '' + try { + const v = await window.wechatDesktop.setCloseBehavior(desired) + desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray' + } catch (e) { + desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败' + await refreshDesktopCloseBehavior() + } finally { + desktopCloseBehaviorLoading.value = false + } +} + const openDesktopSettings = async () => { desktopSettingsOpen.value = true await refreshDesktopAutoLaunch() + await refreshDesktopCloseBehavior() } const closeDesktopSettings = () => { @@ -1782,6 +1838,11 @@ const onDesktopAutoLaunchToggle = async (ev) => { await setDesktopAutoLaunch(checked) } +const onDesktopCloseBehaviorChange = async (ev) => { + const v = String(ev?.target?.value || '').trim() + await setDesktopCloseBehavior(v) +} + const onDesktopAutoRealtimeToggle = async (ev) => { const checked = !!ev?.target?.checked desktopAutoRealtime.value = checked