mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
feat(desktop): detect and persist the display locale
This commit is contained in:
parent
de52656bb2
commit
d92ac7f2c0
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
52
desktop/electron/services/localePreference.test.ts
Normal file
52
desktop/electron/services/localePreference.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
57
desktop/electron/services/localePreference.ts
Normal file
57
desktop/electron/services/localePreference.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!--
|
||||
|
||||
@ -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')),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>> = {}) {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
117
desktop/src/i18n/locale.test.ts
Normal file
117
desktop/src/i18n/locale.test.ts
Normal 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
146
desktop/src/i18n/locale.ts
Normal 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
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -86,6 +86,7 @@ describe('updateStore', () => {
|
||||
updates: true,
|
||||
},
|
||||
app: {
|
||||
...browserHost.app,
|
||||
getVersion: vi.fn().mockResolvedValue('0.4.1'),
|
||||
},
|
||||
updates: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user