// SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; import path from 'node:path'; import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel'; import {getDesktopWindowBehaviorSettings} from '@electron/common/DesktopConfig'; import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity'; import {createChildLogger} from '@electron/common/Logger'; import type { TrayPresenceStatus as SharedTrayPresenceStatus, TrayActionPayload, TrayRuntimeStatePayload, } from '@electron/common/Types'; import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate'; import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt'; import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {onLocaleChange, t} from '@electron/main/MainI18n'; import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron'; const logger = createChildLogger('DesktopTray'); const ICON_DIR_NAMES: Record = { stable: 'icons-stable', canary: 'icons-canary', development: 'icons-development', }; const TRAY_POSITION_GUIDS: Record = { stable: '11c70c9f-a35d-4328-9040-f722dc5fa0a0', canary: '1a39981b-b4cc-46a4-8f7e-9fce187110f5', development: 'd0070fe2-067b-419c-af26-6fd197700d63', }; const APP_NAME = DESKTOP_APP_NAME; const ICON_DIR_NAME = ICON_DIR_NAMES[BUILD_CHANNEL]; const TRAY_POSITION_GUID = TRAY_POSITION_GUIDS[BUILD_CHANNEL]; interface DesktopTrayController { createWindow: () => BrowserWindow; getMainWindow: () => BrowserWindow | null; isMainWindowTakenOver: () => boolean; hideWindow: () => void; setQuitting: (quitting: boolean) => void; showWindow: () => void; } let controller: DesktopTrayController | null = null; let tray: Tray | null = null; let initialTrayDesired: boolean | null = null; let linuxTrayCreatedInProcess = false; let linuxTrayRecreateWarningLogged = false; let trayRestartRequested = false; let pendingTrayMenuRefresh: NodeJS.Timeout | null = null; let pendingTrayActionFlush: NodeJS.Timeout | null = null; let trayActionBridgeWebContentsId: number | null = null; let trayForcedExitTimer: NodeJS.Timeout | null = null; let pendingTrayActions: Array = []; const BIDI_FIRST_STRONG_ISOLATE = '\u2068'; const BIDI_POP_DIRECTIONAL_ISOLATE = '\u2069'; const MAX_PENDING_TRAY_ACTIONS = 16; const LINUX_TRAY_MENU_ACTION_DELAY_MS = 25; const TRAY_FORCED_EXIT_DELAY_MS = 5000; type TrayPresenceStatus = SharedTrayPresenceStatus; const trayState: TrayRuntimeStatePayload = { voiceConnected: false, voiceChannelLabel: null, selfMute: false, selfDeaf: false, presenceStatus: null, buildInfo: null, }; export function updateTrayRuntimeState(update: Partial, webContentsId?: number): void { if (webContentsId !== undefined) { trayActionBridgeWebContentsId = webContentsId; } if (typeof update.buildInfo === 'string' && update.buildInfo !== trayState.buildInfo) { logger.info(`The renderer reported its build: ${update.buildInfo}`); } Object.assign(trayState, update); refreshDesktopTrayMenu(); schedulePendingTrayActionFlush(); } function schedulePendingTrayActionFlush(): void { if (pendingTrayActionFlush) return; pendingTrayActionFlush = setTimeout(() => { pendingTrayActionFlush = null; flushPendingTrayActions(); }, 0); pendingTrayActionFlush.unref?.(); } function getLiveMainWindow(): BrowserWindow | null { const mainWindow = controller?.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) { return null; } return mainWindow; } function canSendTrayAction(mainWindow: BrowserWindow): boolean { const {webContents} = mainWindow; return ( !webContents.isDestroyed() && trayActionBridgeWebContentsId === webContents.id && !webContents.isLoadingMainFrame() && Boolean(webContents.getURL()) ); } function sendTrayAction(mainWindow: BrowserWindow, payload: TrayActionPayload): boolean { try { mainWindow.webContents.send('tray-action', payload); return true; } catch (error) { logger.warn('Failed to send tray action to renderer', {action: payload.action, error}); return false; } } function queueTrayAction(payload: TrayActionPayload): void { if (payload.action === 'set-status') { pendingTrayActions = pendingTrayActions.filter((action) => action.action !== 'set-status'); } pendingTrayActions.push(payload); if (pendingTrayActions.length > MAX_PENDING_TRAY_ACTIONS) { const dropped = pendingTrayActions.shift(); logger.warn('Dropped oldest pending tray action because the renderer bridge is not ready', { action: dropped?.action, limit: MAX_PENDING_TRAY_ACTIONS, }); } const mainWindow = getLiveMainWindow(); if (!mainWindow) { ensureMainWindowVisible(); return; } mainWindow.webContents.once('did-finish-load', schedulePendingTrayActionFlush); } function flushPendingTrayActions(): void { if (pendingTrayActions.length === 0) return; const mainWindow = getLiveMainWindow(); if (!mainWindow || !canSendTrayAction(mainWindow)) return; const actions = pendingTrayActions; pendingTrayActions = []; for (let index = 0; index < actions.length; index += 1) { const action = actions[index]; if (!sendTrayAction(mainWindow, action)) { pendingTrayActions.unshift(...actions.slice(index)); return; } } } function dispatchTrayAction(payload: TrayActionPayload): void { const mainWindow = getLiveMainWindow(); if (!mainWindow || !canSendTrayAction(mainWindow)) { logger.debug('Queueing tray action until the renderer bridge is ready', {action: payload.action}); queueTrayAction(payload); return; } if (!sendTrayAction(mainWindow, payload)) { queueTrayAction(payload); } } export function hasActiveDesktopTray(): boolean { return Boolean(tray && !tray.isDestroyed()); } function getCandidateIconDirs(): Array { const candidates = [ path.join(process.resourcesPath, 'icons'), path.join(process.resourcesPath, ICON_DIR_NAME), process.resourcesPath, path.join(app.getAppPath(), 'build_resources', ICON_DIR_NAME), path.resolve(process.cwd(), 'build_resources', ICON_DIR_NAME), path.resolve(process.cwd(), 'fluxer_desktop', 'build_resources', ICON_DIR_NAME), path.dirname(app.getPath('exe')), ]; return [...new Set(candidates)]; } function createTrayImage( fileNames: Array, options?: { resize?: { width: number; height: number; }; template?: boolean; }, ): Electron.NativeImage | null { for (const dir of getCandidateIconDirs()) { for (const fileName of fileNames) { const candidate = path.join(dir, fileName); if (!fs.existsSync(candidate)) { continue; } let image = nativeImage.createFromPath(candidate); if (image.isEmpty()) { logger.warn('Tray icon candidate could not be decoded', {candidate}); continue; } if (options?.resize) { image = image.resize(options.resize); } if (options?.template) { image.setTemplateImage(true); } return image; } } return null; } function createTrayIcon(): Electron.NativeImage | null { if (process.platform === 'win32') { return ( createTrayImage(['16x16.png'], {resize: {width: 16, height: 16}}) ?? createTrayImage(['32x32.png'], {resize: {width: 16, height: 16}}) ?? createTrayImage(['icon.ico', 'icon.png']) ); } if (process.platform === 'darwin') { return ( createTrayImage(['FluxerTrayTemplate.png'], { resize: {width: 16, height: 16}, template: true, }) ?? createTrayImage(['32x32.png', '16x16.png', '24x24.png', 'icon.png'], { resize: {width: 16, height: 16}, template: true, }) ); } return createTrayImage(['256x256.png', '512x512.png', '128x128.png', '64x64.png', '48x48.png', 'icon.png']); } function ensureMainWindowVisible(): void { if (!controller) return; const mainWindow = controller.getMainWindow(); if ((!mainWindow || mainWindow.isDestroyed()) && !controller.isMainWindowTakenOver()) { controller.createWindow(); } controller.showWindow(); refreshDesktopTrayMenu(); } function toggleMainWindow(): void { if (!controller) return; const mainWindow = controller.getMainWindow(); if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible() && !mainWindow.isMinimized()) { controller.hideWindow(); } else { ensureMainWindowVisible(); } refreshDesktopTrayMenu(); } function armTrayForcedExit(reason: 'quit' | 'restart'): void { if (trayForcedExitTimer) return; trayForcedExitTimer = setTimeout(() => { logger.warn('Tray shutdown did not complete before the forced-exit timeout', { reason, timeoutMs: TRAY_FORCED_EXIT_DELAY_MS, }); app.exit(0); }, TRAY_FORCED_EXIT_DELAY_MS); trayForcedExitTimer.unref?.(); } function quitFromTray(): void { if (!controller) { destroyDesktopTray(); app.quit(); armTrayForcedExit('quit'); return; } controller.setQuitting(true); destroyDesktopTray(); app.quit(); armTrayForcedExit('quit'); } function restartFromTray(): void { if (trayRestartRequested) return; trayRestartRequested = true; controller?.setQuitting(true); destroyDesktopTray(); relaunchStableLaunchPath(); app.quit(); armTrayForcedExit('restart'); } function runTrayMenuAction(action: () => void): void { const run = (): void => { try { action(); } catch (error) { logger.error('Tray menu action failed', {error}); } }; if (process.platform !== 'linux') { run(); return; } const timeout = setTimeout(run, LINUX_TRAY_MENU_ACTION_DELAY_MS); timeout.unref?.(); } function isolateUserText(value: string): string { return `${BIDI_FIRST_STRONG_ISOLATE}${value}${BIDI_POP_DIRECTIONAL_ISOLATE}`; } function buildTrayMenu(): Menu { const mainWindow = controller?.getMainWindow() ?? null; const visible = Boolean( mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible() && !mainWindow.isMinimized(), ); const statusItem = (label: string, status: TrayPresenceStatus): Electron.MenuItemConstructorOptions => ({ label, type: 'radio', checked: trayState.presenceStatus === status, click: () => { runTrayMenuAction(() => { trayState.presenceStatus = status; refreshDesktopTrayMenu(); dispatchTrayAction({action: 'set-status', status}); }); }, }); const menuTemplate: Array = [ { label: visible ? t('desktop.tray.hide', {appName: APP_NAME}) : t('desktop.tray.show', {appName: APP_NAME}), click: () => { runTrayMenuAction(() => { if (visible) { controller?.hideWindow(); refreshDesktopTrayMenu(); } else { ensureMainWindowVisible(); } }); }, }, { label: t('desktop.tray.openSettings'), click: () => { runTrayMenuAction(() => { ensureMainWindowVisible(); const mainWindow = controller?.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; const {webContents} = mainWindow; if (webContents.isLoading() || !webContents.getURL()) { webContents.once('did-finish-load', () => { if (!mainWindow.isDestroyed()) webContents.send('open-settings'); }); } else { webContents.send('open-settings'); } }); }, }, {type: 'separator'}, { label: t('desktop.tray.status'), submenu: [ statusItem(t('desktop.tray.statusOnline'), 'online'), statusItem(t('desktop.tray.statusIdle'), 'idle'), statusItem(t('desktop.tray.statusDnd'), 'dnd'), statusItem(t('desktop.tray.statusInvisible'), 'invisible'), ], }, ]; if (trayState.voiceConnected) { menuTemplate.push({type: 'separator'}); menuTemplate.push({ label: t(trayState.selfMute ? 'desktop.tray.unmuteMic' : 'desktop.tray.muteMic'), click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'toggle-mute'})), }); menuTemplate.push({ label: t(trayState.selfDeaf ? 'desktop.tray.undeafen' : 'desktop.tray.deafen'), click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'toggle-deafen'})), }); menuTemplate.push({ label: trayState.voiceChannelLabel ? t('desktop.tray.disconnectFrom', {channel: isolateUserText(trayState.voiceChannelLabel)}) : t('desktop.tray.disconnectVoice'), click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'disconnect-voice'})), }); } menuTemplate.push({type: 'separator'}); if (getDesktopUpdateState().available) { menuTemplate.push({ label: t('desktop.tray.updateNow', {appName: APP_NAME}), click: () => runTrayMenuAction(startDesktopUpdateFromShell), }); } if (!areModuleUpdatesDisabled()) { menuTemplate.push({ label: t('desktop.tray.checkForUpdates'), click: () => runTrayMenuAction(() => { void checkForUpdatesFromShell(); }), }); } if (trayState.buildInfo) { menuTemplate.push({ label: t('desktop.tray.copyBuildInfo'), click: () => { runTrayMenuAction(() => { void clipboard.writeText(trayState.buildInfo ?? '').catch((error) => { logger.error('Failed to copy build info to the clipboard', {error}); }); }); }, }); } menuTemplate.push({type: 'separator'}); menuTemplate.push({ label: t('desktop.tray.restart', {appName: APP_NAME}), click: () => runTrayMenuAction(restartFromTray), }); menuTemplate.push({ label: t('desktop.tray.quit', {appName: APP_NAME}), click: () => runTrayMenuAction(quitFromTray), }); return Menu.buildFromTemplate(menuTemplate); } function createTray(): Tray | null { const trayIcon = createTrayIcon(); if (!trayIcon) { logger.warn('Tray icon asset could not be resolved; tray disabled'); return null; } try { const nextTray = process.platform === 'darwin' ? new Tray(trayIcon, TRAY_POSITION_GUID) : new Tray(trayIcon); nextTray.setToolTip(APP_NAME); nextTray.setContextMenu(buildTrayMenu()); if (process.platform !== 'darwin') { nextTray.on('click', toggleMainWindow); } nextTray.on('double-click', ensureMainWindowVisible); if (process.platform === 'linux') { linuxTrayCreatedInProcess = true; linuxTrayRecreateWarningLogged = false; } return nextTray; } catch (error) { logger.error('Failed to create tray icon:', error); return null; } } function canCreateTray(lifecycleLocked: boolean): boolean { if (lifecycleLocked) return false; if (process.platform !== 'linux' || !linuxTrayCreatedInProcess) return true; if (!linuxTrayRecreateWarningLogged) { linuxTrayRecreateWarningLogged = true; logger.warn('Suppressing Linux tray recreation after a tray was already created in this process'); } return false; } function setCurrentTrayContextMenu(): void { if (!tray || tray.isDestroyed()) return; try { tray.setContextMenu(buildTrayMenu()); } catch (error) { logger.warn('Failed to refresh desktop tray context menu', {error}); } } export function refreshDesktopTrayMenu(): void { if (!tray || tray.isDestroyed()) return; if (process.platform !== 'linux') { setCurrentTrayContextMenu(); return; } if (pendingTrayMenuRefresh) return; pendingTrayMenuRefresh = setTimeout(() => { pendingTrayMenuRefresh = null; setCurrentTrayContextMenu(); }, 0); pendingTrayMenuRefresh.unref?.(); } function revealHiddenWindowIfTrayUnavailable(): void { if (hasActiveDesktopTray() || !controller) return; const mainWindow = controller.getMainWindow(); if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { controller.showWindow(); } } export function applyDesktopWindowBehaviorSettings(): void { const settings = getDesktopWindowBehaviorSettings(); if (initialTrayDesired === null) { initialTrayDesired = settings.showTrayIcon; } const lifecycleLocked = process.platform === 'linux' && settings.showTrayIcon !== initialTrayDesired; if (!settings.showTrayIcon) { if (!lifecycleLocked) { if (tray && !tray.isDestroyed()) { tray.destroy(); } tray = null; } revealHiddenWindowIfTrayUnavailable(); return; } if (!tray || tray.isDestroyed()) { if (canCreateTray(lifecycleLocked)) { tray = createTray(); } } else { const nextTrayIcon = createTrayIcon(); if (nextTrayIcon) { tray.setImage(nextTrayIcon); } else { logger.warn('Tray icon asset could not be reloaded; keeping existing tray image'); } tray.setToolTip(APP_NAME); } refreshDesktopTrayMenu(); revealHiddenWindowIfTrayUnavailable(); } export function desktopTrayChangePendingRestart(): boolean { if (process.platform !== 'linux' || initialTrayDesired === null) return false; return getDesktopWindowBehaviorSettings().showTrayIcon !== initialTrayDesired; } let trayLocaleSubscribed = false; export function initializeDesktopTray(nextController: DesktopTrayController): void { controller = nextController; applyDesktopWindowBehaviorSettings(); if (!trayLocaleSubscribed) { trayLocaleSubscribed = true; onLocaleChange(() => { if (tray && !tray.isDestroyed()) { tray.setToolTip(APP_NAME); } refreshDesktopTrayMenu(); }); observeDesktopUpdateState(refreshDesktopTrayMenu); } } export function destroyDesktopTray(): void { const existingTray = tray; tray = null; controller = null; trayActionBridgeWebContentsId = null; pendingTrayActions = []; if (pendingTrayMenuRefresh) { clearTimeout(pendingTrayMenuRefresh); pendingTrayMenuRefresh = null; } if (pendingTrayActionFlush) { clearTimeout(pendingTrayActionFlush); pendingTrayActionFlush = null; } if (existingTray && !existingTray.isDestroyed()) { try { existingTray.removeAllListeners(); } catch (error) { logger.warn('Failed to remove desktop tray listeners cleanly', {error}); } try { existingTray.setContextMenu(null); } catch (error) { logger.warn('Failed to clear desktop tray context menu cleanly', {error}); } try { existingTray.destroy(); } catch (error) { logger.warn('Failed to destroy desktop tray icon cleanly', {error}); } } }