Merge pull request #1487 from TriliumNext/tray
Fix tray exception when multiple windows
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate-inverted.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 348 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate-inverted@1.25x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 427 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate-inverted@1.5x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 514 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate-inverted@2x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 649 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 331 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate@1.25x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 409 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate@1.5x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 481 B | 
							
								
								
									
										
											BIN
										
									
								
								images/app-icons/tray/new-windowTemplate@2x.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 626 B | 
| @@ -1,4 +1,4 @@ | |||||||
| import { Menu, Tray } from "electron"; | import { Menu, Tray, BrowserWindow } from "electron"; | ||||||
| import path from "path"; | import path from "path"; | ||||||
| import windowService from "./window.js"; | import windowService from "./window.js"; | ||||||
| import optionService from "./options.js"; | import optionService from "./options.js"; | ||||||
| @@ -17,7 +17,7 @@ import cls from "./cls.js"; | |||||||
| let tray: Tray; | let tray: Tray; | ||||||
| // `mainWindow.isVisible` doesn't work with `mainWindow.show` and `mainWindow.hide` - it returns `false` when the window | // `mainWindow.isVisible` doesn't work with `mainWindow.show` and `mainWindow.hide` - it returns `false` when the window | ||||||
| // is minimized | // is minimized | ||||||
| let isVisible = true; | let windowVisibilityMap: Record<number, boolean> = {};; // Dictionary for storing window ID and its visibility status | ||||||
|  |  | ||||||
| function getTrayIconPath() { | function getTrayIconPath() { | ||||||
|     let name: string; |     let name: string; | ||||||
| @@ -37,53 +37,93 @@ function getIconPath(name: string) { | |||||||
|     return path.join(path.dirname(fileURLToPath(import.meta.url)), "../..", "images", "app-icons", "tray", `${name}Template${suffix}.png`); |     return path.join(path.dirname(fileURLToPath(import.meta.url)), "../..", "images", "app-icons", "tray", `${name}Template${suffix}.png`); | ||||||
| } | } | ||||||
|  |  | ||||||
| function registerVisibilityListener() { | function registerVisibilityListener(window: BrowserWindow) { | ||||||
|     const mainWindow = windowService.getMainWindow(); |     if (!window) { | ||||||
|     if (!mainWindow) { |  | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // They need to be registered before the tray updater is registered |     // They need to be registered before the tray updater is registered | ||||||
|     mainWindow.on("show", () => { |     window.on("show", () => { | ||||||
|         isVisible = true; |         windowVisibilityMap[window.id] = true; | ||||||
|         updateTrayMenu(); |         updateTrayMenu(); | ||||||
|     }); |     }); | ||||||
|     mainWindow.on("hide", () => { |     window.on("hide", () => { | ||||||
|         isVisible = false; |         windowVisibilityMap[window.id] = false; | ||||||
|         updateTrayMenu(); |         updateTrayMenu(); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     mainWindow.on("minimize", updateTrayMenu); |     window.on("minimize", updateTrayMenu); | ||||||
|     mainWindow.on("maximize", updateTrayMenu); |     window.on("maximize", updateTrayMenu); | ||||||
|     if (!isMac) { |  | ||||||
|         // macOS uses template icons which work great on dark & light themes. |  | ||||||
|         nativeTheme.on("updated", updateTrayMenu); |  | ||||||
|     } |  | ||||||
|     ipcMain.on("reload-tray", updateTrayMenu); |  | ||||||
|     i18next.on("languageChanged", updateTrayMenu); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function updateTrayMenu() { | function getWindowTitle(window: BrowserWindow | null) { | ||||||
|     const mainWindow = windowService.getMainWindow(); |     if (!window) { | ||||||
|     if (!mainWindow) { |  | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  |     const title = window.getTitle(); | ||||||
|  |     const titleWithoutAppName = title.replace(/\s-\s[^-]+$/, ''); // Remove the name of the app | ||||||
|  |  | ||||||
|     function ensureVisible() { |     // Limit title maximum length to 17 | ||||||
|         if (mainWindow) { |     if (titleWithoutAppName.length > 20) { | ||||||
|             mainWindow.show(); |         return titleWithoutAppName.substring(0, 17) + '...'; | ||||||
|             mainWindow.focus(); |     } | ||||||
|  |  | ||||||
|  |     return titleWithoutAppName; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function updateWindowVisibilityMap(allWindows: BrowserWindow[]) { | ||||||
|  |     const currentWindowIds: number[] = allWindows.map(window => window.id); | ||||||
|  |  | ||||||
|  |     // Deleting closed windows from windowVisibilityMap | ||||||
|  |     for (const [id, visibility] of Object.entries(windowVisibilityMap)) { | ||||||
|  |         const windowId = Number(id); | ||||||
|  |         if (!currentWindowIds.includes(windowId)) { | ||||||
|  |             delete windowVisibilityMap[windowId]; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Iterate through allWindows to make sure the ID of each window exists in windowVisibilityMap | ||||||
|  |     allWindows.forEach(window => { | ||||||
|  |         const windowId = window.id; | ||||||
|  |         if (!(windowId in windowVisibilityMap)) { | ||||||
|  |             // If it does not exist, it is the newly created window | ||||||
|  |             windowVisibilityMap[windowId] = true; | ||||||
|  |             registerVisibilityListener(window); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | function updateTrayMenu() { | ||||||
|  |     const lastFocusedWindow = windowService.getLastFocusedWindow(); | ||||||
|  |     const allWindows = windowService.getAllWindows(); | ||||||
|  |     updateWindowVisibilityMap(allWindows); | ||||||
|  |  | ||||||
|  |     function ensureVisible(win: BrowserWindow) { | ||||||
|  |         if (win) { | ||||||
|  |             win.show(); | ||||||
|  |             win.focus(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function openNewWindow() { | ||||||
|  |         if (lastFocusedWindow){ | ||||||
|  |             lastFocusedWindow.webContents.send("globalShortcut", "openNewWindow"); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function triggerKeyboardAction(actionName: KeyboardActionNames) { |     function triggerKeyboardAction(actionName: KeyboardActionNames) { | ||||||
|         mainWindow?.webContents.send("globalShortcut", actionName); |         if (lastFocusedWindow){ | ||||||
|         ensureVisible(); |             lastFocusedWindow.webContents.send("globalShortcut", actionName); | ||||||
|  |             ensureVisible(lastFocusedWindow); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function openInSameTab(note: BNote | BRecentNote) { |     function openInSameTab(note: BNote | BRecentNote) { | ||||||
|         mainWindow?.webContents.send("openInSameTab", note.noteId); |         if (lastFocusedWindow){ | ||||||
|         ensureVisible(); |             lastFocusedWindow.webContents.send("openInSameTab", note.noteId); | ||||||
|  |             ensureVisible(lastFocusedWindow); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function buildBookmarksMenu() { |     function buildBookmarksMenu() { | ||||||
| @@ -144,20 +184,44 @@ function updateTrayMenu() { | |||||||
|         return menuItems; |         return menuItems; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const contextMenu = Menu.buildFromTemplate([ |     const windowVisibilityMenuItems: Electron.MenuItemConstructorOptions[] = []; | ||||||
|         { |  | ||||||
|             label: t("tray.show-windows"), |     // Only call getWindowTitle if windowVisibilityMap has more than one window | ||||||
|  |     const showTitle = Object.keys(windowVisibilityMap).length > 1; | ||||||
|  |  | ||||||
|  |     for (const idStr in windowVisibilityMap) { | ||||||
|  |         const id = parseInt(idStr, 10); // Get the ID of the window and make sure it is a number | ||||||
|  |         const isVisible = windowVisibilityMap[id];  | ||||||
|  |         const win = allWindows.find(w => w.id === id); | ||||||
|  |         if (!win) { | ||||||
|  |             continue; | ||||||
|  |         } | ||||||
|  |         windowVisibilityMenuItems.push({ | ||||||
|  |             label: showTitle ? `${t("tray.show-windows")}: ${getWindowTitle(win)}` : t("tray.show-windows"), | ||||||
|             type: "checkbox", |             type: "checkbox", | ||||||
|             checked: isVisible, |             checked: isVisible, | ||||||
|             click: () => { |             click: () => { | ||||||
|                 if (isVisible) { |                 if (isVisible) { | ||||||
|                     mainWindow.hide(); |                     win.hide(); | ||||||
|  |                     windowVisibilityMap[id] = false; | ||||||
|                 } else { |                 } else { | ||||||
|                     ensureVisible(); |                     ensureVisible(win); | ||||||
|  |                     windowVisibilityMap[id] = true; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |  | ||||||
|  |     const contextMenu = Menu.buildFromTemplate([ | ||||||
|  |         ...windowVisibilityMenuItems,  | ||||||
|         { type: "separator" }, |         { type: "separator" }, | ||||||
|  |         { | ||||||
|  |             label: t("tray.open_new_window"), | ||||||
|  |             type: "normal", | ||||||
|  |             icon: getIconPath("new-window"), | ||||||
|  |             click: () => openNewWindow() | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|             label: t("tray.new-note"), |             label: t("tray.new-note"), | ||||||
|             type: "normal", |             type: "normal", | ||||||
| @@ -188,7 +252,10 @@ function updateTrayMenu() { | |||||||
|             type: "normal", |             type: "normal", | ||||||
|             icon: getIconPath("close"), |             icon: getIconPath("close"), | ||||||
|             click: () => { |             click: () => { | ||||||
|                 mainWindow.close(); |                 const windows = BrowserWindow.getAllWindows(); | ||||||
|  |                 windows.forEach(window => { | ||||||
|  |                     window.close(); | ||||||
|  |                 }); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     ]); |     ]); | ||||||
| @@ -197,16 +264,18 @@ function updateTrayMenu() { | |||||||
| } | } | ||||||
|  |  | ||||||
| function changeVisibility() { | function changeVisibility() { | ||||||
|     const window = windowService.getMainWindow(); |     const lastFocusedWindow = windowService.getLastFocusedWindow(); | ||||||
|     if (!window) { |      | ||||||
|  |     if (!lastFocusedWindow) { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (isVisible) { |     // If the window is visible, hide it | ||||||
|         window.hide(); |     if (windowVisibilityMap[lastFocusedWindow.id]) { | ||||||
|  |         lastFocusedWindow.hide(); | ||||||
|     } else { |     } else { | ||||||
|         window.show(); |         lastFocusedWindow.show(); | ||||||
|         window.focus(); |         lastFocusedWindow.focus(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -221,9 +290,15 @@ function createTray() { | |||||||
|     tray.on("click", changeVisibility); |     tray.on("click", changeVisibility); | ||||||
|     updateTrayMenu(); |     updateTrayMenu(); | ||||||
|  |  | ||||||
|     registerVisibilityListener(); |     if (!isMac) { | ||||||
|  |         // macOS uses template icons which work great on dark & light themes. | ||||||
|  |         nativeTheme.on("updated", updateTrayMenu); | ||||||
|  |     } | ||||||
|  |     ipcMain.on("reload-tray", updateTrayMenu); | ||||||
|  |     i18next.on("languageChanged", updateTrayMenu); | ||||||
| } | } | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|     createTray |     createTray, | ||||||
|  |     updateTrayMenu | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ import remoteMain from "@electron/remote/main/index.js"; | |||||||
| import { BrowserWindow, shell, type App, type BrowserWindowConstructorOptions, type WebContents } from "electron"; | import { BrowserWindow, shell, type App, type BrowserWindowConstructorOptions, type WebContents } from "electron"; | ||||||
| import { dialog, ipcMain } from "electron"; | import { dialog, ipcMain } from "electron"; | ||||||
| import { formatDownloadTitle, isDev, isMac, isWindows } from "./utils.js"; | import { formatDownloadTitle, isDev, isMac, isWindows } from "./utils.js"; | ||||||
|  | import tray from "./tray.js"; | ||||||
|  |  | ||||||
| import { fileURLToPath } from "url"; | import { fileURLToPath } from "url"; | ||||||
| import { dirname } from "path"; | import { dirname } from "path"; | ||||||
| @@ -19,6 +20,26 @@ import { t } from "i18next"; | |||||||
| // Prevent the window being garbage collected | // Prevent the window being garbage collected | ||||||
| let mainWindow: BrowserWindow | null; | let mainWindow: BrowserWindow | null; | ||||||
| let setupWindow: BrowserWindow | null; | let setupWindow: BrowserWindow | null; | ||||||
|  | let allWindows: BrowserWindow[] = []; // // Used to store all windows, sorted by the order of focus. | ||||||
|  |  | ||||||
|  | function trackWindowFocus(win: BrowserWindow) { | ||||||
|  |     // We need to get the last focused window from allWindows. If the last window is closed, we return the previous window. | ||||||
|  |     // Therefore, we need to push the window into the allWindows array every time it gets focused. | ||||||
|  |     win.on("focus", () => { | ||||||
|  |         allWindows = allWindows.filter(w => !w.isDestroyed() && w !== win); | ||||||
|  |         allWindows.push(win); | ||||||
|  |         if (!optionService.getOptionBool("disableTray")) { | ||||||
|  |             tray.updateTrayMenu(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     win.on("closed", () => { | ||||||
|  |         allWindows = allWindows.filter(w => !w.isDestroyed()); | ||||||
|  |         if (!optionService.getOptionBool("disableTray")) { | ||||||
|  |             tray.updateTrayMenu(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
| async function createExtraWindow(extraWindowHash: string) { | async function createExtraWindow(extraWindowHash: string) { | ||||||
|     const spellcheckEnabled = optionService.getOptionBool("spellCheckEnabled"); |     const spellcheckEnabled = optionService.getOptionBool("spellCheckEnabled"); | ||||||
| @@ -42,6 +63,8 @@ async function createExtraWindow(extraWindowHash: string) { | |||||||
|     win.loadURL(`http://127.0.0.1:${port}/?extraWindow=1${extraWindowHash}`); |     win.loadURL(`http://127.0.0.1:${port}/?extraWindow=1${extraWindowHash}`); | ||||||
|  |  | ||||||
|     configureWebContents(win.webContents, spellcheckEnabled); |     configureWebContents(win.webContents, spellcheckEnabled); | ||||||
|  |  | ||||||
|  |     trackWindowFocus(win); | ||||||
| } | } | ||||||
|  |  | ||||||
| ipcMain.on("create-extra-window", (event, arg) => { | ipcMain.on("create-extra-window", (event, arg) => { | ||||||
| @@ -154,18 +177,21 @@ async function createMainWindow(app: App) { | |||||||
|     configureWebContents(mainWindow.webContents, spellcheckEnabled); |     configureWebContents(mainWindow.webContents, spellcheckEnabled); | ||||||
|  |  | ||||||
|     app.on("second-instance", (event, commandLine) => { |     app.on("second-instance", (event, commandLine) => { | ||||||
|  |         const lastFocusedWindow = getLastFocusedWindow(); | ||||||
|         if (commandLine.includes("--new-window")) { |         if (commandLine.includes("--new-window")) { | ||||||
|             createExtraWindow(""); |             createExtraWindow(""); | ||||||
|         } else if (mainWindow) { |         } else if (lastFocusedWindow) { | ||||||
|             // Someone tried to run a second instance, we should focus our window. |             // Someone tried to run a second instance, we should focus our window. | ||||||
|             // see www.ts "requestSingleInstanceLock" for the rest of this logic with explanation |             // see www.ts "requestSingleInstanceLock" for the rest of this logic with explanation | ||||||
|             if (mainWindow.isMinimized()) { |             if (lastFocusedWindow.isMinimized()) { | ||||||
|                 mainWindow.restore(); |                 lastFocusedWindow.restore(); | ||||||
|             } |             } | ||||||
|  |             lastFocusedWindow.show();  | ||||||
|             mainWindow.focus(); |             lastFocusedWindow.focus(); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     trackWindowFocus(mainWindow); | ||||||
| } | } | ||||||
|  |  | ||||||
| function getWindowExtraOpts() { | function getWindowExtraOpts() { | ||||||
| @@ -296,10 +322,20 @@ function getMainWindow() { | |||||||
|     return mainWindow; |     return mainWindow; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function getLastFocusedWindow() { | ||||||
|  |     return allWindows.length > 0 ? allWindows[allWindows.length - 1] : null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getAllWindows(){ | ||||||
|  |     return allWindows; | ||||||
|  | } | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|     createMainWindow, |     createMainWindow, | ||||||
|     createSetupWindow, |     createSetupWindow, | ||||||
|     closeSetupWindow, |     closeSetupWindow, | ||||||
|     registerGlobalShortcuts, |     registerGlobalShortcuts, | ||||||
|     getMainWindow |     getMainWindow, | ||||||
|  |     getLastFocusedWindow, | ||||||
|  |     getAllWindows | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -271,7 +271,8 @@ | |||||||
|     "bookmarks": "书签", |     "bookmarks": "书签", | ||||||
|     "today": "打开今天的日记笔记", |     "today": "打开今天的日记笔记", | ||||||
|     "new-note": "新建笔记", |     "new-note": "新建笔记", | ||||||
|     "show-windows": "显示窗口" |     "show-windows": "显示窗口", | ||||||
|  |     "open_new_window": "打开新窗口" | ||||||
|   }, |   }, | ||||||
|   "migration": { |   "migration": { | ||||||
|     "old_version": "由您当前版本的直接迁移不被支持。请先升级到最新的 v0.60.4 然后再到这个版本。", |     "old_version": "由您当前版本的直接迁移不被支持。请先升级到最新的 v0.60.4 然后再到这个版本。", | ||||||
|   | |||||||
| @@ -272,7 +272,8 @@ | |||||||
|     "bookmarks": "Bookmarks", |     "bookmarks": "Bookmarks", | ||||||
|     "today": "Open today's journal note", |     "today": "Open today's journal note", | ||||||
|     "new-note": "New note", |     "new-note": "New note", | ||||||
|     "show-windows": "Show windows" |     "show-windows": "Show windows", | ||||||
|  |     "open_new_window": "Open new window" | ||||||
|   }, |   }, | ||||||
|   "migration": { |   "migration": { | ||||||
|     "old_version": "Direct migration from your current version is not supported. Please upgrade to the latest v0.60.4 first and only then to this version.", |     "old_version": "Direct migration from your current version is not supported. Please upgrade to the latest v0.60.4 first and only then to this version.", | ||||||
|   | |||||||