25 changed files with 552 additions and 76 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.
+18
View File
@@ -0,0 +1,18 @@
# AITURK IDE 1.0.0-beta.6
Türkçe desteği yerel dosya kaydetme, görsel kaydetme, klasör seçme, bulut
bağlantısı ve güncelleme pencerelerine genişletildi. Devam eden işler varken
çıkış uyarısı Türkçe gösterilir; sohbet adları, sayılar ve çıkış koruması
korunur. Ana pencerenin dil değişikliği Windows'taki yerel pencere başlıklarına
ve macOS uygulama menüsüne aktarılır.
Harita paylaşım kodunun geçersiz, kısa, bozuk veya uyumsuz sürümde olması
durumunda gösterilen hatalar çevrilir. İçe/dışa aktarma biçimi, sürüm kontrolü
ve sağlama toplamı denetimi değişmez; sürüm hatası beklenen ve gelen sürümü
göstermeye devam eder.
Paket doğrulama yolları AITURK adına göre düzeltilmiştir. Bu sürümün Windows
paketi ayrı bir yayın varlığı olarak hazırlanır; önceki beta.5 değiştirilmez.
Windows Authenticode imzası yoktur. macOS kaynak ve paket hazırlıkları
mevcuttur, ancak gerçek Mac'te derleme ve çalıştırma doğrulanmadan Mac paketi
hazır olarak duyurulmaz. CLI 1.10.24'ün imzalı yayını ayrı bir süreçtir.
+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 () => {
+60 -1
View File
@@ -6,7 +6,11 @@ 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
selectSavePath(options?: { title?: string; defaultPath?: string }): Promise<string | null>
}
} }
test.beforeAll(async () => { test.beforeAll(async () => {
@@ -75,3 +79,58 @@ 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
)
}
})
test('native save dialog follows language while preserving caller title and path', async () => {
const { app, page } = fixture!
await app.evaluate(({ dialog }) => {
const state = globalThis as typeof globalThis & { originalSave?: unknown; saveOptions?: unknown }
state.originalSave = dialog.showSaveDialog
dialog.showSaveDialog = async (_parent: unknown, options: unknown) => {
state.saveOptions = options
return { canceled: true, filePath: '' }
}
})
try {
for (const locale of ['tr', 'en']) {
const result = await page.evaluate(async language => {
const bridge = (window as unknown as DesktopTestWindow).hermesDesktop
bridge.setUiLanguage(language)
return bridge.selectSavePath({ defaultPath: 'My custom archive.zip' })
}, locale)
expect(result).toBeNull()
const options = await app.evaluate(() => (globalThis as typeof globalThis & { saveOptions?: unknown }).saveOptions)
expect(options).toMatchObject({ title: locale === 'tr' ? 'Kaydet' : 'Save', defaultPath: 'My custom archive.zip' })
}
await page.evaluate(async () => {
const bridge = (window as unknown as DesktopTestWindow).hermesDesktop
bridge.setUiLanguage('tr')
await bridge.selectSavePath({ title: 'My custom title' })
})
expect(await app.evaluate(() => (globalThis as typeof globalThis & { saveOptions?: unknown }).saveOptions))
.toMatchObject({ title: 'My custom title' })
} finally {
await app.evaluate(({ dialog }) => {
const state = globalThis as typeof globalThis & { originalSave?: typeof dialog.showSaveDialog; saveOptions?: unknown }
dialog.showSaveDialog = state.originalSave!
delete state.originalSave
delete state.saveOptions
})
}
})
+41 -22
View File
@@ -261,6 +261,8 @@ import {
resolveOauthRestAuth, resolveOauthRestAuth,
resolveReadinessProbeAuth resolveReadinessProbeAuth
} from './native-auth-decisions' } from './native-auth-decisions'
import { nativeDialogCopy } from './native-dialog-copy'
import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale'
import { import {
nativeRefreshUrl, nativeRefreshUrl,
type NativeTokenSet, type NativeTokenSet,
@@ -2312,8 +2314,8 @@ async function waitForUpdateToFinish() {
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`) rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
dialog.showMessageBox({ dialog.showMessageBox({
type: 'warning', type: 'warning',
title: 'Hermes update', title: nativeText().update,
message: 'The update finished, but needs one more step', message: nativeText().updateMore,
detail: result.message detail: result.message
}) })
} else if (result && result.ok) { } else if (result && result.ok) {
@@ -2321,8 +2323,8 @@ async function waitForUpdateToFinish() {
} else if (result) { } else if (result) {
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`) rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
dialog.showErrorBox( dialog.showErrorBox(
'Hermes update did not finish', nativeText().updateFailed,
`${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}` `${result.message}\n\n${nativeText().details}: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
) )
} }
} catch (err) { } catch (err) {
@@ -6086,11 +6088,11 @@ async function saveImageFromUrl(rawUrl) {
} }
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save Image', title: nativeText().saveImage,
defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName, defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName,
filters: [ filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, { name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] } { name: nativeText().allFiles, extensions: ['*'] }
] ]
}) })
@@ -6755,11 +6757,14 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow)
webContents.send('hermes:window-state-changed', state) webContents.send('hermes:window-state-changed', state)
} }
let nativeUiLocale: NativeMenuLocale = 'tr'
const nativeText = () => nativeDialogCopy(nativeUiLocale)
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 +6891,7 @@ function buildApplicationMenu() {
submenu: [checkForUpdatesItem] submenu: [checkForUpdatesItem]
}) })
return Menu.buildFromTemplate(template) return Menu.buildFromTemplate(localizeNativeMenu(template, nativeUiLocale, APP_NAME))
} }
function toggleDevTools(window) { function toggleDevTools(window) {
@@ -7176,13 +7181,13 @@ function installDownloadHandling() {
try { try {
item.setSaveDialogOptions({ item.setSaveDialogOptions({
title: 'Save File', title: nativeText().saveFile,
defaultPath: path.join(app.getPath('downloads'), filename), defaultPath: path.join(app.getPath('downloads'), filename),
filters: filters:
extension || /^image\//i.test(item.getMimeType() || '') extension || /^image\//i.test(item.getMimeType() || '')
? [ ? [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, { name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] } { name: nativeText().allFiles, extensions: ['*'] }
] ]
: undefined : undefined
}) })
@@ -7535,7 +7540,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
win = new BrowserWindow({ win = new BrowserWindow({
width: 520, width: 520,
height: 720, height: 720,
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', title: silent ? nativeText().cloudConnect : nativeText().gatewaySignIn,
autoHideMenuBar: true, autoHideMenuBar: true,
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
// well under a second, so the window normally never needs to show. We // well under a second, so the window normally never needs to show. We
@@ -7964,7 +7969,7 @@ async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {})
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: filename, defaultPath: filename,
title: 'Save File' title: nativeText().saveFile
}) })
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
@@ -8121,7 +8126,7 @@ async function saveGatewayFileViaDataUrl(
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: filename, defaultPath: filename,
title: 'Save File' title: nativeText().saveFile
}) })
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
@@ -8413,7 +8418,7 @@ function renewPortalAccessSilently() {
width: 520, width: 520,
height: 720, height: 720,
show: false, show: false,
title: 'Renewing Hermes Cloud session…', title: nativeText().cloudRenew,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
@@ -8518,7 +8523,7 @@ function openPortalLoginWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
width: 520, width: 520,
height: 720, height: 720,
title: 'Sign in to Hermes Cloud', title: nativeText().cloudSignIn,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
@@ -16764,7 +16769,7 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => {
} }
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || 'Add context', title: options?.title || nativeText().addContext,
defaultPath: resolvedDefaultPath, defaultPath: resolvedDefaultPath,
properties: properties as any, properties: properties as any,
filters: Array.isArray(options?.filters) ? options.filters : undefined filters: Array.isArray(options?.filters) ? options.filters : undefined
@@ -16787,7 +16792,7 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
// elsewhere (the backend, for profile archives); this only picks the path. // elsewhere (the backend, for profile archives); this only picks the path.
ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => { ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => {
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
title: options?.title || 'Save', title: options?.title || nativeText().save,
defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined, defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined,
filters: Array.isArray(options?.filters) ? options.filters : undefined filters: Array.isArray(options?.filters) ? options.filters : undefined
}) })
@@ -16955,6 +16960,20 @@ 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) => {
const next = resolveNativeMenuLocale(locale, event.sender.id, mainWindow?.webContents.id)
if (next === null || next === nativeUiLocale) {
return
}
nativeUiLocale = next
if (IS_MAC) {
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)) {
@@ -17287,7 +17306,7 @@ ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => { ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
title: 'Choose default project directory', title: nativeText().chooseProjectDirectory,
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home') defaultPath: readDefaultProjectDir() || app.getPath('home')
}) })
@@ -17847,7 +17866,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
return false return false
} }
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff) const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff, nativeUiLocale)
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
if (!prompt || !parent || parent.isDestroyed()) { if (!prompt || !parent || parent.isDestroyed()) {
@@ -17859,7 +17878,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
void dialog void dialog
.showMessageBox(parent, { .showMessageBox(parent, {
buttons: ['Keep Running', 'Quit Anyway'], buttons: [nativeText().keepRunning, nativeText().quitAnyway],
cancelId: 0, cancelId: 0,
defaultId: 0, defaultId: 0,
detail: prompt.detail, detail: prompt.detail,
@@ -0,0 +1,45 @@
import type { NativeMenuLocale } from './native-menu-locale'
const en = {
saveImage: 'Save image',
saveFile: 'Save file',
images: 'Images',
allFiles: 'All files',
addContext: 'Add context',
save: 'Save',
chooseProjectDirectory: 'Choose default project directory',
keepRunning: 'Keep running',
quitAnyway: 'Quit anyway',
update: 'AITURK IDE update',
updateMore: 'The update finished, but needs one more step',
updateFailed: 'AITURK IDE update did not finish',
details: 'Details',
cloudConnect: 'Connecting to the cloud agent…',
gatewaySignIn: 'Sign in to the gateway',
cloudRenew: 'Renewing the cloud session…',
cloudSignIn: 'Sign in to the cloud'
}
const tr: typeof en = {
saveImage: 'Görseli kaydet',
saveFile: 'Dosyayı kaydet',
images: 'Görseller',
allFiles: 'Tüm dosyalar',
addContext: 'Bağlam ekle',
save: 'Kaydet',
chooseProjectDirectory: 'Varsayılan proje klasörünü seçin',
keepRunning: 'Çalışmaya devam et',
quitAnyway: 'Yine de çık',
update: 'AITURK IDE güncellemesi',
updateMore: 'Güncelleme tamamlandı, ancak bir adım daha gerekiyor',
updateFailed: 'AITURK IDE güncellemesi tamamlanamadı',
details: 'Ayrıntılar',
cloudConnect: 'Bulut asistanına bağlanılıyor…',
gatewaySignIn: 'Ağ geçidine giriş yapın',
cloudRenew: 'Bulut oturumu yenileniyor…',
cloudSignIn: 'Bulut hesabına giriş yapın'
}
export function nativeDialogCopy(locale: NativeMenuLocale): typeof en {
return locale === 'tr' ? tr : en
}
@@ -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
+17 -3
View File
@@ -38,7 +38,7 @@ test('quitPromptFor names the running chats', () => {
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false) const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
assert.ok(prompt) assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 2 chats.') assert.equal(prompt.message, 'AITURK is still working on 2 chats.')
assert.ok(prompt.detail.includes('• Fix login')) assert.ok(prompt.detail.includes('• Fix login'))
assert.ok(prompt.detail.includes('• Ship docs')) assert.ok(prompt.detail.includes('• Ship docs'))
}) })
@@ -47,7 +47,7 @@ test('quitPromptFor summarizes past the list cap and counts untitled work', () =
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false) const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
assert.ok(prompt) assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 9 chats.') assert.equal(prompt.message, 'AITURK is still working on 9 chats.')
assert.ok(prompt.detail.includes('• d')) assert.ok(prompt.detail.includes('• d'))
assert.ok(!prompt.detail.includes('• e')) assert.ok(!prompt.detail.includes('• e'))
assert.ok(prompt.detail.includes('• 5 more')) assert.ok(prompt.detail.includes('• 5 more'))
@@ -57,6 +57,20 @@ test('quitPromptFor speaks singular for one chat', () => {
const prompt = quitPromptFor({ count: 1, titles: [] }, false) const prompt = quitPromptFor({ count: 1, titles: [] }, false)
assert.ok(prompt) assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 1 chat.') assert.equal(prompt.message, 'AITURK is still working on 1 chat.')
assert.ok(prompt.detail.includes('mid-turn')) assert.ok(prompt.detail.includes('mid-turn'))
}) })
test('Turkish exit warning preserves custom titles, counts and handoff behavior', () => {
const work = { count: 7, titles: ['Custom English title', 'Türkçe görev'] }
const prompt = quitPromptFor(work, false, 'tr')
assert.ok(prompt)
assert.equal(prompt.message, 'AITURK hâlâ 7 sohbet üzerinde çalışıyor.')
assert.ok(prompt.detail.includes('• Custom English title'))
assert.ok(prompt.detail.includes('• Türkçe görev'))
assert.ok(prompt.detail.includes('• 5 sohbet daha'))
assert.ok(prompt.detail.includes('çalışmasını durdurur'))
assert.equal(quitPromptFor(work, true, 'tr'), null)
assert.equal(quitPromptFor({ count: 0, titles: [] }, false, 'tr'), null)
})
+8 -4
View File
@@ -65,7 +65,7 @@ export interface QuitPrompt {
* are the app replacing itself, not the user walking away, and a modal there * are the app replacing itself, not the user walking away, and a modal there
* would strand the detached script waiting on a PID that never exits. * would strand the detached script waiting on a PID that never exits.
*/ */
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt { export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean, locale: 'en' | 'tr' = 'en'): null | QuitPrompt {
if (quittingForHandoff || work.count < 1) { if (quittingForHandoff || work.count < 1) {
return null return null
} }
@@ -75,18 +75,22 @@ export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): nu
const lines = listed.map(title => `${title}`) const lines = listed.map(title => `${title}`)
if (remaining > 0) { if (remaining > 0) {
lines.push(remaining === 1 ? '• 1 more' : `${remaining} more`) lines.push(locale === 'tr' ? `${remaining} sohbet daha` : remaining === 1 ? '• 1 more' : `${remaining} more`)
} }
return { return {
detail: [ detail: [
lines.join('\n'), lines.join('\n'),
lines.length > 0 ? '' : null, lines.length > 0 ? '' : null,
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.' locale === 'tr'
? 'Çıkış yapmak asistanın devam eden çalışmasını durdurur. Henüz yazmayı tamamlamadığı çalışma kaybolur.'
: 'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
] ]
.filter(line => line !== null) .filter(line => line !== null)
.join('\n') .join('\n')
.trim(), .trim(),
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.` message: locale === 'tr'
? `AITURK hâlâ ${work.count} sohbet üzerinde çalışıyor.`
: work.count === 1 ? 'AITURK is still working on 1 chat.' : `AITURK is still working on ${work.count} chats.`
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.5", "version": "1.0.0-beta.6",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
+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,8 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { EN_SURFACES } from '@/i18n/en-surfaces'
import { TR_SURFACES } from '@/i18n/tr-surfaces'
import type { StarmapGraph } from '@/types/hermes' import type { StarmapGraph } from '@/types/hermes'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code'
function sampleGraph(): StarmapGraph { function sampleGraph(): StarmapGraph {
return { return {
@@ -154,6 +156,36 @@ describe('share-code', () => {
expect(() => decodeShareCode('')).toThrow(ShareCodeError) expect(() => decodeShareCode('')).toThrow(ShareCodeError)
}) })
it('formats real decoding failures in the selected language without changing valid codes', () => {
const graph = sampleGraph()
const valid = encodeShareCode(graph)
const capture = (code: string): unknown => {
try { decodeShareCode(code) } catch (error) { return error }
throw new Error('Expected a decoding failure')
}
const invalid = capture('not a real code !!!')
expect(shareCodeErrorText(invalid, TR_SURFACES.mapErrors)).toBe('Bu metin bir harita koduna benzemiyor.')
expect(shareCodeErrorText(invalid, EN_SURFACES.mapErrors)).toBe("That doesn't look like a map code.")
const raw = atob(valid.slice(3).replace(/-/g, '+').replace(/_/g, '/'))
const expected = raw.charCodeAt(0)
const actual = (expected + 1) % 256
const otherVersion = 'HML' + btoa(String.fromCharCode(actual) + raw.slice(1))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
expect(shareCodeErrorText(capture(otherVersion), TR_SURFACES.mapErrors))
.toBe(TR_SURFACES.mapErrors.version(actual, expected))
const middle = Math.floor(valid.length / 2)
const corrupted = valid.slice(0, middle) + (valid[middle] === 'A' ? 'B' : 'A') + valid.slice(middle + 1)
expect(shareCodeErrorText(capture(corrupted), TR_SURFACES.mapErrors)).toBe(TR_SURFACES.mapErrors.corrupted)
expect(shareCodeErrorText(new Error('internal details'), TR_SURFACES.mapErrors)).toBe('Harita kodu okunamadı.')
expect(encodeShareCode(graph)).toBe(valid)
expect(decodeShareCode(valid).nodes).toHaveLength(graph.nodes.length)
})
it('rejects a corrupted (bit-flipped) code', () => { it('rejects a corrupted (bit-flipped) code', () => {
const code = encodeShareCode(sampleGraph()) const code = encodeShareCode(sampleGraph())
// Flip a mid-payload char (trailing base64 bits can be dropped on decode). // Flip a mid-payload char (trailing base64 bits can be dropped on decode).
@@ -1,3 +1,4 @@
import type { EN_SURFACES } from '@/i18n/en-surfaces'
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout' import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes' import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
@@ -168,6 +169,13 @@ function readGraph(r: BitReader): StarmapGraph {
export class ShareCodeError extends LoadoutError {} export class ShareCodeError extends LoadoutError {}
export function shareCodeErrorText(error: unknown, messages: typeof EN_SURFACES.mapErrors): string {
if (!(error instanceof ShareCodeError) || !error.info) {return messages.unknown}
const info = error.info
return info.kind === 'version' ? messages.version(info.actual, info.expected) : messages[info.kind]
}
const codec = createLoadout<StarmapGraph>({ const codec = createLoadout<StarmapGraph>({
error: ShareCodeError, error: ShareCodeError,
noun: 'map code', noun: 'map code',
@@ -1,6 +1,10 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import { TR_SURFACES } from '@/i18n/tr-surfaces'
import { decodeShareCode, shareCodeErrorText } from './share-code'
import { ShareControls } from './share-controls' import { ShareControls } from './share-controls'
describe('ShareControls', () => { describe('ShareControls', () => {
@@ -8,17 +12,35 @@ describe('ShareControls', () => {
cleanup() cleanup()
}) })
it('opens its dialog when the trigger has a tooltip', async () => { it.each([
render(<ShareControls shareCode="map-code" />) ['en', 'Import / export map'],
['tr', 'Haritayı içe / dışa aktar']
])('opens its dialog with a tooltip in %s', async (locale, title) => {
render(<I18nProvider configClient={null} initialLocale={locale}><ShareControls shareCode="map-code" /></I18nProvider>)
const trigger = screen.getByRole('button', { name: 'Import / export map' }) const trigger = screen.getByRole('button', { name: title })
fireEvent.pointerMove(trigger, { pointerType: 'mouse' }) fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
expect((await screen.findByRole('tooltip')).textContent).toContain('Import / export map') expect((await screen.findByRole('tooltip')).textContent).toContain(title)
fireEvent.click(trigger) fireEvent.click(trigger)
const dialog = await screen.findByRole('dialog') const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('heading', { name: 'Import / export map' })).toBeTruthy() expect(within(dialog).getByRole('heading', { name: title })).toBeTruthy()
})
it('keeps an invalid pasted code editable and displays the Turkish decoder error', async () => {
render(<I18nProvider configClient={null} initialLocale="tr"><ShareControls onImport={code => {
try { decodeShareCode(code);
return null } catch (error) { return shareCodeErrorText(error, TR_SURFACES.mapErrors) }
}} /></I18nProvider>)
fireEvent.click(screen.getByRole('button', { name: 'Haritayı içe / dışa aktar' }))
const dialog = await screen.findByRole('dialog')
const input = within(dialog).getByRole('textbox')
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
}) })
}) })
+3 -3
View File
@@ -12,7 +12,7 @@ import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry' import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu' import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
import { drawScene, drawScramble } from './render' import { drawScene, drawScramble } from './render'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' import { decodeShareCode, encodeShareCode, shareCodeErrorText } from './share-code'
import { ShareControls } from './share-controls' import { ShareControls } from './share-controls'
import { buildSimulation } from './simulation' import { buildSimulation } from './simulation'
import { formatDate } from './text' import { formatDate } from './text'
@@ -198,10 +198,10 @@ export function StarMap({
return null return null
} catch (err) { } catch (err) {
return err instanceof ShareCodeError ? err.message : 'Could not read that map code.' return shareCodeErrorText(err, t.surfaces.mapErrors)
} }
}, },
[onImport] [onImport, t.surfaces.mapErrors]
) )
// Mark the canvas dirty and wake the (otherwise-idle) render loop. // Mark the canvas dirty and wake the (otherwise-idle) render loop.
+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(() => {
+9 -1
View File
@@ -97,5 +97,13 @@ export const EN_SURFACES = {
permanentMemory: 'This memory is removed permanently.', permanentMemory: 'This memory is removed permanently.',
timeline: 'Timeline scrubber', timeline: 'Timeline scrubber',
pause: 'Pause', pause: 'Pause',
playTimeline: 'Play timeline' playTimeline: 'Play timeline',
mapErrors: {
invalid: "That doesn't look like a map code.",
short: 'Map code is too short to be valid.',
corrupted: 'Map code looks corrupted (checksum mismatch).',
malformed: 'Map code is malformed.',
version: (actual: number, expected: number) => `Map code is version ${actual}; this build reads version ${expected}.`,
unknown: 'Could not read that map code.'
}
} }
+9 -1
View File
@@ -76,5 +76,13 @@ export const TR_SURFACES: typeof EN_SURFACES = {
permanentMemory: 'Bu bellek kalıcı olarak silinir.', permanentMemory: 'Bu bellek kalıcı olarak silinir.',
timeline: 'Zaman çizelgesinde gezin', timeline: 'Zaman çizelgesinde gezin',
pause: 'Duraklat', pause: 'Duraklat',
playTimeline: 'Zaman çizelgesini oynat' playTimeline: 'Zaman çizelgesini oynat',
mapErrors: {
invalid: 'Bu metin bir harita koduna benzemiyor.',
short: 'Harita kodu geçerli olamayacak kadar kısa.',
corrupted: 'Harita kodu bozulmuş görünüyor; sağlama toplamı eşleşmiyor.',
malformed: 'Harita kodunun biçimi geçersiz.',
version: (actual: number, expected: number) => `Harita kodunun sürümü ${actual}; bu uygulama ${expected} sürümünü okuyabiliyor.`,
unknown: 'Harita kodu okunamadı.'
}
} }
+18 -8
View File
@@ -183,7 +183,15 @@ function checksum16(buf: Uint8Array): number {
return (h >>> 0) & 0xffff return (h >>> 0) & 0xffff
} }
export class LoadoutError extends Error {} export type LoadoutErrorInfo =
| { kind: 'invalid' | 'short' | 'corrupted' | 'malformed' }
| { kind: 'version'; actual: number; expected: number }
export class LoadoutError extends Error {
constructor(message: string, readonly info?: LoadoutErrorInfo) {
super(message)
}
}
export interface Loadout<T> { export interface Loadout<T> {
decode(code: string): T decode(code: string): T
@@ -202,7 +210,7 @@ export interface LoadoutSpec<T> {
/** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */ /** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */
noun?: string noun?: string
/** Error subclass to throw, so callers can `instanceof` their own type. */ /** Error subclass to throw, so callers can `instanceof` their own type. */
error?: new (message: string) => LoadoutError error?: new (message: string, info?: LoadoutErrorInfo) => LoadoutError
} }
const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum
@@ -239,7 +247,7 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned
if (!raw) { if (!raw) {
throw new Err(`That doesn't look like a ${noun}.`) throw new Err(`That doesn't look like a ${noun}.`, { kind: 'invalid' })
} }
let framed: Uint8Array let framed: Uint8Array
@@ -247,11 +255,11 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
try { try {
framed = fromBase64Url(raw) framed = fromBase64Url(raw)
} catch { } catch {
throw new Err(`That doesn't look like a ${noun}.`) throw new Err(`That doesn't look like a ${noun}.`, { kind: 'invalid' })
} }
if (framed.length <= HEAD_BYTES) { if (framed.length <= HEAD_BYTES) {
throw new Err(`${Noun} is too short to be valid.`) throw new Err(`${Noun} is too short to be valid.`, { kind: 'short' })
} }
const head = new BitReader(framed.subarray(0, HEAD_BYTES)) const head = new BitReader(framed.subarray(0, HEAD_BYTES))
@@ -259,19 +267,21 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
const storedSum = head.uint(16) const storedSum = head.uint(16)
if (version !== spec.version) { if (version !== spec.version) {
throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`) throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`, {
kind: 'version', actual: version, expected: spec.version
})
} }
const payload = framed.subarray(HEAD_BYTES) const payload = framed.subarray(HEAD_BYTES)
if (checksum16(payload) !== storedSum) { if (checksum16(payload) !== storedSum) {
throw new Err(`${Noun} looks corrupted (checksum mismatch).`) throw new Err(`${Noun} looks corrupted (checksum mismatch).`, { kind: 'corrupted' })
} }
try { try {
return spec.read(new BitReader(inflateSync(payload))) return spec.read(new BitReader(inflateSync(payload)))
} catch (err) { } catch (err) {
throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`) throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`, { kind: 'malformed' })
} }
} }
+1 -1
View File
@@ -65,7 +65,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.5", "version": "1.0.0-beta.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",