diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index cd16859df..bfcf8de60 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -1,33 +1,27 @@ -import type { BrowserWindow } from 'electron' - -import type { WidgetsWindowManager } from './windows/widgets' - import { env, platform } from 'node:process' -import { electronApp, is, optimizer } from '@electron-toolkit/utils' +import { electronApp, optimizer } from '@electron-toolkit/utils' import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' -import { app, ipcMain, Menu, nativeImage, Tray } from 'electron' +import { app, ipcMain } from 'electron' import { initMain as initAudioLoopback } from 'electron-audio-loopback' -import { noop, once } from 'es-toolkit' +import { noop } from 'es-toolkit' import { createLoggLogger, injeca } from 'injeca' -import { isLinux, isMacOS } from 'std-env' +import { isLinux } from 'std-env' import icon from '../../resources/icon.png?asset' -import macOSTrayIcon from '../../resources/tray-icon-macos.png?asset' import { openDebugger, setupDebugger } from './app/debugger' -import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed, onAppBeforeQuit } from './libs/bootkit/lifecycle' +import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { setElectronMainDirname } from './libs/electron/location' -import { setupChannelServer } from './services/airi/channel-server' +import { setupServerChannel } from './services/airi/channel-server' +import { setupTray } from './tray' import { setupAboutWindowReusable } from './windows/about' import { setupBeatSync } from './windows/beat-sync' import { setupCaptionWindowManager } from './windows/caption' import { setupChatWindowReusableFunc } from './windows/chat' -import { setupInlayWindow } from './windows/inlay' import { setupMainWindow } from './windows/main' import { setupNoticeWindowManager } from './windows/notice' import { setupSettingsWindowReusableFunc } from './windows/settings' -import { toggleWindowShow } from './windows/shared/window' import { setupWidgetsWindowManager } from './windows/widgets' // TODO: once we refactored eventa to support window-namespaced contexts, @@ -73,70 +67,16 @@ electronApp.setAppUserModelId('ai.moeru.airi') initAudioLoopback() -function setupTray(params: { - mainWindow: BrowserWindow - settingsWindow: () => Promise - captionWindow: ReturnType - widgetsWindow: WidgetsWindowManager - beatSyncBgWindow: BrowserWindow - aboutWindow: () => Promise -}): void { - once(() => { - const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 }) - trayImage.setTemplateImage(isMacOS) - const appTray = new Tray(trayImage) - onAppBeforeQuit(() => appTray.destroy()) - - const contextMenu = Menu.buildFromTemplate([ - { label: 'Show', click: () => toggleWindowShow(params.mainWindow) }, - { type: 'separator' }, - { label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) }, - { label: 'About...', click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, - { type: 'separator' }, - { label: 'Open Inlay...', click: () => setupInlayWindow() }, - { label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, - { label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) }, - { - type: 'submenu', - label: 'Caption Overlay', - submenu: Menu.buildFromTemplate([ - { type: 'checkbox', label: 'Follow window', checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, - { label: 'Reset position', click: async () => await params.captionWindow.resetToSide() }, - ]), - }, - { type: 'separator' }, - ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG - ? [ - { type: 'header', label: 'DevTools' }, - { label: 'Troubleshoot BeatSync...', click: () => params.beatSyncBgWindow.webContents.openDevTools() }, - { type: 'separator' }, - ] as const // :( - : [], - { label: 'Quit', click: () => app.quit() }, - ]) - - appTray.setContextMenu(contextMenu) - appTray.setToolTip('Project AIRI') - appTray.addListener('click', () => toggleWindowShow(params.mainWindow)) - - // On macOS, there's a special double-click event - if (isMacOS) - appTray.addListener('double-click', () => toggleWindowShow(params.mainWindow)) - })() -} - app.whenReady().then(async () => { injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig())) - const channelServerModule = injeca.provide('modules:channel-server', async () => setupChannelServer()) - const widgetsManager = injeca.provide('windows:widgets', { build: () => setupWidgetsWindowManager() }) - const noticeWindow = injeca.provide('windows:notice', { build: () => setupNoticeWindowManager() }) - const aboutWindow = injeca.provide('windows:about', { build: () => setupAboutWindowReusable() }) + const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannel()) + const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager()) + const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager()) + const aboutWindow = injeca.provide('windows:about', () => setupAboutWindowReusable()) // BeatSync will create a background window to capture and process audio. - const beatSync = injeca.provide('windows:beat-sync', { - build: () => setupBeatSync(), - }) + const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync()) const chatWindow = injeca.provide('windows:chat', { dependsOn: { widgetsManager }, @@ -152,6 +92,7 @@ app.whenReady().then(async () => { return window }, }) + const mainWindow = injeca.provide('windows:main', { dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync }, build: async ({ dependsOn }) => { @@ -161,10 +102,12 @@ app.whenReady().then(async () => { return window }, }) + const captionWindow = injeca.provide('windows:caption', { dependsOn: { mainWindow }, build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn), }) + const tray = injeca.provide('app:tray', { dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, beatSync, aboutWindow }, build: async ({ dependsOn }) => { @@ -172,8 +115,9 @@ app.whenReady().then(async () => { return setupTray({ ...setupArgs, beatSyncBgWindow: beatSync.window, aboutWindow }) }, }) + injeca.invoke({ - dependsOn: { mainWindow, tray, channelServerModule }, + dependsOn: { mainWindow, tray, serverChannel }, callback: noop, }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts index 80af346b6..82cf573d9 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts @@ -4,7 +4,7 @@ import { useLogg } from '@guiiai/logg' import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' -export async function setupChannelServer() { +export async function setupServerChannel() { const log = useLogg('main/server-runtime').useGlobalConfig() // Start the server-runtime server with WebSocket support @@ -22,14 +22,13 @@ export async function setupChannelServer() { port: env.PORT ? Number(env.PORT) : 6121, hostname: env.SERVER_RUNTIME_HOSTNAME || 'localhost', reusePort: true, + silent: true, gracefulShutdown: { forceTimeout: 500, gracefulTimeout: 500, }, }) - log.log('@proj-airi/server-runtime started on ws://localhost:6121') - onAppBeforeQuit(async () => { if (serverInstance && typeof serverInstance.close === 'function') { try { @@ -41,6 +40,8 @@ export async function setupChannelServer() { } } }) + + log.log('@proj-airi/server-runtime started on ws://localhost:6121') } catch (error) { log.withError(error).error('failed to start WebSocket server') diff --git a/apps/stage-tamagotchi/src/main/tray/index.ts b/apps/stage-tamagotchi/src/main/tray/index.ts new file mode 100644 index 000000000..b88b800bf --- /dev/null +++ b/apps/stage-tamagotchi/src/main/tray/index.ts @@ -0,0 +1,72 @@ +import type { BrowserWindow } from 'electron' + +import type { setupCaptionWindowManager } from '../windows/caption' +import type { WidgetsWindowManager } from '../windows/widgets' + +import { env } from 'node:process' + +import { is } from '@electron-toolkit/utils' +import { app, Menu, nativeImage, Tray } from 'electron' +import { once } from 'es-toolkit' +import { isMacOS } from 'std-env' + +import icon from '../../resources/icon.png?asset' +import macOSTrayIcon from '../../resources/tray-icon-macos.png?asset' + +import { onAppBeforeQuit } from '../libs/bootkit/lifecycle' +import { setupInlayWindow } from '../windows/inlay' +import { toggleWindowShow } from '../windows/shared/window' + +export function setupTray(params: { + mainWindow: BrowserWindow + settingsWindow: () => Promise + captionWindow: ReturnType + widgetsWindow: WidgetsWindowManager + beatSyncBgWindow: BrowserWindow + aboutWindow: () => Promise +}): void { + once(() => { + const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 }) + trayImage.setTemplateImage(isMacOS) + + const appTray = new Tray(trayImage) + onAppBeforeQuit(() => appTray.destroy()) + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Show', click: () => toggleWindowShow(params.mainWindow) }, + { type: 'separator' }, + { label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) }, + { label: 'About...', click: () => params.aboutWindow().then(window => toggleWindowShow(window)) }, + { type: 'separator' }, + { label: 'Open Inlay...', click: () => setupInlayWindow() }, + { label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, + { label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) }, + { + type: 'submenu', + label: 'Caption Overlay', + submenu: Menu.buildFromTemplate([ + { type: 'checkbox', label: 'Follow window', checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) }, + { label: 'Reset position', click: async () => await params.captionWindow.resetToSide() }, + ]), + }, + { type: 'separator' }, + ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG + ? [ + { type: 'header', label: 'DevTools' }, + { label: 'Troubleshoot BeatSync...', click: () => params.beatSyncBgWindow.webContents.openDevTools() }, + { type: 'separator' }, + ] as const // :( + : [], + { label: 'Quit', click: () => app.quit() }, + ]) + + appTray.setContextMenu(contextMenu) + appTray.setToolTip('Project AIRI') + appTray.addListener('click', () => toggleWindowShow(params.mainWindow)) + + // On macOS, there's a special double-click event + if (isMacOS) { + appTray.addListener('double-click', () => toggleWindowShow(params.mainWindow)) + } + })() +} diff --git a/packages/stage-ui/src/stores/chat.test.ts b/packages/stage-ui/src/stores/chat.test.ts index a4ac69b39..4a9e6f7d7 100644 --- a/packages/stage-ui/src/stores/chat.test.ts +++ b/packages/stage-ui/src/stores/chat.test.ts @@ -51,7 +51,7 @@ vi.mock('./modules', () => ({ })) vi.mock('./mods/api/channel-server', () => ({ - useModsChannelServerStore: () => ({ + useModsServerChannelStore: () => ({ connected: ref(true), initialize: mockInitialize, onContextUpdate: (cb: typeof contextUpdateHandler) => { diff --git a/packages/stage-ui/src/stores/configurator.ts b/packages/stage-ui/src/stores/configurator.ts index 7085080ed..8c7729232 100644 --- a/packages/stage-ui/src/stores/configurator.ts +++ b/packages/stage-ui/src/stores/configurator.ts @@ -1,9 +1,9 @@ import { defineStore } from 'pinia' -import { useModsChannelServerStore } from './mods/api/channel-server' +import { useModsServerChannelStore } from './mods/api/channel-server' export const useConfiguratorByModsChannelServer = defineStore('configurator:adapter:proj-airi:server-sdk', () => { - const { send } = useModsChannelServerStore() + const { send } = useModsServerChannelStore() function updateFor(moduleName: string, config: Record) { send({ diff --git a/packages/stage-ui/src/stores/mods/api/channel-server.ts b/packages/stage-ui/src/stores/mods/api/channel-server.ts index 6946b5648..c06ce9337 100644 --- a/packages/stage-ui/src/stores/mods/api/channel-server.ts +++ b/packages/stage-ui/src/stores/mods/api/channel-server.ts @@ -4,7 +4,7 @@ import { Client } from '@proj-airi/server-sdk' import { defineStore } from 'pinia' import { ref } from 'vue' -export const useModsChannelServerStore = defineStore('mods:channels:proj-airi:server', () => { +export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:server', () => { const connected = ref(false) const client = ref() const initializing = ref | null>(null) diff --git a/packages/stage-ui/src/stores/plugins/chat-context-bridge.ts b/packages/stage-ui/src/stores/plugins/chat-context-bridge.ts index 6851b9ebf..4427aeea5 100644 --- a/packages/stage-ui/src/stores/plugins/chat-context-bridge.ts +++ b/packages/stage-ui/src/stores/plugins/chat-context-bridge.ts @@ -6,7 +6,7 @@ import { useBroadcastChannel } from '@vueuse/core' import { watch } from 'vue' import { CONTEXT_CHANNEL_NAME, useChatStore } from '../chat' -import { useModsChannelServerStore } from '../mods/api/channel-server' +import { useModsServerChannelStore } from '../mods/api/channel-server' let installed = false @@ -18,7 +18,7 @@ export function installChatContextBridge() { } const chatStore = useChatStore() - const modsChannelServer = useModsChannelServerStore() + const modsChannelServer = useModsServerChannelStore() const { post: broadcastContext, data: incomingContext } = useBroadcastChannel, ContextMessage>({ name: CONTEXT_CHANNEL_NAME, })