refactor(stage-tamagotchi): organize main thread, use eventa for call, added lifecycle
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ words:
|
||||
- bailian
|
||||
- bigserial
|
||||
- Bitstream
|
||||
- bootkit
|
||||
- browserbasehq
|
||||
- buildless
|
||||
- bumpp
|
||||
|
||||
Reference in New Issue
Block a user