diff --git a/apps/desktop/docs/aiturk-macos-native-menu.md b/apps/desktop/docs/aiturk-macos-native-menu.md new file mode 100644 index 0000000..fc52bd9 --- /dev/null +++ b/apps/desktop/docs/aiturk-macos-native-menu.md @@ -0,0 +1,35 @@ +# macOS yerel menüsünün Türkçe desteği + +Dosya, Düzen, Görünüm, Pencere, Yardım ve uygulama menüsündeki komutlar +Türkçe gösterilir. Ana pencerenin dil seçimi mevcut `display.language` +ayarından gelir. Dar kapsamlı `setUiLanguage` köprüsü yalnızca menü +etiketlerini günceller; ayrı bir tercih dosyası veya sunucu ayarı oluşturmaz. +Dil kaydı başarısız olursa arayüzle birlikte menü dili de geri alınır. +Yardımcı pencereler ana pencerenin menü dilini değiştiremez. + +Menü öğelerinin eylemleri, Electron rolleri ve klavye kısayolları korunur. +Özellikle renderer tarafından yönetilen yeni pencere, klasör açma, sekme +kapatma ve sayfa yenileme komutlarına yerel kısayol eklenmez. İngilizce +seçilince özgün İngilizce şablon kullanılır; diğer diller yerel menüde +mevcut İngilizce desteğini kullanmaya devam eder. + +Paket doğrulama yolları `package.json` içindeki AITURK ürün ve çalıştırılabilir +dosya adlarını kullanır. Intel Mac için `release/mac`, Apple Silicon için +`release/mac-arm64` aranır. Temiz kurulum denemesi açıkça izole +`AITURK_IDE_HOME` ve Electron kullanıcı dizini seçer. Başka bir Hermes +kurulumunun çalışma dizinine yönelmez. + +## Kanıt ve yayın sınırı + +- Menü birim testleri eylem, rol ve kısayol bütünlüğünü; kaynak pencere ve + dil doğrulamasını kontrol eder. +- React testi yüklenen dil, iyimser geçiş ve kayıt hatasında geri dönüşün + yerel köprüye iletildiğini doğrular. +- Gerçek Electron E2E testi köprüyü kullanır. macOS'ta yerel menü etiketlerini, + Windows'ta uygulama menüsünün kapalı kalmasını kontrol eder. +- Windows üzerinde Türkçe ayar kaydı ve köprü testleri geçti. Paket yolu, + mevcut Windows beta.5 dosyası üzerinde açılış ve içerik kontrolüyle doğrulandı. + +Bu kaynak değişikliği, yayımlanan Windows beta.5 dosyasını değiştirmez. +macOS dalındaki E2E testi, DMG/ZIP derlemesi ve gerçek Mac'te çalıştırma henüz +doğrulanmadı. Mac paketi bu kanıtlar tamamlanmadan yayımlanmış sayılmaz. diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 030dbe9..4a8b3f5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -33,6 +33,9 @@ import { installErrorBannerGuard } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') +const PACKAGE_CONFIG = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')).build as { + executableName: string +} // ─── Credential stripping (matches launch.spec.ts) ────────────────────── @@ -519,16 +522,19 @@ providers: */ function resolvePackagedBinaryPath(): string { if (process.platform === 'win32') { - return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe') + return path.join(RELEASE_ROOT, 'win-unpacked', `${PACKAGE_CONFIG.executableName}.exe`) } if (process.platform === 'darwin') { const arch = process.arch === 'arm64' ? 'arm64' : 'x64' - return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes') + return path.join( + RELEASE_ROOT, arch === 'x64' ? 'mac' : `mac-${arch}`, + `${PACKAGE_CONFIG.executableName}.app`, 'Contents', 'MacOS', PACKAGE_CONFIG.executableName + ) } - return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes') + return path.join(RELEASE_ROOT, 'linux-unpacked', PACKAGE_CONFIG.executableName) } export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath() diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts index d52b261..912f9ef 100644 --- a/apps/desktop/e2e/launch-packaged-app.spec.ts +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -34,9 +34,9 @@ test.afterAll(async () => { fixture = null }) -test('window opens with the Hermes title', async () => { +test('window opens with the AITURK title', async () => { const title = await fixture!.page.title() - expect(title).toContain('Hermes') + expect(title).toContain('AITURK') }) test('renderer loads and shows DOM content', async () => { diff --git a/apps/desktop/e2e/turkish-settings.spec.ts b/apps/desktop/e2e/turkish-settings.spec.ts index aa4a665..ccd7454 100644 --- a/apps/desktop/e2e/turkish-settings.spec.ts +++ b/apps/desktop/e2e/turkish-settings.spec.ts @@ -6,7 +6,10 @@ import { expect, test } from './test' let fixture: MockBackendFixture | null = null type DesktopTestWindow = Window & { - hermesDesktop: { api(request: { path: string }): Promise } + hermesDesktop: { + api(request: { path: string }): Promise + setUiLanguage(locale: string): void + } } test.beforeAll(async () => { @@ -75,3 +78,20 @@ test('Turkish safety settings persist the backend enum through the real desktop await expect(approvals.getByRole('combobox')).toContainText('Elle onayla') await page.screenshot({ path: test.info().outputPath('turkish-safety-settings.png'), fullPage: true }) }) + +test('native language bridge updates the Mac menu and preserves menu-free Windows', async () => { + const { app, page } = fixture! + const platform = await app.evaluate(() => process.platform) + for (const locale of ['en', 'tr']) { + await page.evaluate(language => { + ;(window as unknown as DesktopTestWindow).hermesDesktop.setUiLanguage(language) + }, locale) + await expect + .poll(() => app.evaluate(({ Menu }) => Menu.getApplicationMenu()?.items.map((item: { label: string }) => item.label) ?? null)) + .toEqual( + platform === 'darwin' + ? expect.arrayContaining([locale === 'tr' ? 'Dosya' : 'File', locale === 'tr' ? 'Düzen' : 'Edit']) + : null + ) + } +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e724d5a..707f888 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -261,6 +261,7 @@ import { resolveOauthRestAuth, resolveReadinessProbeAuth } from './native-auth-decisions' +import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale' import { nativeRefreshUrl, type NativeTokenSet, @@ -6755,11 +6756,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) webContents.send('hermes:window-state-changed', state) } +let nativeMenuLocale: NativeMenuLocale = 'tr' + function buildApplicationMenu() { const template = [] const checkForUpdatesItem = { - label: 'AITURK IDE güncellemeleri…', + label: 'Check for Updates…', click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested() } @@ -6886,7 +6889,7 @@ function buildApplicationMenu() { submenu: [checkForUpdatesItem] }) - return Menu.buildFromTemplate(template) + return Menu.buildFromTemplate(localizeNativeMenu(template, nativeMenuLocale, APP_NAME)) } function toggleDevTools(window) { @@ -16955,6 +16958,22 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { } }) +// Language stays in backend display.language; this only updates native labels. +ipcMain.on('hermes:ui-language', (event, locale) => { + if (!IS_MAC) { + return + } + + const next = resolveNativeMenuLocale(locale, event.sender.id, mainWindow?.webContents.id) + + if (next === null || next === nativeMenuLocale) { + return + } + + nativeMenuLocale = next + Menu.setApplicationMenu(buildApplicationMenu()) +}) + // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). ipcMain.on('hermes:native-theme', (_event, mode) => { if (!THEME_SOURCES.has(mode)) { diff --git a/apps/desktop/electron/native-menu-locale.test.ts b/apps/desktop/electron/native-menu-locale.test.ts new file mode 100644 index 0000000..2d99305 --- /dev/null +++ b/apps/desktop/electron/native-menu-locale.test.ts @@ -0,0 +1,53 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { describe, expect, it, vi } from 'vitest' + +import { localizeNativeMenu, resolveNativeMenuLocale } from './native-menu-locale' + +describe('native menu language', () => { + it('translates nested labels without changing actions, roles or keyboard ownership', () => { + const openFolder = vi.fn() + const zoom = vi.fn() + + const template: MenuItemConstructorOptions[] = [ + { + label: 'File', + submenu: [{ label: 'Open Folder…', click: openFolder }, { type: 'separator' }, { role: 'quit' }] + }, + { + label: 'View', + submenu: [{ label: 'Zoom In', accelerator: 'CommandOrControl+Plus', click: zoom }, { role: 'togglefullscreen' }] + }, + { label: 'My custom action', enabled: false } + ] + + const translated = localizeNativeMenu(template, 'tr', 'AITURK IDE') + const file = translated[0].submenu as MenuItemConstructorOptions[] + const view = translated[1].submenu as MenuItemConstructorOptions[] + expect(translated[0].label).toBe('Dosya') + expect(file[0]).toEqual({ label: 'Klasör aç…', click: openFolder }) + expect(file[1]).toEqual({ type: 'separator' }) + expect(file[2]).toEqual({ role: 'quit', label: 'AITURK IDE uygulamasından çık' }) + expect(view[0]).toEqual({ label: 'Yakınlaştır', accelerator: 'CommandOrControl+Plus', click: zoom }) + expect(view[1].role).toBe('togglefullscreen') + expect(translated[2]).toEqual(template[2]) + expect(localizeNativeMenu(template, 'en', 'AITURK IDE')).toBe(template) + expect((template[0].submenu as MenuItemConstructorOptions[])[0].label).toBe('Open Folder…') + expect(openFolder).not.toHaveBeenCalled() + expect(zoom).not.toHaveBeenCalled() + }) + + it('accepts only supported language messages from the primary renderer', () => { + expect(resolveNativeMenuLocale('tr', 42, 42)).toBe('tr') + expect(resolveNativeMenuLocale('en', 42, 42)).toBe('en') + expect(resolveNativeMenuLocale('ja', 42, 42)).toBe('en') + + for (const locale of ['tr', 'en']) { + expect(resolveNativeMenuLocale(locale, 43, 42)).toBeNull() + expect(resolveNativeMenuLocale(locale, 42, undefined)).toBeNull() + } + + for (const value of [null, {}, ['tr'], 'unsupported', '__proto__']) { + expect(resolveNativeMenuLocale(value, 42, 42)).toBeNull() + } + }) +}) diff --git a/apps/desktop/electron/native-menu-locale.ts b/apps/desktop/electron/native-menu-locale.ts new file mode 100644 index 0000000..eb5e191 --- /dev/null +++ b/apps/desktop/electron/native-menu-locale.ts @@ -0,0 +1,90 @@ +import type { MenuItemConstructorOptions } from 'electron' + +export type NativeMenuLocale = 'en' | 'tr' + +// Only the primary settings surface owns the app-wide menu language. Utility +// windows have their own I18nProvider and must not overwrite that preference. +export function resolveNativeMenuLocale( + value: unknown, + senderId: number, + primaryId: number | undefined +): NativeMenuLocale | null { + if (primaryId === undefined || senderId !== primaryId) { + return null + } + + if (value === 'tr') { + return 'tr' + } + + if (typeof value === 'string' && ['en', 'zh', 'zh-hant', 'ja', 'ar', 'ru'].includes(value)) { + return 'en' + } + + return null +} + +export function localizeNativeMenu( + template: MenuItemConstructorOptions[], + locale: NativeMenuLocale, + appName: string +): MenuItemConstructorOptions[] { + if (locale !== 'tr') { + return template + } + + const labels: Record = { + [`About ${appName}`]: `${appName} Hakkında`, + 'Check for Updates…': 'Güncellemeleri kontrol et…', + File: 'Dosya', + 'New Window': 'Yeni pencere', + 'Open Folder…': 'Klasör aç…', + Close: 'Kapat', + Edit: 'Düzen', + View: 'Görünüm', + Reload: 'Yenile', + 'Toggle Developer Tools': 'Geliştirici araçlarını aç/kapat', + 'Actual Size': 'Gerçek boyut', + 'Zoom In': 'Yakınlaştır', + 'Zoom Out': 'Uzaklaştır', + Window: 'Pencere', + Help: 'Yardım' + } + + const roles: Partial, string>> = { + services: 'Servisler', + hide: `${appName} uygulamasını gizle`, + hideOthers: 'Diğerlerini gizle', + unhide: 'Tümünü göster', + quit: `${appName} uygulamasından çık`, + undo: 'Geri al', + redo: 'Yinele', + cut: 'Kes', + copy: 'Kopyala', + paste: 'Yapıştır', + pasteAndMatchStyle: 'Biçimlendirmeden yapıştır', + delete: 'Sil', + selectAll: 'Tümünü seç', + forceReload: 'Uygulamayı zorla yenile', + togglefullscreen: 'Tam ekranı aç/kapat', + minimize: 'Simge durumuna küçült', + zoom: 'Pencereyi büyüt/küçült', + front: 'Tümünü öne getir', + close: 'Kapat' + } + + return template.map(item => { + const label = + item.label !== undefined + ? Object.hasOwn(labels, item.label) + ? labels[item.label] + : item.label + : item.role && roles[item.role] + + return { + ...item, + ...(label !== undefined && { label }), + ...(Array.isArray(item.submenu) && { submenu: localizeNativeMenu(item.submenu, locale, appName) }) + } + }) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index fd96687..68d2d95 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true const launchFlags = ipcRenderer.sendSync('hermes:launch-flags') contextBridge.exposeInMainWorld('hermesDesktop', { + setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale), glassSupported: translucencySupport?.glass === true, translucencySupported: translucencySupport?.translucency === true, // Launch-flag fact: the app was started with --local, so the renderer may diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index ec94ddc..faaa2e8 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -19,10 +19,10 @@ const PLATFORM = process.platform // launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow). const APP = (() => { if (PLATFORM === 'darwin') { - const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app') + const appPath = path.join(RELEASE_ROOT, ARCH === 'x64' ? 'mac' : `mac-${ARCH}`, `${PACKAGE_JSON.build.executableName}.app`) return { appPath, - binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'), + binary: path.join(appPath, 'Contents', 'MacOS', PACKAGE_JSON.build.executableName), resourcesPath: path.join(appPath, 'Contents', 'Resources'), asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'), unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html') @@ -32,7 +32,7 @@ const APP = (() => { const unpacked = path.join(RELEASE_ROOT, 'win-unpacked') return { appPath: unpacked, - binary: path.join(unpacked, 'Hermes.exe'), + binary: path.join(unpacked, `${PACKAGE_JSON.build.executableName}.exe`), resourcesPath: path.join(unpacked, 'resources'), asarPath: path.join(unpacked, 'resources', 'app.asar'), unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') @@ -42,24 +42,24 @@ const APP = (() => { const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked') return { appPath: unpacked, - binary: path.join(unpacked, 'Hermes'), + binary: path.join(unpacked, PACKAGE_JSON.build.executableName), resourcesPath: path.join(unpacked, 'resources'), asarPath: path.join(unpacked, 'resources', 'app.asar'), unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') } })() -// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's -// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere -// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own -// HERMES_HOME and never touches this. -const DEFAULT_HERMES_HOME = (() => { +// Match AITURK's product-owned runtime; never inspect another Hermes install. +const DEFAULT_AGENT_HOME = (() => { + if (process.env.AITURK_IDE_HOME) return path.resolve(process.env.AITURK_IDE_HOME) + const userData = process.env.AITURK_IDE_USER_DATA_DIR || process.env.HERMES_DESKTOP_USER_DATA_DIR + if (userData) return path.join(path.resolve(userData), 'agent-home') if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) { - return path.join(process.env.LOCALAPPDATA, 'hermes') + return path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE', 'agent') } - return path.join(os.homedir(), '.hermes') + return path.join(os.homedir(), '.aiturk-ide', 'agent') })() -const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv') +const VENV_ROOT = path.join(DEFAULT_AGENT_HOME, 'hermes-agent', 'venv') const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install') function die(message) { @@ -124,10 +124,10 @@ function ensurePackagedApp() { function resolveDmgPath() { if (!exists(RELEASE_ROOT)) { - return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`) + return path.join(RELEASE_ROOT, `AITURK-IDE-${PACKAGE_JSON.version}-mac-${ARCH}.dmg`) } - const prefix = `Hermes-${PACKAGE_JSON.version}` + const prefix = `AITURK-IDE-${PACKAGE_JSON.version}-mac-` const candidates = fs .readdirSync(RELEASE_ROOT) .filter(name => name.endsWith('.dmg')) @@ -141,15 +141,15 @@ function resolveDmgPath() { return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) - : path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`) + : path.join(RELEASE_ROOT, `AITURK-IDE-${PACKAGE_JSON.version}-mac-${ARCH}.dmg`) } function resolveNsisPath() { - // electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}' + // Select only this AITURK release and architecture. if (!exists(RELEASE_ROOT)) return null const candidates = fs .readdirSync(RELEASE_ROOT) - .filter(name => /\.exe$/i.test(name) && /win/i.test(name)) + .filter(name => name === `AITURK-IDE-${PACKAGE_JSON.version}-win-${ARCH}.exe`) .sort((a, b) => { const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs @@ -261,6 +261,9 @@ function launchFresh() { env.HERMES_DESKTOP_TEST_MODE = 'fresh-install' env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir env.HERMES_HOME = hermesHome + env.AITURK_IDE_HOME = hermesHome + env.AITURK_IDE_USER_DATA_DIR = userDataDir + delete env.HERMES_DESKTOP_PYTHON delete env.HERMES_DESKTOP_HERMES delete env.HERMES_DESKTOP_HERMES_ROOT @@ -275,7 +278,7 @@ function launchFresh() { console.log('\nFresh install sandbox:') console.log(` root: ${sandbox}`) console.log(` electron userData: ${userDataDir}`) - console.log(` HERMES_HOME: ${hermesHome}`) + console.log(` AITURK_IDE_HOME: ${hermesHome}`) console.log(` cwd: ${cwd}`) return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') } @@ -399,8 +402,8 @@ function printArtifacts(options = {}) { function help() { console.log(`Usage: - npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes - npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME + npm run test:desktop:existing # build packaged app, launch with the existing AITURK profile + npm run test:desktop:fresh # build packaged app, launch with temp userData + AITURK_IDE_HOME npm run test:desktop:dmg # (macOS only) build DMG and open it npm run test:desktop:nsis # (win32 only) build NSIS installer npm run test:desktop:all # build installer, validate app payload, print paths diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0362b8f..aff6423 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -17,6 +17,7 @@ export {} declare global { interface Window { hermesDesktop: { + setUiLanguage?: (locale: string) => void // Resolve a backend connection. Omit `profile` (or pass the primary) for // the window's backend; pass a named profile to lazily spawn/reuse that // profile's backend from the pool. diff --git a/apps/desktop/src/i18n/context.test.tsx b/apps/desktop/src/i18n/context.test.tsx index 7baee22..b770a2b 100644 --- a/apps/desktop/src/i18n/context.test.tsx +++ b/apps/desktop/src/i18n/context.test.tsx @@ -30,6 +30,42 @@ describe('I18nProvider', () => { vi.restoreAllMocks() }) + it('keeps the native menu aligned with loaded, optimistic and rolled-back language', async () => { + const original = window.hermesDesktop + const setUiLanguage = vi.fn() + window.hermesDesktop = { ...original, setUiLanguage } + let rejectSave: (error: Error) => void = () => undefined + + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }), + saveConfig: vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSave = reject + }) + ) + } + + try { + render( + + + + ) + await waitFor(() => expect(setUiLanguage).toHaveBeenLastCalledWith('en')) + fireEvent.click(screen.getByRole('button', { name: 'switch' })) + await waitFor(() => expect(configClient.saveConfig).toHaveBeenCalledTimes(1)) + expect(setUiLanguage).toHaveBeenLastCalledWith('tr') + rejectSave(new Error('Cannot save language')) + await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('Cannot save language')) + expect(setUiLanguage).toHaveBeenLastCalledWith('en') + expect(screen.getByTestId('locale').textContent).toBe('en') + } finally { + cleanup() + window.hermesDesktop = original + } + }) + it('defaults to Turkish without a config client', () => { render( diff --git a/apps/desktop/src/i18n/context.tsx b/apps/desktop/src/i18n/context.tsx index 87c6337..54e4c86 100644 --- a/apps/desktop/src/i18n/context.tsx +++ b/apps/desktop/src/i18n/context.tsx @@ -105,6 +105,10 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini localeRef.current = locale setRuntimeI18nLocale(locale) applyDocumentLocale(locale) + + if (typeof window !== 'undefined') { + window.hermesDesktop?.setUiLanguage?.(locale) + } }, [locale]) useEffect(() => {