fix(stage-tamagotchi): double close caused crash, simplified onboarding, added safeClose (#1243)

---------

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
freezinlove
2026-03-10 17:39:58 +08:00
committed by GitHub
co-authored by Neko Ayaka
parent 2b5cebed1e
commit 5415b2850e
15 changed files with 91 additions and 37 deletions
@@ -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}',
+1
View File
@@ -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",
@@ -1,11 +1,13 @@
import type { BrowserWindow } from 'electron'
import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main'
export function createReusableWindow(setupFn: () => BrowserWindow | Promise<BrowserWindow>): { getWindow: () => Promise<BrowserWindow> } {
let window: BrowserWindow | undefined
let windowSetupFnPromise: Promise<BrowserWindow> | undefined
const ensureWindow = async () => {
if (window && !window.isDestroyed())
if (window && !isRendererUnavailable(window))
return window
if (windowSetupFnPromise)
@@ -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<typeof createC
defineInvokeHandler(params.context, electronWindowClose, (_, options) => {
if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) {
params.window.close()
safeClose(params.window)
}
})
}
@@ -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)
})
})
},
@@ -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 })
@@ -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<Payload extends RequestWindowPaylo
const ctx = windows.get(id)
if (!ctx)
return
if (!ctx.window.isDestroyed())
ctx.window.close()
safeClose(ctx.window)
windows.delete(id)
}
@@ -5,6 +5,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main'
import { isMacOS } from 'std-env'
import { createServerChannelService } from '../../services/airi/channel-server'
@@ -15,7 +16,7 @@ export function toggleWindowShow(window?: BrowserWindow | null): void {
if (!window) {
return
}
if (window.isDestroyed()) {
if (isRendererUnavailable(window)) {
return
}
+8 -4
View File
@@ -18,7 +18,7 @@ import { listProvidersForPluginHost, shouldPublishPluginHostCapabilities } from
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { toast, Toaster } from 'vue-sonner'
@@ -77,6 +77,7 @@ const listMcpTools = useElectronEventaInvoke(electronMcpListTools)
const callMcpTool = useElectronEventaInvoke(electronMcpCallTool)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const openOnboarding = useElectronEventaInvoke(electronOpenOnboarding)
const shouldHandleOnboardingInCurrentWindow = computed(() => 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 })
@@ -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()
}
</script>
@@ -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<void, Locale>('eventa:invoke:electron:i18n:set-locale')
@@ -3,6 +3,7 @@ export type { LoopOptions } from './loop'
export {
createRendererLoop,
isRendererUnavailable,
safeClose,
shouldStopForRendererError,
stopLoopWhenRendererIsGone,
} from './renderer-loop'
@@ -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) {
+46 -9
View File
@@ -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<string, unknown> | 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,
+3
View File
@@ -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