Skip to content

Commit d4828b1

Browse files
committed
feat(desktop): close-to-tray setting
1 parent 78ace41 commit d4828b1

4 files changed

Lines changed: 270 additions & 0 deletions

File tree

desktop/src/icon.ico

279 KB
Binary file not shown.

desktop/src/main.cjs

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const {
22
app,
33
BrowserWindow,
44
Menu,
5+
Tray,
56
ipcMain,
67
globalShortcut,
78
dialog,
@@ -19,6 +20,10 @@ const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
1920
let backendProc = null;
2021
let backendStdioStream = null;
2122
let resolvedDataDir = null;
23+
let mainWindow = null;
24+
let tray = null;
25+
let isQuitting = false;
26+
let desktopSettings = null;
2227

2328
function nowIso() {
2429
return new Date().toISOString();
@@ -109,6 +114,163 @@ function logMain(line) {
109114
} catch {}
110115
}
111116

117+
function getDesktopSettingsPath() {
118+
const dir = getUserDataDir();
119+
if (!dir) return null;
120+
return path.join(dir, "desktop-settings.json");
121+
}
122+
123+
function loadDesktopSettings() {
124+
if (desktopSettings) return desktopSettings;
125+
126+
const defaults = {
127+
// 'tray' (default): closing the window hides it to the system tray.
128+
// 'exit': closing the window quits the app.
129+
closeBehavior: "tray",
130+
};
131+
132+
const p = getDesktopSettingsPath();
133+
if (!p) {
134+
desktopSettings = { ...defaults };
135+
return desktopSettings;
136+
}
137+
138+
try {
139+
if (!fs.existsSync(p)) {
140+
desktopSettings = { ...defaults };
141+
return desktopSettings;
142+
}
143+
const raw = fs.readFileSync(p, { encoding: "utf8" });
144+
const parsed = JSON.parse(raw || "{}");
145+
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
146+
} catch (err) {
147+
desktopSettings = { ...defaults };
148+
logMain(`[main] failed to load settings: ${err?.message || err}`);
149+
}
150+
151+
return desktopSettings;
152+
}
153+
154+
function persistDesktopSettings() {
155+
const p = getDesktopSettingsPath();
156+
if (!p) return;
157+
if (!desktopSettings) return;
158+
159+
try {
160+
fs.mkdirSync(path.dirname(p), { recursive: true });
161+
fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" });
162+
} catch (err) {
163+
logMain(`[main] failed to persist settings: ${err?.message || err}`);
164+
}
165+
}
166+
167+
function getCloseBehavior() {
168+
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
169+
return v === "exit" ? "exit" : "tray";
170+
}
171+
172+
function setCloseBehavior(next) {
173+
const v = String(next || "").trim().toLowerCase();
174+
loadDesktopSettings();
175+
desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray";
176+
persistDesktopSettings();
177+
return desktopSettings.closeBehavior;
178+
}
179+
180+
function getTrayIconPath() {
181+
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
182+
const shipped = path.join(__dirname, "icon.ico");
183+
try {
184+
if (fs.existsSync(shipped)) return shipped;
185+
} catch {}
186+
187+
// Dev fallback (not available in packaged builds).
188+
const dev = path.resolve(__dirname, "..", "build", "icon.ico");
189+
try {
190+
if (fs.existsSync(dev)) return dev;
191+
} catch {}
192+
193+
return null;
194+
}
195+
196+
function showMainWindow() {
197+
if (!mainWindow) return;
198+
try {
199+
mainWindow.setSkipTaskbar(false);
200+
} catch {}
201+
try {
202+
if (mainWindow.isMinimized()) mainWindow.restore();
203+
} catch {}
204+
try {
205+
mainWindow.show();
206+
} catch {}
207+
try {
208+
mainWindow.focus();
209+
} catch {}
210+
}
211+
212+
function createTray() {
213+
if (tray) return tray;
214+
if (!app.isPackaged) return null;
215+
216+
const iconPath = getTrayIconPath();
217+
if (!iconPath) {
218+
logMain("[main] tray icon not found; disabling tray behavior");
219+
return null;
220+
}
221+
222+
try {
223+
tray = new Tray(iconPath);
224+
} catch (err) {
225+
tray = null;
226+
logMain(`[main] failed to create tray: ${err?.message || err}`);
227+
return null;
228+
}
229+
230+
try {
231+
tray.setToolTip("WeChatDataAnalysis");
232+
} catch {}
233+
234+
try {
235+
tray.setContextMenu(
236+
Menu.buildFromTemplate([
237+
{
238+
label: "显示",
239+
click: () => showMainWindow(),
240+
},
241+
{
242+
label: "退出",
243+
click: () => {
244+
isQuitting = true;
245+
app.quit();
246+
},
247+
},
248+
])
249+
);
250+
} catch {}
251+
252+
try {
253+
tray.on("click", () => showMainWindow());
254+
tray.on("double-click", () => showMainWindow());
255+
} catch {}
256+
257+
return tray;
258+
}
259+
260+
function destroyTray() {
261+
if (!tray) return;
262+
try {
263+
tray.destroy();
264+
} catch {}
265+
tray = null;
266+
}
267+
268+
function ensureTrayForCloseBehavior() {
269+
const behavior = getCloseBehavior();
270+
if (behavior === "tray") createTray();
271+
else destroyTray();
272+
}
273+
112274
function getBackendStdioLogPath(dataDir) {
113275
return path.join(dataDir, "backend-stdio.log");
114276
}
@@ -335,6 +497,26 @@ function createMainWindow() {
335497
},
336498
});
337499

500+
win.on("close", (event) => {
501+
// In packaged builds, we default to "close -> minimize to tray" unless the user opts out.
502+
if (!app.isPackaged) return;
503+
if (isQuitting) return;
504+
if (getCloseBehavior() !== "tray") return;
505+
if (!tray) return;
506+
507+
try {
508+
event.preventDefault();
509+
win.setSkipTaskbar(true);
510+
win.hide();
511+
try {
512+
tray.displayBalloon({
513+
title: "WeChatDataAnalysis",
514+
content: "已最小化到托盘,可从托盘图标再次打开。",
515+
});
516+
} catch {}
517+
} catch {}
518+
});
519+
338520
win.on("closed", () => {
339521
stopBackend();
340522
});
@@ -409,6 +591,26 @@ function registerWindowIpc() {
409591
return on;
410592
}
411593
});
594+
595+
ipcMain.handle("app:getCloseBehavior", () => {
596+
try {
597+
return getCloseBehavior();
598+
} catch (err) {
599+
logMain(`[main] getCloseBehavior failed: ${err?.message || err}`);
600+
return "tray";
601+
}
602+
});
603+
604+
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
605+
try {
606+
const next = setCloseBehavior(behavior);
607+
ensureTrayForCloseBehavior();
608+
return next;
609+
} catch (err) {
610+
logMain(`[main] setCloseBehavior failed: ${err?.message || err}`);
611+
return getCloseBehavior();
612+
}
613+
});
412614
}
413615

414616
async function main() {
@@ -428,6 +630,8 @@ async function main() {
428630
await waitForBackend({ timeoutMs: 30_000 });
429631

430632
const win = createMainWindow();
633+
mainWindow = win;
634+
ensureTrayForCloseBehavior();
431635

432636
const startUrl =
433637
process.env.ELECTRON_START_URL ||
@@ -455,6 +659,8 @@ app.on("will-quit", () => {
455659
});
456660

457661
app.on("before-quit", () => {
662+
isQuitting = true;
663+
destroyTray();
458664
stopBackend();
459665
});
460666

desktop/src/preload.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
88

99
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
1010
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
11+
12+
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
13+
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
1114
});

frontend/pages/chat/[[username]].vue

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,25 @@
16001600
{{ desktopAutoLaunchError }}
16011601
</div>
16021602

1603+
<div class="flex items-center justify-between gap-4">
1604+
<div class="min-w-0">
1605+
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
1606+
<div class="text-xs text-gray-500">点击关闭按钮时:默认最小化到托盘</div>
1607+
</div>
1608+
<select
1609+
class="text-sm px-2 py-1 rounded-md border border-gray-200"
1610+
:disabled="desktopCloseBehaviorLoading"
1611+
:value="desktopCloseBehavior"
1612+
@change="onDesktopCloseBehaviorChange"
1613+
>
1614+
<option value="tray">最小化到托盘</option>
1615+
<option value="exit">直接退出</option>
1616+
</select>
1617+
</div>
1618+
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
1619+
{{ desktopCloseBehaviorError }}
1620+
</div>
1621+
16031622
<div class="flex items-center justify-between gap-4">
16041623
<div class="min-w-0">
16051624
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
@@ -1717,6 +1736,10 @@ const desktopAutoLaunch = ref(false)
17171736
const desktopAutoLaunchLoading = ref(false)
17181737
const desktopAutoLaunchError = ref('')
17191738

1739+
const desktopCloseBehavior = ref('tray') // tray | exit
1740+
const desktopCloseBehaviorLoading = ref(false)
1741+
const desktopCloseBehaviorError = ref('')
1742+
17201743
const readLocalBool = (key) => {
17211744
if (!process.client) return false
17221745
try {
@@ -1768,9 +1791,42 @@ const setDesktopAutoLaunch = async (enabled) => {
17681791
}
17691792
}
17701793

1794+
const refreshDesktopCloseBehavior = async () => {
1795+
if (!process.client || typeof window === 'undefined') return
1796+
if (!window.wechatDesktop?.getCloseBehavior) return
1797+
desktopCloseBehaviorLoading.value = true
1798+
desktopCloseBehaviorError.value = ''
1799+
try {
1800+
const v = await window.wechatDesktop.getCloseBehavior()
1801+
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
1802+
} catch (e) {
1803+
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
1804+
} finally {
1805+
desktopCloseBehaviorLoading.value = false
1806+
}
1807+
}
1808+
1809+
const setDesktopCloseBehavior = async (behavior) => {
1810+
if (!process.client || typeof window === 'undefined') return
1811+
if (!window.wechatDesktop?.setCloseBehavior) return
1812+
const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
1813+
desktopCloseBehaviorLoading.value = true
1814+
desktopCloseBehaviorError.value = ''
1815+
try {
1816+
const v = await window.wechatDesktop.setCloseBehavior(desired)
1817+
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
1818+
} catch (e) {
1819+
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
1820+
await refreshDesktopCloseBehavior()
1821+
} finally {
1822+
desktopCloseBehaviorLoading.value = false
1823+
}
1824+
}
1825+
17711826
const openDesktopSettings = async () => {
17721827
desktopSettingsOpen.value = true
17731828
await refreshDesktopAutoLaunch()
1829+
await refreshDesktopCloseBehavior()
17741830
}
17751831

17761832
const closeDesktopSettings = () => {
@@ -1782,6 +1838,11 @@ const onDesktopAutoLaunchToggle = async (ev) => {
17821838
await setDesktopAutoLaunch(checked)
17831839
}
17841840

1841+
const onDesktopCloseBehaviorChange = async (ev) => {
1842+
const v = String(ev?.target?.value || '').trim()
1843+
await setDesktopCloseBehavior(v)
1844+
}
1845+
17851846
const onDesktopAutoRealtimeToggle = async (ev) => {
17861847
const checked = !!ev?.target?.checked
17871848
desktopAutoRealtime.value = checked

0 commit comments

Comments
 (0)