Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/desktop/changelog/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions apps/desktop/layer/main/src/ipc/services/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export class AppService extends IpcService {
quitAndInstall()
}

@IpcMethod()
relaunch(): void {
app.relaunch()
app.exit(0)
}

@IpcMethod()
readClipboard(): string {
return clipboard.readText()
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/layer/main/src/ipc/services/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export class SettingService extends IpcService {
}

@IpcMethod()
setMinimizeToTray(minimize: boolean): void {
setTrayConfig(minimize)
setMinimizeToTray(minimize: boolean): boolean {
return setTrayConfig(minimize)
}

@IpcMethod()
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/layer/main/src/lib/tray.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => {
isMacOS: false,
isMAS: false,
isWindows: false,
isLinux: false,
}

return {
Expand Down Expand Up @@ -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
Expand All @@ -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)
})
})
43 changes: 35 additions & 8 deletions apps/desktop/layer/main/src/lib/tray.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
19 changes: 17 additions & 2 deletions apps/desktop/layer/renderer/src/hooks/biz/useTraySetting.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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],
)
}
2 changes: 2 additions & 0 deletions locales/settings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/settings/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "隐私",
Expand Down
Loading