From 22f1e79aca7ac88de240a156b461632388ac8a6f Mon Sep 17 00:00:00 2001 From: Sline Date: Sat, 4 Oct 2025 21:54:25 +0800 Subject: [PATCH] feat(tray): electron tray (#635) --- apps/stage-tamagotchi/src/main/index.ts | 86 ++++++++++++++++++++-- apps/stage-tamagotchi/src/renderer/App.vue | 12 ++- apps/stage-tamagotchi/src/shared/eventa.ts | 1 + 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 7b86d049f..88e731aa9 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -6,18 +6,22 @@ import { fileURLToPath } from 'node:url' import { electronApp, is, optimizer } from '@electron-toolkit/utils' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel } from '@guiiai/logg' -import { defineInvokeHandler } from '@unbird/eventa' +import { defineInvoke, defineInvokeHandler } from '@unbird/eventa' import { createContext } from '@unbird/eventa/adapters/electron/main' -import { app, BrowserWindow, ipcMain, screen, shell } from 'electron' +import { app, BrowserWindow, ipcMain, Menu, screen, shell, Tray } from 'electron' import { isMacOS } from 'std-env' import icon from '../../resources/icon.png?asset' -import { electronCursorPoint, electronStartTrackingCursorPoint } from '../shared/eventa' +import { electronCursorPoint, electronOpenSettings, electronStartTrackingCursorPoint } from '../shared/eventa' setGlobalFormat(Format.Pretty) setGlobalLogLevel(LogLevel.Log) +// Store the eventa context and invokers to reuse them +let eventaContext: ReturnType | null = null +let openSettingsInvoker: ReturnType> | null = null + if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222') if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) { @@ -30,10 +34,12 @@ if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { app.dock?.setIcon(icon) +let mainWindow: BrowserWindow | null = null +let appTray: Tray | null = null let trackCursorPointInterval: NodeJS.Timeout | undefined function createWindow(): void { - const mainWindow = new BrowserWindow({ + mainWindow = new BrowserWindow({ title: 'AIRI', width: 916.0, height: 1245.0, @@ -49,7 +55,8 @@ function createWindow(): void { hasShadow: false, }) - const { context } = createContext(ipcMain, mainWindow) + eventaContext = createContext(ipcMain, mainWindow) + const { context } = eventaContext defineInvokeHandler(context, electronStartTrackingCursorPoint, () => { trackCursorPointInterval = setInterval(() => { @@ -60,6 +67,9 @@ function createWindow(): void { }, 32) }) + // Create the openSettings invoker once and store it for reuse + openSettingsInvoker = defineInvoke(context, electronOpenSettings) + mainWindow.setAlwaysOnTop(true) if (isMacOS) { mainWindow.setWindowButtonVisibility(false) @@ -80,6 +90,63 @@ function createWindow(): void { } } +function createTray(): void { + if (appTray) { + return + } + + const showMainWindow = (): void => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + } + } + + // Create tray icon + appTray = new Tray(icon) + + // Define tray menu + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show Window', + click: showMainWindow, + }, + { type: 'separator' }, + { + label: 'Settings', + click: () => { + if (mainWindow) { + showMainWindow() + // Send the open settings command using the pre-created invoker + if (openSettingsInvoker) { + openSettingsInvoker(undefined) + } + } + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + app.quit() + }, + }, + ]) + + // Set tray properties + appTray.setContextMenu(contextMenu) + appTray.setToolTip('Project AIRI') + appTray.addListener('click', showMainWindow) + + // On macOS, there's a special double-click event + if (platform === 'darwin') { + appTray.addListener('double-click', showMainWindow) + } +} + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. @@ -127,6 +194,7 @@ app.whenReady().then(() => { app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window)) createWindow() + createTray() }).catch((err) => { console.error('Error during app initialization:', err) }) @@ -143,3 +211,11 @@ app.on('window-all-closed', () => { app.quit() } }) + +// Clean up tray when app quits +app.on('before-quit', () => { + if (appTray) { + appTray.destroy() + appTray = null + } +}) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 69bbd5be0..89954e341 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -2,14 +2,14 @@ import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models' import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' import { useSettings } from '@proj-airi/stage-ui/stores/settings' -import { defineInvoke } from '@unbird/eventa' +import { defineInvoke, defineInvokeHandler } from '@unbird/eventa' import { createContext } from '@unbird/eventa/adapters/electron/renderer' import { storeToRefs } from 'pinia' import { onMounted, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { RouterView } from 'vue-router' +import { RouterView, useRouter } from 'vue-router' -import { electronStartTrackingCursorPoint } from '../shared/eventa' +import { electronOpenSettings, electronStartTrackingCursorPoint } from '../shared/eventa' import { useWindowMode } from './stores/window-controls' useWindowMode() @@ -18,6 +18,7 @@ const displayModelsStore = useDisplayModelsStore() const settingsStore = useSettings() const { language, themeColorsHue, themeColorsHueDynamic } = storeToRefs(settingsStore) const onboardingStore = useOnboardingStore() +const router = useRouter() watch(language, () => { i18n.locale.value = language.value @@ -33,6 +34,11 @@ onMounted(async () => { const { context } = createContext(window.electron.ipcRenderer) const startTrackingCursorPoint = defineInvoke(context, electronStartTrackingCursorPoint) await startTrackingCursorPoint(undefined) + + // Listen for open-settings IPC message from main process + defineInvokeHandler(context, electronOpenSettings, () => { + router.push('/settings') + }) }) watch(themeColorsHue, () => { diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 98cd493ef..60e351180 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -15,3 +15,4 @@ interface Point { export const electronCursorPoint = defineEventa('electron:eventa:event:cursor-point') export const electronStartTrackingCursorPoint = defineInvokeEventa('electron:eventa:invoke:start-tracking-cursor-point') +export const electronOpenSettings = defineInvokeEventa('electron:eventa:invoke:open-settings')