From 75afd01b4b1009d6fdaf430ab2a462825ba87ccb Mon Sep 17 00:00:00 2001 From: yilsem Date: Sun, 6 Sep 2026 05:34:34 +0300 Subject: [PATCH] Prepare IDE beta 6 with Turkish native dialogs and map errors --- apps/desktop/docs/aiturk-turkish-beta6.md | 18 ++++++ apps/desktop/e2e/turkish-settings.spec.ts | 39 +++++++++++++ apps/desktop/electron/main.ts | 58 +++++++++---------- apps/desktop/electron/native-dialog-copy.ts | 45 ++++++++++++++ apps/desktop/electron/quit-guard.test.ts | 20 ++++++- apps/desktop/electron/quit-guard.ts | 12 ++-- apps/desktop/package.json | 2 +- .../src/app/starmap/share-code.test.ts | 34 ++++++++++- apps/desktop/src/app/starmap/share-code.ts | 8 +++ .../src/app/starmap/share-controls.test.tsx | 32 ++++++++-- apps/desktop/src/app/starmap/star-map.tsx | 6 +- apps/desktop/src/i18n/en-surfaces.ts | 10 +++- apps/desktop/src/i18n/tr-surfaces.ts | 10 +++- apps/desktop/src/lib/loadout.ts | 26 ++++++--- package-lock.json | 2 +- 15 files changed, 265 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/docs/aiturk-turkish-beta6.md create mode 100644 apps/desktop/electron/native-dialog-copy.ts diff --git a/apps/desktop/docs/aiturk-turkish-beta6.md b/apps/desktop/docs/aiturk-turkish-beta6.md new file mode 100644 index 0000000..134192f --- /dev/null +++ b/apps/desktop/docs/aiturk-turkish-beta6.md @@ -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. diff --git a/apps/desktop/e2e/turkish-settings.spec.ts b/apps/desktop/e2e/turkish-settings.spec.ts index ccd7454..2bacbad 100644 --- a/apps/desktop/e2e/turkish-settings.spec.ts +++ b/apps/desktop/e2e/turkish-settings.spec.ts @@ -9,6 +9,7 @@ type DesktopTestWindow = Window & { hermesDesktop: { api(request: { path: string }): Promise setUiLanguage(locale: string): void + selectSavePath(options?: { title?: string; defaultPath?: string }): Promise } } @@ -95,3 +96,41 @@ test('native language bridge updates the Mac menu and preserves menu-free Window ) } }) + +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 + }) + } +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 707f888..bd8c336 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -261,6 +261,7 @@ import { resolveOauthRestAuth, resolveReadinessProbeAuth } from './native-auth-decisions' +import { nativeDialogCopy } from './native-dialog-copy' import { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale' import { nativeRefreshUrl, @@ -2313,8 +2314,8 @@ async function waitForUpdateToFinish() { rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`) dialog.showMessageBox({ type: 'warning', - title: 'Hermes update', - message: 'The update finished, but needs one more step', + title: nativeText().update, + message: nativeText().updateMore, detail: result.message }) } else if (result && result.ok) { @@ -2322,8 +2323,8 @@ async function waitForUpdateToFinish() { } else if (result) { rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`) dialog.showErrorBox( - 'Hermes update did not finish', - `${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}` + nativeText().updateFailed, + `${result.message}\n\n${nativeText().details}: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}` ) } } catch (err) { @@ -6087,11 +6088,11 @@ async function saveImageFromUrl(rawUrl) { } const result = await dialog.showSaveDialog(mainWindow, { - title: 'Save Image', + title: nativeText().saveImage, defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName, filters: [ - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, - { name: 'All Files', extensions: ['*'] } + { name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, + { name: nativeText().allFiles, extensions: ['*'] } ] }) @@ -6756,7 +6757,8 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) webContents.send('hermes:window-state-changed', state) } -let nativeMenuLocale: NativeMenuLocale = 'tr' +let nativeUiLocale: NativeMenuLocale = 'tr' +const nativeText = () => nativeDialogCopy(nativeUiLocale) function buildApplicationMenu() { const template = [] @@ -6889,7 +6891,7 @@ function buildApplicationMenu() { submenu: [checkForUpdatesItem] }) - return Menu.buildFromTemplate(localizeNativeMenu(template, nativeMenuLocale, APP_NAME)) + return Menu.buildFromTemplate(localizeNativeMenu(template, nativeUiLocale, APP_NAME)) } function toggleDevTools(window) { @@ -7179,13 +7181,13 @@ function installDownloadHandling() { try { item.setSaveDialogOptions({ - title: 'Save File', + title: nativeText().saveFile, defaultPath: path.join(app.getPath('downloads'), filename), filters: extension || /^image\//i.test(item.getMimeType() || '') ? [ - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, - { name: 'All Files', extensions: ['*'] } + { name: nativeText().images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, + { name: nativeText().allFiles, extensions: ['*'] } ] : undefined }) @@ -7538,7 +7540,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) { win = new BrowserWindow({ width: 520, height: 720, - title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', + title: silent ? nativeText().cloudConnect : nativeText().gatewaySignIn, autoHideMenuBar: true, // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in // well under a second, so the window normally never needs to show. We @@ -7967,7 +7969,7 @@ async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {}) const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, - title: 'Save File' + title: nativeText().saveFile }) if (result.canceled || !result.filePath) { @@ -8124,7 +8126,7 @@ async function saveGatewayFileViaDataUrl( const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, - title: 'Save File' + title: nativeText().saveFile }) if (result.canceled || !result.filePath) { @@ -8416,7 +8418,7 @@ function renewPortalAccessSilently() { width: 520, height: 720, show: false, - title: 'Renewing Hermes Cloud session…', + title: nativeText().cloudRenew, autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -8521,7 +8523,7 @@ function openPortalLoginWindow() { win = new BrowserWindow({ width: 520, height: 720, - title: 'Sign in to Hermes Cloud', + title: nativeText().cloudSignIn, autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -16767,7 +16769,7 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { } const result = await dialog.showOpenDialog(mainWindow, { - title: options?.title || 'Add context', + title: options?.title || nativeText().addContext, defaultPath: resolvedDefaultPath, properties: properties as any, filters: Array.isArray(options?.filters) ? options.filters : undefined @@ -16790,7 +16792,7 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => { // elsewhere (the backend, for profile archives); this only picks the path. ipcMain.handle('hermes:selectSavePath', async (_event, options: any = {}) => { const result = await dialog.showSaveDialog(mainWindow, { - title: options?.title || 'Save', + title: options?.title || nativeText().save, defaultPath: options?.defaultPath ? String(options.defaultPath) : undefined, filters: Array.isArray(options?.filters) ? options.filters : undefined }) @@ -16960,18 +16962,16 @@ 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) { + if (next === null || next === nativeUiLocale) { return } - nativeMenuLocale = next - Menu.setApplicationMenu(buildApplicationMenu()) + nativeUiLocale = next + if (IS_MAC) { + Menu.setApplicationMenu(buildApplicationMenu()) + } }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). @@ -17306,7 +17306,7 @@ ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => { ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => { const result = await dialog.showOpenDialog({ - title: 'Choose default project directory', + title: nativeText().chooseProjectDirectory, properties: ['openDirectory', 'createDirectory'], defaultPath: readDefaultProjectDir() || app.getPath('home') }) @@ -17866,7 +17866,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean { return false } - const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff) + const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff, nativeUiLocale) const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] if (!prompt || !parent || parent.isDestroyed()) { @@ -17878,7 +17878,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean { void dialog .showMessageBox(parent, { - buttons: ['Keep Running', 'Quit Anyway'], + buttons: [nativeText().keepRunning, nativeText().quitAnyway], cancelId: 0, defaultId: 0, detail: prompt.detail, diff --git a/apps/desktop/electron/native-dialog-copy.ts b/apps/desktop/electron/native-dialog-copy.ts new file mode 100644 index 0000000..ae81f00 --- /dev/null +++ b/apps/desktop/electron/native-dialog-copy.ts @@ -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 +} diff --git a/apps/desktop/electron/quit-guard.test.ts b/apps/desktop/electron/quit-guard.test.ts index 8888f99..f81a9e8 100644 --- a/apps/desktop/electron/quit-guard.test.ts +++ b/apps/desktop/electron/quit-guard.test.ts @@ -38,7 +38,7 @@ test('quitPromptFor names the running chats', () => { const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false) 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('• 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) 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('• e')) 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) 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')) }) + + +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) +}) diff --git a/apps/desktop/electron/quit-guard.ts b/apps/desktop/electron/quit-guard.ts index e2bb40e..746370e 100644 --- a/apps/desktop/electron/quit-guard.ts +++ b/apps/desktop/electron/quit-guard.ts @@ -65,7 +65,7 @@ export interface QuitPrompt { * 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. */ -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) { return null } @@ -75,18 +75,22 @@ export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): nu const lines = listed.map(title => `• ${title}`) 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 { detail: [ lines.join('\n'), 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) .join('\n') .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.` } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4ef9c5e..cc0e7b2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "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.", "author": "AITURK / TurkServis", "repository": { diff --git a/apps/desktop/src/app/starmap/share-code.test.ts b/apps/desktop/src/app/starmap/share-code.test.ts index 6cf33f8..8ef9525 100644 --- a/apps/desktop/src/app/starmap/share-code.test.ts +++ b/apps/desktop/src/app/starmap/share-code.test.ts @@ -1,8 +1,10 @@ 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 { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' +import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code' function sampleGraph(): StarmapGraph { return { @@ -154,6 +156,36 @@ describe('share-code', () => { 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', () => { const code = encodeShareCode(sampleGraph()) // Flip a mid-payload char (trailing base64 bits can be dropped on decode). diff --git a/apps/desktop/src/app/starmap/share-code.ts b/apps/desktop/src/app/starmap/share-code.ts index 7531715..86a41cc 100644 --- a/apps/desktop/src/app/starmap/share-code.ts +++ b/apps/desktop/src/app/starmap/share-code.ts @@ -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 { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes' @@ -168,6 +169,13 @@ function readGraph(r: BitReader): StarmapGraph { 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({ error: ShareCodeError, noun: 'map code', diff --git a/apps/desktop/src/app/starmap/share-controls.test.tsx b/apps/desktop/src/app/starmap/share-controls.test.tsx index 048b28e..5c90841 100644 --- a/apps/desktop/src/app/starmap/share-controls.test.tsx +++ b/apps/desktop/src/app/starmap/share-controls.test.tsx @@ -1,6 +1,10 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' 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' describe('ShareControls', () => { @@ -8,17 +12,35 @@ describe('ShareControls', () => { cleanup() }) - it('opens its dialog when the trigger has a tooltip', async () => { - render() + it.each([ + ['en', 'Import / export map'], + ['tr', 'Haritayı içe / dışa aktar'] + ])('opens its dialog with a tooltip in %s', async (locale, title) => { + render() - const trigger = screen.getByRole('button', { name: 'Import / export map' }) + const trigger = screen.getByRole('button', { name: title }) 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) 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( { + try { decodeShareCode(code); + + return null } catch (error) { return shareCodeErrorText(error, TR_SURFACES.mapErrors) } + }} />) + 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 !!!') }) }) diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index 5ababc1..20df2b0 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -12,7 +12,7 @@ import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry' import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu' 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 { buildSimulation } from './simulation' import { formatDate } from './text' @@ -198,10 +198,10 @@ export function StarMap({ return null } 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. diff --git a/apps/desktop/src/i18n/en-surfaces.ts b/apps/desktop/src/i18n/en-surfaces.ts index 3e9b7b1..517613c 100644 --- a/apps/desktop/src/i18n/en-surfaces.ts +++ b/apps/desktop/src/i18n/en-surfaces.ts @@ -97,5 +97,13 @@ export const EN_SURFACES = { permanentMemory: 'This memory is removed permanently.', timeline: 'Timeline scrubber', 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.' + } } diff --git a/apps/desktop/src/i18n/tr-surfaces.ts b/apps/desktop/src/i18n/tr-surfaces.ts index 3db9971..b4a0158 100644 --- a/apps/desktop/src/i18n/tr-surfaces.ts +++ b/apps/desktop/src/i18n/tr-surfaces.ts @@ -76,5 +76,13 @@ export const TR_SURFACES: typeof EN_SURFACES = { permanentMemory: 'Bu bellek kalıcı olarak silinir.', timeline: 'Zaman çizelgesinde gezin', 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ı.' + } } diff --git a/apps/desktop/src/lib/loadout.ts b/apps/desktop/src/lib/loadout.ts index b687690..adb9a9e 100644 --- a/apps/desktop/src/lib/loadout.ts +++ b/apps/desktop/src/lib/loadout.ts @@ -183,7 +183,15 @@ function checksum16(buf: Uint8Array): number { 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 { decode(code: string): T @@ -202,7 +210,7 @@ export interface LoadoutSpec { /** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */ noun?: string /** 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 @@ -239,7 +247,7 @@ export function createLoadout(spec: LoadoutSpec): Loadout { const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned 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 @@ -247,11 +255,11 @@ export function createLoadout(spec: LoadoutSpec): Loadout { try { framed = fromBase64Url(raw) } 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) { - 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)) @@ -259,19 +267,21 @@ export function createLoadout(spec: LoadoutSpec): Loadout { const storedSum = head.uint(16) 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) if (checksum16(payload) !== storedSum) { - throw new Err(`${Noun} looks corrupted (checksum mismatch).`) + throw new Err(`${Noun} looks corrupted (checksum mismatch).`, { kind: 'corrupted' }) } try { return spec.read(new BitReader(inflateSync(payload))) } 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' }) } } diff --git a/package-lock.json b/package-lock.json index 6e801e4..c0a8a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",