This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user