diff --git a/scripts/build-linux-appimage.js b/scripts/build-linux-appimage.js index cd41ff1..d12f119 100644 --- a/scripts/build-linux-appimage.js +++ b/scripts/build-linux-appimage.js @@ -11,7 +11,7 @@ let receivedSignal; export const APPIMAGE_CONFIG = JSON.stringify({ bundle: { resources: { - 'engine-dist/': null, + '../THIRD_PARTY_NOTICES.md': 'THIRD_PARTY_NOTICES.md', }, }, }); diff --git a/scripts/build-linux-appimage.node-test.js b/scripts/build-linux-appimage.node-test.js index 0f1c69f..158473b 100644 --- a/scripts/build-linux-appimage.node-test.js +++ b/scripts/build-linux-appimage.node-test.js @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js'; -test('AppImage config removes only engine-dist from Tauri resources', () => { +test('AppImage config keeps notices while excluding the staged engine payload', () => { assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), { bundle: { resources: { - 'engine-dist/': null, + '../THIRD_PARTY_NOTICES.md': 'THIRD_PARTY_NOTICES.md', }, }, }); diff --git a/src/utils/dateTime.test.ts b/src/utils/dateTime.test.ts index 508343f..26fd05b 100644 --- a/src/utils/dateTime.test.ts +++ b/src/utils/dateTime.test.ts @@ -58,6 +58,14 @@ describe('date/time formatting', () => { expect(formatted).toContain('123'); }); + it('uses the default Persian date when only locale or timezone options are supplied', () => { + expect(formatDateTime(instant, { + locale: 'fa', + calendar: 'persian', + options: { timeZone: 'UTC' } + })).toBe('۱۴۰۵/۰۱/۰۱'); + }); + it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => { expect(formatDateTime('not-a-timestamp', { locale: 'en' })).toBe('-'); expect(isCalendarPreference('gregorian')).toBe(true); diff --git a/src/utils/dateTime.ts b/src/utils/dateTime.ts index 0ff50c0..a789e2e 100644 --- a/src/utils/dateTime.ts +++ b/src/utils/dateTime.ts @@ -79,7 +79,10 @@ const formatPersianDateTime = ( const hasDateOptions = Object.keys(options).some(key => DATE_OPTION_KEYS.has(key)); const hasTimeOptions = options.timeStyle !== undefined || TIME_OPTION_KEYS.some(key => (options as unknown as Record)[key] !== undefined); - const includeDate = Object.keys(options).length === 0 || hasDateOptions; + // Locale and timezone options do not select a date or time field by + // themselves. Match Intl's default date behavior for those option-only + // calls instead of returning an empty string. + const includeDate = hasDateOptions || !hasTimeOptions; const parts: string[] = []; if (includeDate) { diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 9021494..50fa740 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -66,6 +66,13 @@ describe('download connection resolution', () => { }); describe('download filename matching', () => { + it('matches frontend filenames to the backend Windows device-name canonicalization', () => { + expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt'); + expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip'); + expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true); + expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false); + }); + it('truncates long names by UTF-8 bytes while preserving the extension', () => { const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index dbe73e6..5998b71 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -48,6 +48,17 @@ export const DOWNLOAD_CONNECTIONS_MAX = 16; // bound is also conservative for Windows filename components. export const MAX_DOWNLOAD_FILENAME_BYTES = 255; const FILENAME_TRUNCATION_MARKER = '…'; +const WINDOWS_RESERVED_FILENAME_STEMS = new Set([ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'CLOCK$', + 'CONIN$', + 'CONOUT$', + ...Array.from({ length: 9 }, (_, index) => `COM${index + 1}`), + ...Array.from({ length: 9 }, (_, index) => `LPT${index + 1}`) +]); const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length; @@ -150,7 +161,14 @@ export const canonicalizeDownloadFileName = (fileName: string): string => { .replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-') .trim() .replace(/[. ]+$/g, ''); - const canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; + let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; + const reservedStem = canonical.split('.')[0]?.toUpperCase(); + if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) { + const extensionStart = canonical.lastIndexOf('.'); + const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical; + const extension = extensionStart > 0 ? canonical.slice(extensionStart) : ''; + canonical = `${base}-${extension}`; + } if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical; const extensionStart = canonical.lastIndexOf('.');