feat(desktop): detect and persist the display locale

This commit is contained in:
程序员阿江(Relakkes) 2026-07-29 13:09:03 +08:00
parent de52656bb2
commit d92ac7f2c0
26 changed files with 715 additions and 26 deletions

View File

@ -16,6 +16,9 @@ describe('Electron IPC capabilities', () => {
it('rejects channels outside the desktop host contract', () => {
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetVersion)).toBe(true)
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetLocalePreference)).toBe(true)
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appSetLocalePreference)).toBe(true)
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages)).toBe(true)
expect(isElectronIpcChannel('ipcRenderer:send-anything')).toBe(false)
})
@ -74,6 +77,8 @@ describe('Electron IPC capabilities', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: '' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890', extra: true })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.appSetLocalePreference, 'zh-TW')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.appSetLocalePreference, 'fr')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsList, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsList, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsCreateFromImage, {
@ -199,6 +204,15 @@ describe('Electron IPC capabilities', () => {
})
it('gives the pet renderer only runtime bootstrap and companion controls', () => {
expect(isElectronIpcChannelAllowedForPetWindow(
ELECTRON_IPC_CHANNELS.appGetLocalePreference,
)).toBe(true)
expect(isElectronIpcChannelAllowedForPetWindow(
ELECTRON_IPC_CHANNELS.appSetLocalePreference,
)).toBe(false)
expect(isElectronIpcChannelAllowedForPetWindow(
ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages,
)).toBe(true)
expect(isElectronIpcChannelAllowedForPetWindow(
ELECTRON_IPC_CHANNELS.runtimeGetServerUrl,
)).toBe(true)

View File

@ -203,8 +203,18 @@ const updateCheckOptions: Validator = value => {
return value.proxy === undefined || (typeof value.proxy === 'string' && value.proxy.trim().length > 0)
}
const localePreference: Validator = value =>
value === 'en'
|| value === 'zh'
|| value === 'zh-TW'
|| value === 'jp'
|| value === 'kr'
export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.appGetLocalePreference]: noPayload,
[ELECTRON_IPC_CHANNELS.appSetLocalePreference]: localePreference,
[ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken]: noPayload,
@ -274,6 +284,8 @@ const allowedChannels = new Set<ElectronIpcChannel>(
)
const petWindowChannels = new Set<ElectronIpcChannel>([
ELECTRON_IPC_CHANNELS.appGetLocalePreference,
ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages,
ELECTRON_IPC_CHANNELS.runtimeGetServerUrl,
ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken,
ELECTRON_IPC_CHANNELS.petsList,

View File

@ -1,5 +1,8 @@
export const ELECTRON_IPC_CHANNELS = {
appGetVersion: 'desktop:app:get-version',
appGetLocalePreference: 'desktop:app:get-locale-preference',
appSetLocalePreference: 'desktop:app:set-locale-preference',
appGetPreferredSystemLanguages: 'desktop:app:get-preferred-system-languages',
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
runtimeGetLocalAccessToken: 'desktop:runtime:get-local-access-token',
runtimeGetPetAccessToken: 'desktop:runtime:get-pet-access-token',
@ -66,6 +69,7 @@ export const ELECTRON_IPC_CHANNELS = {
export const ELECTRON_EVENT_CHANNELS = {
event: 'desktop:event',
appLocaleChanged: 'desktop:app:locale-changed',
webviewDragDrop: 'desktop:webview:drag-drop',
notificationAction: 'desktop:notification:action',
updateDownloadEvent: 'desktop:update:download-event',

View File

@ -58,6 +58,11 @@ import {
writePetWindowPosition,
type PetWindowDragPayload,
} from './services/petWindow'
import {
readLocalePreference,
writeLocalePreference,
} from './services/localePreference'
import type { Locale } from '../src/i18n/locale'
import {
createCustomPetCatalogLoader,
createCustomPetFromAtlas,
@ -378,6 +383,13 @@ function emitNotificationAction(payload: unknown) {
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.notificationAction, payload)
}
function broadcastLocaleChanged(locale: Locale) {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) continue
window.webContents.send(ELECTRON_EVENT_CHANNELS.appLocaleChanged, locale)
}
}
async function handleCommandInvoke(payload: unknown): Promise<unknown> {
const { command, args } = payload as { command: string, args?: Record<string, unknown> }
@ -409,6 +421,22 @@ function registerIpcHandlers() {
void getPreviewService().sendMessageToRenderer(event.sender, raw, mainWindow?.webContents)
})
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
registerHandler(
ELECTRON_IPC_CHANNELS.appGetLocalePreference,
() => readLocalePreference(app),
)
registerHandler(ELECTRON_IPC_CHANNELS.appSetLocalePreference, (event, payload) => {
if (currentWindow(event) !== mainWindow) {
throw new Error('Only the main window can change the locale preference')
}
const locale = payload as Locale
writeLocalePreference(app, locale)
broadcastLocaleChanged(locale)
})
registerHandler(
ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages,
() => app.getPreferredSystemLanguages(),
)
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
registerHandler(
ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken,

View File

@ -4,7 +4,9 @@ import {
validateElectronIpcPayload,
} from './ipc/capabilities'
import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
import { ELECTRON_EVENT_CHANNELS } from './ipc/channels'
import type { DesktopHost } from '../src/lib/desktopHost/types'
import type { Locale } from '../src/i18n/locale'
function invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
if (!isElectronIpcChannelAllowedForPetWindow(channel)) {
@ -37,6 +39,19 @@ const petHost = {
// server-enforced companion capability, never the desktop master token.
getLocalAccessToken: () => invoke<string | null>(ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken),
},
app: {
getLocalePreference: () =>
invoke<Locale | null>(ELECTRON_IPC_CHANNELS.appGetLocalePreference),
getPreferredSystemLanguages: () =>
invoke<string[]>(ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages),
onLocaleChanged: (handler: (locale: Locale) => void) => {
const listener = (_event: Electron.IpcRendererEvent, locale: Locale) => handler(locale)
ipcRenderer.on(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener)
return Promise.resolve(() => {
ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener)
})
},
},
appearance: {
// The pet window is transparent and shares the renderer bootstrap, which
// reports the applied theme. It has no native chrome to sync, and it must

View File

@ -0,0 +1,52 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
LOCALE_PREFERENCE_FILE,
localePreferencePath,
readLocalePreference,
writeLocalePreference,
} from './localePreference'
describe('locale preference persistence', () => {
let root: string
let app: { getPath(name: 'userData'): string }
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-locale-preference-'))
app = { getPath: () => root }
})
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true })
})
it('treats a missing preference as follow-system mode', () => {
expect(readLocalePreference(app)).toBeNull()
})
it('persists a valid manual locale across service instances', () => {
writeLocalePreference(app, 'jp')
expect(readLocalePreference(app)).toBe('jp')
expect(JSON.parse(fs.readFileSync(localePreferencePath(app), 'utf8'))).toEqual({
locale: 'jp',
})
})
it('ignores malformed, unsupported, and extra persisted fields', () => {
fs.writeFileSync(localePreferencePath(app), '{ broken')
expect(readLocalePreference(app)).toBeNull()
fs.writeFileSync(localePreferencePath(app), JSON.stringify({ locale: 'fr' }))
expect(readLocalePreference(app)).toBeNull()
fs.writeFileSync(localePreferencePath(app), JSON.stringify({ locale: 'en', extra: true }))
expect(readLocalePreference(app)).toBeNull()
})
it('stores the preference in the app-owned userData directory', () => {
expect(localePreferencePath(app)).toBe(path.join(root, LOCALE_PREFERENCE_FILE))
})
})

View File

@ -0,0 +1,57 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import {
isLocale,
type Locale,
} from '../../src/i18n/locale'
export const LOCALE_PREFERENCE_FILE = 'locale-preference.json'
export type LocalePreferenceAppLike = {
getPath(name: 'userData'): string
}
type StoredLocalePreference = {
locale: Locale
}
export function localePreferencePath(app: LocalePreferenceAppLike): string {
return path.join(app.getPath('userData'), LOCALE_PREFERENCE_FILE)
}
export function readLocalePreference(app: LocalePreferenceAppLike): Locale | null {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(localePreferencePath(app), 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
const entries = Object.entries(parsed)
if (entries.length !== 1 || entries[0]?.[0] !== 'locale') return null
return isLocale(entries[0][1]) ? entries[0][1] : null
} catch {
return null
}
}
export function writeLocalePreference(
app: LocalePreferenceAppLike,
locale: Locale,
): void {
if (!isLocale(locale)) {
throw new Error(`Unsupported locale preference: ${String(locale)}`)
}
const target = localePreferencePath(app)
const configDir = path.dirname(target)
const temporary = path.join(configDir, `.${LOCALE_PREFERENCE_FILE}.${randomUUID()}.tmp`)
const preference: StoredLocalePreference = { locale }
fs.mkdirSync(configDir, { recursive: true })
try {
fs.writeFileSync(temporary, `${JSON.stringify(preference, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
})
fs.renameSync(temporary, target)
} finally {
fs.rmSync(temporary, { force: true })
}
}

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--

View File

@ -146,6 +146,7 @@ function installElectronDesktopHost() {
zoom: true,
},
app: {
...browserHost.app,
getVersion: vi.fn().mockResolvedValue('0.3.2'),
},
dialogs: {
@ -2414,6 +2415,7 @@ describe('Settings > About tab', () => {
updates: true,
},
app: {
...browserHost.app,
getVersion: vi.fn().mockRejectedValue(new Error('version IPC failed')),
},
}

View File

@ -1,13 +1,18 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { BrowserAddressBar } from './BrowserAddressBar'
import { useSettingsStore } from '../../stores/settingsStore'
const baseProps = {
url: 'http://localhost:5173/', canGoBack: false, canGoForward: false,
onNavigate: vi.fn(), onBack: vi.fn(), onForward: vi.fn(), onReload: vi.fn(),
}
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
})
afterEach(() => {
cleanup()
})

View File

@ -1,6 +1,6 @@
import '@testing-library/jest-dom'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
Object.defineProperty(globalThis, 'ResizeObserver', {
@ -30,6 +30,10 @@ import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
import { useSettingsStore } from '../../stores/settingsStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()

View File

@ -1,9 +1,10 @@
import { createRef } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
import { useSettingsStore } from '../../stores/settingsStore'
const options = ['low', 'medium', 'high', 'xhigh', 'max'] as const
const labels = {
@ -14,6 +15,10 @@ const labels = {
max: '最大',
}
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
})
afterEach(cleanup)
function renderPopover(overrides: Partial<React.ComponentProps<typeof ReasoningEffortPopover>> = {}) {

View File

@ -1,8 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { splitStartupError, StartupErrorView } from './StartupErrorView'
import { useSettingsStore } from '../../stores/settingsStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
})
describe('splitStartupError', () => {
it('separates the timeout message from captured sidecar logs', () => {

View File

@ -17,6 +17,11 @@ vi.mock('../chat/MermaidRenderer', () => ({
}))
import { MarkdownRenderer, __markdownParseCacheInternals } from './MarkdownRenderer'
import { useSettingsStore } from '../../stores/settingsStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'zh' })
})
function visibleMathText(container: HTMLElement): string {
const clone = container.cloneNode(true) as HTMLElement

View File

@ -5,8 +5,7 @@ import { zh } from './locales/zh'
import { zh as zhTW } from './locales/zh-TW'
import { jp } from './locales/jp'
import { kr } from './locales/kr'
export type Locale = 'en' | 'zh' | 'zh-TW' | 'jp' | 'kr'
import type { Locale } from './locale'
const translations: Record<Locale, Record<string, string>> = {
en,
@ -65,3 +64,4 @@ export function t(key: TranslationKey, params?: Record<string, string | number>)
}
export type { TranslationKey }
export type { Locale } from './locale'

View File

@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
initializeLocale,
resolveSupportedLocale,
subscribeLocaleChanges,
} from './locale'
function mockBrowserLanguages(languages: string[], language = languages[0] ?? '') {
vi.spyOn(window.navigator, 'languages', 'get').mockReturnValue(languages)
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue(language)
}
describe('locale detection', () => {
beforeEach(() => {
vi.restoreAllMocks()
window.localStorage.clear()
document.documentElement.lang = 'en'
})
it('normalizes BCP 47 tags and selects the first supported language', () => {
expect(resolveSupportedLocale(['fr-FR', 'zh_Hant_HK', 'en-US'])).toBe('zh-TW')
expect(resolveSupportedLocale(['zh-Hans-HK'])).toBe('zh')
})
it('uses Electron preferred system languages before the renderer fallback', async () => {
mockBrowserLanguages(['en-US'])
const host = {
getLocalePreference: vi.fn().mockResolvedValue(null),
getPreferredSystemLanguages: vi.fn().mockResolvedValue(['fr-FR', 'ko-KR']),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn().mockResolvedValue(() => {}),
}
await expect(initializeLocale(host)).resolves.toBe('kr')
expect(document.documentElement.lang).toBe('ko')
})
it('falls back to renderer languages when the native lookup fails', async () => {
mockBrowserLanguages(['ja-JP'])
await expect(initializeLocale({
getLocalePreference: () => Promise.reject(new Error('IPC unavailable')),
getPreferredSystemLanguages: () => Promise.reject(new Error('IPC unavailable')),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn().mockResolvedValue(() => {}),
})).resolves.toBe('jp')
expect(document.documentElement.lang).toBe('ja')
})
it('migrates a stored main-window choice without consulting the system again', async () => {
window.localStorage.setItem('cc-haha-locale', 'zh-TW')
const host = {
getLocalePreference: vi.fn().mockResolvedValue(null),
getPreferredSystemLanguages: vi.fn().mockResolvedValue(['en-US']),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn().mockResolvedValue(() => {}),
}
await expect(initializeLocale(host)).resolves.toBe('zh-TW')
expect(host.setLocalePreference).toHaveBeenCalledWith('zh-TW')
expect(host.getLocalePreference).not.toHaveBeenCalled()
expect(host.getPreferredSystemLanguages).not.toHaveBeenCalled()
expect(document.documentElement.lang).toBe('zh-TW')
})
it('uses the app-level manual preference in an isolated companion partition', async () => {
mockBrowserLanguages(['zh-CN'])
await expect(initializeLocale({
getLocalePreference: vi.fn().mockResolvedValue('jp'),
getPreferredSystemLanguages: vi.fn().mockResolvedValue(['zh-CN']),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn().mockResolvedValue(() => {}),
})).resolves.toBe('jp')
expect(document.documentElement.lang).toBe('ja')
})
it('applies app-level locale changes to an already running companion window', async () => {
let onLocaleChanged: ((locale: 'kr') => void) | undefined
const listener = vi.fn()
const unsubscribe = subscribeLocaleChanges(listener)
await initializeLocale({
getLocalePreference: vi.fn().mockResolvedValue('en'),
getPreferredSystemLanguages: vi.fn().mockResolvedValue(['en-US']),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn(async (handler) => {
onLocaleChanged = handler
return () => {}
}),
})
onLocaleChanged?.('kr')
expect(listener).toHaveBeenLastCalledWith('kr')
expect(document.documentElement.lang).toBe('ko')
unsubscribe()
})
it('uses navigator.language when navigator.languages is unavailable', async () => {
vi.spyOn(window.navigator, 'languages', 'get').mockImplementation(() => {
throw new Error('languages unavailable')
})
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ja-JP')
await expect(initializeLocale({
getLocalePreference: vi.fn().mockResolvedValue(null),
getPreferredSystemLanguages: vi.fn().mockRejectedValue(new Error('IPC unavailable')),
setLocalePreference: vi.fn().mockResolvedValue(undefined),
onLocaleChanged: vi.fn().mockResolvedValue(() => {}),
})).resolves.toBe('jp')
})
})

146
desktop/src/i18n/locale.ts Normal file
View File

@ -0,0 +1,146 @@
export type Locale = 'en' | 'zh' | 'zh-TW' | 'jp' | 'kr'
export const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const VALID_LOCALES: readonly Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
const DOCUMENT_LANG: Record<Locale, string> = {
en: 'en',
zh: 'zh-CN',
'zh-TW': 'zh-TW',
jp: 'ja',
kr: 'ko',
}
let initializedLocale: Locale | null = null
const localeChangeListeners = new Set<(locale: Locale) => void>()
export type LocaleRuntime = {
getLocalePreference(): Promise<Locale | null>
getPreferredSystemLanguages(): Promise<string[]>
setLocalePreference(locale: Locale): Promise<void>
onLocaleChanged(handler: (locale: Locale) => void): Promise<() => void>
}
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string'
&& (VALID_LOCALES as readonly string[]).includes(value)
}
export function readBrowserLanguages(): string[] {
if (typeof navigator === 'undefined') return []
try {
const languages = navigator.languages
if (Array.isArray(languages) && languages.length > 0) return [...languages]
} catch {
// Continue to the singular language fallback.
}
try {
return navigator.language ? [navigator.language] : []
} catch {
return []
}
}
function mapLanguageTag(languageTag: string): Locale | null {
const [language, ...subtags] = languageTag.trim().replace(/_/g, '-').toLowerCase().split('-')
if (language === 'en') return 'en'
if (language === 'ja') return 'jp'
if (language === 'ko') return 'kr'
if (language !== 'zh') return null
if (subtags.includes('hant')) return 'zh-TW'
if (subtags.includes('hans')) return 'zh'
return subtags.some(subtag => subtag === 'tw' || subtag === 'hk' || subtag === 'mo')
? 'zh-TW'
: 'zh'
}
export function resolveSupportedLocale(languageTags: readonly string[]): Locale {
for (const languageTag of languageTags) {
const locale = mapLanguageTag(languageTag)
if (locale) return locale
}
return 'en'
}
export function readStoredLocale(): Locale | null {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (isLocale(stored)) return stored
} catch {
// localStorage can be unavailable in locked-down browser contexts.
}
return null
}
export function getInitialLocale(): Locale {
return readStoredLocale()
?? initializedLocale
?? resolveSupportedLocale(readBrowserLanguages())
}
export function applyDocumentLocale(locale: Locale): void {
if (typeof document !== 'undefined') document.documentElement.lang = DOCUMENT_LANG[locale]
}
function applyResolvedLocale(locale: Locale): void {
initializedLocale = locale
applyDocumentLocale(locale)
for (const listener of localeChangeListeners) listener(locale)
}
export function subscribeLocaleChanges(listener: (locale: Locale) => void): () => void {
localeChangeListeners.add(listener)
return () => {
localeChangeListeners.delete(listener)
}
}
export async function initializeLocale(
runtime: LocaleRuntime,
): Promise<Locale> {
try {
await runtime.onLocaleChanged((locale) => {
if (isLocale(locale)) applyResolvedLocale(locale)
})
} catch {
// A host without event support can still initialize from the snapshot below.
}
const storedLocale = readStoredLocale()
if (storedLocale) {
try {
await runtime.setLocalePreference(storedLocale)
} catch {
// localStorage remains the main-window compatibility source.
}
applyResolvedLocale(storedLocale)
return storedLocale
}
try {
const appPreference = await runtime.getLocalePreference()
if (isLocale(appPreference)) {
applyResolvedLocale(appPreference)
return appPreference
}
} catch {
// Fall through to system language detection.
}
let languageTags: string[] = []
try {
const preferredSystemLanguages = await runtime.getPreferredSystemLanguages()
languageTags = preferredSystemLanguages.length > 0
? preferredSystemLanguages
: readBrowserLanguages()
} catch {
languageTags = readBrowserLanguages()
}
const locale = resolveSupportedLocale(languageTags)
applyResolvedLocale(locale)
return locale
}

View File

@ -6,6 +6,7 @@ import type {
NotificationPermissionState,
} from './types'
import { buildTraceWindowUrl } from '../traceLaunch'
import { readBrowserLanguages } from '../../i18n/locale'
const browserCapabilities: DesktopHostCapabilities = {
appMode: false,
@ -51,6 +52,18 @@ export const browserHost: DesktopHost = {
async getVersion() {
return '0.1.0'
},
async getLocalePreference() {
return null
},
async setLocalePreference() {
// Browser/H5 preferences stay in renderer localStorage.
},
async getPreferredSystemLanguages() {
return readBrowserLanguages()
},
async onLocaleChanged() {
return noopUnlisten
},
},
commands: {
async invoke() {

View File

@ -73,6 +73,29 @@ describe('desktop host contract', () => {
})).resolves.toBeUndefined()
})
it('uses browser language preferences outside Electron', async () => {
const languages = vi.spyOn(window.navigator, 'languages', 'get').mockReturnValue(['ja-JP', 'en-US'])
await expect(browserHost.app.getPreferredSystemLanguages()).resolves.toEqual(['ja-JP', 'en-US'])
await expect(browserHost.app.getLocalePreference()).resolves.toBeNull()
await expect(browserHost.app.setLocalePreference('jp')).resolves.toBeUndefined()
await expect(browserHost.app.onLocaleChanged(vi.fn())).resolves.toEqual(expect.any(Function))
languages.mockRestore()
})
it('uses navigator.language when the browser language list is unavailable', async () => {
const languages = vi.spyOn(window.navigator, 'languages', 'get').mockImplementation(() => {
throw new Error('languages unavailable')
})
const language = vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR')
await expect(browserHost.app.getPreferredSystemLanguages()).resolves.toEqual(['ko-KR'])
languages.mockRestore()
language.mockRestore()
})
it('detects the browser fallback when native host globals are absent', () => {
expect(createDesktopHost({ electronHost: null })).toBe(browserHost)
})

View File

@ -3,6 +3,41 @@ import { ELECTRON_EVENT_CHANNELS, ELECTRON_IPC_CHANNELS } from '../../../electro
import { createElectronHost } from './electronHost'
describe('electron desktop host', () => {
it('synchronizes locale preferences through narrow app IPC boundaries', async () => {
const invoke = vi.fn()
.mockResolvedValueOnce('jp')
.mockResolvedValueOnce(['zh-Hant-TW', 'en-US'])
.mockResolvedValueOnce(undefined)
const subscribe = vi.fn().mockResolvedValue(() => {})
const host = createElectronHost({
invoke,
subscribe,
})
await expect(host.app.getLocalePreference()).resolves.toBe('jp')
await expect(host.app.getPreferredSystemLanguages()).resolves.toEqual(['zh-Hant-TW', 'en-US'])
await host.app.setLocalePreference('kr')
const handler = vi.fn()
await host.app.onLocaleChanged(handler)
expect(invoke).toHaveBeenNthCalledWith(
1,
ELECTRON_IPC_CHANNELS.appGetLocalePreference,
undefined,
)
expect(invoke).toHaveBeenNthCalledWith(
2,
ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages,
undefined,
)
expect(invoke).toHaveBeenNthCalledWith(
3,
ELECTRON_IPC_CHANNELS.appSetLocalePreference,
'kr',
)
expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.appLocaleChanged, handler)
})
it('wraps dialog, shell URL, and shell path calls in explicit IPC channels', async () => {
const invoke = vi.fn().mockResolvedValue('/tmp/report.md')
const host = createElectronHost({

View File

@ -80,6 +80,10 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
},
app: {
getVersion: () => invoke(ELECTRON_IPC_CHANNELS.appGetVersion),
getLocalePreference: () => invoke(ELECTRON_IPC_CHANNELS.appGetLocalePreference),
setLocalePreference: locale => invoke(ELECTRON_IPC_CHANNELS.appSetLocalePreference, locale),
getPreferredSystemLanguages: () => invoke(ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages),
onLocaleChanged: handler => subscribe(ELECTRON_EVENT_CHANNELS.appLocaleChanged, handler),
},
commands: {
invoke: (command, args) => invoke(ELECTRON_IPC_CHANNELS.commandInvoke, { command, args }),

View File

@ -2,6 +2,7 @@ import type {
AppMode as SettingsAppMode,
AppModeConfig as SettingsAppModeConfig,
} from '../../types/settings'
import type { Locale } from '../../i18n/locale'
export type DesktopHostKind = 'browser' | 'electron'
@ -246,6 +247,10 @@ export type DesktopHost = {
}
app: {
getVersion(): Promise<string>
getLocalePreference(): Promise<Locale | null>
setLocalePreference(locale: Locale): Promise<void>
getPreferredSystemLanguages(): Promise<string[]>
onLocaleChanged(handler: (locale: Locale) => void): Promise<DesktopHostUnlisten>
}
commands: {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>

View File

@ -5,6 +5,8 @@ import './theme/globals.css'
import { initializeAppZoom } from './lib/appZoom'
import { initializeTouchH5 } from './lib/touchH5'
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
import { getDesktopHost } from './lib/desktopHost'
import { initializeLocale } from './i18n/locale'
declare global {
interface Window {
@ -45,6 +47,7 @@ export async function bootstrapDesktopApp(
loadModules: () => Promise<DesktopBootstrapModules> = loadDesktopBootstrapModules,
) {
try {
await initializeLocale(getDesktopHost().app)
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await loadModules()
initializeTheme()
installClientDiagnosticsCapture()

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '../api/client'
import type { Locale } from '../i18n/locale'
import { browserHost } from '../lib/desktopHost/browserHost'
function createDeferred<T>() {
@ -12,25 +13,151 @@ function createDeferred<T>() {
return { promise, resolve, reject }
}
function mockSystemLanguages(languages: string[], language = languages[0] ?? '') {
vi.spyOn(window.navigator, 'languages', 'get').mockReturnValue(languages)
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue(language)
}
describe('settingsStore locale defaults', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.resetModules()
window.localStorage.clear()
})
it('defaults to Chinese when no locale is stored', async () => {
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().locale).toBe('zh')
afterEach(() => {
delete window.desktopHost
})
it('keeps a stored locale override', async () => {
window.localStorage.setItem('cc-haha-locale', 'en')
it.each([
['en-GB', 'en'],
['zh-CN', 'zh'],
['zh-SG', 'zh'],
['zh-Hant', 'zh-TW'],
['zh-HK', 'zh-TW'],
['ja-JP', 'jp'],
['ko-KR', 'kr'],
] as const)('maps the system language %s to %s', async (systemLanguage, expectedLocale) => {
mockSystemLanguages([systemLanguage])
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().locale).toBe(expectedLocale)
})
it('uses the first supported language in the system preference list', async () => {
mockSystemLanguages(['fr-FR', 'ja-JP', 'en-US'])
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().locale).toBe('jp')
})
it('defaults to English when no system language is supported', async () => {
mockSystemLanguages(['fr-FR', 'de-DE'])
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().locale).toBe('en')
})
it('keeps a stored locale override', async () => {
mockSystemLanguages(['en-US'])
window.localStorage.setItem('cc-haha-locale', 'zh-TW')
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().locale).toBe('zh-TW')
expect(document.documentElement.lang).toBe('zh-TW')
useSettingsStore.getState().setLocale('jp')
expect(window.localStorage.getItem('cc-haha-locale')).toBe('jp')
expect(document.documentElement.lang).toBe('ja')
})
it('persists a manual desktop choice and restores it after a simulated restart', async () => {
mockSystemLanguages(['zh-CN'])
const setLocalePreference = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
app: {
...browserHost.app,
setLocalePreference,
},
}
const firstLoad = await import('./settingsStore')
firstLoad.useSettingsStore.getState().setLocale('jp')
await vi.waitFor(() => {
expect(setLocalePreference).toHaveBeenCalledWith('jp')
})
expect(window.localStorage.getItem('cc-haha-locale')).toBe('jp')
vi.resetModules()
mockSystemLanguages(['ko-KR'])
const restarted = await import('./settingsStore')
expect(restarted.useSettingsStore.getState().locale).toBe('jp')
expect(document.documentElement.lang).toBe('ja')
})
it('keeps the local choice when desktop persistence fails', async () => {
mockSystemLanguages(['en-US'])
const persistenceError = new Error('disk unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
app: {
...browserHost.app,
setLocalePreference: vi.fn().mockRejectedValue(persistenceError),
},
}
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.getState().setLocale('kr')
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'[desktop] Failed to persist locale preference',
persistenceError,
)
})
expect(useSettingsStore.getState().locale).toBe('kr')
expect(window.localStorage.getItem('cc-haha-locale')).toBe('kr')
})
it('applies a locale event from another desktop window', async () => {
mockSystemLanguages(['en-US'])
let emitLocale: ((locale: Locale) => void) | undefined
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
app: {
...browserHost.app,
getLocalePreference: vi.fn().mockResolvedValue('en'),
onLocaleChanged: vi.fn(async (handler) => {
emitLocale = handler
return () => {}
}),
},
}
const { useSettingsStore } = await import('./settingsStore')
const { initializeLocale } = await import('../i18n/locale')
await initializeLocale(window.desktopHost.app)
emitLocale?.('zh-TW')
expect(useSettingsStore.getState().locale).toBe('zh-TW')
expect(document.documentElement.lang).toBe('zh-TW')
})
})
describe('settingsStore UI zoom', () => {

View File

@ -36,24 +36,19 @@ import {
readStoredAppZoomLevel,
} from '../lib/appZoom'
import { useUIStore } from './uiStore'
import {
applyDocumentLocale,
getInitialLocale,
LOCALE_STORAGE_KEY,
subscribeLocaleChanges,
} from '../i18n/locale'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
export const UI_ZOOM_MIN = MIN_APP_ZOOM
export const UI_ZOOM_MAX = MAX_APP_ZOOM
export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
const VALID_LOCALES: readonly Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
function getStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored && (VALID_LOCALES as readonly string[]).includes(stored)) return stored as Locale
} catch { /* localStorage unavailable */ }
return 'zh'
}
type SettingsStore = {
permissionMode: PermissionMode
currentModel: ModelInfo | null
@ -178,6 +173,9 @@ const DEFAULT_TRACE_CAPTURE_SETTINGS: TraceCaptureSettings = {
storageDir: '',
}
const initialLocale = getInitialLocale()
applyDocumentLocale(initialLocale)
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -187,7 +185,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
autoModeOptInAccepted: false,
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
locale: initialLocale,
chatSendBehavior: 'enter',
outputStyle: DEFAULT_OUTPUT_STYLE,
outputStyles: DEFAULT_OUTPUT_STYLE_OPTIONS,
@ -344,7 +342,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
setLocale: (locale) => {
set({ locale })
applyDocumentLocale(locale)
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }
void getDesktopHost().app.setLocalePreference(locale).catch((error) => {
console.error('[desktop] Failed to persist locale preference', error)
})
},
// Kept as the Settings page's entry point; uiStore owns the state.
@ -622,6 +624,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
},
}))
subscribeLocaleChanges((locale) => {
useSettingsStore.setState({ locale })
applyDocumentLocale(locale)
})
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
return {
mode: settings?.mode ?? 'auto',

View File

@ -86,6 +86,7 @@ describe('updateStore', () => {
updates: true,
},
app: {
...browserHost.app,
getVersion: vi.fn().mockResolvedValue('0.4.1'),
},
updates: {