Compare commits

...

6 Commits

Author SHA1 Message Date
NimBold 31fbc5495b fix(ci): exclude engine payload from initial AppImage bundle 2026-07-30 05:53:43 +03:30
NimBold 33dd2cb9c6 chore(release): prepare Firelink 1.3.1 2026-07-30 05:19:42 +03:30
NimBold 924e108f3c fix(release): harden packaging and filename localization 2026-07-30 05:08:26 +03:30
NimBold 1634118f11 fix(ci): gate macOS-only dock badge test import 2026-07-30 03:32:18 +03:30
NimBold 31cf4a1c50 fix(ci): reuse Linux binary for AppImage packaging 2026-07-30 03:16:37 +03:30
NimBold 4e504b8e74 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
2026-07-30 02:48:29 +03:30
22 changed files with 647 additions and 55 deletions
+2 -3
View File
@@ -93,12 +93,11 @@ jobs:
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
- name: Build Linux AppImage
- name: Build Linux AppImage from existing binary
if: runner.os == 'Linux'
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
- name: Install pinned appimagetool (Linux only)
if: runner.os == 'Linux'
env:
+20 -4
View File
@@ -5,6 +5,25 @@ All notable changes to Firelink will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.1] - 2026-07-30
This hotfix improves filename handling, localized display details, and release reliability across platforms.
### Fixes
- Prevent filename errors for long downloads and Windows-reserved names such as `CON` and `COM1`. Names are shortened safely while keeping their file extensions, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
- Correct Persian date formatting in affected settings and timezone cases, while preserving Hebrew locale formatting.
- Fix remaining light-theme overlay and contrast details reported with the broader appearance and localization work in [#31](https://github.com/nimbold/Firelink/issues/31).
### Improvements
- Make Linux AppImage packaging more reliable and avoid compiling the same Rust release binary twice.
- Strengthen cross-platform release checks so Windows and Linux test builds handle macOS-only code correctly.
### Compatibility
- Firelink Companion `2.1.0` remains the current compatible extension. No extension update is required for this hotfix; use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest).
## [1.3.0] - 2026-07-29
This release focuses on smoother queue control, a more comfortable interface, and safer recovery when downloads or browser handoffs are interrupted.
@@ -30,12 +49,9 @@ This release focuses on smoother queue control, a more comfortable interface, an
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the users settings.
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
### Known limitation
- Very long media-generated filenames can still exceed an operating systems filename limit. For [#29](https://github.com/nimbold/Firelink/issues/29), the current workaround is to shorten the filename in **Properties** before starting the download.
## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
+3 -3
View File
@@ -28,9 +28,9 @@ It uses a Rust and Tauri backend with a React and TypeScript interface. Required
## Status
Firelink `1.3.0` is the latest desktop release.
Firelink `1.3.1` is the latest desktop release.
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0).
It is paired with [the latest Firelink Companion release, `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0). The Companion did not change for this hotfix.
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
@@ -76,7 +76,7 @@ All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separat
## Browser integration
[Firelink Companion `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release.
[Firelink Companion `2.1.0`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.1.0) connects browser downloads, links, and media pages to Firelink. Use the latest Companion release with the latest Firelink release; no extension update is needed for Firelink `1.3.1`.
Captured links open Firelink's Add window for review before they are started or queued.
+1 -1
View File
@@ -25,7 +25,7 @@ Firelink never falls back to system-installed media tools.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "firelink",
"version": "1.3.0",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "firelink",
"version": "1.3.0",
"version": "1.3.1",
"license": "MIT",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.3.0",
"version": "1.3.1",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
-8
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
@@ -53,11 +52,4 @@ process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
fs.rmSync(engineDist, { recursive: true, force: true });
fs.mkdirSync(engineDist, { recursive: true });
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
}
runNpmScript('build');
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
let activeChild;
let receivedSignal;
export const APPIMAGE_CONFIG = JSON.stringify({
bundle: {
resources: {
// Tauri merges --config values using JSON Merge Patch. An omitted key
// would leave the base engine resource enabled; null explicitly removes
// it for this first packaging pass. The verified payload is added back
// by repack-linux-appimage-engines.js after linuxdeploy finishes.
'engine-dist/': null,
},
},
});
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function signalExitCode(signal) {
return {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143,
}[signal] ?? 1;
}
function npmInvocation(args) {
if (process.env.npm_execpath) {
return [process.execPath, [process.env.npm_execpath, ...args]];
}
if (process.platform === 'win32') {
return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]];
}
return ['npm', args];
}
export function appImageBundleArguments(target) {
return [
'run',
'tauri',
'--',
'bundle',
'-vv',
'--target',
target,
'--bundles',
'appimage',
'--config',
APPIMAGE_CONFIG,
];
}
function run(command, args, options = {}) {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
windowsHide: true,
detached: process.platform !== 'win32',
});
activeChild = child;
return new Promise((resolve, reject) => {
let settled = false;
child.once('error', error => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', (code, signal) => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
resolve({ code, signal });
}
});
});
}
async function runChecked(command, args, label = command) {
let result;
try {
result = await run(command, args);
} catch (error) {
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
}
if (result.signal) {
const error = new Error(`${label} was terminated by ${result.signal}.`);
error.exitCode = signalExitCode(result.signal);
throw error;
}
if (result.code !== 0) {
const error = new Error(`${label} exited with status ${result.code}.`);
error.exitCode = result.code ?? 1;
throw error;
}
return result;
}
function assertSafeTarget(target) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) {
throw new Error(`Invalid target triple: ${target}`);
}
}
async function main() {
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE;
if (!target) {
throw new Error('Pass --target <triple>.');
}
assertSafeTarget(target);
// The native-package build has already staged and verified the engines.
// Verify once more before creating the AppImage so a failed preparation
// cannot produce an artifact that later appears valid only because its
// payload is absent.
await runChecked(
process.execPath,
['scripts/verify-binaries.js', '--staged', '--target', target],
'staged engine verification'
);
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling');
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
console.log(`Built the Linux AppImage from the existing ${target} release binary.`);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const handleSignal = signal => {
receivedSignal ??= signal;
// npm and Tauri can have Rust/packaging descendants. On POSIX, the child
// is a detached process-group leader, so signal the whole group instead of
// leaving descendants running after the wrapper exits.
if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) {
try {
if (process.platform === 'win32' || !activeChild.pid) {
activeChild.kill(signal);
} else {
process.kill(-activeChild.pid, signal);
}
} catch (error) {
if (error.code !== 'ESRCH') {
console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`);
}
}
} else {
process.exitCode = signalExitCode(signal);
}
};
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.once(signal, () => handleSignal(signal));
}
main()
.catch(error => {
console.error(`[FAIL] ${error.message}`);
process.exitCode = error.exitCode ?? 1;
})
.finally(() => {
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.removeAllListeners(signal);
}
});
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js';
test('AppImage config explicitly removes the staged engine payload', () => {
assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), {
bundle: {
resources: {
'engine-dist/': null,
},
},
});
});
test('AppImage bundling uses the existing binary instead of tauri build', () => {
const args = appImageBundleArguments('x86_64-unknown-linux-gnu');
assert.equal(args[3], 'bundle');
assert.equal(args.includes('build'), false);
assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]);
});
+1 -1
View File
@@ -1364,7 +1364,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.3.0"
version = "1.3.1"
dependencies = [
"apple-native-keyring-store",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.3.0"
version = "1.3.1"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
+61 -1
View File
@@ -10,6 +10,9 @@ struct DownloadOwnershipRecord {
}
pub fn canonical_download_filename(filename: &str) -> String {
const MAX_FILENAME_BYTES: usize = 255;
const TRUNCATION_MARKER: &str = "";
let leaf = filename.replace('\\', "/");
let leaf = Path::new(&leaf)
.file_name()
@@ -31,7 +34,7 @@ pub fn canonical_download_filename(filename: &str) -> String {
})
.collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
if sanitized.is_empty() || matches!(sanitized, "." | "..") {
let canonical = if sanitized.is_empty() || matches!(sanitized, "." | "..") {
"download".to_string()
} else if crate::platform::is_windows_reserved_filename(sanitized) {
let path = Path::new(sanitized);
@@ -45,7 +48,46 @@ pub fn canonical_download_filename(filename: &str) -> String {
}
} else {
sanitized.to_string()
};
if canonical.len() <= MAX_FILENAME_BYTES {
return canonical;
}
let extension = Path::new(&canonical)
.extension()
.and_then(|value| value.to_str())
.map(|value| format!(".{value}"))
.unwrap_or_default();
let base = canonical
.strip_suffix(&extension)
.unwrap_or(canonical.as_str());
let base_budget = MAX_FILENAME_BYTES
.saturating_sub(extension.len())
.saturating_sub(TRUNCATION_MARKER.len());
if base_budget == 0 {
return truncate_utf8_to_bytes(&canonical, MAX_FILENAME_BYTES);
}
format!(
"{}{}{}",
truncate_utf8_to_bytes(base, base_budget),
TRUNCATION_MARKER,
extension
)
}
fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
let mut end = 0;
for (index, character) in value.char_indices() {
let next = index + character.len_utf8();
if next > max_bytes {
break;
}
end = next;
}
value[..end].to_string()
}
pub fn expected_primary_path(
@@ -256,6 +298,24 @@ mod tests {
assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
}
#[test]
fn truncates_long_filenames_by_utf8_bytes_and_preserves_extension() {
let filename = canonical_download_filename(&format!("{}.mp4", "title ".repeat(100)));
assert!(filename.len() <= 255);
assert!(filename.ends_with(".mp4"));
assert!(filename.contains('…'));
}
#[test]
fn truncates_multibyte_filenames_at_character_boundaries() {
let filename = canonical_download_filename(&format!("{}.mkv", "😀".repeat(100)));
assert!(filename.len() <= 255);
assert!(filename.ends_with(".mkv"));
assert!(!filename.contains('\u{fffd}'));
}
#[test]
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
let valid = json!({
+4 -1
View File
@@ -5334,6 +5334,7 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<()
static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
#[cfg(target_os = "macos")]
fn should_apply_dock_badge_update(
current_session: u64,
current_generation: u64,
@@ -6856,7 +6857,6 @@ mod tests {
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
has_resumable_download_assets, is_media_artifact_name,
should_cleanup_media_artifacts_after_failure,
should_apply_dock_badge_update,
should_retry_without_browser_cookies,
retry_metadata_with_cookies, should_retry_metadata_with_cookies,
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
@@ -6869,6 +6869,8 @@ mod tests {
normalize_media_connections,
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
};
#[cfg(target_os = "macos")]
use super::should_apply_dock_badge_update;
use serde_json::json;
use std::time::{Duration, Instant};
@@ -8041,6 +8043,7 @@ mod tests {
assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video"));
}
#[cfg(target_os = "macos")]
#[test]
fn dock_badge_updates_reject_stale_sessions_and_generations() {
assert!(should_apply_dock_badge_update(1, 99, 2, 1));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Firelink",
"version": "1.3.0",
"version": "1.3.1",
"identifier": "com.nimbold.firelink",
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
+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
)
};
+45 -5
View File
@@ -17,13 +17,53 @@ 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('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', () => {
+104
View File
@@ -28,6 +28,103 @@ 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);
// 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) {
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 +143,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
+33
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,36 @@ 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`);
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',
+83 -2
View File
@@ -43,6 +43,38 @@ 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 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;
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 +158,59 @@ 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';
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('.');
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}`;
};
/**