fix(stage-tamagotchi): language persistence electron fix for #1658 (#1812)

This commit is contained in:
LiaoZiqi-GZFLS
2026-05-13 12:07:25 +08:00
committed by GitHub
parent 2c432fc1c8
commit 0086eee9d6
7 changed files with 312 additions and 14 deletions
@@ -3,7 +3,7 @@ import { object, optional, picklist, string } from 'valibot'
import { createConfig } from '../libs/electron/persistence'
export const globalAppConfigSchema = object({
language: optional(string(), 'en'),
language: optional(string()),
updateChannel: optional(picklist(['latest', 'stable', 'alpha', 'beta', 'nightly', 'canary'])),
})
@@ -1,4 +1,3 @@
import type { LocaleDetector } from '@intlify/core'
import type { createContext } from '@moeru/eventa/adapters/electron/main'
import type { BrowserWindow } from 'electron'
import type { ProvidedBy } from 'injeca'
@@ -18,14 +17,11 @@ export async function createI18nService(params: { context: ReturnType<typeof cre
defineInvokeHandler(params.context, i18nSetLocale, (locale) => {
const current = config.get()
if (current) {
config.update({ ...current, language: locale as string })
}
config.update({ ...current, language: locale as string })
params.i18n.locale(locale)
})
defineInvokeHandler(params.context, i18nGetLocale, () => {
const locale = params.i18n.locale as () => string | LocaleDetector<any[]> | undefined
return locale()
return config.get()?.language
})
}
+12 -6
View File
@@ -20,7 +20,6 @@ import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { toast, Toaster } from 'vue-sonner'
@@ -32,6 +31,7 @@ import {
electronGodotStageStatusChanged,
electronSettingsNavigate,
electronStartTrackMousePosition,
i18nGetLocale,
i18nSetLocale,
} from '../shared/eventa'
import {
@@ -50,13 +50,13 @@ import {
} from '../shared/eventa/plugin/host'
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
import { useLanguage } from './composables/use-language'
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
import { useStageWindowLifecycleStore } from './stores/stage-window-lifecycle'
const { isDark: dark } = useTheme()
const i18n = useI18n()
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
@@ -92,6 +92,7 @@ const unloadPlugin = useElectronEventaInvoke(electronPluginUnload)
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
@@ -156,10 +157,7 @@ void mcpToolsStore.refresh().catch((error) => {
})
void refreshPluginRuntimeTools()
watch(language, () => {
i18n.locale.value = language.value || 'en'
setLocale(language.value || 'en')
})
const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale)
watch([activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions], () => {
if (activeProvider.value) {
@@ -198,6 +196,14 @@ context.value.on(electronGodotStageStatusChanged, (event) => {
})
onMounted(async () => {
// NOTICE: Issue #1658
// When Electron restarts, renderer localStorage may not be flushed to disk.
// The store's onMounted hook falls back to navigator.language, which triggers
// watch(language) and overwrites the main-process config with the OS locale.
// We must restore the correct locale from main process before allowing sync.
// https://github.com/moeru-ai/airi/issues/1658
await restoreLocale()
analyticsStore.initialize()
await displayModelsStore.initialize()
cardStore.initialize()
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useLanguage } from './use-language'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: { value: 'en' },
}),
}))
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
vi.mock('@proj-airi/stage-shared/composables', () => ({
useLocalStorageManualReset: vi.fn((key: string, initialValue: string) => {
// Tests control persisted state by pre-seeding localStorage before each case
const stored = localStorageMock.getItem(key)
const value = stored !== null ? stored : initialValue
return ref(value)
}),
}))
describe('useLanguage', () => {
beforeEach(() => {
vi.restoreAllMocks()
localStorageMock.clear()
})
// ROOT CAUSE:
// https://github.com/moeru-ai/airi/issues/1658
// When Electron restarts, renderer localStorage may not be flushed.
// The store's onMounted hook falls back to navigator.language, then
// watch(language) propagates that wrong locale back to main config.
// useLanguage prevents this by guarding sync until the correct
// locale is restored from the main-process config.
it('issue #1658: restores correct locale from main process when store fallback is wrong', async () => {
const language = ref('zh-Hans') // simulate store fallback to OS locale
const getMainLocale = vi.fn(async () => 'zh-Hant') // main has user selection
const setLocale = vi.fn(async () => {})
// No persisted language in localStorage → renderer lost its setting
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
expect(getMainLocale).toHaveBeenCalledTimes(1)
expect(language.value).toBe('zh-Hant')
expect(setLocale).toHaveBeenCalledWith('zh-Hant')
})
it('issue #1658: does not change language when main locale matches store', async () => {
const language = ref('zh-Hant')
const getMainLocale = vi.fn(async () => 'zh-Hant')
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
expect(language.value).toBe('zh-Hant')
expect(setLocale).toHaveBeenCalledWith('zh-Hant')
})
it('does not overwrite valid renderer locale when persisted language exists', async () => {
localStorage.setItem('settings/language', 'ja')
const language = ref('ja') // user explicitly set this before
const getMainLocale = vi.fn(async () => 'en')
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
// Should NOT call getMainLocale because renderer has persisted value
expect(getMainLocale).not.toHaveBeenCalled()
expect(language.value).toBe('ja')
expect(setLocale).toHaveBeenCalledWith('ja')
})
it('preserves OS locale on first launch when main has no saved language', async () => {
const language = ref('zh-Hans') // OS-detected fallback
const getMainLocale = vi.fn(async () => undefined) // no config file yet
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
expect(getMainLocale).toHaveBeenCalledTimes(1)
expect(language.value).toBe('zh-Hans') // keep OS fallback
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
})
it('restores explicit English choice after localStorage loss', async () => {
const language = ref('zh-Hans') // store fallback to OS locale
const getMainLocale = vi.fn(async () => 'en') // user explicitly chose English
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
expect(getMainLocale).toHaveBeenCalledTimes(1)
expect(language.value).toBe('en')
expect(setLocale).toHaveBeenCalledWith('en')
})
it('continues startup when getMainLocale fails', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const language = ref('zh-Hans')
const getMainLocale = vi.fn(async () => {
throw new Error('IPC timeout')
})
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
// Should not throw; should still enable sync and use current value
expect(language.value).toBe('zh-Hans')
expect(setLocale).toHaveBeenCalledWith('zh-Hans')
expect(consoleSpy).toHaveBeenCalledWith(
'[useLanguage] Failed to get locale from main process, using fallback:',
expect.any(Error),
)
consoleSpy.mockRestore()
})
it('does not sync to main before restore is called', () => {
const language = ref('en')
const getMainLocale = vi.fn(async () => 'en')
const setLocale = vi.fn(async () => {})
useLanguage(language, getMainLocale, setLocale)
// Simulate the store's onMounted fallback changing language
language.value = 'zh-Hans'
expect(setLocale).not.toHaveBeenCalled()
})
it('syncs to main after restore is called', async () => {
const language = ref('en')
const getMainLocale = vi.fn(async () => 'en')
const setLocale = vi.fn(async () => {})
const { restore } = useLanguage(language, getMainLocale, setLocale)
await restore()
// Clear the restore() call so we only assert the post-restore sync
setLocale.mockClear()
// Now changes should propagate
language.value = 'ja'
await nextTick()
expect(setLocale).toHaveBeenCalledWith('ja')
})
})
@@ -0,0 +1,63 @@
import type { Ref } from 'vue'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* Manages language sync between renderer and main process, guarding
* against Electron localStorage flush issues on restart.
*
* Use when:
* - Electron restarts and renderer localStorage may not have been flushed
*
* Expects:
* - `language` is the reactive language ref from the settings store
* - `getMainLocale` returns the raw locale persisted in main-process config
* (`undefined` when no config exists yet, a string when user saved one)
* - `setLocale` syncs the renderer locale back to main process
*
* Returns:
* - `restore()` to be called during component onMounted
*/
export function useLanguage(
language: Ref<string>,
getMainLocale: () => Promise<unknown>,
setLocale: (locale: string) => Promise<unknown> | unknown,
) {
const i18n = useI18n()
const persistedLanguage = useLocalStorageManualReset<string>('settings/language', '')
const hasPersistedLanguage = persistedLanguage.value !== ''
let isLocaleSynced = false
// Guard: do not propagate the store's navigator.language fallback back
// to main-process config before we have verified the correct locale.
watch(language, () => {
i18n.locale.value = language.value || 'en'
if (isLocaleSynced) {
void setLocale(language.value || 'en')
}
})
async function restore() {
// Only trust main-process locale when renderer has lost its own setting.
// When main returns undefined, no language has ever been explicitly saved
// (true first launch), so we keep the renderer's OS-detected fallback.
// When main returns a string, that is the user's explicit choice.
if (!hasPersistedLanguage) {
try {
const mainLocale = await getMainLocale()
if (typeof mainLocale === 'string' && mainLocale && mainLocale !== language.value) {
language.value = mainLocale
}
}
catch (error) {
console.warn('[useLanguage] Failed to get locale from main process, using fallback:', error)
}
}
isLocaleSynced = true
void setLocale(language.value || 'en')
}
return { restore }
}
@@ -414,7 +414,7 @@ export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa
export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electron:auth:logout')
export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
export const i18nGetLocale = defineInvokeEventa<Locale>('eventa:invoke:electron:i18n:get-locale')
export const i18nGetLocale = defineInvokeEventa<string | undefined>('eventa:invoke:electron:i18n:get-locale')
export { electron } from '@proj-airi/electron-eventa'
export * from '@proj-airi/electron-eventa/electron-updater'
@@ -0,0 +1,67 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsGeneral } from './general'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
describe('store settings-general', () => {
let store: Record<string, string>
let localStorageMock: Storage
beforeEach(() => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
setActivePinia(pinia)
store = {}
localStorageMock = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { for (const key in store) delete store[key] }),
length: 0,
key: vi.fn(() => null),
} as unknown as Storage
vi.stubGlobal('localStorage', localStorageMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
// ROOT CAUSE:
// https://github.com/moeru-ai/airi/issues/1658
// When Electron client is fully restarted, renderer localStorage may not be
// flushed to disk. On next startup, getLanguage() finds no persisted value
// and falls back to navigator.language (OS locale), ignoring the user's
// previous selection.
it('issue #1658: falls back to navigator.language when localStorage is empty', () => {
// Simulate Electron restart where localStorage for language is lost
vi.stubGlobal('navigator', { language: 'zh-CN' })
const settingsStore = useSettingsGeneral()
const resolvedLanguage = settingsStore.getLanguage()
// navigator.language 'zh-CN' gets remapped to 'zh-Hans'
expect(resolvedLanguage).toBe('zh-Hans')
expect(localStorageMock.getItem).toHaveBeenCalledWith('settings/language')
})
it('issue #1658: returns persisted language when localStorage has a value', () => {
// User previously selected Traditional Chinese
store['settings/language'] = 'zh-Hant'
vi.stubGlobal('navigator', { language: 'zh-CN' })
const settingsStore = useSettingsGeneral()
const resolvedLanguage = settingsStore.getLanguage()
// Should respect the persisted language, not fallback to navigator.language
expect(resolvedLanguage).toBe('zh-Hant')
})
})