mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
589 lines
17 KiB
TypeScript
589 lines
17 KiB
TypeScript
// 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<BuildChannel, string> = {
|
|
stable: 'icons-stable',
|
|
canary: 'icons-canary',
|
|
development: 'icons-development',
|
|
};
|
|
const TRAY_POSITION_GUIDS: Record<BuildChannel, string> = {
|
|
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<TrayActionPayload> = [];
|
|
|
|
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<TrayRuntimeStatePayload>, 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<string> {
|
|
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<string>,
|
|
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<Electron.MenuItemConstructorOptions> = [
|
|
{
|
|
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});
|
|
}
|
|
}
|
|
}
|