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 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()
+2 -2
View File
@@ -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 () => {
+21 -1
View File
@@ -6,7 +6,10 @@ import { expect, test } from './test'
let fixture: MockBackendFixture | null = null
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 () => {
@@ -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
)
}
})
+21 -2
View File
@@ -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)) {
@@ -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')
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
+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).
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
+1
View File
@@ -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.
+36
View File
@@ -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(
<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', () => {
render(
<I18nProvider configClient={null}>
+4
View File
@@ -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(() => {