fix(downloads): harden filenames and localized surfaces

Bound generated filenames to cross-platform component limits, preserve extensions, and keep duplicate renames unique. Correct light-theme compositing tokens and enforce Persian date formatting while preserving Hebrew locale formatting.

Fixes #29
Refs #31
This commit is contained in:
NimBold
2026-07-30 02:48:29 +03:30
parent 4df6315be1
commit 4e504b8e74
10 changed files with 356 additions and 33 deletions
+2 -4
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -1078,8 +1078,6 @@ export const AddDownloadsModal = () => {
);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
let newName = finalFile;
let exists = true;
const batchTargets: Array<{ location: string; fileName: string }> = [];
@@ -1098,7 +1096,7 @@ export const AddDownloadsModal = () => {
}
while (exists && count < 1000) {
newName = `${base} (${count})${ext}`;
newName = downloadFileNameWithSuffix(finalFile, ` (${count})`);
let storeHas = false;
const currentSettings = useSettingsStore.getState();
for (const download of useDownloadStore.getState().downloads) {
+6 -5
View File
@@ -22,7 +22,8 @@
--bg-input: 0 0% 100%;
--border-modal: 0 0% 85%;
--surface-raised: 0 0% 100%;
--surface-overlay: 0 0% 100% / 0.95;
/* Keep this token alpha-free because some consumers apply their own /alpha. */
--surface-overlay: 0 0% 100%;
--shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%;
--sidebar-panel-bg: 0 0% 96%;
@@ -66,7 +67,7 @@
--bg-input: 0 0% 100%;
--border-modal: 0 0% 85%;
--surface-raised: 0 0% 100%;
--surface-overlay: 0 0% 100% / 0.95;
--surface-overlay: 0 0% 100%;
--shadow-color: 220 10% 20% / 0.1;
--sidebar-shell-bg: 0 0% 92%;
--sidebar-panel-bg: 0 0% 96%;
@@ -911,7 +912,7 @@ html[data-list-density="relaxed"] {
.add-download-playlist-media-type-option.is-selected {
background: hsl(var(--surface-raised) / 0.9);
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
box-shadow: 0 1px 2px hsl(var(--shadow-color));
color: hsl(var(--text-primary));
}
@@ -2508,7 +2509,7 @@ html[data-list-density="relaxed"] {
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border-color));
border-bottom-color: hsl(var(--border-color) / 0.72);
border-bottom-color: hsl(var(--border-color));
border-radius: 4px;
background: hsl(var(--item-hover));
color: hsl(var(--text-secondary));
@@ -3106,7 +3107,7 @@ body.is-queue-dragging * {
.add-download-nested-fields {
padding-inline-start: 1.25rem;
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5);
border-inline-start: 2px solid hsl(var(--border-modal));
}
.add-download-advanced-fields {
+24 -1
View File
@@ -3,6 +3,7 @@ import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimat
import { useDownloadProgressStore } from './downloadProgressStore';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn(),
@@ -113,6 +114,27 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
});
it('normalizes an overlong filename edited in Properties before persisting it', async () => {
useDownloadStore.setState({
downloads: [{
id: 'properties-long-name',
url: 'https://example.com/video',
fileName: 'video.mp4',
status: 'ready',
category: 'Movies',
dateAdded: ''
}] as any[]
});
await useDownloadStore.getState().applyProperties('properties-long-name', {
fileName: `${'title '.repeat(100)}.mp4`
});
const fileName = useDownloadStore.getState().downloads[0].fileName;
expect(new TextEncoder().encode(fileName).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(fileName.endsWith('.mp4')).toBe(true);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true
@@ -1563,7 +1585,7 @@ describe('useDownloadStore', () => {
})];
}
if (cmd === 'enqueue_many') {
return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }];
return [{ id: 'startup-accepted', success: true, filename: 'normalized.bin' }];
}
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
if (cmd === 'resume_download') return true;
@@ -1575,6 +1597,7 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
fileName: 'normalized.bin',
status: 'queued',
hasBeenDispatched: true
});
+33 -11
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -809,13 +809,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const state = get();
const item = state.downloads.find(d => d.id === id);
if (!item) return;
const normalizedUpdates = updates.fileName === undefined
? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates);
state.updateDownload(id, normalizedUpdates);
return;
}
@@ -828,7 +831,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
state.unregisterBackendIds([id]);
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
}
state.updateDownload(id, updates);
state.updateDownload(id, normalizedUpdates);
if (isRegistered || wasDispatching) {
const dispatched = await dispatchItemInternal(id);
if (dispatched) {
@@ -847,7 +850,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
state.unregisterBackendIds([id]);
}
state.updateDownload(id, updates);
state.updateDownload(id, normalizedUpdates);
}
};
@@ -1306,21 +1309,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
const destPath = await effectiveDestinationForItem(item, settings);
const normalizedItem = {
...item,
fileName: canonicalizeDownloadFileName(item.fileName)
};
const destPath = await effectiveDestinationForItem(normalizedItem, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
const queueItems = get().downloads.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = maxPos + 1;
const { sizeBytes, ...downloadDraft } = item;
const { sizeBytes, ...downloadDraft } = normalizedItem;
const ownedItem: DownloadItem = {
...downloadDraft,
totalBytes: item.totalBytes ?? sizeBytes,
totalIsEstimate: item.totalIsEstimate ?? (
item.isMedia === true && item.size?.trim().startsWith('~')
totalBytes: normalizedItem.totalBytes ?? sizeBytes,
totalIsEstimate: normalizedItem.totalIsEstimate ?? (
normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
),
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
destination: destPath,
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId,
@@ -2055,6 +2062,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
.filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
);
const acceptedFilenames = new Map(
results
.filter(result => result.success && Boolean(result.filename))
.map(result => [result.id, result.filename!])
);
const acceptedIdSet = new Set(registeredIds);
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
@@ -2089,7 +2101,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
lastError: failedErrors.get(download.id)
}
: liveAcceptedIds.has(download.id)
? { ...download, hasBeenDispatched: true, lastError: undefined }
? {
...download,
...(acceptedFilenames.has(download.id)
? {
fileName: acceptedFilenames.get(download.id),
category: categoryForFileName(acceptedFilenames.get(download.id)!)
}
: {}),
hasBeenDispatched: true,
lastError: undefined
}
: download
)
};
+37 -5
View File
@@ -17,13 +17,45 @@ describe('date/time formatting', () => {
});
it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
const hebrewOptions: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian' })).toBe('۱۴۰۵/۰۱/۰۱');
expect(formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: { dateStyle: 'medium', timeStyle: 'short' }
})).toContain('۱۴۰۵/۰۱/۰۱');
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options: hebrewOptions })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', hebrewOptions).format(instant)
);
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
});
it('keeps Persian dates zero-padded when the caller requests weekday or time', () => {
const formatted = formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
});
expect(formatted).toContain('۱۴۰۵/۰۱/۰۱');
});
it('does not drop Persian time-only Intl fields', () => {
const formatted = formatDateTime(
new Date('2026-03-21T14:30:00.123Z'),
{
locale: 'en',
calendar: 'persian',
options: { fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions
}
);
expect(formatted).toContain('123');
});
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
+101
View File
@@ -28,6 +28,100 @@ const localeWithCalendar = (locale: string | null | undefined, calendar: Calenda
const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value);
const DATE_OPTION_KEYS = new Set([
'dateStyle',
'era',
'month',
'day',
'year',
'weekday'
]);
const TIME_OPTION_KEYS = [
'hour',
'hour12',
'hourCycle',
'minute',
'second',
'timeZoneName',
'dayPeriod',
'fractionalSecondDigits'
] as const;
const persianDateParts = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
const formatter = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const values = new Map(formatter.formatToParts(date)
.filter(part => part.type === 'year' || part.type === 'month' || part.type === 'day')
.map(part => [part.type, part.value]));
const year = values.get('year');
const month = values.get('month');
const day = values.get('day');
if (!year || !month || !day) return formatter.format(date);
return `${year}/${month}/${day}`;
};
const formatPersianDateTime = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
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<string, unknown>)[key] !== undefined);
const includeDate = Object.keys(options).length === 0 || hasDateOptions;
const parts: string[] = [];
if (includeDate) {
let dateText = persianDateParts(date, locale, options);
if (options.weekday) {
const weekday = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
weekday: options.weekday
}).format(date);
dateText = `${weekday}, ${dateText}`;
}
parts.push(dateText);
}
if (hasTimeOptions) {
const timeOptions: Intl.DateTimeFormatOptions = {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone
};
for (const key of TIME_OPTION_KEYS) {
const value = (options as unknown as Record<string, unknown>)[key];
if (value !== undefined) Object.assign(timeOptions, { [key]: value });
}
if (options.timeStyle) {
timeOptions.hour = 'numeric';
timeOptions.minute = '2-digit';
if (options.timeStyle === 'medium' || options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.second = '2-digit';
}
if (options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.timeZoneName = options.timeStyle === 'full' ? 'long' : 'short';
}
}
parts.push(new Intl.DateTimeFormat(locale, timeOptions).format(date));
}
return parts.join(', ');
};
/**
* Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional
@@ -46,6 +140,13 @@ export const formatDateTime = (
const options = config.options ?? {};
try {
if (calendar === 'persian') {
return formatPersianDateTime(
date,
localeWithCalendar(config.locale, calendar),
options
);
}
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar),
options
+26
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import {
downloadFileNamesMatch,
downloadFileNameWithSuffix,
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -63,6 +66,29 @@ describe('download connection resolution', () => {
});
describe('download filename matching', () => {
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mp4')).toBe(true);
expect(filename).toContain('…');
});
it('does not split a multibyte character at the filesystem boundary', () => {
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mkv')).toBe(true);
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
});
it('keeps alternate names unique and bounded after long-name truncation', () => {
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith(' (1).mp4')).toBe(true);
});
it('matches case and path spelling while preserving the actual filename', () => {
expect(downloadFileNamesMatch(
'Media\\Example.Show.S01E01.MKV',
+65 -2
View File
@@ -43,6 +43,27 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
// Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components.
export const MAX_DOWNLOAD_FILENAME_BYTES = 255;
const FILENAME_TRUNCATION_MARKER = '…';
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
const truncateUtf8ToBytes = (value: string, maxBytes: number): string => {
if (maxBytes <= 0) return '';
let bytes = 0;
let result = '';
for (const character of value) {
const characterBytes = utf8ByteLength(character);
if (bytes + characterBytes > maxBytes) break;
result += character;
bytes += characterBytes;
}
return result;
};
/**
* Resolve persisted/user-entered connection values before they cross into the
* backend. Older rows may omit the value, while malformed rows can contain
@@ -126,10 +147,52 @@ export const fileNameFromUrl = (rawUrl: string): string => {
export const canonicalizeDownloadFileName = (fileName: string): string => {
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
const sanitized = leaf
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.trim()
.replace(/[. ]+$/g, '');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
const canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(extension)
- utf8ByteLength(FILENAME_TRUNCATION_MARKER);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(canonical, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${FILENAME_TRUNCATION_MARKER}${extension}`;
};
/**
* Create a deterministic alternate filename without exceeding the same
* component limit as canonicalizeDownloadFileName. The suffix is intended for
* trusted generated values such as " (1)".
*/
export const downloadFileNameWithSuffix = (fileName: string, suffix: string): string => {
const canonical = canonicalizeDownloadFileName(fileName);
const safeSuffix = suffix
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.replace(/[. ]+$/g, '');
if (!safeSuffix.trim()) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(safeSuffix)
- utf8ByteLength(extension);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(`${base}${safeSuffix}${extension}`, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${safeSuffix}${extension}`;
};
/**