From ed65f3ef5f2717b72cad0cdd290b28d745fdfa97 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 26 Feb 2026 17:49:10 +0800 Subject: [PATCH] fix(stage-tamagotchi): websocket started duplicated-ly --- .../services/airi/channel-server/index.ts | 133 ++++++++++++++---- apps/stage-tamagotchi/src/renderer/App.vue | 11 +- .../pages/settings/system/general.vue | 21 ++- .../stores/settings/server-channel.ts | 12 ++ apps/stage-tamagotchi/src/shared/eventa.ts | 17 ++- 5 files changed, 157 insertions(+), 37 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts 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 5acd33d78..aace83793 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 @@ -1,3 +1,5 @@ +import type { ElectronServerChannelTlsConfig } from '../../../../shared/eventa' + import { X509Certificate } from 'node:crypto' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { isIP } from 'node:net' @@ -11,11 +13,79 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { app, ipcMain } from 'electron' import { createCA, createCert } from 'mkcert' import { x } from 'tinyexec' +import { nullable, object, record, string, unknown } from 'valibot' +import { z } from 'zod' -import { electronRestartWebSocketServer, electronStartWebSocketServer } from '../../../../shared/eventa' -import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' +import { + electronApplyServerChannelConfig, + electronGetServerChannelConfig, + electronRestartWebSocketServer, + + electronStartWebSocketServer, +} from '../../../../shared/eventa' +import { onAppBeforeQuit, onAppReady } from '../../../libs/bootkit/lifecycle' +import { createConfig } from '../../../libs/electron/persistence' let serverInstance: { close: (closeActiveConnections?: boolean) => Promise } | null = null +let isServerQuitHookRegistered = false + +const channelServerConfigSchema = object({ + websocketTlsConfig: nullable(record(string(), unknown())), +}) + +const channelServerInvokeConfigSchema = z.object({ + websocketTlsConfig: z.record(z.string(), z.unknown()).nullable().optional(), +}).strict() + +const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, { + default: { + websocketTlsConfig: null, + }, + autoHeal: true, +}) + +function getChannelServerConfig() { + return channelServerConfigStore.get() ?? { websocketTlsConfig: null } +} + +function normalizeChannelServerOptions( + payload: unknown, + fallback = getChannelServerConfig(), +) { + const parsed = channelServerInvokeConfigSchema.safeParse(payload) + if (!parsed.success) { + return fallback + } + + return { + websocketTlsConfig: parsed.data.websocketTlsConfig ?? fallback.websocketTlsConfig, + } +} + +function registerServerQuitHook() { + if (isServerQuitHookRegistered) + return + + isServerQuitHookRegistered = true + + onAppBeforeQuit(async () => { + const log = useLogg('main/server-runtime').useGlobalConfig() + if (serverInstance && typeof serverInstance.close === 'function') { + try { + await serverInstance.close() + log.log('WebSocket server closed') + } + catch (error) { + const nodejsError = error as NodeJS.ErrnoException + if ('code' in nodejsError && nodejsError.code === 'ERR_SERVER_NOT_RUNNING') { + return + } + + log.withError(error).error('Error closing WebSocket server') + } + } + }) +} function getLocalIPs(): string[] { const interfaces = networkInterfaces() @@ -190,10 +260,10 @@ async function getOrCreateCertificate() { return { cert, key } } -export async function setupServerChannel(options?: { websocketSecureEnabled?: boolean }) { +export async function setupServerChannel(options?: { websocketTlsConfig?: ElectronServerChannelTlsConfig | null }) { const log = useLogg('main/server-runtime').useGlobalConfig() - const secureEnabled = options?.websocketSecureEnabled ?? false + const secureEnabled = options?.websocketTlsConfig != null try { const serverRuntime = await import('@proj-airi/server-runtime') @@ -255,30 +325,13 @@ export async function setupServerChannel(options?: { websocketSecureEnabled?: bo else { log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`) } - - onAppBeforeQuit(async () => { - if (serverInstance && typeof serverInstance.close === 'function') { - try { - await serverInstance.close() - log.log('WebSocket server closed') - } - catch (error) { - const nodejsError = error as NodeJS.ErrnoException - if ('code' in nodejsError && nodejsError.code === 'ERR_SERVER_NOT_RUNNING') { - return - } - - log.withError(error).error('Error closing WebSocket server') - } - } - }) } catch (error) { log.withError(error).error('failed to start WebSocket server') } } -export async function restartServerChannel(options?: { websocketSecureEnabled?: boolean }) { +export async function restartServerChannel(options?: { websocketTlsConfig?: ElectronServerChannelTlsConfig | null }) { const log = useLogg('main/server-runtime').useGlobalConfig() log.log('restarting server channel', { options }) @@ -299,12 +352,44 @@ export async function restartServerChannel(options?: { websocketSecureEnabled?: export function setupServerChannelHandlers() { const { context } = createContext(ipcMain) + channelServerConfigStore.setup() + registerServerQuitHook() + + defineInvokeHandler(context, electronGetServerChannelConfig, async () => { + return getChannelServerConfig() + }) + + defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => { + const current = getChannelServerConfig() + const next = normalizeChannelServerOptions(req, current) + const changed = JSON.stringify(next.websocketTlsConfig) !== JSON.stringify(current.websocketTlsConfig) + + channelServerConfigStore.update(next) + + if (changed) { + await restartServerChannel(next) + } + else if (!serverInstance) { + await setupServerChannel(next) + } + + return next + }) defineInvokeHandler(context, electronStartWebSocketServer, async (req) => { - await setupServerChannel({ websocketSecureEnabled: req?.websocketSecureEnabled }) + const options = normalizeChannelServerOptions(req) + await setupServerChannel(options) }) defineInvokeHandler(context, electronRestartWebSocketServer, async (req) => { - await restartServerChannel({ websocketSecureEnabled: req?.websocketSecureEnabled }) + const current = getChannelServerConfig() + const options = normalizeChannelServerOptions(req, current) + channelServerConfigStore.update(options) + await restartServerChannel(options) + }) + + onAppReady(async () => { + const options = getChannelServerConfig() + await setupServerChannel(options) }) } diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index db0140d48..be38e9299 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -24,6 +24,7 @@ import { toast, Toaster } from 'vue-sonner' import ResizeHandler from './components/ResizeHandler.vue' import { + electronGetServerChannelConfig, electronOpenSettings, electronPluginInspect, electronPluginList, @@ -33,11 +34,11 @@ import { electronPluginUnload, electronPluginUpdateCapability, electronStartTrackMousePosition, - electronStartWebSocketServer, pluginProtocolListProviders, pluginProtocolListProvidersEventName, } from '../shared/eventa' import { useElectronEventaContext, useElectronEventaInvoke } from './composables/electron-vueuse' +import { useServerChannelSettingsStore } from './stores/settings/server-channel' const { isDark: dark } = useTheme() const i18n = useI18n() @@ -45,6 +46,7 @@ const contextBridgeStore = useContextBridgeStore() const displayModelsStore = useDisplayModelsStore() const settingsStore = useSettings() const { language, themeColorsHue, themeColorsHueDynamic } = storeToRefs(settingsStore) +const serverChannelSettingsStore = useServerChannelSettingsStore() const onboardingStore = useOnboardingStore() const router = useRouter() const route = useRoute() @@ -65,10 +67,9 @@ watch(dark, () => updateThemeColor(), { immediate: true }) watch(route, () => updateThemeColor(), { immediate: true }) onMounted(() => updateThemeColor()) -const startWebSocketServer = useElectronEventaInvoke(electronStartWebSocketServer) - onMounted(async () => { const context = useElectronEventaContext() + const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig) const listPlugins = useElectronEventaInvoke(electronPluginList) const setPluginEnabled = useElectronEventaInvoke(electronPluginSetEnabled) const loadEnabledPlugins = useElectronEventaInvoke(electronPluginLoadEnabled) @@ -93,7 +94,9 @@ onMounted(async () => { await chatSessionStore.initialize() await displayModelsStore.loadDisplayModelsFromIndexedDB() await settingsStore.initializeStageModel() - await startWebSocketServer({ websocketSecureEnabled: settingsStore.websocketSecureEnabled }) + + const serverChannelConfig = await getServerChannelConfig() + serverChannelSettingsStore.websocketTlsConfig = serverChannelConfig.websocketTlsConfig await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err)) await contextBridgeStore.initialize() diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/general.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/general.vue index a660e8ddc..3ab240325 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/general.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/general.vue @@ -1,21 +1,27 @@ @@ -23,14 +29,15 @@ watch(() => settings.websocketSecureEnabled, async (newValue) => { diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts new file mode 100644 index 000000000..eb50705ad --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts @@ -0,0 +1,12 @@ +import type { ElectronServerChannelTlsConfig } from '../../../shared/eventa' + +import { useLocalStorage } from '@vueuse/core' +import { defineStore } from 'pinia' + +export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => { + const websocketTlsConfig = useLocalStorage('settings/server-channel/websocket-tls-config', null) + + return { + websocketTlsConfig, + } +}) diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 83c143ed8..ca7c16e42 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -2,13 +2,24 @@ import { defineEventa, defineInvokeEventa } from '@moeru/eventa' export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke:electron:start-tracking-mouse-position') export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window') + export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electron:windows:main:devtools:open') export const electronOpenSettings = defineInvokeEventa('eventa:invoke:electron:windows:settings:open') export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open') export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open') export const electronOpenDevtoolsWindow = defineInvokeEventa('eventa:invoke:electron:windows:devtools:open') -export const electronStartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:start-websocket-server') -export const electronRestartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:restart-websocket-server') + +export interface ElectronServerChannelTlsConfig { + [key: string]: unknown +} +export const electronStartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:start-websocket-server') +export const electronRestartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:restart-websocket-server') +export interface ElectronServerChannelConfig { + websocketTlsConfig: ElectronServerChannelTlsConfig | null +} +export const electronGetServerChannelConfig = defineInvokeEventa('eventa:invoke:electron:server-channel:get-config') +export const electronApplyServerChannelConfig = defineInvokeEventa>('eventa:invoke:electron:server-channel:apply-config') + export const electronPluginList = defineInvokeEventa('eventa:invoke:electron:plugins:list') export const electronPluginSetEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:set-enabled') export const electronPluginLoadEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:load-enabled') @@ -16,8 +27,10 @@ export const electronPluginLoad = defineInvokeEventa('eventa:invoke:electron:plugins:unload') export const electronPluginInspect = defineInvokeEventa('eventa:invoke:electron:plugins:inspect') export const electronPluginUpdateCapability = defineInvokeEventa('eventa:invoke:electron:plugins:capability:update') + export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' export const pluginProtocolListProviders = defineInvokeEventa>(pluginProtocolListProvidersEventName) + export const captionIsFollowingWindowChanged = defineEventa('eventa:event:electron:windows:caption-overlay:is-following-window-changed') export const captionGetIsFollowingWindow = defineInvokeEventa('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')