Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75afd01b4b | ||
|
|
7724d65e92 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 !!!')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Vendored
+1
@@ -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.
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ı.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user