From 0198f0271f6d21c71287527c9db61ed5ae2d0122 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Mon, 6 Oct 2025 22:19:00 +0800 Subject: [PATCH] refactor(stage-tamagotchi): organize main thread, use eventa for call, added lifecycle --- apps/stage-tamagotchi/src/main/app/bundle.ts | 0 .../stage-tamagotchi/src/main/app/debugger.ts | 52 +++++ apps/stage-tamagotchi/src/main/index.ts | 189 +++--------------- .../src/main/libs/bootkit/lifecycle.ts | 33 +++ .../src/main/libs/concurrent/index.ts | 13 ++ .../src/main/libs/event-loop/index.ts | 1 + .../src/main/libs/event-loop/loop.ts | 50 +++++ .../src/main/services/fade-on-hover/index.ts | 21 ++ .../windows/main/eventa/index.electron.ts | 13 ++ .../src/main/windows/main/eventa/index.web.ts | 28 +++ .../src/main/windows/main/index.ts | 53 +++++ .../src/main/windows/shared/index.ts | 1 + .../src/main/windows/shared/tray.ts | 0 .../src/main/windows/shared/window.ts | 25 +++ apps/stage-tamagotchi/src/renderer/App.vue | 2 +- cspell.config.yaml | 1 + 16 files changed, 322 insertions(+), 160 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/app/bundle.ts create mode 100644 apps/stage-tamagotchi/src/main/app/debugger.ts create mode 100644 apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts create mode 100644 apps/stage-tamagotchi/src/main/libs/concurrent/index.ts create mode 100644 apps/stage-tamagotchi/src/main/libs/event-loop/index.ts create mode 100644 apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts create mode 100644 apps/stage-tamagotchi/src/main/services/fade-on-hover/index.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/main/eventa/index.electron.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/main/eventa/index.web.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/main/index.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/shared/index.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/shared/tray.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/shared/window.ts diff --git a/apps/stage-tamagotchi/src/main/app/bundle.ts b/apps/stage-tamagotchi/src/main/app/bundle.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/stage-tamagotchi/src/main/app/debugger.ts b/apps/stage-tamagotchi/src/main/app/debugger.ts new file mode 100644 index 000000000..515baf762 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/app/debugger.ts @@ -0,0 +1,52 @@ +import http from 'node:http' + +import { env } from 'node:process' + +import { app, shell } from 'electron' + +export function setupDebugger() { + 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) { + throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`) + } + + app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort)) + app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`) + } +} + +export function openDebugger() { + if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { + const remoteDebugEndpoint = `http://localhost:${env.APP_REMOTE_DEBUG_PORT || '9222'}` + + http.get(`${remoteDebugEndpoint}/json`, (res) => { + let data = '' + res.on('data', chunk => data += chunk) + res.on('end', () => { + try { + const targets = JSON.parse(data) + if (targets.length <= 0) { + console.warn('[Remote Debugging] No targets found') + return + } + + let wsUrl = targets[0].webSocketDebuggerUrl + if (!wsUrl.startsWith('ws://')) { + console.warn('[Remote Debugging] Invalid WebSocket URL:', wsUrl) + return + } + + wsUrl = wsUrl.substring(5) + console.info(`Inspect remotely: ${remoteDebugEndpoint}/devtools/inspector.html?ws=${wsUrl}`) + shell.openExternal(`${remoteDebugEndpoint}/devtools/inspector.html?ws=${wsUrl}`) + } + catch (err) { + console.error('[Remote Debugging] Failed to parse metadata from /json:', err) + } + }) + }).on('error', (err) => { + console.error('[Remote Debugging] Failed to fetch metadata from /json:', err) + }) + } +} diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 97435078c..96fe7ec84 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -1,161 +1,66 @@ +import type http from 'node:http' + +import type { BrowserWindow } from 'electron' import type { Listener } from 'listhen' -import http from 'node:http' +import { platform } from 'node:process' -import { dirname, join } from 'node:path' -import { env, platform } from 'node:process' -import { fileURLToPath } from 'node:url' - -import { electronApp, is, optimizer } from '@electron-toolkit/utils' +import { electronApp, optimizer } from '@electron-toolkit/utils' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel } from '@guiiai/logg' -import { defineInvoke, defineInvokeHandler } from '@unbird/eventa' -import { createContext } from '@unbird/eventa/adapters/electron/main' -import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from 'electron' +import { app, Menu, nativeImage, Tray } from 'electron' import { isMacOS } from 'std-env' import icon from '../../resources/icon.png?asset' import macOSTrayIcon from '../../resources/tray-icon-macos.png?asset' -import { electronCursorPoint, electronOpenSettings, electronStartTrackingCursorPoint } from '../shared/eventa' +import { openDebugger, setupDebugger } from './app/debugger' +import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' +import { setup } from './windows/main' +import { useWebInvokes } from './windows/main/eventa/index.web' +import { toggleWindowShow } from './windows/shared/window' setGlobalFormat(Format.Pretty) setGlobalLogLevel(LogLevel.Log) - -// Server instance -let serverInstance: Listener | null = null - -// 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) { - throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`) - } - - app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort)) - app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`) -} +setupDebugger() app.dock?.setIcon(icon) +electronApp.setAppUserModelId('ai.moeru.airi') +let serverInstance: Listener | null = null let mainWindow: BrowserWindow | null = null let appTray: Tray | null = null -let trackCursorPointInterval: NodeJS.Timeout | undefined - -function createWindow(): void { - mainWindow = new BrowserWindow({ - title: 'AIRI', - width: 450.0, - height: 600.0, - show: false, - icon, - webPreferences: { - preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), - sandbox: false, - }, - frame: false, - titleBarStyle: isMacOS ? 'hidden' : undefined, - transparent: true, - hasShadow: false, - }) - - eventaContext = createContext(ipcMain, mainWindow) - const { context } = eventaContext - - defineInvokeHandler(context, electronStartTrackingCursorPoint, () => { - trackCursorPointInterval = setInterval(() => { - const dipPos = screen.getCursorScreenPoint() - // const pos = screen.dipToScreenPoint(dipPos) - context.emit(electronCursorPoint, dipPos) - // mainWindow.webContents.send(electronCursorPoint.id, dipPos) - }, 32) - }) - - // Create the openSettings invoker once and store it for reuse - openSettingsInvoker = defineInvoke(context, electronOpenSettings) - - mainWindow.setAlwaysOnTop(true) - if (isMacOS) { - mainWindow.setWindowButtonVisibility(false) - } - mainWindow.on('ready-to-show', () => mainWindow!.show()) - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(env.ELECTRON_RENDERER_URL) - } - else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } -} 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(nativeImage.createFromPath(macOSTrayIcon).resize({ width: 16 })) - // Define tray menu const contextMenu = Menu.buildFromTemplate([ - { - label: 'Show Window', - click: showMainWindow, - }, + { label: 'Show Window', click: () => toggleWindowShow(mainWindow) }, { type: 'separator' }, { label: 'Settings', click: () => { - if (mainWindow) { - showMainWindow() - // Send the open settings command using the pre-created invoker - if (openSettingsInvoker) { - openSettingsInvoker(undefined) - } - } + toggleWindowShow(mainWindow) + const web = useWebInvokes() + web.openSettings() }, }, { type: 'separator' }, - { - label: 'Quit', - click: () => { - app.quit() - }, - }, + { label: 'Quit', click: () => app.quit() }, ]) - // Set tray properties appTray.setContextMenu(contextMenu) appTray.setToolTip('Project AIRI') - appTray.addListener('click', showMainWindow) - + appTray.addListener('click', () => toggleWindowShow(mainWindow)) // On macOS, there's a special double-click event - if (platform === 'darwin') { - appTray.addListener('double-click', showMainWindow) + if (isMacOS) { + appTray.addListener('double-click', () => toggleWindowShow(mainWindow)) } } -// 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. app.whenReady().then(async () => { // Start the server-runtime server with WebSocket support try { @@ -181,49 +86,19 @@ app.whenReady().then(async () => { console.error('Failed to start WebSocket server:', error) } - if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) { - const remoteDebugEndpoint = `http://localhost:${env.APP_REMOTE_DEBUG_PORT || '9222'}` + mainWindow = setup() + createTray() - http.get(`${remoteDebugEndpoint}/json`, (res) => { - let data = '' - res.on('data', chunk => data += chunk) - res.on('end', () => { - try { - const targets = JSON.parse(data) - if (targets.length <= 0) { - console.warn('[Remote Debugging] No targets found') - return - } + // Lifecycle + emitAppReady() - let wsUrl = targets[0].webSocketDebuggerUrl - if (!wsUrl.startsWith('ws://')) { - console.warn('[Remote Debugging] Invalid WebSocket URL:', wsUrl) - return - } - - wsUrl = wsUrl.substring(5) - console.info(`Inspect remotely: ${remoteDebugEndpoint}/devtools/inspector.html?ws=${wsUrl}`) - shell.openExternal(`${remoteDebugEndpoint}/devtools/inspector.html?ws=${wsUrl}`) - } - catch (err) { - console.error('[Remote Debugging] Failed to parse metadata from /json:', err) - } - }) - }).on('error', (err) => { - console.error('[Remote Debugging] Failed to fetch metadata from /json:', err) - }) - } - - // Set app user model id for windows - electronApp.setAppUserModelId('ai.moeru.airi') + // Extra + openDebugger() // Default open or close DevTools by F12 in development // and ignore CommandOrControl + R in production. // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window)) - - createWindow() - createTray() }).catch((err) => { console.error('Error during app initialization:', err) }) @@ -232,9 +107,7 @@ app.whenReady().then(async () => { // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { - if (trackCursorPointInterval) { - clearInterval(trackCursorPointInterval) - } + emitAppWindowAllClosed() if (platform !== 'darwin') { app.quit() @@ -243,9 +116,7 @@ app.on('window-all-closed', () => { // Clean up server and intervals when app quits app.on('before-quit', async () => { - if (trackCursorPointInterval) { - clearInterval(trackCursorPointInterval) - } + emitAppBeforeQuit() // Close the server if it's running if (serverInstance && typeof serverInstance.close === 'function') { diff --git a/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts b/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts new file mode 100644 index 000000000..357985dbe --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/bootkit/lifecycle.ts @@ -0,0 +1,33 @@ +const onAppReadyHooks = [] as (() => Promise | void)[] +const onAppBeforeQuitHooks = [] as (() => Promise | void)[] +const onAppWindowAllClosedHooks = [] as (() => Promise | void)[] + +export function onAppReady(fn: () => Promise | void) { + onAppReadyHooks.push(fn) +} + +export async function emitAppReady() { + for (const fn of onAppReadyHooks) { + await fn() + } +} + +export function onAppBeforeQuit(fn: () => Promise | void) { + onAppBeforeQuitHooks.push(fn) +} + +export async function emitAppBeforeQuit() { + for (const fn of onAppBeforeQuitHooks) { + await fn() + } +} + +export function onAppWindowAllClosed(fn: () => Promise | void) { + onAppWindowAllClosedHooks.push(fn) +} + +export async function emitAppWindowAllClosed() { + for (const fn of onAppWindowAllClosedHooks) { + await fn() + } +} diff --git a/apps/stage-tamagotchi/src/main/libs/concurrent/index.ts b/apps/stage-tamagotchi/src/main/libs/concurrent/index.ts new file mode 100644 index 000000000..966d81826 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/concurrent/index.ts @@ -0,0 +1,13 @@ +export function useOnce(fn: () => T): () => T { + let called = false + let result: T + + return () => { + if (!called) { + called = true + result = fn() + } + + return result + } +} diff --git a/apps/stage-tamagotchi/src/main/libs/event-loop/index.ts b/apps/stage-tamagotchi/src/main/libs/event-loop/index.ts new file mode 100644 index 000000000..837fa5e34 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/event-loop/index.ts @@ -0,0 +1 @@ +export { useLoop } from './loop' diff --git a/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts b/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts new file mode 100644 index 000000000..6e0deb888 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts @@ -0,0 +1,50 @@ +export function useLoop(fn: () => Promise | void, options?: { interval?: number, autoStart?: boolean }) { + let timer: NodeJS.Timeout | null = null + let shouldRun = options?.autoStart ?? true + + const loopIteration = () => { + if (!shouldRun) { + return + } + + const res = fn() + if (res instanceof Promise) { + res.finally(() => { + timer = setTimeout(loopIteration, options?.interval ?? 1000 / 60) // Default to ~60 FPS + }) + } + else { + timer = setTimeout(loopIteration, options?.interval ?? 1000 / 60) + } + } + + if (shouldRun) { + loopIteration() + } + + return { + start: () => { + shouldRun = true + if (!timer) { + loopIteration() + } + }, + resume: () => { + shouldRun = true + if (!timer) { + loopIteration() + } + }, + pause: () => { + shouldRun = false + }, + stop: () => { + shouldRun = false + + if (timer) { + clearTimeout(timer) + timer = null + } + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/fade-on-hover/index.ts b/apps/stage-tamagotchi/src/main/services/fade-on-hover/index.ts new file mode 100644 index 000000000..164b91509 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/fade-on-hover/index.ts @@ -0,0 +1,21 @@ +import type { createContext } from '@unbird/eventa/adapters/electron/main' + +import { defineInvokeHandler } from '@unbird/eventa' +import { screen } from 'electron' + +import { electronCursorPoint, electronStartTrackingCursorPoint } from '../../../shared/eventa' +import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' +import { useLoop } from '../../libs/event-loop' + +export function createFadeOnHoverService(context: ReturnType['context']) { + const { start, stop } = useLoop(() => { + const dipPos = screen.getCursorScreenPoint() + context.emit(electronCursorPoint, dipPos) + }, { + autoStart: false, + }) + + onAppWindowAllClosed(() => stop()) + onAppBeforeQuit(() => stop()) + defineInvokeHandler(context, electronStartTrackingCursorPoint, () => start()) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/eventa/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/eventa/index.electron.ts new file mode 100644 index 000000000..50d9af777 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/main/eventa/index.electron.ts @@ -0,0 +1,13 @@ +import type { BrowserWindow } from 'electron' + +import { createContext } from '@unbird/eventa/adapters/electron/main' +import { ipcMain } from 'electron' + +import { createFadeOnHoverService } from '../../../services/fade-on-hover' + +export function setupAppInvokeHandlers(window: BrowserWindow) { + const eventaContext = createContext(ipcMain, window) + const { context } = eventaContext + + createFadeOnHoverService(context) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/eventa/index.web.ts b/apps/stage-tamagotchi/src/main/windows/main/eventa/index.web.ts new file mode 100644 index 000000000..a88dd3c27 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/main/eventa/index.web.ts @@ -0,0 +1,28 @@ +import type { BrowserWindow } from 'electron' + +import { defineInvoke } from '@unbird/eventa' +import { createContext } from '@unbird/eventa/adapters/electron/main' +import { ipcMain } from 'electron' + +import { electronOpenSettings } from '../../../../shared/eventa' + +let webInvokes: { + openSettings: () => Promise +} + +export function setupWebInvokes(window: BrowserWindow) { + const eventaContext = createContext(ipcMain, window) + const { context } = eventaContext + + webInvokes = { + openSettings: defineInvoke(context, electronOpenSettings), + } +} + +export function useWebInvokes() { + if (!webInvokes) { + throw new Error('App client is not initialized') + } + + return webInvokes +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts new file mode 100644 index 000000000..5a61016e0 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -0,0 +1,53 @@ +import { dirname, join } from 'node:path' +import { env } from 'node:process' +import { fileURLToPath } from 'node:url' + +import { is } from '@electron-toolkit/utils' +import { BrowserWindow, shell } from 'electron' +import { isMacOS } from 'std-env' + +import icon from '../../../../resources/icon.png?asset' + +import { transparentWindowConfig } from '../shared' +import { setupAppInvokeHandlers } from './eventa/index.electron' +import { setupWebInvokes } from './eventa/index.web' + +export function setup() { + const window = new BrowserWindow({ + title: 'AIRI', + width: 450.0, + height: 600.0, + show: false, + icon, + webPreferences: { + preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'), + sandbox: false, + }, + ...transparentWindowConfig(), + }) + + window.setAlwaysOnTop(true) + if (isMacOS) { + window.setWindowButtonVisibility(false) + } + + window.on('ready-to-show', () => window!.show()) + window.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && env.ELECTRON_RENDERER_URL) { + window.loadURL(env.ELECTRON_RENDERER_URL) + } + else { + window.loadFile(join(__dirname, '../renderer/index.html')) + } + + setupAppInvokeHandlers(window) + setupWebInvokes(window) + + return window +} diff --git a/apps/stage-tamagotchi/src/main/windows/shared/index.ts b/apps/stage-tamagotchi/src/main/windows/shared/index.ts new file mode 100644 index 000000000..de1528d57 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/shared/index.ts @@ -0,0 +1 @@ +export { toggleWindowShow, transparentWindowConfig } from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/tray.ts b/apps/stage-tamagotchi/src/main/windows/shared/tray.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts new file mode 100644 index 000000000..0f5e99949 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/shared/window.ts @@ -0,0 +1,25 @@ +import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' + +import { isMacOS } from 'std-env' + +export function toggleWindowShow(window?: BrowserWindow | null): void { + if (!window) { + return + } + + if (window.isMinimized()) { + window.restore() + } + + window.show() + window.focus() +} + +export function transparentWindowConfig(): BrowserWindowConstructorOptions { + return { + frame: false, + titleBarStyle: isMacOS ? 'hidden' : undefined, + transparent: true, + hasShadow: false, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 89954e341..477f11da6 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -33,7 +33,7 @@ onMounted(async () => { const { context } = createContext(window.electron.ipcRenderer) const startTrackingCursorPoint = defineInvoke(context, electronStartTrackingCursorPoint) - await startTrackingCursorPoint(undefined) + await startTrackingCursorPoint() // Listen for open-settings IPC message from main process defineInvokeHandler(context, electronOpenSettings, () => { diff --git a/cspell.config.yaml b/cspell.config.yaml index 8147d63dc..768992ba1 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -27,6 +27,7 @@ words: - bailian - bigserial - Bitstream + - bootkit - browserbasehq - buildless - bumpp