diff --git a/src/main/ipc/pet-bundle-import.ts b/src/main/ipc/pet-bundle-import.ts index 4ecba5db827..a5352a8b971 100644 --- a/src/main/ipc/pet-bundle-import.ts +++ b/src/main/ipc/pet-bundle-import.ts @@ -1,4 +1,5 @@ import { translateMain } from '../i18n/main-i18n' +import { PET_BUNDLE_DEFAULT_LABEL, PetImportError } from '../../shared/pet-import-failure-copy' import { BrowserWindow, dialog } from 'electron' import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' @@ -34,7 +35,7 @@ export async function importPetBundle( const pickedStat = await stat(picked) bundleDir = pickedStat.isDirectory() ? picked : dirname(picked) } catch { - throw new Error('Could not read the selected path.') + throw new PetImportError('pathUnreadable') } const manifestPath = join(bundleDir, 'pet.json') @@ -42,13 +43,13 @@ export async function importPetBundle( try { manifestStat = await stat(manifestPath) } catch { - throw new Error('Bundle is missing pet.json.') + throw new PetImportError('manifestMissing') } if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) { - throw new Error('pet.json is invalid.') + throw new PetImportError('manifestInvalid') } if (await isSymlink(manifestPath)) { - throw new Error('pet.json must not be a symlink.') + throw new PetImportError('manifestSymlink') } let manifest: ResolvedPetManifest @@ -60,7 +61,11 @@ export async function importPetBundle( } manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw))) } catch (error) { - throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) + throw new PetImportError( + 'manifestParseFailed', + {}, + error instanceof Error ? error.message : 'parse error' + ) } const { sheetSrc, sheetClass } = await resolveBundleSpritesheetSource(manifest, bundleDir) @@ -84,11 +89,11 @@ export async function importPetBundle( await rename(tmpDir, destDir) } catch { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) - throw new Error('Could not save the pet bundle.') + throw new PetImportError('bundleSaveFailed') } const rawLabel = (manifest.displayName ?? manifest.id ?? basename(bundleDir)).trim() - const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Pet bundle' + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : PET_BUNDLE_DEFAULT_LABEL return { id, label, diff --git a/src/main/ipc/pet-bundle-sprite-metadata.ts b/src/main/ipc/pet-bundle-sprite-metadata.ts index 09d70858a5c..08750873101 100644 --- a/src/main/ipc/pet-bundle-sprite-metadata.ts +++ b/src/main/ipc/pet-bundle-sprite-metadata.ts @@ -1,4 +1,5 @@ import { nativeImage } from 'electron' +import { PetImportError } from '../../shared/pet-import-failure-copy' import { readFile } from 'node:fs/promises' import type { CustomPet } from '../../shared/pet-types' import { MAX_BYTES } from './pet-import-size-limits' @@ -36,38 +37,45 @@ export async function buildBundleSprite( const sheetBuf = await readFile(sheetSrc) // Recheck the read size because the file may have grown after stat. if (sheetBuf.byteLength > MAX_BYTES) { - throw new Error('Spritesheet exceeded the size limit.') + throw new PetImportError('sheetSizeLimitExceeded') } const dims = await readSheetDimensions(sheetBuf) if (!dims) { - throw new Error('Could not decode the spritesheet image.') + throw new PetImportError('sheetUndecodable') } const { width: fw, height: fh } = manifest.frame if (dims.width % fw !== 0 || dims.height % fh !== 0) { - throw new Error( - `Spritesheet ${dims.width}×${dims.height} is not a clean multiple of frame ${fw}×${fh}.` - ) + throw new PetImportError('sheetFrameMismatch', { + sheetWidth: dims.width, + sheetHeight: dims.height, + frameWidth: fw, + frameHeight: fh + }) } const columns = dims.width / fw const rows = dims.height / fh if (manifest.animations) { for (const [name, anim] of Object.entries(manifest.animations)) { if (anim.row >= rows) { - throw new Error(`Animation "${name}" references row ${anim.row} but sheet has ${rows}.`) + throw new PetImportError('animationRowOutOfRange', { name, row: anim.row, rows }) } if (anim.frames > columns) { - throw new Error( - `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` - ) + throw new PetImportError('animationTooManyFrames', { + name, + frames: anim.frames, + columns + }) } if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) { - throw new Error( - `Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.` - ) + throw new PetImportError('animationDurationMismatch', { + name, + durations: anim.frameDurationsMs.length, + frames: anim.frames + }) } } if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { - throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) + throw new PetImportError('defaultAnimationMissing', { name: manifest.defaultAnimation }) } } sprite = { diff --git a/src/main/ipc/pet-bundle-spritesheet-source.ts b/src/main/ipc/pet-bundle-spritesheet-source.ts index 5885d1a5d92..5076de7d1fa 100644 --- a/src/main/ipc/pet-bundle-spritesheet-source.ts +++ b/src/main/ipc/pet-bundle-spritesheet-source.ts @@ -1,4 +1,5 @@ import { stat } from 'node:fs/promises' +import { PetImportError } from '../../shared/pet-import-failure-copy' import { isAbsolute, resolve, sep } from 'node:path' import { classifyFile } from './pet-image-formats' import { MAX_BYTES } from './pet-import-size-limits' @@ -16,39 +17,39 @@ export async function resolveBundleSpritesheetSource( isAbsolute(normalizedSpritePath) || /^[a-zA-Z]:/.test(manifest.spritesheetPath) ) { - throw new Error('spritesheetPath must be relative to the bundle.') + throw new PetImportError('sheetPathNotRelative') } // Accept Windows-exported bundles on macOS/Linux. const sheetSrc = resolve(bundleDir, normalizedSpritePath) const bundleResolved = resolve(bundleDir) if (sheetSrc === bundleResolved) { - throw new Error('spritesheetPath must point to a file, not the bundle root.') + throw new PetImportError('sheetPathIsRoot') } const bundleRoot = bundleResolved + sep // Match paths case-insensitively on Windows. const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { - throw new Error('spritesheetPath escapes the bundle.') + throw new PetImportError('sheetPathEscapes') } if (await isSymlink(sheetSrc)) { - throw new Error('spritesheet must not be a symlink.') + throw new PetImportError('sheetSymlink') } const sheetClass = classifyFile(sheetSrc) if (!sheetClass || sheetClass.ext === '.svg') { // Sprite layouts require raster dimensions. - throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.') + throw new PetImportError('sheetUnsupportedType') } let sheetStat: Awaited> try { sheetStat = await stat(sheetSrc) } catch { - throw new Error('Spritesheet file not found.') + throw new PetImportError('sheetNotFound') } if (!sheetStat.isFile()) { - throw new Error('Spritesheet path is not a file.') + throw new PetImportError('sheetNotAFile') } if (sheetStat.size > MAX_BYTES) { - throw new Error(`Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`) + throw new PetImportError('sheetTooLarge', { size: (sheetStat.size / (1024 * 1024)).toFixed(1) }) } return { sheetSrc, sheetClass } } diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 64e5126eedd..9881da4637b 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -168,8 +168,11 @@ describe('registerPetHandlers', () => { }) showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) - await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( - 'declares 1 frame durations but 2 frames' - ) + await expect(getHandler('pet:importPetBundle')({ sender: {} })).resolves.toEqual({ + ok: false, + error: 'Animation "idle" declares 1 frame durations but 2 frames.', + errorCode: 'animationDurationMismatch', + errorParams: { name: 'idle', durations: 1, frames: 2 } + }) }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 38cc0f65488..1141bc18efb 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -1,4 +1,9 @@ import { translateMain } from '../i18n/main-i18n' +import { + CUSTOM_PET_DEFAULT_LABEL, + PetImportError, + type PetImportFailure +} from '../../shared/pet-import-failure-copy' import { BrowserWindow, dialog, ipcMain } from 'electron' import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' @@ -17,73 +22,90 @@ const PetFileRequestSchema = z.object({ kind: z.enum(['image', 'bundle']).optional() }) +// Known refusals return as data so the renderer can translate them by code. +async function withPetImportFailure( + run: () => Promise +): Promise { + try { + return await run() + } catch (error) { + if (error instanceof PetImportError) { + return error.failure + } + throw error + } +} + export function registerPetHandlers(): void { - ipcMain.handle('pet:import', async (event): Promise => { - const senderWindow = - BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() - const options: Electron.OpenDialogOptions = { - title: translateMain('native.dialog.pickPet', 'Pick pet'), - properties: ['openFile'], - // APNG uses .png; its missing macOS UTI can hide other formats when listed separately. - filters: [ - { - name: translateMain('native.dialog.petImage', 'Pet image'), - extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] - } - ] - } - const result = senderWindow - ? await dialog.showOpenDialog(senderWindow, options) - : await dialog.showOpenDialog(options) - if (result.canceled || result.filePaths.length === 0) { - return null - } - const src = result.filePaths[0] - const classified = classifyFile(src) - if (!classified) { - throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.') - } - let srcStat: Awaited> - try { - srcStat = await stat(src) - } catch { - throw new Error('Could not read the selected file.') - } - if (!srcStat.isFile()) { - throw new Error('Selected path is not a file') - } - if (srcStat.size > MAX_BYTES) { - throw new Error( - `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` - ) - } + ipcMain.handle('pet:import', (event) => + withPetImportFailure(async (): Promise => { + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + const options: Electron.OpenDialogOptions = { + title: translateMain('native.dialog.pickPet', 'Pick pet'), + properties: ['openFile'], + // APNG uses .png; its missing macOS UTI can hide other formats when listed separately. + filters: [ + { + name: translateMain('native.dialog.petImage', 'Pet image'), + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] + } + ] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const src = result.filePaths[0] + const classified = classifyFile(src) + if (!classified) { + throw new PetImportError('unsupportedFile') + } + let srcStat: Awaited> + try { + srcStat = await stat(src) + } catch { + throw new PetImportError('fileUnreadable') + } + if (!srcStat.isFile()) { + throw new PetImportError('notAFile') + } + if (srcStat.size > MAX_BYTES) { + throw new PetImportError('fileTooLarge', { + size: (srcStat.size / (1024 * 1024)).toFixed(1), + max: MAX_BYTES / (1024 * 1024) + }) + } - const dir = getPetsDir() - await mkdir(dir, { recursive: true }) - const id = randomUUID() - // Keep the extension so pet:read can recover MIME type without another lookup. - const fileName = `${id}${classified.ext}` - const dest = join(dir, fileName) - try { - await copyFile(src, dest) - } catch { - await rm(dest, { force: true }).catch(() => {}) - throw new Error('Could not save the pet.') - } + const dir = getPetsDir() + await mkdir(dir, { recursive: true }) + const id = randomUUID() + // Keep the extension so pet:read can recover MIME type without another lookup. + const fileName = `${id}${classified.ext}` + const dest = join(dir, fileName) + try { + await copyFile(src, dest) + } catch { + await rm(dest, { force: true }).catch(() => {}) + throw new PetImportError('saveFailed') + } - const rawLabel = basename(src, extname(src)).trim() - const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom pet' - return { - id, - label, - fileName, - mimeType: classified.mimeType, - kind: 'image' - } - }) + const rawLabel = basename(src, extname(src)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : CUSTOM_PET_DEFAULT_LABEL + return { + id, + label, + fileName, + mimeType: classified.mimeType, + kind: 'image' + } + }) + ) - ipcMain.handle('pet:importPetBundle', async (event): Promise => - importPetBundle(event) + ipcMain.handle('pet:importPetBundle', (event) => + withPetImportFailure(() => importPetBundle(event)) ) ipcMain.handle( diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 96539f0955f..982ea5e452d 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -13,6 +13,10 @@ import type { ShellOpenLocalPathResult } from '../../shared/shell-open-types' import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' +import { + repoIconPickFailure, + type RepoIconPickResult +} from '../../shared/repo-icon-pick-failure-copy' import type { Store } from '../persistence' import { EXTERNAL_EDITOR_CLI_COMMAND, @@ -270,41 +274,38 @@ export function registerShellHandlers( return result.filePaths[0] }) - ipcMain.handle( - 'shell:pickRepoIconImage', - async (): Promise<{ dataUrl: string; fileName: string } | null> => { - const result = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: [ - { - name: translateMain('native.dialog.repoIconImages', 'Repo icon images'), - extensions: ['png'] - } - ] - }) - if (result.canceled || result.filePaths.length === 0) { - return null - } - - const filePath = result.filePaths[0] - const extension = extname(filePath).toLowerCase() - const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension] - if (!mimeType) { - throw new Error('Repo icons must be PNG files.') - } - - const stats = await stat(filePath) - if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { - throw new Error('Repo icon image must be 256KB or smaller.') - } - - const buffer = await readFile(filePath) - return { - dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, - fileName: basename(filePath) - } + ipcMain.handle('shell:pickRepoIconImage', async (): Promise => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [ + { + name: translateMain('native.dialog.repoIconImages', 'Repo icon images'), + extensions: ['png'] + } + ] + }) + if (result.canceled || result.filePaths.length === 0) { + return null } - ) + + const filePath = result.filePaths[0] + const extension = extname(filePath).toLowerCase() + const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension] + if (!mimeType) { + return repoIconPickFailure('notPng') + } + + const stats = await stat(filePath) + if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return repoIconPickFailure('tooLarge') + } + + const buffer = await readFile(filePath) + return { + dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, + fileName: basename(filePath) + } + }) ipcMain.handle('shell:pickAudio', async (): Promise => { const result = await dialog.showOpenDialog({ diff --git a/src/preload/api/pet-api.ts b/src/preload/api/pet-api.ts index 8dffdda300b..ebb6937b43a 100644 --- a/src/preload/api/pet-api.ts +++ b/src/preload/api/pet-api.ts @@ -1,8 +1,9 @@ import type { CustomPet } from '../../shared/pet-types' +import type { PetImportFailure } from '../../shared/pet-import-failure-copy' export type PetApi = { - import: () => Promise - importPetBundle: () => Promise + import: () => Promise + importPetBundle: () => Promise read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise } diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts index c51751b3161..e957f0b1535 100644 --- a/src/preload/api/pet-bridge.ts +++ b/src/preload/api/pet-bridge.ts @@ -1,10 +1,12 @@ import { ipcRenderer } from 'electron' import type { CustomPet } from '../../shared/pet-types' +import type { PetImportFailure } from '../../shared/pet-import-failure-copy' import type { PreloadApi } from '../api-types' export const petApi = { - import: (): Promise => ipcRenderer.invoke('pet:import'), - importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + import: (): Promise => ipcRenderer.invoke('pet:import'), + importPetBundle: (): Promise => + ipcRenderer.invoke('pet:importPetBundle'), read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => ipcRenderer.invoke('pet:read', id, fileName, kind), delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts index b3d42ae2cff..a5b360c8f92 100644 --- a/src/preload/api/shell-api.ts +++ b/src/preload/api/shell-api.ts @@ -1,3 +1,4 @@ +import type { RepoIconPickResult } from '../../shared/repo-icon-pick-failure-copy' import type { ExecutionHostId } from '../../shared/execution-host' import type { ShellPathExistence } from '../../shared/path-existence-batch' import type { @@ -30,10 +31,7 @@ export type ShellApi = { pickAttachment: () => Promise pickAttachments: () => Promise pickImage: () => Promise - pickRepoIconImage: () => Promise<{ - dataUrl: string - fileName: string - } | null> + pickRepoIconImage: () => Promise pickAudio: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index 759a7e679d2..0d260d7d247 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -1,3 +1,4 @@ +import type { RepoIconPickResult } from '../../shared/repo-icon-pick-failure-copy' import { ipcRenderer } from 'electron' import type { ShellOpenExternalEditorRequest, @@ -41,7 +42,7 @@ export const shellApi = { pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), - pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => + pickRepoIconImage: (): Promise => ipcRenderer.invoke('shell:pickRepoIconImage'), pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), diff --git a/src/renderer/src/components/pet/pet-import-failure-text.test.ts b/src/renderer/src/components/pet/pet-import-failure-text.test.ts new file mode 100644 index 00000000000..c1e6da6d212 --- /dev/null +++ b/src/renderer/src/components/pet/pet-import-failure-text.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { i18n } from '@/i18n/i18n' +import es from '@/i18n/locales/es.json' +import { PetImportError } from '../../../../shared/pet-import-failure-copy' +import { customPetLabel, petImportFailureText } from './pet-import-failure-text' + +const originalLanguage = i18n.language + +afterEach(async () => { + await i18n.changeLanguage(originalLanguage) +}) + +describe('pet import failure text', () => { + it('localizes a coded failure while main keeps the English error', async () => { + i18n.addResourceBundle('es', 'translation', es) + await i18n.changeLanguage('es') + const { failure } = new PetImportError('fileTooLarge', { size: '12.5', max: 10 }) + expect(failure.error).toBe('File is too large (12.5 MB). Max is 10 MB.') + expect(petImportFailureText(failure)).toBe( + es.pet.importErrors.fileTooLarge.replace('{{size}}', '12.5').replace('{{max}}', '10') + ) + const parse = new PetImportError('manifestParseFailed', {}, 'Unexpected token }').failure + expect(petImportFailureText(parse)).toContain('Unexpected token }') + }) + + it('shows main English for unknown codes or missing params', async () => { + i18n.addResourceBundle('es', 'translation', es) + await i18n.changeLanguage('es') + expect(petImportFailureText({ ok: false, error: 'Newer', errorCode: 'future' })).toBe('Newer') + expect(petImportFailureText({ ok: false, error: 'English', errorCode: 'fileTooLarge' })).toBe( + 'English' + ) + }) + + it('translates only the stored default names', async () => { + i18n.addResourceBundle('es', 'translation', es) + await i18n.changeLanguage('es') + expect(customPetLabel({ label: 'Custom pet', kind: 'image' })).toBe(es.pet.defaultLabels.image) + expect(customPetLabel({ label: 'Custom pet' })).toBe(es.pet.defaultLabels.image) + expect(customPetLabel({ label: 'Pet bundle', kind: 'bundle' })).toBe( + es.pet.defaultLabels.bundle + ) + expect(customPetLabel({ label: 'Mochi', kind: 'bundle' })).toBe('Mochi') + }) +}) diff --git a/src/renderer/src/components/pet/pet-import-failure-text.ts b/src/renderer/src/components/pet/pet-import-failure-text.ts new file mode 100644 index 00000000000..80f8f381735 --- /dev/null +++ b/src/renderer/src/components/pet/pet-import-failure-text.ts @@ -0,0 +1,156 @@ +import { translate } from '@/i18n/i18n' +import type { CustomPet } from '../../../../shared/pet-types' +import { + CUSTOM_PET_DEFAULT_LABEL, + PET_BUNDLE_DEFAULT_LABEL, + PET_IMPORT_FAILURE_COPY as EN, + type PetImportFailure, + type PetImportFailureCode +} from '../../../../shared/pet-import-failure-copy' + +type Values = { + text: (name: string) => string | null + count: (name: string) => number | null +} + +// null = required values missing; caller shows main's English `error`. +const COPY: Record string | null> = { + unsupportedFile: () => translate('pet.importErrors.unsupportedFile', EN.unsupportedFile), + fileUnreadable: () => translate('pet.importErrors.fileUnreadable', EN.fileUnreadable), + notAFile: () => translate('pet.importErrors.notAFile', EN.notAFile), + fileTooLarge: ({ text, count }) => { + const size = text('size') + const max = count('max') + return size === null || max === null + ? null + : translate('pet.importErrors.fileTooLarge', EN.fileTooLarge, { size, max }) + }, + saveFailed: () => translate('pet.importErrors.saveFailed', EN.saveFailed), + pathUnreadable: () => translate('pet.importErrors.pathUnreadable', EN.pathUnreadable), + manifestMissing: () => translate('pet.importErrors.manifestMissing', EN.manifestMissing), + manifestInvalid: () => translate('pet.importErrors.manifestInvalid', EN.manifestInvalid), + manifestSymlink: () => translate('pet.importErrors.manifestSymlink', EN.manifestSymlink), + manifestParseFailed: ({ text }) => { + const detail = text('detail') + return detail === null + ? null + : translate('pet.importErrors.manifestParseFailed', EN.manifestParseFailed, { detail }) + }, + bundleSaveFailed: () => translate('pet.importErrors.bundleSaveFailed', EN.bundleSaveFailed), + sheetPathNotRelative: () => + translate('pet.importErrors.sheetPathNotRelative', EN.sheetPathNotRelative), + sheetPathIsRoot: () => translate('pet.importErrors.sheetPathIsRoot', EN.sheetPathIsRoot), + sheetPathEscapes: () => translate('pet.importErrors.sheetPathEscapes', EN.sheetPathEscapes), + sheetSymlink: () => translate('pet.importErrors.sheetSymlink', EN.sheetSymlink), + sheetUnsupportedType: () => + translate('pet.importErrors.sheetUnsupportedType', EN.sheetUnsupportedType), + sheetNotFound: () => translate('pet.importErrors.sheetNotFound', EN.sheetNotFound), + sheetNotAFile: () => translate('pet.importErrors.sheetNotAFile', EN.sheetNotAFile), + sheetTooLarge: ({ text }) => { + const size = text('size') + return size === null + ? null + : translate('pet.importErrors.sheetTooLarge', EN.sheetTooLarge, { size }) + }, + sheetSizeLimitExceeded: () => + translate('pet.importErrors.sheetSizeLimitExceeded', EN.sheetSizeLimitExceeded), + sheetUndecodable: () => translate('pet.importErrors.sheetUndecodable', EN.sheetUndecodable), + sheetFrameMismatch: ({ count }) => { + const sheetWidth = count('sheetWidth') + const sheetHeight = count('sheetHeight') + const frameWidth = count('frameWidth') + const frameHeight = count('frameHeight') + return sheetWidth === null || + sheetHeight === null || + frameWidth === null || + frameHeight === null + ? null + : translate('pet.importErrors.sheetFrameMismatch', EN.sheetFrameMismatch, { + sheetWidth, + sheetHeight, + frameWidth, + frameHeight + }) + }, + animationRowOutOfRange: ({ text, count }) => { + const name = text('name') + const row = count('row') + const rows = count('rows') + return name === null || row === null || rows === null + ? null + : translate('pet.importErrors.animationRowOutOfRange', EN.animationRowOutOfRange, { + name, + row, + rows + }) + }, + animationTooManyFrames: ({ text, count }) => { + const name = text('name') + const frames = count('frames') + const columns = count('columns') + return name === null || frames === null || columns === null + ? null + : translate('pet.importErrors.animationTooManyFrames', EN.animationTooManyFrames, { + name, + frames, + columns + }) + }, + animationDurationMismatch: ({ text, count }) => { + const name = text('name') + const durations = count('durations') + const frames = count('frames') + return name === null || durations === null || frames === null + ? null + : translate('pet.importErrors.animationDurationMismatch', EN.animationDurationMismatch, { + name, + durations, + frames + }) + }, + defaultAnimationMissing: ({ text }) => { + const name = text('name') + return name === null + ? null + : translate('pet.importErrors.defaultAnimationMissing', EN.defaultAnimationMissing, { + name + }) + } +} + +function isPetImportFailureCode(code: unknown): code is PetImportFailureCode { + return typeof code === 'string' && Object.hasOwn(COPY, code) +} + +/** Unknown or uncoded failures show main's English text. */ +export function petImportFailureText(failure: PetImportFailure): string { + if (!isPetImportFailureCode(failure.errorCode)) { + return failure.error + } + const params = failure.errorParams ?? {} + const read = (name: string): unknown => (Object.hasOwn(params, name) ? params[name] : undefined) + return ( + COPY[failure.errorCode]({ + text: (name) => { + const value = name === 'detail' ? failure.errorDetail : read(name) + return typeof value === 'string' ? value : null + }, + count: (name) => { + const value = read(name) + return typeof value === 'number' && Number.isFinite(value) ? value : null + } + }) ?? failure.error + ) +} + +/** Default names are stored in English; show them in the UI language. */ +export function customPetLabel(pet: Pick): string { + if (pet.kind === 'bundle') { + return pet.label === PET_BUNDLE_DEFAULT_LABEL + ? translate('pet.defaultLabels.bundle', PET_BUNDLE_DEFAULT_LABEL) + : pet.label + } + return pet.label === CUSTOM_PET_DEFAULT_LABEL + ? translate('pet.defaultLabels.image', CUSTOM_PET_DEFAULT_LABEL) + : pet.label +} diff --git a/src/renderer/src/components/settings/repository/identity/RepositoryIconTabs.tsx b/src/renderer/src/components/settings/repository/identity/RepositoryIconTabs.tsx index b33406eceda..375fb55ffb1 100644 --- a/src/renderer/src/components/settings/repository/identity/RepositoryIconTabs.tsx +++ b/src/renderer/src/components/settings/repository/identity/RepositoryIconTabs.tsx @@ -11,6 +11,7 @@ import { getRepoLucideIconOptions } from '../../../repo/repo-icon' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { repoIconPickFailureText } from './repo-icon-pick-failure-text' const RepositoryIconEmojiPicker = lazyWithRetry( () => @@ -46,6 +47,10 @@ export function RepositoryIconTabs({ if (!result || !mountedRef.current) { return } + if ('error' in result) { + toast.error(repoIconPickFailureText(result)) + return + } onSetIcon({ type: 'image', src: result.dataUrl, diff --git a/src/renderer/src/components/settings/repository/identity/repo-icon-pick-failure-text.ts b/src/renderer/src/components/settings/repository/identity/repo-icon-pick-failure-text.ts new file mode 100644 index 00000000000..7bad43a9d6b --- /dev/null +++ b/src/renderer/src/components/settings/repository/identity/repo-icon-pick-failure-text.ts @@ -0,0 +1,20 @@ +import { translate } from '@/i18n/i18n' +import { + REPO_ICON_PICK_FAILURE_COPY as EN, + type RepoIconPickFailure, + type RepoIconPickFailureCode +} from '../../../../../../shared/repo-icon-pick-failure-copy' + +const COPY: Record string> = { + notPng: () => translate('settings.repository.iconPickErrors.notPng', EN.notPng), + tooLarge: () => translate('settings.repository.iconPickErrors.tooLarge', EN.tooLarge) +} + +function isRepoIconPickFailureCode(code: unknown): code is RepoIconPickFailureCode { + return typeof code === 'string' && Object.hasOwn(COPY, code) +} + +/** Unknown or uncoded failures show main's English text. */ +export function repoIconPickFailureText(failure: RepoIconPickFailure): string { + return isRepoIconPickFailureCode(failure.errorCode) ? COPY[failure.errorCode]() : failure.error +} diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.tsx b/src/renderer/src/components/status-bar/PetStatusSegment.tsx index b1881267c9b..11352eb9474 100644 --- a/src/renderer/src/components/status-bar/PetStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PetStatusSegment.tsx @@ -18,6 +18,8 @@ import { BUNDLED_PET, BUNDLED_PETS, findBundledPet, isBundledPetId } from '../pe import { PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/pet-types' import { translate } from '@/i18n/i18n' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' +import { isPetImportFailure } from '../../../../shared/pet-import-failure-copy' +import { customPetLabel, petImportFailureText } from '../pet/pet-import-failure-text' // Keep visibility separate from experimental enablement so users can dismiss the pet without revisiting Settings. function PetStatusSegmentInner(): React.JSX.Element { @@ -37,7 +39,11 @@ function PetStatusSegmentInner(): React.JSX.Element { const bundled = isBundledPetId(petId) const activeBundled = bundled ? (findBundledPet(petId) ?? BUNDLED_PET) : null const activeCustom = bundled ? null : customPets.find((m) => m.id === petId) - const activeLabel = activeBundled ? activeBundled.label : (activeCustom?.label ?? 'Pet') + const activeLabel = activeBundled + ? activeBundled.label + : activeCustom + ? customPetLabel(activeCustom) + : 'Pet' const label = petVisible ? activeLabel : `${activeLabel} hidden` const handleImport = async (): Promise => { @@ -58,6 +64,10 @@ function PetStatusSegmentInner(): React.JSX.Element { if (!model) { return } + if (isPetImportFailure(model)) { + toast.error(petImportFailureText(model)) + return + } addCustomPet(model) if (!petVisible) { setPetVisible(true) @@ -91,6 +101,10 @@ function PetStatusSegmentInner(): React.JSX.Element { if (!model) { return } + if (isPetImportFailure(model)) { + toast.error(petImportFailureText(model)) + return + } addCustomPet(model) if (!petVisible) { setPetVisible(true) @@ -214,14 +228,14 @@ function PetStatusSegmentInner(): React.JSX.Element { {selected ? : null} - {model.label} + {customPetLabel(model)}