Prepare IDE beta 6 with Turkish native dialogs and map errors

This commit is contained in:
2026-09-06 05:34:34 +03:00
parent 7724d65e92
commit 75afd01b4b
15 changed files with 265 additions and 57 deletions
+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.
+39
View File
@@ -9,6 +9,7 @@ type DesktopTestWindow = Window & {
hermesDesktop: { hermesDesktop: {
api<T = unknown>(request: { path: string }): Promise<T> api<T = unknown>(request: { path: string }): Promise<T>
setUiLanguage(locale: string): void setUiLanguage(locale: string): void
selectSavePath(options?: { title?: string; defaultPath?: string }): Promise<string | null>
} }
} }
@@ -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
})
}
})
+29 -29
View File
@@ -261,6 +261,7 @@ 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 { localizeNativeMenu, type NativeMenuLocale, resolveNativeMenuLocale } from './native-menu-locale'
import { import {
nativeRefreshUrl, nativeRefreshUrl,
@@ -2313,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) {
@@ -2322,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) {
@@ -6087,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: ['*'] }
] ]
}) })
@@ -6756,7 +6757,8 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow)
webContents.send('hermes:window-state-changed', state) webContents.send('hermes:window-state-changed', state)
} }
let nativeMenuLocale: NativeMenuLocale = 'tr' let nativeUiLocale: NativeMenuLocale = 'tr'
const nativeText = () => nativeDialogCopy(nativeUiLocale)
function buildApplicationMenu() { function buildApplicationMenu() {
const template = [] const template = []
@@ -6889,7 +6891,7 @@ function buildApplicationMenu() {
submenu: [checkForUpdatesItem] submenu: [checkForUpdatesItem]
}) })
return Menu.buildFromTemplate(localizeNativeMenu(template, nativeMenuLocale, APP_NAME)) return Menu.buildFromTemplate(localizeNativeMenu(template, nativeUiLocale, APP_NAME))
} }
function toggleDevTools(window) { function toggleDevTools(window) {
@@ -7179,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
}) })
@@ -7538,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
@@ -7967,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) {
@@ -8124,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) {
@@ -8416,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,
@@ -8521,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,
@@ -16767,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
@@ -16790,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
}) })
@@ -16960,18 +16962,16 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
// Language stays in backend display.language; this only updates native labels. // Language stays in backend display.language; this only updates native labels.
ipcMain.on('hermes:ui-language', (event, locale) => { ipcMain.on('hermes:ui-language', (event, locale) => {
if (!IS_MAC) {
return
}
const next = resolveNativeMenuLocale(locale, event.sender.id, mainWindow?.webContents.id) const next = resolveNativeMenuLocale(locale, event.sender.id, mainWindow?.webContents.id)
if (next === null || next === nativeMenuLocale) { if (next === null || next === nativeUiLocale) {
return return
} }
nativeMenuLocale = next nativeUiLocale = next
Menu.setApplicationMenu(buildApplicationMenu()) 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).
@@ -17306,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')
}) })
@@ -17866,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()) {
@@ -17878,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
}
+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": {
@@ -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.
+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",