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:
co-authored by
Neko Ayaka
parent
2b5cebed1e
commit
5415b2850e
@@ -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}',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user