From 5415b2850ee89c1be66de29b6571510e04d954a0 Mon Sep 17 00:00:00 2001 From: freezinlove Date: Tue, 10 Mar 2026 17:39:58 +0800 Subject: [PATCH] fix(stage-tamagotchi): double close caused crash, simplified onboarding, added `safeClose` (#1243) --------- Co-authored-by: Neko Ayaka --- .../electron-builder.config.ts | 1 + apps/stage-tamagotchi/package.json | 1 + .../libs/electron/window-manager/reusable.ts | 4 +- .../src/main/services/electron/window.ts | 4 +- .../src/main/windows/notice/index.ts | 4 +- .../src/main/windows/onboarding/index.ts | 9 +-- .../main/windows/shared/referenced-window.ts | 5 +- .../src/main/windows/shared/window.ts | 3 +- apps/stage-tamagotchi/src/renderer/App.vue | 12 ++-- .../src/renderer/pages/onboarding.vue | 10 +--- apps/stage-tamagotchi/src/shared/eventa.ts | 2 - packages/electron-vueuse/src/main/index.ts | 1 + .../electron-vueuse/src/main/renderer-loop.ts | 14 ++++- packages/stage-ui/src/stores/onboarding.ts | 55 ++++++++++++++++--- pnpm-lock.yaml | 3 + 15 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts index fce1fe8ad..7c5739f31 100644 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ b/apps/stage-tamagotchi/electron-builder.config.ts @@ -75,6 +75,7 @@ export default { '!**/node_modules/**/{CHANGELOG.md,README.md,README,readme.md,readme}', '!**/node_modules/**/{.turbo,test,src,__tests__,tests,example,examples}', '**/node_modules/debug/**/*', + '**/node_modules/superjson/**/*', '!electron.vite.config.{js,ts,mjs,cjs}', '!vite.config.{js,ts,mjs,cjs}', '!uno.config.{js,ts,mjs,cjs}', diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 2d7eb67a6..3336444d2 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -117,6 +117,7 @@ "shiki": "^3.23.0", "splitpanes": "catalog:", "std-env": "^4.0.0-rc.1", + "superjson": "catalog:", "three": "^0.183.1", "unified": "^11.0.5", "unspeech": "catalog:xsai", diff --git a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts index 2579d0ab2..50cff8f03 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts @@ -1,11 +1,13 @@ import type { BrowserWindow } from 'electron' +import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main' + export function createReusableWindow(setupFn: () => BrowserWindow | Promise): { getWindow: () => Promise } { let window: BrowserWindow | undefined let windowSetupFnPromise: Promise | undefined const ensureWindow = async () => { - if (window && !window.isDestroyed()) + if (window && !isRendererUnavailable(window)) return window if (windowSetupFnPromise) diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts index 775fde347..259b32a82 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/window.ts @@ -3,7 +3,7 @@ import type { BrowserWindow } from 'electron' import { defineInvokeHandler } from '@moeru/eventa' import { bounds, startLoopGetBounds } from '@proj-airi/electron-eventa' -import { createRendererLoop } from '@proj-airi/electron-vueuse/main' +import { createRendererLoop, safeClose } from '@proj-airi/electron-vueuse/main' import { electron, electronWindowClose, electronWindowSetAlwaysOnTop } from '../../../shared/eventa' import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' @@ -84,7 +84,7 @@ export function createWindowService(params: { context: ReturnType { if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) { - params.window.close() + safeClose(params.window) } }) } diff --git a/apps/stage-tamagotchi/src/main/windows/notice/index.ts b/apps/stage-tamagotchi/src/main/windows/notice/index.ts index 4b2180c25..6d4106d4e 100644 --- a/apps/stage-tamagotchi/src/main/windows/notice/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/notice/index.ts @@ -7,6 +7,7 @@ import type { ServerChannel } from '../../services/airi/channel-server' import { join, resolve } from 'node:path' import { defineInvokeHandler } from '@moeru/eventa' +import { safeClose } from '@proj-airi/electron-vueuse/main' import { BrowserWindow as ElectronBrowserWindow, shell } from 'electron' import icon from '../../../../resources/icon.png?asset' @@ -67,8 +68,7 @@ export function setupNoticeWindowManager(params: { if (!action?.id || action.id !== handle.id) return resolve(action.action === 'confirm') - if (!handle.window.isDestroyed()) - handle.window.close() + safeClose(handle.window) }) }) }, diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index f59b7d6a0..2d5f2b7e1 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -5,11 +5,12 @@ import { join, resolve } from 'node:path' import { defineInvokeHandler } from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/main' +import { safeClose } from '@proj-airi/electron-vueuse/main' import { BrowserWindow, ipcMain, shell } from 'electron' import icon from '../../../../resources/icon.png?asset' -import { electronOnboardingClose, electronOnboardingCompleted, electronOnboardingSkipped } from '../../../shared/eventa' +import { electronOnboardingClose } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createReusableWindow } from '../../libs/electron/window-manager' import { toggleWindowShow } from '../shared' @@ -64,9 +65,9 @@ export function setupOnboardingWindowManager(params: { const { context } = createContext(ipcMain, newWindow) - defineInvokeHandler(context, electronOnboardingClose, async () => newWindow.close()) - defineInvokeHandler(context, electronOnboardingCompleted, async () => newWindow.close()) - defineInvokeHandler(context, electronOnboardingSkipped, async () => newWindow.close()) + defineInvokeHandler(context, electronOnboardingClose, async () => { + safeClose(newWindow) + }) await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) diff --git a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts b/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts index aef61aa6a..4eca9739f 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/referenced-window.ts @@ -6,6 +6,7 @@ import type { ServerChannel } from '../../services/airi/channel-server' import { defineInvokeHandler } from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/main' +import { safeClose } from '@proj-airi/electron-vueuse/main' import { ipcMain } from 'electron' import { setupBaseWindowElectronInvokes } from './window' @@ -90,8 +91,8 @@ export function createReferencedWindowManager route.path === '/') // NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers. pluginHostInspectorStore.setBridge({ @@ -108,12 +109,15 @@ onMounted(() => updateThemeColor()) onMounted(async () => { analyticsStore.initialize() cardStore.initialize() - onboardingStore.initializeSetupCheck() await chatSessionStore.initialize() await displayModelsStore.loadDisplayModelsFromIndexedDB() await settingsStore.initializeStageModel() + if (shouldHandleOnboardingInCurrentWindow.value) { + await onboardingStore.initializeSetupCheck() + } + const serverChannelConfig = await getServerChannelConfig() serverChannelSettingsStore.websocketTlsConfig = serverChannelConfig.websocketTlsConfig @@ -147,8 +151,8 @@ watch(themeColorsHueDynamic, () => { document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value) }, { immediate: true }) -watch(() => onboardingStore.shouldShowSetup, () => { - if (onboardingStore.shouldShowSetup) { +watch([() => onboardingStore.shouldShowSetup, shouldHandleOnboardingInCurrentWindow], ([shouldShowSetup, shouldHandleOnboarding]) => { + if (shouldHandleOnboarding && shouldShowSetup) { openOnboarding() } }, { immediate: true }) diff --git a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue index de7887ef5..a8124ce0b 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue @@ -5,11 +5,7 @@ import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' import { useTheme } from '@proj-airi/ui' import { computed } from 'vue' -import { - electronOnboardingClose, - electronOnboardingCompleted, - electronOnboardingSkipped, -} from '../../shared/eventa' +import { electronOnboardingClose } from '../../shared/eventa' const onboardingStore = useOnboardingStore() const { isDark } = useTheme() @@ -17,18 +13,14 @@ const { isDark } = useTheme() const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white') const closeWindow = useElectronEventaInvoke(electronOnboardingClose) -const markCompleted = useElectronEventaInvoke(electronOnboardingCompleted) -const markSkipped = useElectronEventaInvoke(electronOnboardingSkipped) async function handleSkipped() { onboardingStore.markSetupSkipped() - await markSkipped() await closeWindow() } async function handleConfigured() { onboardingStore.markSetupCompleted() - await markCompleted() await closeWindow() } diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 549e5f01d..ebb27b8d2 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -208,8 +208,6 @@ export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Re // Onboarding window events export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close') -export const electronOnboardingCompleted = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:completed') -export const electronOnboardingSkipped = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:skipped') export const electronOpenOnboarding = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:open') export const i18nSetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:set-locale') diff --git a/packages/electron-vueuse/src/main/index.ts b/packages/electron-vueuse/src/main/index.ts index baed30e4c..8d76d918d 100644 --- a/packages/electron-vueuse/src/main/index.ts +++ b/packages/electron-vueuse/src/main/index.ts @@ -3,6 +3,7 @@ export type { LoopOptions } from './loop' export { createRendererLoop, isRendererUnavailable, + safeClose, shouldStopForRendererError, stopLoopWhenRendererIsGone, } from './renderer-loop' diff --git a/packages/electron-vueuse/src/main/renderer-loop.ts b/packages/electron-vueuse/src/main/renderer-loop.ts index a6c4ea417..210e51635 100644 --- a/packages/electron-vueuse/src/main/renderer-loop.ts +++ b/packages/electron-vueuse/src/main/renderer-loop.ts @@ -6,8 +6,20 @@ import { useLoop } from './loop' const rendererDisposedMessage = 'Render frame was disposed before WebFrameMain could be accessed' +export function safeClose(window?: BrowserWindow | null): boolean { + if (!window) { + return false + } + if (isRendererUnavailable(window)) { + return false + } + + window.close() + return true +} + export function isRendererUnavailable(window: BrowserWindow) { - return window.isDestroyed() || window.webContents.isDestroyed() || window.webContents.isCrashed() + return window.isDestroyed() || window?.webContents?.isDestroyed() || window?.webContents?.isCrashed() } export function shouldStopForRendererError(error: unknown) { diff --git a/packages/stage-ui/src/stores/onboarding.ts b/packages/stage-ui/src/stores/onboarding.ts index dd7cdb046..2759c41fb 100644 --- a/packages/stage-ui/src/stores/onboarding.ts +++ b/packages/stage-ui/src/stores/onboarding.ts @@ -1,9 +1,16 @@ import { useLocalStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, nextTick, ref } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import { useProvidersStore } from './providers' +const essentialProviderIds = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible'] as const +const credentialBasedEssentialProviderIds = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'deepseek'] as const + +function hasNonEmptyText(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + export const useOnboardingStore = defineStore('onboarding', () => { const providersStore = useProvidersStore() @@ -16,27 +23,56 @@ export const useOnboardingStore = defineStore('onboarding', () => { // Check if any essential provider is configured const hasEssentialProviderConfigured = computed(() => { - const essentialProviders = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible'] - return essentialProviders.some(providerId => providersStore.configuredProviders[providerId]) + return essentialProviderIds.some(providerId => providersStore.configuredProviders[providerId]) + }) + + // Fallback for app startup timing: + // If configured state has not been revalidated yet, infer "configured" + // from persisted essential credentials. + const hasEssentialProviderCredentialConfigured = computed(() => { + return credentialBasedEssentialProviderIds.some((providerId) => { + const providerConfig = providersStore.providers[providerId] as Record | undefined + if (!providerConfig) { + return false + } + + return hasNonEmptyText(providerConfig.apiKey) + }) }) // Check if first-time setup should be shown const needsOnboarding = computed(() => { - // Don't show if already completed or skipped - if (hasCompletedSetup.value || hasSkippedSetup.value) { - console.warn('Onboarding already completed or skipped') + if (hasSkippedSetup.value) { + console.warn('Onboarding already skipped') return false } - // Don't show if user already has essential providers configured - if (hasEssentialProviderConfigured.value) { - console.warn('Essential provider already configured, no onboarding needed') + const hasConfiguredEssentialProvider = hasEssentialProviderCredentialConfigured.value || hasEssentialProviderConfigured.value + + // Recover from stale completed flag (e.g. setup window closed unexpectedly): + // only treat completion as final when an essential provider is actually configured. + if (hasCompletedSetup.value && hasConfiguredEssentialProvider) { + console.warn('Onboarding already completed with configured provider') + return false + } + + // Don't show if user already has persisted essential credentials/runtime config. + if (hasConfiguredEssentialProvider) { + console.warn('Essential provider credentials already configured, no onboarding needed') return false } return true }) + // Keep in-memory display flag aligned with persisted onboarding status + // when setup is completed/skipped from another window (desktop multi-window case). + watch(needsOnboarding, (needSetup) => { + if (!needSetup) { + shouldShowSetup.value = false + } + }) + // Initialize setup check async function initializeSetupCheck() { if (needsOnboarding.value) { @@ -76,6 +112,7 @@ export const useOnboardingStore = defineStore('onboarding', () => { hasSkippedSetup, shouldShowSetup, hasEssentialProviderConfigured, + hasEssentialProviderCredentialConfigured, needsOnboarding, initializeSetupCheck, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8889da115..7c0e0f024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1187,6 +1187,9 @@ importers: std-env: specifier: ^4.0.0-rc.1 version: 4.0.0-rc.1 + superjson: + specifier: 'catalog:' + version: 2.2.6 three: specifier: ^0.183.1 version: 0.183.1