Localize macOS application menu and correct AITURK package checks

This commit is contained in:
2026-09-06 05:29:21 +03:00
parent 5e76f58637
commit 7724d65e92
12 changed files with 296 additions and 28 deletions
@@ -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.
+9 -3
View File
@@ -33,6 +33,9 @@ import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') 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) ────────────────────── // ─── Credential stripping (matches launch.spec.ts) ──────────────────────
@@ -519,16 +522,19 @@ providers:
*/ */
function resolvePackagedBinaryPath(): string { function resolvePackagedBinaryPath(): string {
if (process.platform === 'win32') { 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') { if (process.platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64' 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() export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
+2 -2
View File
@@ -34,9 +34,9 @@ test.afterAll(async () => {
fixture = null fixture = null
}) })
test('window opens with the Hermes title', async () => { test('window opens with the AITURK title', async () => {
const title = await fixture!.page.title() const title = await fixture!.page.title()
expect(title).toContain('Hermes') expect(title).toContain('AITURK')
}) })
test('renderer loads and shows DOM content', async () => { test('renderer loads and shows DOM content', async () => {
+21 -1
View File
@@ -6,7 +6,10 @@ import { expect, test } from './test'
let fixture: MockBackendFixture | null = null let fixture: MockBackendFixture | null = null
type DesktopTestWindow = Window & { type DesktopTestWindow = Window & {
hermesDesktop: { api<T = unknown>(request: { path: string }): Promise<T> } hermesDesktop: {
api<T = unknown>(request: { path: string }): Promise<T>
setUiLanguage(locale: string): void
}
} }
test.beforeAll(async () => { 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 expect(approvals.getByRole('combobox')).toContainText('Elle onayla')
await page.screenshot({ path: test.info().outputPath('turkish-safety-settings.png'), fullPage: true }) 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
)
}
})
+21 -2
View File
@@ -261,6 +261,7 @@ import {
resolveOauthRestAuth, resolveOauthRestAuth,
resolveReadinessProbeAuth resolveReadinessProbeAuth
} from './native-auth-decisions' } from './native-auth-decisions'
import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale'
import { import {
nativeRefreshUrl, nativeRefreshUrl,
type NativeTokenSet, type NativeTokenSet,
@@ -6755,11 +6756,13 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow)
webContents.send('hermes:window-state-changed', state) webContents.send('hermes:window-state-changed', state)
} }
let nativeMenuLocale: NativeMenuLocale = 'tr'
function buildApplicationMenu() { function buildApplicationMenu() {
const template = [] const template = []
const checkForUpdatesItem = { const checkForUpdatesItem = {
label: 'AITURK IDE güncellemeleri…', label: 'Check for Updates…',
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested() click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
} }
@@ -6886,7 +6889,7 @@ function buildApplicationMenu() {
submenu: [checkForUpdatesItem] submenu: [checkForUpdatesItem]
}) })
return Menu.buildFromTemplate(template) return Menu.buildFromTemplate(localizeNativeMenu(template, nativeMenuLocale, APP_NAME))
} }
function toggleDevTools(window) { 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). // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
ipcMain.on('hermes:native-theme', (_event, mode) => { ipcMain.on('hermes:native-theme', (_event, mode) => {
if (!THEME_SOURCES.has(mode)) { if (!THEME_SOURCES.has(mode)) {
@@ -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()
}
})
})
@@ -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<string, string> = {
[`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<Record<NonNullable<MenuItemConstructorOptions['role']>, 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) })
}
})
}
+1
View File
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags') const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
contextBridge.exposeInMainWorld('hermesDesktop', { contextBridge.exposeInMainWorld('hermesDesktop', {
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
glassSupported: translucencySupport?.glass === true, glassSupported: translucencySupport?.glass === true,
translucencySupported: translucencySupport?.translucency === true, translucencySupported: translucencySupport?.translucency === true,
// Launch-flag fact: the app was started with --local, so the renderer may // Launch-flag fact: the app was started with --local, so the renderer may
+23 -20
View File
@@ -19,10 +19,10 @@ const PLATFORM = process.platform
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow). // launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
const APP = (() => { const APP = (() => {
if (PLATFORM === 'darwin') { 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 { return {
appPath, appPath,
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'), binary: path.join(appPath, 'Contents', 'MacOS', PACKAGE_JSON.build.executableName),
resourcesPath: path.join(appPath, 'Contents', 'Resources'), resourcesPath: path.join(appPath, 'Contents', 'Resources'),
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'), asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html') 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') const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
return { return {
appPath: unpacked, appPath: unpacked,
binary: path.join(unpacked, 'Hermes.exe'), binary: path.join(unpacked, `${PACKAGE_JSON.build.executableName}.exe`),
resourcesPath: path.join(unpacked, 'resources'), resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'), asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') 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') const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
return { return {
appPath: unpacked, appPath: unpacked,
binary: path.join(unpacked, 'Hermes'), binary: path.join(unpacked, PACKAGE_JSON.build.executableName),
resourcesPath: path.join(unpacked, 'resources'), resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'), asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
} }
})() })()
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's // Match AITURK's product-owned runtime; never inspect another Hermes install.
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere const DEFAULT_AGENT_HOME = (() => {
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own if (process.env.AITURK_IDE_HOME) return path.resolve(process.env.AITURK_IDE_HOME)
// HERMES_HOME and never touches this. const userData = process.env.AITURK_IDE_USER_DATA_DIR || process.env.HERMES_DESKTOP_USER_DATA_DIR
const DEFAULT_HERMES_HOME = (() => { if (userData) return path.join(path.resolve(userData), 'agent-home')
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) { 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') const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
function die(message) { function die(message) {
@@ -124,10 +124,10 @@ function ensurePackagedApp() {
function resolveDmgPath() { function resolveDmgPath() {
if (!exists(RELEASE_ROOT)) { 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 const candidates = fs
.readdirSync(RELEASE_ROOT) .readdirSync(RELEASE_ROOT)
.filter(name => name.endsWith('.dmg')) .filter(name => name.endsWith('.dmg'))
@@ -141,15 +141,15 @@ function resolveDmgPath() {
return candidates.length > 0 return candidates.length > 0
? path.join(RELEASE_ROOT, candidates[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() { 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 if (!exists(RELEASE_ROOT)) return null
const candidates = fs const candidates = fs
.readdirSync(RELEASE_ROOT) .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) => { .sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).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_TEST_MODE = 'fresh-install'
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome 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
delete env.HERMES_DESKTOP_HERMES_ROOT delete env.HERMES_DESKTOP_HERMES_ROOT
@@ -275,7 +278,7 @@ function launchFresh() {
console.log('\nFresh install sandbox:') console.log('\nFresh install sandbox:')
console.log(` root: ${sandbox}`) console.log(` root: ${sandbox}`)
console.log(` electron userData: ${userDataDir}`) console.log(` electron userData: ${userDataDir}`)
console.log(` HERMES_HOME: ${hermesHome}`) console.log(` AITURK_IDE_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`) console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') } return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
@@ -399,8 +402,8 @@ function printArtifacts(options = {}) {
function help() { function help() {
console.log(`Usage: console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes 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 + HERMES_HOME 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:dmg # (macOS only) build DMG and open it
npm run test:desktop:nsis # (win32 only) build NSIS installer npm run test:desktop:nsis # (win32 only) build NSIS installer
npm run test:desktop:all # build installer, validate app payload, print paths npm run test:desktop:all # build installer, validate app payload, print paths
+1
View File
@@ -17,6 +17,7 @@ export {}
declare global { declare global {
interface Window { interface Window {
hermesDesktop: { hermesDesktop: {
setUiLanguage?: (locale: string) => void
// Resolve a backend connection. Omit `profile` (or pass the primary) for // Resolve a backend connection. Omit `profile` (or pass the primary) for
// the window's backend; pass a named profile to lazily spawn/reuse that // the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool. // profile's backend from the pool.
+36
View File
@@ -30,6 +30,42 @@ describe('I18nProvider', () => {
vi.restoreAllMocks() 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(
<I18nProvider configClient={configClient}>
<LanguageProbe target="tr" />
</I18nProvider>
)
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', () => { it('defaults to Turkish without a config client', () => {
render( render(
<I18nProvider configClient={null}> <I18nProvider configClient={null}>
+4
View File
@@ -105,6 +105,10 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
localeRef.current = locale localeRef.current = locale
setRuntimeI18nLocale(locale) setRuntimeI18nLocale(locale)
applyDocumentLocale(locale) applyDocumentLocale(locale)
if (typeof window !== 'undefined') {
window.hermesDesktop?.setUiLanguage?.(locale)
}
}, [locale]) }, [locale])
useEffect(() => { useEffect(() => {