fix(stage-tamagotchi): websocket started duplicated-ly

This commit is contained in:
Neko Ayaka
2026-02-26 17:49:10 +08:00
parent 82144b50ae
commit ed65f3ef5f
5 changed files with 157 additions and 37 deletions
@@ -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<void> } | 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)
})
}
+7 -4
View File
@@ -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()
@@ -1,21 +1,27 @@
<script setup lang="ts">
import SettingsGeneralFields from '@proj-airi/stage-pages/components/settings-general-fields.vue'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronRestartWebSocketServer } from '../../../../shared/eventa'
import { electronApplyServerChannelConfig } from '../../../../shared/eventa'
import { useElectronEventaInvoke } from '../../../composables/electron-vueuse'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
const settings = useSettings()
const serverChannelSettingsStore = useServerChannelSettingsStore()
const { websocketTlsConfig } = storeToRefs(serverChannelSettingsStore)
const { t } = useI18n()
const restartServer = useElectronEventaInvoke(electronRestartWebSocketServer)
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
watch(() => settings.websocketSecureEnabled, async (newValue) => {
await restartServer({ websocketSecureEnabled: newValue })
function setWebSocketTlsEnabled(value: boolean) {
websocketTlsConfig.value = value ? {} : null
}
watch(() => websocketTlsConfig.value != null, async (newValue) => {
await applyServerChannelConfig({ websocketTlsConfig: newValue ? {} : null })
})
</script>
@@ -23,14 +29,15 @@ watch(() => settings.websocketSecureEnabled, async (newValue) => {
<SettingsGeneralFields>
<template #additional-fields>
<FieldCheckbox
v-model="settings.websocketSecureEnabled"
v-motion
:model-value="websocketTlsConfig != null"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (5 * 10)"
:delay="5 * 50"
:label="t('settings.websocket-secure-enabled.title')"
:description="t('settings.websocket-secure-enabled.description')"
@update:model-value="setWebSocketTlsEnabled"
/>
</template>
</SettingsGeneralFields>
@@ -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<ElectronServerChannelTlsConfig | null>('settings/server-channel/websocket-tls-config', null)
return {
websocketTlsConfig,
}
})
+15 -2
View File
@@ -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<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
export const electronStartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:start-websocket-server')
export const electronRestartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:restart-websocket-server')
export interface ElectronServerChannelTlsConfig {
[key: string]: unknown
}
export const electronStartWebSocketServer = defineInvokeEventa<void, { websocketTlsConfig: ElectronServerChannelTlsConfig | null }>('eventa:invoke:electron:start-websocket-server')
export const electronRestartWebSocketServer = defineInvokeEventa<void, { websocketTlsConfig: ElectronServerChannelTlsConfig | null }>('eventa:invoke:electron:restart-websocket-server')
export interface ElectronServerChannelConfig {
websocketTlsConfig: ElectronServerChannelTlsConfig | null
}
export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig>('eventa:invoke:electron:server-channel:get-config')
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
@@ -16,8 +27,10 @@ export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { n
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')