import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { appUpdater } from '@electron/service/updater'; import configManager from '@electron/service/config-service'; import logManager from '@electron/service/logger'; import { windowManager } from '@electron/service/window-service'; import { createTranslator } from '@electron/utils'; import { CONFIG_KEYS, MAIN_WIN_SIZE, WINDOW_NAMES } from '@runtime/lib/constants'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; type GatewayStatus = 'connected' | 'disconnected' | 'reconnecting'; let tray: Tray | null = null; let mainWindowRef: BrowserWindow | null = null; let gatewayStatus: GatewayStatus = 'disconnected'; let removeLanguageListener: (() => void) | null = null; let quitHookBound = false; let t: ReturnType = createTranslator(); function getIconsDir(): string { if (app.isPackaged) { return join(process.resourcesPath, 'resources', 'icons'); } return join(app.getAppPath(), 'resources', 'icons'); } function resolveTrayIcon() { const iconsDir = getIconsDir(); const iconPath = process.platform === 'win32' ? join(iconsDir, 'icon.ico') : process.platform === 'darwin' ? join(iconsDir, 'icon.png') : join(iconsDir, '32x32.png'); let icon = nativeImage.createFromPath(iconPath); if (icon.isEmpty()) { icon = nativeImage.createFromPath(join(iconsDir, 'icon.png')); } if (process.platform === 'darwin' && !icon.isEmpty()) { icon.setTemplateImage(true); } return icon; } function getGatewayStatusLabelKey(status: GatewayStatus): string { switch (status) { case 'connected': return 'tray.status.running'; case 'reconnecting': return 'tray.status.restarting'; case 'disconnected': default: return 'tray.status.stopped'; } } function rememberMainWindow(window: BrowserWindow | null | undefined): BrowserWindow | null { if (!window || window.isDestroyed()) { return null; } mainWindowRef = window; return window; } function buildMainWindowUrl(route: string): string { const normalizedHash = route.startsWith('#') ? route.slice(1) : route; if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { const url = new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL); url.pathname = '/'; url.hash = normalizedHash; return url.toString(); } const url = pathToFileURL(join(app.getAppPath(), 'dist', 'index.html')); url.hash = normalizedHash; return url.toString(); } function resolveMainWindow(): BrowserWindow | null { const trackedWindow = rememberMainWindow(mainWindowRef); if (trackedWindow) { return trackedWindow; } const currentWindow = rememberMainWindow(windowManager.get(WINDOW_NAMES.MAIN)); if (currentWindow) { return currentWindow; } return rememberMainWindow(windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE)); } function showMainWindow(): BrowserWindow | null { const mainWindow = resolveMainWindow(); if (!mainWindow) { return null; } if (mainWindow.isMinimized()) { mainWindow.restore(); } if (!mainWindow.isVisible()) { mainWindow.show(); } mainWindow.focus(); return mainWindow; } function navigateMainWindow(route: string): void { const mainWindow = showMainWindow(); if (!mainWindow) { return; } const hashRoute = route.startsWith('#') ? route : `#${route}`; const applyRoute = () => { if (mainWindow.isDestroyed()) { return; } void mainWindow.webContents.executeJavaScript( `window.location.hash = ${JSON.stringify(hashRoute)};`, true, ).catch((error) => { logManager.warn(`Tray navigation fallback reload for ${hashRoute}`, error); void mainWindow.loadURL(buildMainWindowUrl(hashRoute)); }); }; if (mainWindow.webContents.isLoadingMainFrame()) { mainWindow.webContents.once('did-finish-load', applyRoute); return; } applyRoute(); } function toggleMainWindow(): void { const mainWindow = resolveMainWindow(); if (!mainWindow) { return; } if (mainWindow.isVisible()) { mainWindow.hide(); return; } showMainWindow(); } function buildContextMenu() { const gatewayStatusLabel = t(getGatewayStatusLabelKey(gatewayStatus)) ?? gatewayStatus; return Menu.buildFromTemplate([ { label: t('tray.showWindow') ?? 'Show Window', click: () => { showMainWindow(); }, }, { type: 'separator' }, { label: t('tray.gatewayStatus') ?? 'Gateway Status', enabled: false, }, { label: gatewayStatusLabel, type: 'checkbox', checked: gatewayStatus === 'connected', enabled: false, }, { type: 'separator' }, { label: t('tray.quickActions') ?? 'Quick Actions', submenu: [ { label: t('tray.openChat') ?? 'Open Chat', click: () => { navigateMainWindow('/home'); }, }, { label: t('tray.openSettings') ?? 'Open Settings', click: () => { navigateMainWindow('/setting?view=general'); }, }, ], }, { type: 'separator' }, { label: t('tray.checkForUpdates') ?? 'Check for Updates...', click: () => { void appUpdater.checkForUpdates(); }, }, { type: 'separator' }, { label: t('tray.quit') ?? 'Quit ZN-AI', click: () => { app.quit(); }, }, ]); } function refreshTray(): void { if (!tray) { tray = new Tray(resolveTrayIcon()); } const tooltip = [t('tray.tooltip') ?? 'ZN-AI', t(getGatewayStatusLabelKey(gatewayStatus)) ?? gatewayStatus] .filter(Boolean) .join(' - '); tray.setToolTip(tooltip); tray.setContextMenu(buildContextMenu()); tray.removeAllListeners('click'); tray.removeAllListeners('double-click'); tray.on('click', toggleMainWindow); tray.on('double-click', () => { showMainWindow(); }); } function ensureLanguageListener(): void { if (removeLanguageListener) { return; } removeLanguageListener = configManager.onConfigChange((config) => { if (!config[CONFIG_KEYS.LANGUAGE]) { return; } t = createTranslator(); if (tray) { refreshTray(); } }); } export function createTray(mainWindow: BrowserWindow): Tray { rememberMainWindow(mainWindow); ensureLanguageListener(); refreshTray(); if (!quitHookBound) { quitHookBound = true; app.once('before-quit', () => { destroyTray(); quitHookBound = false; }); } return tray as Tray; } export function updateTrayStatus(status: GatewayStatus): void { gatewayStatus = status; if (tray) { refreshTray(); } } export function destroyTray(): void { tray?.destroy(); tray = null; mainWindowRef = null; if (removeLanguageListener) { removeLanguageListener(); removeLanguageListener = null; } }