diff --git a/apps/stage-tamagotchi/src/main/configs/global.ts b/apps/stage-tamagotchi/src/main/configs/global.ts index 211307bda..88477d414 100644 --- a/apps/stage-tamagotchi/src/main/configs/global.ts +++ b/apps/stage-tamagotchi/src/main/configs/global.ts @@ -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'])), }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts b/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts index 303e08af6..e98df79c8 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/i18n/index.ts @@ -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 { 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 | undefined - return locale() + return config.get()?.language }) } diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 754c9c6a0..cc51f7841 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -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() diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts new file mode 100644 index 000000000..2d30094cd --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-language.ts b/apps/stage-tamagotchi/src/renderer/composables/use-language.ts new file mode 100644 index 000000000..c56a3ef94 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-language.ts @@ -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, + getMainLocale: () => Promise, + setLocale: (locale: string) => Promise | unknown, +) { + const i18n = useI18n() + const persistedLanguage = useLocalStorageManualReset('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 } +} diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 3c07b3ee4..b99a5accd 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -414,7 +414,7 @@ export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electron:auth:logout') export const i18nSetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:set-locale') -export const i18nGetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:get-locale') +export const i18nGetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:get-locale') export { electron } from '@proj-airi/electron-eventa' export * from '@proj-airi/electron-eventa/electron-updater' diff --git a/packages/stage-ui/src/stores/settings/general.test.ts b/packages/stage-ui/src/stores/settings/general.test.ts new file mode 100644 index 000000000..eca515879 --- /dev/null +++ b/packages/stage-ui/src/stores/settings/general.test.ts @@ -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 + 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') + }) +})