refactor(stage-tamagotchi): organize main thread, use eventa for call, added lifecycle

This commit is contained in:
Neko Ayaka
2025-10-06 22:20:06 +08:00
parent 4e5e51c362
commit 0198f0271f
16 changed files with 322 additions and 160 deletions
@@ -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)
})
}
}
+30 -159
View File
@@ -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<typeof createContext> | null = null
let openSettingsInvoker: ReturnType<typeof defineInvoke<void, void>> | 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<void, void>(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') {
@@ -0,0 +1,33 @@
const onAppReadyHooks = [] as (() => Promise<void> | void)[]
const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[]
const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[]
export function onAppReady(fn: () => Promise<void> | void) {
onAppReadyHooks.push(fn)
}
export async function emitAppReady() {
for (const fn of onAppReadyHooks) {
await fn()
}
}
export function onAppBeforeQuit(fn: () => Promise<void> | void) {
onAppBeforeQuitHooks.push(fn)
}
export async function emitAppBeforeQuit() {
for (const fn of onAppBeforeQuitHooks) {
await fn()
}
}
export function onAppWindowAllClosed(fn: () => Promise<void> | void) {
onAppWindowAllClosedHooks.push(fn)
}
export async function emitAppWindowAllClosed() {
for (const fn of onAppWindowAllClosedHooks) {
await fn()
}
}
@@ -0,0 +1,13 @@
export function useOnce<T>(fn: () => T): () => T {
let called = false
let result: T
return () => {
if (!called) {
called = true
result = fn()
}
return result
}
}
@@ -0,0 +1 @@
export { useLoop } from './loop'
@@ -0,0 +1,50 @@
export function useLoop(fn: () => Promise<void> | 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
}
},
}
}
@@ -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<typeof createContext>['context']) {
const { start, stop } = useLoop(() => {
const dipPos = screen.getCursorScreenPoint()
context.emit(electronCursorPoint, dipPos)
}, {
autoStart: false,
})
onAppWindowAllClosed(() => stop())
onAppBeforeQuit(() => stop())
defineInvokeHandler(context, electronStartTrackingCursorPoint, () => start())
}
@@ -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)
}
@@ -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<void>
}
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
}
@@ -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
}
@@ -0,0 +1 @@
export { toggleWindowShow, transparentWindowConfig } from './window'
@@ -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,
}
}
+1 -1
View File
@@ -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, () => {
+1
View File
@@ -27,6 +27,7 @@ words:
- bailian
- bigserial
- Bitstream
- bootkit
- browserbasehq
- buildless
- bumpp