From be8298af2f001158c1d4150fa15f979f595ad3fe Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Wed, 22 Apr 2026 07:19:53 +0800 Subject: [PATCH] feat: implement tray functionality with status updates and localization support --- electron/gateway/manager.ts | 5 +- electron/locales/messages.ts | 33 +++ electron/main/tray.ts | 286 +++++++++++++++++++++++++ electron/service/tray-service/index.ts | 100 --------- electron/service/updater/index.ts | 25 +-- electron/wins/index.ts | 12 +- 6 files changed, 341 insertions(+), 120 deletions(-) create mode 100644 electron/main/tray.ts delete mode 100644 electron/service/tray-service/index.ts diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index ce1dece..7e0d476 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -1,6 +1,7 @@ import { BrowserWindow } from 'electron'; import { windowManager } from '@electron/service/window-service'; import logManager from '@electron/service/logger'; +import { updateTrayStatus } from '@electron/main/tray'; import type { GatewayEvent, RuntimeRefreshTopic } from './types'; import * as chatHandlers from './handlers/chat'; import * as providerHandlers from './handlers/provider'; @@ -20,15 +21,15 @@ class GatewayManager { private setStatus(status: 'connected' | 'disconnected' | 'reconnecting'): void { this.status = status; + updateTrayStatus(status); this.broadcast({ type: 'gateway:status', status }); } async init(): Promise { if (this.initialized) return; this.initialized = true; - this.status = 'connected'; logManager.info('GatewayManager initialized'); - this.broadcast({ type: 'gateway:status', status: 'connected' }); + this.setStatus('connected'); } async start(): Promise { diff --git a/electron/locales/messages.ts b/electron/locales/messages.ts index 6c41575..a89a1bc 100644 --- a/electron/locales/messages.ts +++ b/electron/locales/messages.ts @@ -31,6 +31,17 @@ export const runtimeLocaleMessages: Record<'en' | 'zh' | 'th', RuntimeMessageTre tray: { tooltip: 'ZN-AI', showWindow: 'Show Window', + gatewayStatus: 'Gateway Status', + status: { + running: 'Running', + stopped: 'Stopped', + restarting: 'Restarting', + }, + quickActions: 'Quick Actions', + openChat: 'Open Chat', + openSettings: 'Open Settings', + checkForUpdates: 'Check for Updates...', + quit: 'Quit ZN-AI', exit: 'Exit', }, }, @@ -62,6 +73,17 @@ export const runtimeLocaleMessages: Record<'en' | 'zh' | 'th', RuntimeMessageTre tray: { tooltip: 'ZN-AI', showWindow: '显示窗口', + gatewayStatus: '网关状态', + status: { + running: '运行中', + stopped: '已停止', + restarting: '重启中', + }, + quickActions: '快捷操作', + openChat: '打开聊天', + openSettings: '打开设置', + checkForUpdates: '检查更新...', + quit: '退出 ZN-AI', exit: '退出', }, }, @@ -93,6 +115,17 @@ export const runtimeLocaleMessages: Record<'en' | 'zh' | 'th', RuntimeMessageTre tray: { tooltip: 'ZN-AI', showWindow: 'แสดงหน้าต่าง', + gatewayStatus: 'สถานะเกตเวย์', + status: { + running: 'กำลังทำงาน', + stopped: 'หยุดทำงาน', + restarting: 'กำลังรีสตาร์ต', + }, + quickActions: 'การดำเนินการด่วน', + openChat: 'เปิดแชท', + openSettings: 'เปิดการตั้งค่า', + checkForUpdates: 'ตรวจสอบการอัปเดต...', + quit: 'ออกจาก ZN-AI', exit: 'ออก', }, }, diff --git a/electron/main/tray.ts b/electron/main/tray.ts new file mode 100644 index 0000000..043f9f3 --- /dev/null +++ b/electron/main/tray.ts @@ -0,0 +1,286 @@ +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; + } +} diff --git a/electron/service/tray-service/index.ts b/electron/service/tray-service/index.ts deleted file mode 100644 index 000f67f..0000000 --- a/electron/service/tray-service/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Tray, Menu, ipcMain, app } from 'electron' -import { createTranslator, createLogo } from '@electron/utils' -import { CONFIG_KEYS, IPC_EVENTS, WINDOW_NAMES, MAIN_WIN_SIZE } from '@runtime/lib/constants' - -import logManager from '@electron/service/logger' -// TODO: shortcutManager -import windowManager from '@electron/service/window-service' -import configManager from '@electron/service/config-service' - -let t: ReturnType = createTranslator(); - -class TrayService { - private static _instance: TrayService; - private _tray: Tray | null = null; - private _removeLanguageListener?: () => void; - - private _setupLanguageChangeListener() { - this._removeLanguageListener = configManager.onConfigChange((config) => { - if (!config[CONFIG_KEYS.LANGUAGE]) return; - - // 切换语言后,重新创建翻译器 - t = createTranslator(); - - - if (this._tray) { - this._updateTray(); - } - }) - } - - private _updateTray() { - if (!this._tray) { - this._tray = new Tray(createLogo()); - } - - const showWindow = () => { - const mainWindow = windowManager.get(WINDOW_NAMES.MAIN); - - if (mainWindow && !mainWindow?.isDestroyed() && mainWindow?.isVisible() && !mainWindow?.isFocused()) { - return mainWindow.focus(); - } - - if (mainWindow?.isMinimized()) { - return mainWindow?.restore(); - } - - if (mainWindow?.isVisible() && mainWindow?.isFocused()) return; - - windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE); - } - - this._tray.setToolTip(t('tray.tooltip') ?? 'Diona Application'); - - // TODO: 依赖快捷键Service - this._tray.setContextMenu(Menu.buildFromTemplate([ - { label: t('tray.showWindow'), accelerator: 'CmdOrCtrl+N', click: showWindow }, - { type: 'separator' }, - { label: t('settings.title'), click: () => ipcMain.emit(`${IPC_EVENTS.OPEN_WINDOW}:${WINDOW_NAMES.SETTING}`) }, - { role: 'quit', label: t('tray.exit') } - ])); - - this._tray.removeAllListeners('click'); - this._tray.on('click', showWindow); - } - - private constructor() { - this._setupLanguageChangeListener(); - logManager.info('TrayService initialized successfully.'); - } - - public static getInstance() { - if (!this._instance) { - this._instance = new TrayService(); - } - return this._instance; - } - - public create() { - if (this._tray) return; - this._updateTray(); - app.on('quit', () => { - this.destroy(); - //TODO: 移除快捷键 - }) - } - - public destroy() { - this._tray?.destroy(); - this._tray = null; - //TODO: 移除快捷键 - if (this._removeLanguageListener) { - this._removeLanguageListener(); - this._removeLanguageListener = void 0; - } - } -} - -export const trayManager = TrayService.getInstance(); -export default trayManager; - diff --git a/electron/service/updater/index.ts b/electron/service/updater/index.ts index 26e873f..1b80371 100644 --- a/electron/service/updater/index.ts +++ b/electron/service/updater/index.ts @@ -47,18 +47,7 @@ export class AppUpdater { } private registerHandlers() { - ipcMain.handle(IPC_EVENTS.UPDATE_CHECK, () => { - if (app.isPackaged) { - return autoUpdater.checkForUpdates(); - } else { - // 在开发环境下模拟 - this.sendToRenderer(IPC_EVENTS.UPDATE_STATUS_CHANGED, { status: 'checking' }); - setTimeout(() => { - this.sendToRenderer(IPC_EVENTS.UPDATE_STATUS_CHANGED, { status: 'not-available' }); - }, 1500); - return null; - } - }); + ipcMain.handle(IPC_EVENTS.UPDATE_CHECK, () => this.checkForUpdates()); ipcMain.handle(IPC_EVENTS.UPDATE_DOWNLOAD, () => { if (app.isPackaged) { @@ -78,6 +67,18 @@ export class AppUpdater { return app.getVersion(); }); } + + public checkForUpdates() { + if (app.isPackaged) { + return autoUpdater.checkForUpdates(); + } + + this.sendToRenderer(IPC_EVENTS.UPDATE_STATUS_CHANGED, { status: 'checking' }); + setTimeout(() => { + this.sendToRenderer(IPC_EVENTS.UPDATE_STATUS_CHANGED, { status: 'not-available' }); + }, 1500); + return null; + } } export const appUpdater = AppUpdater.getInstance(); diff --git a/electron/wins/index.ts b/electron/wins/index.ts index 45004e6..0db4f66 100644 --- a/electron/wins/index.ts +++ b/electron/wins/index.ts @@ -5,17 +5,17 @@ import { windowManager } from '@electron/service/window-service' import { menuManager } from '@electron/service/menu-service' import { logManager } from '@electron/service/logger' import { configManager } from '@electron/service/config-service' -import { trayManager } from '@electron/service/tray-service' import { TabManager } from '@service/tab-manager' import { registerWindowHandlers } from '@electron/ipc/window-handlers' +import { createTray, destroyTray } from '@electron/main/tray' -const handleTray = (minimizeToTray: boolean) => { +const handleTray = (minimizeToTray: boolean, mainWindow: BrowserWindow) => { if (minimizeToTray) { - trayManager.create(); + createTray(mainWindow); return; } - trayManager.destroy(); + destroyTray(); } const registerMenus = (window: BrowserWindow) => { @@ -105,10 +105,10 @@ export function setupMainWindow() { configManager.onConfigChange((config) => { if (minimizeToTray === config[CONFIG_KEYS.MINIMIZE_TO_TRAY]) return; minimizeToTray = config[CONFIG_KEYS.MINIMIZE_TO_TRAY]; - handleTray(minimizeToTray); + handleTray(minimizeToTray, mainWindow); }); - handleTray(minimizeToTray); + handleTray(minimizeToTray, mainWindow); registerMenus(mainWindow); registerWindowHandlers(mainWindow);