diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449a4..1986722eb3f 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -4,8 +4,12 @@ ## Improvements +- On Linux, turning off "Minimize to tray" now offers to restart Folo so the tray icon is actually removed + ## No longer broken +- Fixed the system tray icon piling up on Linux (waybar, KDE Plasma) every time "Minimize to tray" was toggled, leaving dead icons whose menus did nothing + ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributor @jing2uo for fixing the Linux system tray icon diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index b5b2ef88b2a..1f189b91e8c 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -128,6 +128,12 @@ export class AppService extends IpcService { quitAndInstall() } + @IpcMethod() + relaunch(): void { + app.relaunch() + app.exit(0) + } + @IpcMethod() readClipboard(): string { return clipboard.readText() diff --git a/apps/desktop/layer/main/src/ipc/services/setting.ts b/apps/desktop/layer/main/src/ipc/services/setting.ts index 5ade827d595..5747adf826f 100644 --- a/apps/desktop/layer/main/src/ipc/services/setting.ts +++ b/apps/desktop/layer/main/src/ipc/services/setting.ts @@ -59,8 +59,8 @@ export class SettingService extends IpcService { } @IpcMethod() - setMinimizeToTray(minimize: boolean): void { - setTrayConfig(minimize) + setMinimizeToTray(minimize: boolean): boolean { + return setTrayConfig(minimize) } @IpcMethod() diff --git a/apps/desktop/layer/main/src/lib/tray.test.ts b/apps/desktop/layer/main/src/lib/tray.test.ts index 8ff44715069..2ca54235cff 100644 --- a/apps/desktop/layer/main/src/lib/tray.test.ts +++ b/apps/desktop/layer/main/src/lib/tray.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => { isMacOS: false, isMAS: false, isWindows: false, + isLinux: false, } return { @@ -113,6 +114,7 @@ describe("tray", () => { mocks.env.isMacOS = false mocks.env.isMAS = false mocks.env.isWindows = false + mocks.env.isLinux = false mocks.getBadgeCount.mockReturnValue(0) mocks.store.get.mockReturnValue(true) mocks.trayInstances.length = 0 @@ -128,4 +130,32 @@ describe("tray", () => { expect(mocks.trayInstances[0]!.destroy).not.toHaveBeenCalled() expect(mocks.trayInstances[0]!.setContextMenu).toHaveBeenCalledTimes(2) }) + + it("destroys the native tray when disabled off Linux", async () => { + const { registerAppTray, setTrayConfig } = await import("./tray") + + registerAppTray() + const needsRestart = setTrayConfig(false) + + expect(mocks.trayInstances[0]!.destroy).toHaveBeenCalledTimes(1) + expect(needsRestart).toBe(false) + }) + + it("keeps the native tray on Linux when disabled and reports a restart is needed", async () => { + // Electron's Tray.destroy() can't remove a StatusNotifierItem icon while the + // process runs, so recreating it would stack a dead icon. Keep the instance + // and let the caller offer a restart instead. + mocks.env.isLinux = true + const { registerAppTray, setTrayConfig } = await import("./tray") + + registerAppTray() + const needsRestart = setTrayConfig(false) + + expect(mocks.trayInstances[0]!.destroy).not.toHaveBeenCalled() + expect(needsRestart).toBe(true) + + // Re-enabling reuses the same instance rather than creating a second one. + setTrayConfig(true) + expect(mocks.trayInstances).toHaveLength(1) + }) }) diff --git a/apps/desktop/layer/main/src/lib/tray.ts b/apps/desktop/layer/main/src/lib/tray.ts index b731e4f7ebb..49989ed5ba8 100644 --- a/apps/desktop/layer/main/src/lib/tray.ts +++ b/apps/desktop/layer/main/src/lib/tray.ts @@ -1,7 +1,7 @@ import { name } from "@pkg" import { app, Menu, nativeImage, Tray } from "electron" -import { isMacOS, isMAS, isWindows } from "~/env" +import { isLinux, isMacOS, isMAS, isWindows } from "~/env" import { getTrayIconPath } from "~/helper" import { logger, revealLogFile } from "~/logger" import { WindowManager } from "~/manager/window" @@ -115,21 +115,48 @@ const showWindow = () => { } const destroyAppTray = () => { - if (tray) { - tray.destroy() - tray = null - } + if (!tray) return + + // On Linux, `Tray.destroy()` does not remove the icon from StatusNotifierItem + // trays (waybar, KDE Plasma, …). Chromium's StatusIconLinuxDbus un-exports its + // own D-Bus objects but never tells `org.kde.StatusNotifierWatcher` the item + // is gone, and the item is registered by object path on the process-wide + // session-bus connection whose name outlives the tray — so the host gets no + // `NameOwnerChanged` and keeps a dead icon. Toggling `minimizeToTray` back on + // then stacks a *second* icon (Chromium bumps its global StatusNotifierItem + // id), with only the newest menu wired to live handlers. The whole pile only + // clears when the app fully exits. + // + // Keep the single Tray instance for the app's lifetime on Linux. The window + // close handler reads `getTrayConfig()` on every close, so a tray icon that + // outlives a disabled setting is inert; the renderer asks the user to restart + // to actually remove it (see `setTrayConfig`'s return value). + // + // Refs: #3940, #4985, #3207 + if (isLinux) return + + tray.destroy() + tray = null } const DEFAULT_MINIMIZE_TO_TRAY = false export const getTrayConfig = () => store.get("minimizeToTray") ?? DEFAULT_MINIMIZE_TO_TRAY -export const setTrayConfig = (input: boolean) => { +/** + * @returns `true` when the change could not be applied to the tray icon live and + * the app must be restarted for it to take effect (Linux disabling only). + */ +export const setTrayConfig = (input: boolean): boolean => { store.set("minimizeToTray", input) if (input) { registerAppTray() - } else { - destroyAppTray() + return false } + + // `destroyAppTray()` can't remove the icon on Linux (see above); report that a + // restart is needed so the renderer can offer it. + const needsRestart = isLinux && tray !== null + destroyAppTray() + return needsRestart } diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts b/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts index 843cc22e607..077f222e456 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts @@ -1,6 +1,8 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { atom, useAtomValue, useSetAtom } from "jotai" import { useCallback } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" import { ipcServices } from "~/lib/client" @@ -19,12 +21,25 @@ export const useMinimizeToTrayValue = () => useAtomValue(minimizeToTrayAtom) export const useSetMinimizeToTray = () => { const setMinimizeToTray = useSetAtom(minimizeToTrayAtom) + const { t } = useTranslation("settings") return useCallback( (value: boolean) => { if (!IN_ELECTRON) return setMinimizeToTray(value) - ipcServices?.setting.setMinimizeToTray(value) + void (async () => { + // On Linux, disabling the tray can't remove the icon from the running + // process (Electron limitation). The main process tells us so; offer a + // restart. + const needsRestart = await ipcServices?.setting.setMinimizeToTray(value) + if (!needsRestart) return + toast(t("general.minimize_to_tray.restart_to_remove"), { + action: { + label: t("general.minimize_to_tray.restart_now"), + onClick: () => ipcServices?.app.relaunch(), + }, + }) + })() }, - [setMinimizeToTray], + [setMinimizeToTray, t], ) } diff --git a/locales/settings/en.json b/locales/settings/en.json index 6a759588bd7..61ec674acd4 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -344,6 +344,8 @@ "general.mark_as_read.title": "Mark as read", "general.minimize_to_tray.description": "Minimize to system tray when closing window.", "general.minimize_to_tray.label": "Minimize to tray", + "general.minimize_to_tray.restart_now": "Restart now", + "general.minimize_to_tray.restart_to_remove": "The tray icon will be removed after Folo restarts.", "general.network": "Network", "general.open_links_in_external_app.label": "Open links in external app", "general.privacy": "Privacy", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 6f749b4a138..1096bd1f85c 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -344,6 +344,8 @@ "general.mark_as_read.title": "标记已读", "general.minimize_to_tray.description": "关闭窗口时最小化到系统托盘。", "general.minimize_to_tray.label": "最小化到托盘", + "general.minimize_to_tray.restart_now": "立即重启", + "general.minimize_to_tray.restart_to_remove": "重启 Folo 后托盘图标才会移除。", "general.network": "网络", "general.open_links_in_external_app.label": "在外部应用内打开链接", "general.privacy": "隐私",