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`;
1920let backendProc = null ;
2021let backendStdioStream = null ;
2122let resolvedDataDir = null ;
23+ let mainWindow = null ;
24+ let tray = null ;
25+ let isQuitting = false ;
26+ let desktopSettings = null ;
2227
2328function 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+
112274function 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
414616async 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
457661app . on ( "before-quit" , ( ) => {
662+ isQuitting = true ;
663+ destroyTray ( ) ;
458664 stopBackend ( ) ;
459665} ) ;
460666
0 commit comments