diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index 85eef9b2..5beba48c 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -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) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index c5149016..01ed590a 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -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( ) const petWindowChannels = new Set([ + ELECTRON_IPC_CHANNELS.appGetLocalePreference, + ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages, ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken, ELECTRON_IPC_CHANNELS.petsList, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 4fd0d036..eecb2a2c 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -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', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index bc671ad2..0f86247f 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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 { const { command, args } = payload as { command: string, args?: Record } @@ -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, diff --git a/desktop/electron/pet-preload.ts b/desktop/electron/pet-preload.ts index f2ecce11..a970da31 100644 --- a/desktop/electron/pet-preload.ts +++ b/desktop/electron/pet-preload.ts @@ -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(channel: ElectronIpcChannel, payload?: unknown): Promise { if (!isElectronIpcChannelAllowedForPetWindow(channel)) { @@ -37,6 +39,19 @@ const petHost = { // server-enforced companion capability, never the desktop master token. getLocalAccessToken: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetPetAccessToken), }, + app: { + getLocalePreference: () => + invoke(ELECTRON_IPC_CHANNELS.appGetLocalePreference), + getPreferredSystemLanguages: () => + invoke(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 diff --git a/desktop/electron/services/localePreference.test.ts b/desktop/electron/services/localePreference.test.ts new file mode 100644 index 00000000..c9ba808f --- /dev/null +++ b/desktop/electron/services/localePreference.test.ts @@ -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)) + }) +}) diff --git a/desktop/electron/services/localePreference.ts b/desktop/electron/services/localePreference.ts new file mode 100644 index 00000000..bc1d296f --- /dev/null +++ b/desktop/electron/services/localePreference.ts @@ -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 }) + } +} diff --git a/desktop/index.html b/desktop/index.html index 7869ffd5..83ef535d 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -1,5 +1,5 @@ - +