fix(app): harden release contracts across shell, queue, and engine boundaries

- Shell & UI: restore focus on sidebar reveal, guard window drag regions, contain context menu Escape keys, and fallback gracefully when a filtered queue is deleted.
- Download table: fix sort order for estimated sizes, descending null values, 3-state sort cycle, and desktop keyboard row navigation.
- Add window: harden duplicate resolution against unmanaged disk targets, draft id keying, and media credential isolation.
- Properties window: add individual Torrent file path copy, horizontal scroll protection for narrow and RTL layouts, and strict numeric bounds on property edits.
- Settings & scheduler: add draft buffering for integer inputs, sanitize persisted scheduler options, synchronize post-queue action countdown cancellation, and localize token regeneration.
- Engine & backend: isolate loopback JSON-RPC from environment proxies with no_proxy, normalize media cookie sources, dispatch container formats case-insensitively, and preserve snake_case arguments in torrent Tauri commands.
- Verification & packaging: isolate Companion git tag resolution from sandboxed git configs, and add regression tests for workflow normalization and portable packaging.
This commit is contained in:
NimBold
2026-09-03 19:28:27 +03:30
parent 41b525ea18
commit 248b4ac460
39 changed files with 1480 additions and 219 deletions
+16
View File
@@ -23,3 +23,19 @@ test('macOS release verification uses the app mounted from the final DMG', () =>
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
});
test('release workflow normalizes all 6 distribution target artifacts', () => {
assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
});
test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
});
+10 -2
View File
@@ -12,12 +12,20 @@ function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function exactVersionTag(extensionRoot, expectedTag) {
export function exactVersionTag(extensionRoot, expectedTag) {
try {
const tags = execFileSync(
'git',
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
env: {
...process.env,
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL || (process.platform === 'win32' ? 'NUL' : '/dev/null'),
GIT_CONFIG_NOSYSTEM: '1',
},
}
)
.split(/\r?\n/)
.map(tag => tag.trim())
+25 -1
View File
@@ -3,7 +3,8 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifyCompanionRelease } from './verify-companion-release.js';
import { execFileSync } from 'node:child_process';
import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
function createFixture(packageVersion, manifestVersion) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
@@ -116,3 +117,26 @@ test('rejects a Companion tag for another version', () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
try {
const gitEnv = {
...process.env,
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.com',
};
execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
assert.equal(exactVersionTag(root, 'v2.0.8'), null);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+69 -2
View File
@@ -731,8 +731,9 @@ mod tests {
use super::{
acknowledge_extension_download, add_server_identity, claim_request_at,
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
@@ -1182,4 +1183,70 @@ mod tests {
expected
);
}
#[test]
fn sanitize_filename_strips_path_traversal_and_rejects_empty_or_special_names() {
assert_eq!(sanitize_filename("../../etc/passwd"), Some("passwd".to_string()));
assert_eq!(
sanitize_filename(r"..\..\Windows\System32\calc.exe"),
Some("calc.exe".to_string())
);
assert_eq!(
sanitize_filename("valid_report.pdf"),
Some("valid_report.pdf".to_string())
);
assert!(sanitize_filename(".").is_none());
assert!(sanitize_filename("..").is_none());
assert!(sanitize_filename("").is_none());
assert!(sanitize_filename(" ").is_none());
assert!(sanitize_filename(&"a".repeat(256)).is_none());
}
#[test]
fn normalize_url_rejects_dangerous_or_unsupported_schemes() {
assert!(normalize_url("file:///etc/passwd").is_none());
assert!(normalize_url("javascript:alert(1)").is_none());
assert!(normalize_url("data:text/html,<h1>test</h1>").is_none());
assert!(normalize_url("blob:https://example.com/uuid").is_none());
assert_eq!(
normalize_url("https://example.com/file.zip"),
Some("https://example.com/file.zip".to_string())
);
assert_eq!(
normalize_url("http://example.com/file.zip"),
Some("http://example.com/file.zip".to_string())
);
assert_eq!(
normalize_url("ftp://example.com/file.zip"),
Some("ftp://example.com/file.zip".to_string())
);
assert_eq!(
normalize_url("sftp://example.com/file.zip"),
Some("sftp://example.com/file.zip".to_string())
);
assert_eq!(
normalize_url("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"),
Some("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string())
);
}
#[test]
fn same_origin_url_strictly_matches_scheme_host_and_port() {
assert!(same_origin_url(
"https://example.com/path1",
"https://example.com/path2"
));
assert!(!same_origin_url(
"http://example.com/path",
"https://example.com/path"
));
assert!(!same_origin_url(
"https://example.com:8443/path",
"https://example.com/path"
));
assert!(!same_origin_url(
"https://other.example/path",
"https://example.com/path"
));
}
}
+1 -1
View File
@@ -570,7 +570,7 @@ pub enum ListRowDensity {
Relaxed,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum PostQueueAction {
+115 -32
View File
@@ -2640,6 +2640,13 @@ async fn fetch_media_metadata(
) -> Result<MediaMetadata, String> {
properties_window::ensure_main_window(&caller)?;
validate_http_url_route(&url)?;
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let password = password.filter(|p| !p.is_empty());
let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let cache_key = media_metadata_cache_key(
&url,
&cookie_browser,
@@ -2777,6 +2784,13 @@ async fn fetch_media_playlist_metadata(
) -> Result<MediaPlaylistMetadata, String> {
properties_window::ensure_main_window(&caller)?;
validate_http_url_route(&url)?;
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let password = password.filter(|p| !p.is_empty());
let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let result = fetch_media_playlist_metadata_uncached(
app_handle.clone(),
@@ -2862,8 +2876,8 @@ async fn fetch_media_playlist_metadata_uncached(
.arg("no-youtube-unavailable-videos");
if let Some(browser) = cookie_browser.as_deref() {
if !browser.is_empty() {
cmd = cmd.arg("--cookies-from-browser").arg(browser);
if !browser.is_empty() && browser != "none" {
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
}
}
@@ -2973,8 +2987,8 @@ async fn fetch_media_metadata_uncached(
.arg("%(.{title,duration,thumbnail,formats})j");
if let Some(browser) = cookie_browser.as_deref() {
if !browser.is_empty() {
cmd = cmd.arg("--cookies-from-browser").arg(browser);
if !browser.is_empty() && browser != "none" {
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
}
}
@@ -4048,6 +4062,7 @@ pub async fn rpc_call(
payload.insert("params".to_string(), serde_json::json!(p));
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(3))
.build()
.map_err(|e| e.to_string())?;
@@ -4748,6 +4763,49 @@ fn normalize_media_cookie_source(source: Option<&str>) -> Result<Option<String>,
Err("Unsupported media browser-cookie source".to_string())
}
fn ytdlp_cookie_browser_arg(browser: &str) -> String {
let trimmed = browser.trim();
if trimmed.eq_ignore_ascii_case("safari") {
"safari:".to_string()
} else {
trimmed.to_ascii_lowercase()
}
}
fn media_format_and_container_args(format: &str, safe_filename: &str) -> Vec<String> {
let mut args = vec!["-f".to_string(), format.to_string()];
let lower_filename = safe_filename.to_ascii_lowercase();
if lower_filename.ends_with(".mp3") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "mp3".to_string()]);
} else if lower_filename.ends_with(".m4a") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "m4a".to_string()]);
} else if lower_filename.ends_with(".opus") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "opus".to_string()]);
} else if lower_filename.ends_with(".flac") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "flac".to_string()]);
} else if lower_filename.ends_with(".aac") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "aac".to_string()]);
} else if lower_filename.ends_with(".wav") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "wav".to_string()]);
} else if lower_filename.ends_with(".alac") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "alac".to_string()]);
} else if lower_filename.ends_with(".ogg") {
args.extend(["-x".to_string(), "--audio-format".to_string(), "vorbis".to_string()]);
} else if lower_filename.ends_with(".mp4") {
args.extend(["--merge-output-format".to_string(), "mp4".to_string()]);
} else if lower_filename.ends_with(".webm") {
args.extend(["--merge-output-format".to_string(), "webm".to_string()]);
} else {
args.extend([
"--merge-output-format".to_string(),
"mkv".to_string(),
"--remux-video".to_string(),
"mkv".to_string(),
]);
}
args
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn start_media_download_internal(
app_handle: tauri::AppHandle,
@@ -4904,12 +4962,8 @@ pub(crate) async fn start_media_download_internal(
}
if let Some(cs) = effective_cookie_source.as_ref() {
let mut cs = cs.clone();
if !cs.is_empty() && cs != "none" {
if cs == "safari" {
cs = "safari:".to_string()
}
cmd = cmd.arg("--cookies-from-browser").arg(cs);
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(cs));
}
}
@@ -4924,27 +4978,8 @@ pub(crate) async fn start_media_download_internal(
}
if let Some(format) = format_selector.as_ref() {
cmd = cmd.arg("-f").arg(format);
if safe_filename.ends_with(".mp3") {
cmd = cmd.arg("-x").arg("--audio-format").arg("mp3");
} else if safe_filename.ends_with(".m4a") {
cmd = cmd.arg("-x").arg("--audio-format").arg("m4a");
} else if safe_filename.ends_with(".opus") {
cmd = cmd.arg("-x").arg("--audio-format").arg("opus");
} else if safe_filename.ends_with(".mp4") {
cmd = cmd.arg("--merge-output-format").arg("mp4");
} else if safe_filename.ends_with(".webm") {
cmd = cmd.arg("--merge-output-format").arg("webm");
} else {
// `--merge-output-format` only affects split video/audio
// selections. A progressive MP4/WebM stream already includes
// audio, so also request remuxing to keep an MKV option from
// producing a mismatched file extension and container.
cmd = cmd
.arg("--merge-output-format")
.arg("mkv")
.arg("--remux-video")
.arg("mkv");
for arg in media_format_and_container_args(format, &safe_filename) {
cmd = cmd.arg(arg);
}
}
@@ -10116,7 +10151,7 @@ async fn set_torrent_upload_limit(
.await
}
#[tauri::command]
#[tauri::command(rename_all = "snake_case")]
async fn set_torrent_peer_options(
caller: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
@@ -10379,7 +10414,7 @@ async fn get_torrent_file_selection(
Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref()))
}
#[tauri::command]
#[tauri::command(rename_all = "snake_case")]
async fn set_torrent_file_selection(
caller: tauri::WebviewWindow,
properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>,
@@ -14120,6 +14155,8 @@ mod tests {
parse_media_playlist_metadata,
normalize_media_connections,
normalize_media_cookie_source,
media_format_and_container_args,
ytdlp_cookie_browser_arg,
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
validate_torrent_metadata_network_policy,
aria2_gid_not_found,
@@ -17688,6 +17725,52 @@ mod tests {
assert!(normalize_media_cookie_source(Some("unknown-browser")).is_err());
}
#[test]
fn ytdlp_cookie_browser_arg_formats_safari_and_other_browsers() {
assert_eq!(ytdlp_cookie_browser_arg("safari"), "safari:");
assert_eq!(ytdlp_cookie_browser_arg(" Safari "), "safari:");
assert_eq!(ytdlp_cookie_browser_arg("chrome"), "chrome");
assert_eq!(ytdlp_cookie_browser_arg(" Firefox "), "firefox");
}
#[test]
fn media_format_and_container_args_dispatches_audio_and_video_correctly() {
// Audio extractions (case-insensitive)
let mp3_args = media_format_and_container_args("ba", "song.MP3");
assert_eq!(mp3_args, vec!["-f", "ba", "-x", "--audio-format", "mp3"]);
let m4a_args = media_format_and_container_args("140", "song.m4a");
assert_eq!(m4a_args, vec!["-f", "140", "-x", "--audio-format", "m4a"]);
let opus_args = media_format_and_container_args("251", "track.Opus");
assert_eq!(opus_args, vec!["-f", "251", "-x", "--audio-format", "opus"]);
let flac_args = media_format_and_container_args("ba", "lossless.flac");
assert_eq!(flac_args, vec!["-f", "ba", "-x", "--audio-format", "flac"]);
let aac_args = media_format_and_container_args("ba", "audio.aac");
assert_eq!(aac_args, vec!["-f", "ba", "-x", "--audio-format", "aac"]);
let wav_args = media_format_and_container_args("ba", "recording.wav");
assert_eq!(wav_args, vec!["-f", "ba", "-x", "--audio-format", "wav"]);
let ogg_args = media_format_and_container_args("ba", "audio.ogg");
assert_eq!(ogg_args, vec!["-f", "ba", "-x", "--audio-format", "vorbis"]);
// Video containers
let mp4_args = media_format_and_container_args("137+140", "video.MP4");
assert_eq!(mp4_args, vec!["-f", "137+140", "--merge-output-format", "mp4"]);
let webm_args = media_format_and_container_args("248+251", "video.webm");
assert_eq!(webm_args, vec!["-f", "248+251", "--merge-output-format", "webm"]);
let mkv_args = media_format_and_container_args("301+251", "video.mkv");
assert_eq!(
mkv_args,
vec!["-f", "301+251", "--merge-output-format", "mkv", "--remux-video", "mkv"]
);
}
#[test]
#[ignore = "requires network and a local yt-dlp executable"]
fn filters_live_youtube_metadata_from_env() {
+138 -1
View File
@@ -454,6 +454,9 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
sanitize_integer_setting(state, "minimumNormalDownloadSpeedKiB", |value| value.as_u64().is_some());
sanitize_integer_setting(state, "lastCustomSpeedLimitKiB", |value| value.as_u64().is_some());
sanitize_allowed_string(state, "lastCustomSpeedLimitUnit", &["KB/s", "MB/s"]);
sanitize_integer_setting(state, "proxyPort", |value| {
value
.as_u64()
@@ -483,19 +486,78 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
})
});
for key in [
"categorySubfoldersEnabled",
"logsEnabled",
"isSidebarVisible",
"isFoldersCollapsed",
"schedulerRunning",
"retryNotFoundErrors",
"adaptiveMirrorSelection",
"showNotifications",
"playCompletionSound",
"autoAddClipboardLinks",
"showDockBadge",
"showMenuBarIcon",
"torrentEnableDht",
"torrentEnableDht6",
"torrentEnablePex",
"torrentEnableLpd",
"torrentSeparateSeedSlots",
"torrentIpv6Enabled",
"askWhereToSaveEachFile",
"rememberLastUsedDownloadDirectory",
"preventsSleepWhileDownloading",
"preventsDisplaySleepWhileDownloading",
"autoCheckUpdates",
"keychainAccessGranted",
] {
sanitize_boolean_setting(state, key);
}
for key in ["proxyHost", "customUserAgent"] {
for key in [
"proxyHost",
"customUserAgent",
"globalSpeedLimit",
"torrentOverallUploadLimit",
"baseDownloadFolder",
"schedulerLastStartKey",
"schedulerLastStopKey",
] {
sanitize_string_setting(state, key);
}
if let Some(presets) = state.get("speedLimitPresetValues") {
if !presets.is_array() {
state.remove("speedLimitPresetValues");
} else if let Some(presets_arr) = state.get_mut("speedLimitPresetValues").and_then(Value::as_array_mut) {
presets_arr.retain(|v| v.as_f64().is_some_and(|f| f.is_finite() && f > 0.0));
if presets_arr.is_empty() {
state.remove("speedLimitPresetValues");
}
}
}
if let Some(roots) = state.get("approvedDownloadRoots") {
if !roots.is_array() {
state.remove("approvedDownloadRoots");
} else if let Some(roots_arr) = state.get_mut("approvedDownloadRoots").and_then(Value::as_array_mut) {
roots_arr.retain(|v| v.as_str().is_some());
}
}
if let Some(active_ids) = state.get("schedulerActiveDownloadIds") {
if !active_ids.is_array() {
state.remove("schedulerActiveDownloadIds");
} else if let Some(ids_arr) = state.get_mut("schedulerActiveDownloadIds").and_then(Value::as_array_mut) {
ids_arr.retain(|v| v.as_str().is_some());
}
}
if let Some(overrides) = state.get("categoryDirectoryOverrides") {
if !overrides.is_object() {
state.remove("categoryDirectoryOverrides");
}
}
if let Some(subfolders) = state.get("categorySubfolders") {
if !subfolders.is_object() {
state.remove("categorySubfolders");
}
}
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
});
@@ -590,6 +652,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"postQueueAction",
&["none", "sleep", "restart", "shutdown"],
);
for key in ["enabled", "stopTimeEnabled", "everyday"] {
sanitize_boolean_setting(scheduler, key);
}
for key in ["startTime", "stopTime"] {
sanitize_string_setting(scheduler, key);
}
if let Some(days) = scheduler.get("selectedDays") {
if !days.is_array() {
scheduler.remove("selectedDays");
} else if let Some(days_arr) = scheduler.get_mut("selectedDays").and_then(Value::as_array_mut) {
days_arr.retain(|v| v.as_u64().is_some_and(|n| n <= 6));
if days_arr.is_empty() {
scheduler.remove("selectedDays");
}
}
}
if let Some(queue_ids) = scheduler.get("selectedQueueIds") {
if !queue_ids.is_array() {
scheduler.remove("selectedQueueIds");
} else if let Some(queue_ids_arr) = scheduler.get_mut("selectedQueueIds").and_then(Value::as_array_mut) {
queue_ids_arr.retain(|v| v.as_str().is_some_and(|s| !s.trim().is_empty()));
if queue_ids_arr.is_empty() {
scheduler.remove("selectedQueueIds");
}
}
}
}
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
@@ -762,6 +850,19 @@ fn validate_settings(settings: &mut PersistedSettings) {
.ok()
.flatten()
.unwrap_or_default();
settings.last_custom_speed_limit_ki_b = settings.last_custom_speed_limit_ki_b.clamp(1, 10_485_760);
settings.speed_limit_preset_values.retain(|v| v.is_finite() && *v > 0.0);
if settings.speed_limit_preset_values.is_empty() {
settings.speed_limit_preset_values = default_settings().speed_limit_preset_values;
}
settings.scheduler.selected_days.retain(|d| (0..=6).contains(d));
if settings.scheduler.selected_days.is_empty() {
settings.scheduler.selected_days = default_settings().scheduler.selected_days;
}
settings.scheduler.selected_queue_ids.retain(|q| !q.trim().is_empty());
if settings.scheduler.selected_queue_ids.is_empty() {
settings.scheduler.selected_queue_ids = default_settings().scheduler.selected_queue_ids;
}
if !matches!(
settings.last_custom_speed_limit_unit.as_str(),
"KB/s" | "MB/s"
@@ -1396,6 +1497,42 @@ mod tests {
assert!(settings.is_sidebar_visible);
}
#[test]
fn decodes_malformed_speed_and_scheduler_settings_without_error() {
let stored = json!({
"state": {
"minimumNormalDownloadSpeedKiB": "very-fast",
"lastCustomSpeedLimitKiB": -50,
"lastCustomSpeedLimitUnit": "TB/s",
"speedLimitPresetValues": ["not-a-number", -1.0, 0.0],
"approvedDownloadRoots": 12345,
"scheduler": {
"enabled": "yes",
"postQueueAction": "explode",
"selectedDays": [99, "monday"],
"selectedQueueIds": ["", " "]
},
"schedulerRunning": "active",
"schedulerActiveDownloadIds": "none"
},
"version": 6
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
assert_eq!(settings.last_custom_speed_limit_ki_b, 1024);
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
assert_eq!(settings.speed_limit_preset_values, default_settings().speed_limit_preset_values);
assert_eq!(settings.approved_download_roots, default_settings().approved_download_roots);
assert!(!settings.scheduler.enabled);
assert_eq!(settings.scheduler.post_queue_action, crate::ipc::PostQueueAction::None);
assert_eq!(settings.scheduler.selected_days, default_settings().scheduler.selected_days);
assert_eq!(settings.scheduler.selected_queue_ids, default_settings().scheduler.selected_queue_ids);
assert!(!settings.scheduler_running);
assert!(settings.scheduler_active_download_ids.is_empty());
}
#[test]
fn preserves_valid_torrent_network_settings() {
let stored = json!({
+70
View File
@@ -123,3 +123,73 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
);
assert!(is_permanent_network_error("HTTP 403 Forbidden"));
}
#[derive(serde::Deserialize, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
struct TorrentPeerOptionsArgs {
id: String,
max_peers: Option<i64>,
peer_speed_limit: Option<String>,
}
#[derive(serde::Deserialize, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
struct TorrentFileSelectionArgs {
id: String,
selected_indices: Option<Vec<u32>>,
}
#[derive(serde::Deserialize, PartialEq, Debug)]
#[serde(rename_all = "camelCase")]
struct BuggyTorrentPeerOptionsArgs {
id: String,
max_peers: Option<i64>,
peer_speed_limit: Option<String>,
}
#[derive(serde::Deserialize, PartialEq, Debug)]
#[serde(rename_all = "camelCase")]
struct BuggyTorrentFileSelectionArgs {
id: String,
selected_indices: Option<Vec<u32>>,
}
#[test]
fn ipc_snake_case_deserialization_contract_preserves_torrent_arguments() {
let peer_payload = serde_json::json!({
"id": "dl-123",
"max_peers": 42,
"peer_speed_limit": "2M"
});
// The fixed snake_case contract receives and preserves the frontend's arguments:
let fixed_peer: TorrentPeerOptionsArgs = serde_json::from_value(peer_payload.clone())
.expect("snake_case deserializer should parse torrent peer options");
assert_eq!(fixed_peer.id, "dl-123");
assert_eq!(fixed_peer.max_peers, Some(42));
assert_eq!(fixed_peer.peer_speed_limit.as_deref(), Some("2M"));
// The buggy default camelCase contract dropped the arguments to None silently:
let buggy_peer: BuggyTorrentPeerOptionsArgs = serde_json::from_value(peer_payload)
.expect("camelCase deserializer parses but silently drops snake_case keys");
assert_eq!(buggy_peer.id, "dl-123");
assert_eq!(buggy_peer.max_peers, None);
assert_eq!(buggy_peer.peer_speed_limit, None);
let selection_payload = serde_json::json!({
"id": "dl-456",
"selected_indices": [1, 3, 5]
});
// The fixed snake_case contract receives and preserves selected file indices:
let fixed_selection: TorrentFileSelectionArgs = serde_json::from_value(selection_payload.clone())
.expect("snake_case deserializer should parse selected indices");
assert_eq!(fixed_selection.id, "dl-456");
assert_eq!(fixed_selection.selected_indices, Some(vec![1, 3, 5]));
// The buggy default camelCase contract dropped selected indices to None (selecting all files):
let buggy_selection: BuggyTorrentFileSelectionArgs = serde_json::from_value(selection_payload)
.expect("camelCase deserializer parses but silently drops snake_case keys");
assert_eq!(buggy_selection.id, "dl-456");
assert_eq!(buggy_selection.selected_indices, None);
}
+32
View File
@@ -94,3 +94,35 @@ async fn production_rpc_client_preserves_http_gateway_context() {
);
stop_server(shutdown, task).await;
}
#[tokio::test]
async fn production_rpc_client_bypasses_environment_proxy() {
let app = Router::new().route("/jsonrpc", post(successful_rpc));
let (address, shutdown, task) = start_server(app).await;
// Even if an invalid or hostile HTTP proxy is set in the environment,
// loopback JSON-RPC calls must bypass the proxy and connect directly to loopback.
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(val) => std::env::set_var(self.0, val),
None => std::env::remove_var(self.0),
}
}
}
let _guard = EnvGuard("HTTP_PROXY", std::env::var("HTTP_PROXY").ok());
std::env::set_var("HTTP_PROXY", "http://192.0.2.1:8080");
let result = rpc_call(
address.port(),
"test-secret",
"aria2.getVersion",
json!([{"include": "version"}]),
)
.await
.expect("RPC client must bypass HTTP_PROXY and succeed over loopback");
assert_eq!(result, json!({"version": "test"}));
stop_server(shutdown, task).await;
}
+77 -29
View File
@@ -42,12 +42,18 @@ import { formatDownloadBytes } from './utils/downloadProgress';
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
import { createMainWindowSizePersistence } from './utils/mainWindowState';
import { createSidebarResizeSession } from './utils/sidebarResize';
import {
resolveFallbackFilter,
shouldRestoreSidebarRevealFocus,
shouldRestoreSidebarToggleFocus
} from './utils/sidebarFocus';
import type { MainWindowSize } from './bindings/MainWindowSize';
import {
beginSchedulerControl,
consumeSchedulerHandoffIds,
handoffSupersededSchedulerIds,
isSchedulerControlCurrent
isSchedulerControlCurrent,
registerPostActionCanceller
} from './utils/schedulerControl';
import { createSerialTaskQueue } from './utils/serialTaskQueue';
@@ -202,7 +208,9 @@ function App() {
});
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
const restoreSidebarFocusRef = useRef(false);
const restoreRevealFocusRef = useRef(false);
const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
@@ -244,6 +252,18 @@ function App() {
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
const downloads = useDownloadStore(state => state.downloads);
const queues = useDownloadStore(state => state.queues);
useEffect(() => {
const fallback = resolveFallbackFilter(
filter,
queues.map(queue => queue.id),
queues.length > 0,
);
if (fallback !== filter) {
setFilter(fallback as SidebarFilter);
}
}, [filter, queues]);
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const queuedCount = downloads.filter(download =>
download.status === 'queued' || download.status === 'staged'
@@ -262,6 +282,8 @@ function App() {
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null);
const pendingPostActionToastId = useRef<string | null>(null);
const pendingForceActionToastId = useRef<string | null>(null);
const startupResumeStarted = useRef(false);
const startupInputReady = useRef(false);
const extensionProcessing = useRef(createSerialTaskQueue());
@@ -308,7 +330,15 @@ function App() {
window.clearTimeout(pendingPostActionTimer.current);
pendingPostActionTimer.current = null;
}
}, []);
if (pendingPostActionToastId.current !== null) {
removeToast(pendingPostActionToastId.current);
pendingPostActionToastId.current = null;
}
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
}, [removeToast]);
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
const update = frontendReadyUpdate.current
@@ -341,13 +371,15 @@ function App() {
const actionLabel = t($ => $.scheduler.postActions[action]);
let timerId: number | null = null;
let toastId: string | null = null;
const showForceActionToast = () => {
let forceToastId: string | null = null;
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
const proceed = () => {
if (forceToastId !== null) {
removeToast(forceToastId);
forceToastId = null;
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
invoke('perform_system_action', { action, force: true }).catch(error => {
console.error('Forced scheduled post action failed:', error);
@@ -358,7 +390,7 @@ function App() {
});
});
};
forceToastId = addToast({
pendingForceActionToastId.current = addToast({
variant: 'warning',
isActionable: true,
duration: 0,
@@ -391,16 +423,8 @@ function App() {
});
});
};
const cancel = () => {
clearPendingPostActionTimer();
timerId = null;
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
};
toastId = addToast({
const toastId = addToast({
variant: 'warning',
isActionable: true,
onDismiss: clearPendingPostActionTimer,
@@ -410,18 +434,19 @@ function App() {
<button
type="button"
className="app-button px-2 py-1"
onClick={cancel}
onClick={clearPendingPostActionTimer}
>
{t($ => $.actions.cancel)}
</button>
</div>
)
});
pendingPostActionToastId.current = toastId;
timerId = window.setTimeout(() => {
if (toastId !== null) {
if (pendingPostActionToastId.current === toastId) {
removeToast(toastId);
toastId = null;
pendingPostActionToastId.current = null;
}
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
@@ -466,24 +491,43 @@ function App() {
}, []);
useEffect(() => {
if (isSidebarVisible) return;
if (restoreSidebarFocusRef.current) {
restoreSidebarFocusRef.current = false;
sidebarRevealRef.current?.focus({ preventScroll: true });
if (!isSidebarVisible) {
if (restoreSidebarFocusRef.current) {
restoreSidebarFocusRef.current = false;
sidebarRevealRef.current?.focus({ preventScroll: true });
}
return;
}
if (restoreRevealFocusRef.current) {
restoreRevealFocusRef.current = false;
sidebarToggleRef.current?.focus({ preventScroll: true });
}
}, [isSidebarVisible]);
const handleSidebarToggle = () => {
const activeElement = document.activeElement;
if (isSidebarVisible) {
const activeElement = document.activeElement;
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
&& Boolean(activeElement.closest('.app-sidebar-shell'));
restoreSidebarFocusRef.current = shouldRestoreSidebarRevealFocus(
activeElement,
document.querySelector('.app-sidebar-shell'),
);
restoreRevealFocusRef.current = false;
} else {
restoreRevealFocusRef.current = shouldRestoreSidebarToggleFocus(
activeElement,
sidebarRevealRef.current,
);
restoreSidebarFocusRef.current = false;
}
toggleSidebar();
};
useEffect(() => {
return clearPendingPostActionTimer;
const unregister = registerPostActionCanceller(clearPendingPostActionTimer);
return () => {
unregister();
clearPendingPostActionTimer();
};
}, [clearPendingPostActionTimer]);
useEffect(() => {
@@ -1209,6 +1253,7 @@ function App() {
>
<Sidebar
selectedFilter={filter}
toggleButtonRef={sidebarToggleRef}
onToggleSidebar={handleSidebarToggle}
onSelectFilter={(f) => {
setFilter(f);
@@ -1234,7 +1279,10 @@ function App() {
<button
type="button"
ref={sidebarRevealRef}
onClick={toggleSidebar}
data-tauri-drag-region="false"
onPointerDown={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
onClick={handleSidebarToggle}
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
title={t($ => $.actions.showSidebar)}
aria-label={t($ => $.actions.showSidebar)}
+134 -71
View File
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isMediaUrl, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -228,7 +228,7 @@ export const AddDownloadsModal = () => {
const modalRef = useModalFocus(isAddModalOpen);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<string | number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -322,15 +322,28 @@ export const AddDownloadsModal = () => {
const requestContextForUrl = (url: string) =>
pendingAddRequestContexts[normalizeComparableUrl(url)];
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
const headersForRow = (sourceUrl: string) => {
const headersForRow = (sourceUrl: string, isMedia = false) => {
if (headersManuallyEditedRef.current) return headers.trim();
const context = requestContextForUrl(sourceUrl);
const media = isMedia || context?.media === true || isMediaUrl(sourceUrl);
if (media) {
const raw = context ? extensionHeaders(context) : (hasExtensionRequestContext ? '' : headers.trim());
return raw
.split(/\r?\n/)
.filter(line => {
const separator = line.indexOf(':');
return separator > 0 && !headerNameHasCredentialMaterial(line.slice(0, separator));
})
.join('\n')
.trim();
}
if (context) return extensionHeaders(context).trim();
return hasExtensionRequestContext ? '' : headers.trim();
};
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl, isMedia = false) => {
if (cookiesManuallyEditedRef.current) return cookies.trim();
const context = requestContextForUrl(sourceUrl);
if (isMedia || context?.media === true || isMediaUrl(sourceUrl)) return '';
const scopedCookies = cookieScopeForUrl(context, targetUrl);
if (scopedCookies) return scopedCookies;
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
@@ -440,7 +453,9 @@ export const AddDownloadsModal = () => {
pendingAddHeaders
].filter(Boolean).join('\n'));
headersManuallyEditedRef.current = false;
setCookies(initialContext?.cookies || pendingAddCookies);
const isSingleInitialMedia = initialContext?.media === true
|| (initialUrlLines.length === 1 && isMediaUrl(initialUrlLines[0]));
setCookies(isSingleInitialMedia ? '' : (initialContext?.cookies || pendingAddCookies));
cookiesManuallyEditedRef.current = false;
setMirrors('');
setIsQueueMenuOpen(false);
@@ -1011,6 +1026,11 @@ export const AddDownloadsModal = () => {
}
} catch (e) {
console.error("Failed to select folder:", e);
addToast({
message: e instanceof Error ? e.message : String(e),
variant: 'error',
isActionable: true
});
}
};
@@ -1178,7 +1198,7 @@ export const AddDownloadsModal = () => {
++folderPickerRequestRef.current;
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const destinationOverrides: Record<string | number, string> = {};
const settings = useSettingsStore.getState();
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
@@ -1201,6 +1221,7 @@ export const AddDownloadsModal = () => {
if (selected && typeof selected === 'string') {
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
destinationOverrides[index] = approvedPath;
destinationOverrides[item.id] = approvedPath;
const currentSettings = useSettingsStore.getState();
if (currentSettings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
@@ -1213,6 +1234,11 @@ export const AddDownloadsModal = () => {
}
} catch (e) {
console.error("Failed to select folder:", e);
addToast({
message: e instanceof Error ? e.message : String(e),
variant: 'error',
isActionable: true
});
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
@@ -1257,7 +1283,7 @@ export const AddDownloadsModal = () => {
);
if (urlMatch) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
resolution: 'rename',
@@ -1266,7 +1292,7 @@ export const AddDownloadsModal = () => {
});
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
resolution: 'rename',
@@ -1313,7 +1339,7 @@ export const AddDownloadsModal = () => {
const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id)
&& !isTransferLocked(filenameMatch.status);
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) },
resolution: canReplace ? 'replace' : 'rename',
@@ -1368,7 +1394,7 @@ export const AddDownloadsModal = () => {
: false;
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: {
type: 'file',
@@ -1416,7 +1442,7 @@ export const AddDownloadsModal = () => {
resolution: 'rename' | 'replace' | 'skip';
replaceFingerprint?: string;
}[],
destinationOverrides: Record<number, string> = {}
destinationOverrides: Record<string | number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
item.selected === false ? null : item
@@ -1426,10 +1452,14 @@ export const AddDownloadsModal = () => {
if (resolutions) {
for (const res of resolutions) {
const idx = parseInt(res.id);
const idx = itemsToAdd.findIndex((candidate, index) =>
candidate !== null && (candidate.id === res.id || String(index) === res.id)
);
if (idx === -1) continue;
const item = itemsToAdd[idx];
if (!item) continue;
const conflict = conflicts.find(c => c.id === res.id);
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[idx];
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
@@ -1442,7 +1472,7 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx],
itemOverride,
item.isTorrent === true
);
@@ -1455,11 +1485,12 @@ export const AddDownloadsModal = () => {
const candidateFile = candidate.isMedia
? mediaFileNameForSelectedFormat(candidate.file, candidate)
: canonicalizeDownloadFileName(candidate.file);
const candidateOverride = destinationOverrides[candidate.id] ?? destinationOverrides[candidateIndex];
const candidateLocation = await destinationForFile(
candidateFile,
finalLocation,
useSharedDestination,
destinationOverrides[candidateIndex],
candidateOverride,
candidate.isTorrent === true
);
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
@@ -1522,7 +1553,7 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx],
itemOverride,
item.isTorrent === true
);
const store = useDownloadStore.getState();
@@ -1530,7 +1561,7 @@ export const AddDownloadsModal = () => {
? store.downloads.find(download => download.id === conflict.existingDownloadId)
: undefined;
const currentSettings = useSettingsStore.getState();
if (!existingItem && !conflict?.existingDownloadId) {
if (!existingItem) {
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
@@ -1549,58 +1580,89 @@ export const AddDownloadsModal = () => {
}
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (!existingItem) {
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: res.replaceFingerprint
};
continue;
}
const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
// Completed replacements must remove the old file so the
// new transfer cannot be treated as an already-complete
// aria2 target. A torrent replacement also needs a fresh
// identity because its cached metadata is keyed by the
// new row ID and its output contract differs from a normal
// file transfer. Unfinished ordinary rows use the in-place
// path to preserve their resumable assets and progress.
await store.removeDownload(existingItem.id, true, false);
} else {
const contextUrl = requestContextUrlForRow(item);
const replaced = await store.replaceDownload(existingItem.id, {
url: item.downloadUrl,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(contextUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
lastError: undefined
}, pendingAction);
if (!replaced) {
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
if (!existingItem) {
let diskTargetKind: string | null = null;
let diskTargetFingerprint: string | undefined;
let diskTargetOwner: string | undefined;
try {
const targetInfo = await invoke('inspect_download_target', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
diskTargetKind = targetInfo.kind;
diskTargetFingerprint = targetInfo.fingerprint;
diskTargetOwner = targetInfo.ownedBy;
} catch (e) {
console.error("Failed to check if file exists on disk:", e);
}
// The existing row was updated in place; do not create a
// second identity for the same filename.
itemsToAdd[idx] = null;
updatedCount += 1;
continue;
}
}
}
if (diskTargetKind === 'regularFile' && diskTargetFingerprint && !diskTargetOwner) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: diskTargetFingerprint
};
continue;
}
if (diskTargetKind === 'missing' || !diskTargetKind) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: undefined
};
continue;
}
if (res.replaceFingerprint && diskTargetFingerprint === res.replaceFingerprint) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: res.replaceFingerprint
};
continue;
}
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
// Completed replacements must remove the old file so the
// new transfer cannot be treated as an already-complete
// aria2 target. A torrent replacement also needs a fresh
// identity because its cached metadata is keyed by the
// new row ID and its output contract differs from a normal
// file transfer. Unfinished ordinary rows use the in-place
// path to preserve their resumable assets and progress.
await store.removeDownload(existingItem.id, true, false);
} else {
const contextUrl = requestContextUrlForRow(item);
const replaced = await store.replaceDownload(existingItem.id, {
url: item.downloadUrl,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(contextUrl, item.isMedia) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
mirrors: mirrors.trim() || undefined,
lastError: undefined
}, pendingAction);
if (!replaced) {
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
// The existing row was updated in place; do not create a
// second identity for the same filename.
itemsToAdd[idx] = null;
updatedCount += 1;
continue;
}
}
}
}
let addedCount = 0;
const failures: string[] = [];
@@ -1629,8 +1691,8 @@ export const AddDownloadsModal = () => {
id,
cache: true,
proxy: proxy ?? undefined,
headers: headersForRow(contextUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined,
headers: headersForRow(contextUrl, item.isMedia) || undefined,
cookies: cookiesForRow(contextUrl, item.sourceUrl, item.isMedia) || undefined,
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
torrent: true
});
@@ -1642,6 +1704,7 @@ export const AddDownloadsModal = () => {
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const category = categoryForFileName(finalFile, item.isTorrent === true);
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[itemIndex];
const added = await addDownload({
id,
url: item.downloadUrl,
@@ -1658,18 +1721,18 @@ export const AddDownloadsModal = () => {
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
? sftpHostKeyMd.trim() || undefined
: undefined,
headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined,
headers: item.isTorrent ? undefined : headersForRow(contextUrl, item.isMedia) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined,
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
destination: useSharedDestination || saveInDedicatedFolder || itemOverride
? await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[itemIndex],
itemOverride,
item.isTorrent === true
)
: undefined,
+17 -2
View File
@@ -50,6 +50,7 @@ interface DownloadItemProps {
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
onRowKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, download: DownloadItemType) => void;
}
export const DownloadItem = React.memo<DownloadItemProps>(({
@@ -74,6 +75,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onMoveInQueue,
onQueueDragStart,
onClick,
onRowKeyDown,
}) => {
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
@@ -518,7 +520,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX || rect.left;
const y = e.clientY || rect.bottom + 4;
setContextMenu({ x, y, id: download.id });
}}
className="app-icon-button main-control-button"
title={t($ => $.downloads.actions.options)}
@@ -584,11 +589,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
event.preventDefault();
event.stopPropagation();
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
return;
}
onRowKeyDown?.(event, download);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
const isKeyboard = (e.clientX === 0 && e.clientY === 0) || (e.button === 0 && e.detail === 0);
let x = e.clientX;
let y = e.clientY;
if (isKeyboard && rowRef.current) {
const rect = rowRef.current.getBoundingClientRect();
x = rect.left + 40;
y = rect.top + rect.height / 2;
}
setContextMenu({ x, y, id: download.id });
}}
>
<div
+141 -11
View File
@@ -739,6 +739,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
persistColumnWidths(widths);
persistColumnOrder(order);
persistColumnAlignments(alignments);
setQueueSortConfig(null);
setColumnMenu(null);
};
@@ -749,6 +750,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (contextMenuRef.current || columnMenuRef.current) {
event.preventDefault();
event.stopPropagation();
}
setContextMenu(null);
setColumnMenu(null);
}
@@ -1752,8 +1757,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [sortedDownloads]);
useEffect(() => {
setContextMenu(null);
setColumnMenu(null);
setQueueSortConfig(null);
}, [filter, isQueueFilter]);
const writeToClipboard = useCallback(async (text: string): Promise<void> => {
try {
await writeClipboardText(text);
} catch {
await navigator.clipboard.writeText(text);
}
}, []);
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (suppressQueueClickRef.current) {
clearQueueClickSuppression();
@@ -1789,6 +1805,82 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setContextMenu(menu);
}, [clampMenuPosition]);
const handleRowKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>, item: DownloadItem) => {
if (e.target instanceof Element && e.target.closest('button, a, input, textarea, select')) {
return;
}
if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
const row = queueRowForId(item.id);
const rect = row?.getBoundingClientRect();
const x = rect ? rect.left + 40 : 100;
const y = rect ? rect.top + rect.height / 2 : 100;
handleContextMenu({ x, y, id: item.id });
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleDownloadDoubleClick(item);
return;
}
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const nextSelection = updateDownloadSelection({
orderedIds: sortedDownloadsRef.current.map(d => d.id),
selectedIds: selectedIdsRef.current,
lastSelectedId: lastSelectedIdRef.current,
targetId: item.id,
extendRange: e.shiftKey,
toggle: true,
});
setSelectedIds(nextSelection.selectedIds);
setLastSelectedId(nextSelection.lastSelectedId);
return;
}
if (
!e.altKey &&
!e.metaKey &&
!e.ctrlKey &&
(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')
) {
const items = sortedDownloadsRef.current;
const currentIndex = items.findIndex(d => d.id === item.id);
if (currentIndex === -1) return;
let targetIndex = currentIndex;
if (e.key === 'ArrowDown') targetIndex = Math.min(items.length - 1, currentIndex + 1);
else if (e.key === 'ArrowUp') targetIndex = Math.max(0, currentIndex - 1);
else if (e.key === 'Home') targetIndex = 0;
else if (e.key === 'End') targetIndex = items.length - 1;
if (targetIndex !== currentIndex) {
e.preventDefault();
e.stopPropagation();
const targetItem = items[targetIndex];
const nextSelection = updateDownloadSelection({
orderedIds: items.map(d => d.id),
selectedIds: selectedIdsRef.current,
lastSelectedId: lastSelectedIdRef.current,
targetId: targetItem.id,
extendRange: e.shiftKey,
toggle: false,
});
setSelectedIds(nextSelection.selectedIds);
setLastSelectedId(nextSelection.lastSelectedId);
const targetElement = queueRowForId(targetItem.id);
targetElement?.focus({ preventScroll: false });
targetElement?.scrollIntoView({ block: 'nearest' });
}
}
}, [handleContextMenu, handleDownloadDoubleClick]);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
if (
queueDragStateRef.current ||
@@ -1821,15 +1913,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [moveInQueue, showInteractionError, t]);
const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
current?.column === column
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' };
if (isQueueFilter) {
setQueueSortConfig(update);
setQueueSortConfig(current => {
if (current?.column !== column) {
return { column, direction: 'asc' };
}
if (current.direction === 'asc') {
return { column, direction: 'desc' };
}
return null;
});
} else {
setSortConfig(current => update(current));
setSortConfig(current =>
current?.column === column
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' }
);
}
};
@@ -2302,6 +2401,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
onMoveInQueue={handleMoveInQueue}
onQueueDragStart={stableHandleQueueDragStart}
onClick={handleItemClick}
onRowKeyDown={handleRowKeyDown}
/>
))}
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
@@ -2346,6 +2446,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
onClick={event => event.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const menu = columnMenuRef.current;
if (!menu) return;
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
if (buttons.length === 0) return;
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIdx = e.key === 'ArrowDown'
? (activeIdx + 1) % buttons.length
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
buttons[nextIdx]?.focus();
}
}}
>
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
@@ -2394,6 +2509,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
left: contextMenuPosition?.x,
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const menu = contextMenuRef.current;
if (!menu) return;
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
if (buttons.length === 0) return;
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIdx = e.key === 'ArrowDown'
? (activeIdx + 1) % buttons.length
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
buttons[nextIdx]?.focus();
}
}}
>
{selectedIds.size > 1 ? (() => {
const selectedDownloads = Array.from(selectedIds)
@@ -2457,7 +2587,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
.map(id => downloads.find(d => d.id === id)?.url)
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(urls).catch(error => {
writeToClipboard(urls).catch(error => {
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
});
}}
@@ -2564,7 +2694,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url).catch(error => {
writeToClipboard(contextItem.url).catch(error => {
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
});
}}
@@ -2579,7 +2709,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setContextMenu(null);
try {
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
await writeClipboardText(magnet);
await writeToClipboard(magnet);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
}
@@ -2600,7 +2730,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
return;
}
try {
await navigator.clipboard.writeText(fullPath);
await writeToClipboard(fullPath);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
}
+3 -2
View File
@@ -55,6 +55,7 @@ import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREA
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
import { copyTorrentFilePath } from '../utils/torrentFilePath';
import { WindowControls } from './WindowControls';
import {
TORRENT_ENCRYPTION_POLICY_DISABLED,
@@ -1324,7 +1325,7 @@ export const PropertiesWindowApp = () => {
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
{isTorrent && <>
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, snapshot.appearance.locale)}</strong></div></div>
</>}
</div>
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
@@ -1447,7 +1448,7 @@ export const PropertiesWindowApp = () => {
{activeTab === 'files' && isTorrent && <div className="space-y-3">
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] p-2" dir="auto" title={file.relativePath}><div className="flex items-center gap-1.5 min-w-0"><span className="truncate flex-1 min-w-0">{file.relativePath}</span><button type="button" className="app-icon-button shrink-0 opacity-70 hover:opacity-100 focus-visible:opacity-100" aria-label={t($ => $.downloadTable.copyFilePath)} title={t($ => $.downloadTable.copyFilePath)} onClick={event => { event.preventDefault(); event.stopPropagation(); void copyTorrentFilePath(file.relativePath, writeClipboardText).then(() => setNotice(t($ => $.logs.copied))).catch(() => setErrorMessage(t($ => $.downloadTable.copyPathFailed))); }}><Copy size={12} aria-hidden="true" /></button></div></td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
+23 -8
View File
@@ -205,6 +205,8 @@ export default function SchedulerView() {
variant: 'success'
});
} else {
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
}
};
@@ -213,17 +215,30 @@ export default function SchedulerView() {
const generation = beginSchedulerControl();
const savedQueueIds = savedSettings.selectedQueueIds
.filter(queueId => availableQueueIds.has(queueId));
const savedQueueSet = new Set(savedQueueIds);
const trackedIdsOutsideSavedQueues = useSettingsStore.getState().schedulerActiveDownloadIds
.filter(id => {
const queueId = useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID;
return !savedQueueSet.has(queueId);
});
const targetQueueIds = new Set<string>([
...savedQueueIds,
...effectiveSelectedQueueIds
]);
const trackedDownloadIds = useSettingsStore.getState().schedulerActiveDownloadIds;
const downloads = useDownloadStore.getState().downloads;
for (const id of trackedDownloadIds) {
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
if (availableQueueIds.has(queueId)) {
targetQueueIds.add(queueId);
}
}
const targetQueueList = Array.from(targetQueueIds);
const targetQueueSet = new Set(targetQueueList);
const trackedIdsOutsideQueues = trackedDownloadIds.filter(id => {
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
return !targetQueueSet.has(queueId);
});
const counts = await Promise.all(
savedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
targetQueueList.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
const directPauseResults = await Promise.allSettled(
trackedIdsOutsideSavedQueues.map(id => useDownloadStore.getState().pauseDownload(id))
trackedIdsOutsideQueues.map(id => useDownloadStore.getState().pauseDownload(id))
);
if (!isSchedulerControlCurrent(generation)) return;
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
+47 -9
View File
@@ -466,6 +466,12 @@ const engineRunId = useRef(0);
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
() => String(settings.maxConcurrentDownloads)
);
const [maxAutomaticRetriesInput, setMaxAutomaticRetriesInput] = useState(
() => String(settings.maxAutomaticRetries)
);
const [minimumNormalDownloadSpeedKiBInput, setMinimumNormalDownloadSpeedKiBInput] = useState(
() => String(settings.minimumNormalDownloadSpeedKiB)
);
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
() => String(settings.torrentMaxOpenFiles)
@@ -490,6 +496,14 @@ const engineRunId = useRef(0);
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
}, [settings.maxConcurrentDownloads]);
useEffect(() => {
setMaxAutomaticRetriesInput(String(settings.maxAutomaticRetries));
}, [settings.maxAutomaticRetries]);
useEffect(() => {
setMinimumNormalDownloadSpeedKiBInput(String(settings.minimumNormalDownloadSpeedKiB));
}, [settings.minimumNormalDownloadSpeedKiB]);
useEffect(() => {
setProxyPortInput(String(settings.proxyPort));
}, [settings.proxyPort]);
@@ -1068,14 +1082,24 @@ runEngineChecks(false);
</div>
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
onBlur={(e) => {
const val = Number(e.target.value);
if (val < 0) settings.setMaxAutomaticRetries(0);
if (val > 10) settings.setMaxAutomaticRetries(10);
value={maxAutomaticRetriesInput}
onChange={(e) => {
const value = e.target.value;
setMaxAutomaticRetriesInput(value);
if (value !== '' && Number.isFinite(Number(value))) {
settings.setMaxAutomaticRetries(Number(value));
}
}}
onBlur={(e) => commitBoundedIntegerInput(
e.target.value,
settings.maxAutomaticRetries,
0,
10,
settings.setMaxAutomaticRetries,
setMaxAutomaticRetriesInput
)}
className="app-control w-24 text-center"
aria-label={t($ => $.settings.downloads.automaticRetries)}
/>
</div>
<div className="mac-settings-row">
@@ -1085,8 +1109,22 @@ runEngineChecks(false);
</div>
<input
type="number" min="0" max="1048576"
value={settings.minimumNormalDownloadSpeedKiB}
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
value={minimumNormalDownloadSpeedKiBInput}
onChange={(event) => {
const value = event.target.value;
setMinimumNormalDownloadSpeedKiBInput(value);
if (value !== '' && Number.isFinite(Number(value))) {
settings.setMinimumNormalDownloadSpeedKiB(Number(value));
}
}}
onBlur={(event) => commitBoundedIntegerInput(
event.target.value,
settings.minimumNormalDownloadSpeedKiB,
0,
1048576,
settings.setMinimumNormalDownloadSpeedKiB,
setMinimumNormalDownloadSpeedKiBInput
)}
className="app-control w-24 text-center"
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
/>
@@ -2260,7 +2298,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={11} /> Regenerate
<RefreshCw size={11} /> {t($ => $.settings.integrations.regenerateToken)}
</button>
</div>
</div>
+11 -2
View File
@@ -21,10 +21,11 @@ interface SidebarProps {
selectedFilter: SidebarFilter;
onToggleSidebar?: () => void;
onSelectFilter: (filter: SidebarFilter) => void;
toggleButtonRef?: React.Ref<HTMLButtonElement>;
}
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
const { selectedFilter, onToggleSidebar, onSelectFilter, toggleButtonRef } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const {
activeView,
@@ -116,7 +117,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setContextMenu(null);
if (event.key === 'Escape' && contextMenuRef.current) {
event.preventDefault();
event.stopPropagation();
setContextMenu(null);
}
};
window.addEventListener('click', handleCloseMenu);
window.addEventListener('keydown', handleEscape);
@@ -388,6 +393,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
ref={toggleButtonRef}
data-tauri-drag-region="false"
onPointerDown={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
onClick={onToggleSidebar ?? toggleSidebar}
className="sidebar-toggle-button"
title={t($ => $.actions.hideSidebar)}
+1
View File
@@ -1121,6 +1121,7 @@ const common = {
tokenCopied: 'Token copied to clipboard!',
tokenCopyFailed: 'Could not copy token: {{detail}}',
pairingTokenRegenerated: 'Pairing token regenerated',
regenerateToken: 'Regenerate Token',
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
getExtension: 'Get Extension',
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
+1
View File
@@ -1121,6 +1121,7 @@ const fa = {
tokenCopied: 'توکن در کلیپ‌بورد کپی شد!',
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
pairingTokenRegenerated: 'توکن جفت‌سازی دوباره ایجاد شد',
regenerateToken: 'ایجاد دوباره توکن',
regenerateFailed: 'ایجاد دوباره توکن جفت‌سازی ناموفق بود: {{detail}}',
getExtension: 'دریافت افزونه',
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
+1
View File
@@ -1121,6 +1121,7 @@ const he = {
tokenCopied: 'האסימון הועתק ללוח!',
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
regenerateToken: 'צור אסימון מחדש',
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
getExtension: 'קבלת התוסף',
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const ru = {
tokenCopied: 'Токен скопирован в буфер обмена!',
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
regenerateToken: 'Сгенерировать токен заново',
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
getExtension: 'Получить расширение',
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const uk = {
tokenCopied: 'Токен скопійовано в буфер обміну!',
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
regenerateToken: 'Згенерувати токен наново',
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
getExtension: 'Отримати розширення',
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const zhCN = {
tokenCopied: '令牌已复制到剪贴板!',
tokenCopyFailed: '无法复制令牌:{{detail}}',
pairingTokenRegenerated: '配对令牌已重新生成',
regenerateToken: '重新生成令牌',
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
getExtension: '获取扩展',
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
+1
View File
@@ -2701,6 +2701,7 @@ html[data-list-density="relaxed"] {
color: hsl(var(--text-secondary));
background: transparent;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.sidebar-toggle-button:hover {
+1
View File
@@ -154,6 +154,7 @@ type CommandMap = {
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
get_supported_media_domains: { args: undefined; result: string[] };
is_supported_media: { args: { url: string }; result: boolean };
db_save_settings: { args: { data: string }; result: void };
db_load_settings: { args: undefined; result: string | null };
canonicalize_torrent_network_setting: {
+52
View File
@@ -641,4 +641,56 @@ describe('Properties window bridge', () => {
expect(assigned).toBe(unlisten);
expect(unlisten).not.toHaveBeenCalled();
});
it('strictly validates numeric boundaries, speed limits, and tracker syntax in patch copies', () => {
const baseItem = { isTorrent: false, status: 'ready' as const };
const torrentItem = { isTorrent: true, status: 'paused' as const };
// Connections bounds (1 to 16, whole numbers)
expect(() => copyEditablePropertiesPatch({ connections: 0 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: 17 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: 1.5 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: Number.NaN }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(copyEditablePropertiesPatch({ connections: 1 }, baseItem)).toMatchObject({ connections: 1 });
expect(copyEditablePropertiesPatch({ connections: 16 }, baseItem)).toMatchObject({ connections: 16 });
// Torrent max peers bounds (0 to 1000, whole numbers)
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: -1 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 1001 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 10.5 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 0 }, torrentItem)).toMatchObject({ torrentMaxPeers: 0 });
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 1000 }, torrentItem)).toMatchObject({ torrentMaxPeers: 1000 });
// Speed limit normalization and invalid formats
expect(() => copyEditablePropertiesPatch({ speedLimit: 'invalid' }, baseItem)).toThrow('Invalid download speed limit');
expect(() => copyEditablePropertiesPatch({ speedLimit: '0M' }, baseItem)).toThrow('Invalid download speed limit');
expect(() => copyEditablePropertiesPatch({ speedLimit: '-5M' }, baseItem)).toThrow('Invalid download speed limit');
expect(copyEditablePropertiesPatch({ speedLimit: '2M' }, baseItem)).toMatchObject({ speedLimit: '2M' });
expect(copyEditablePropertiesPatch({ speedLimit: ' 500K ' }, baseItem)).toMatchObject({ speedLimit: '500K' });
expect(copyEditablePropertiesPatch({ speedLimit: '' }, baseItem).speedLimit).toBeUndefined();
// Torrent seed settings
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: -1 }, torrentItem)).toThrow('Invalid torrentSeedTime');
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: Number.NaN }, torrentItem)).toThrow('Invalid torrentSeedTime');
expect(() => copyEditablePropertiesPatch({ torrentSeedRatio: -0.1 }, torrentItem)).toThrow('Invalid torrentSeedRatio');
expect(copyEditablePropertiesPatch({ torrentSeedTime: 0 }, torrentItem)).toMatchObject({ torrentSeedTime: 0 });
expect(copyEditablePropertiesPatch({ torrentSeedRatio: 1.5 }, torrentItem)).toMatchObject({ torrentSeedRatio: 1.5 });
// Torrent stop timeout
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: -1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: 7 * 24 * 60 * 60 + 1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
expect(copyEditablePropertiesPatch({ torrentStopTimeout: 3600 }, torrentItem)).toMatchObject({ torrentStopTimeout: 3600 });
// Torrent trackers validation
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'not-a-url' }, torrentItem)).toThrow('Invalid Torrent tracker list');
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'ftp://unsupported.tracker/announce' }, torrentItem)).toThrow('Invalid Torrent tracker list');
expect(copyEditablePropertiesPatch({ torrentTrackers: 'https://tracker.example/announce' }, torrentItem))
.toMatchObject({ torrentTrackers: 'https://tracker.example/announce' });
// Torrent policies
expect(() => copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentEncryptionPolicy');
expect(() => copyEditablePropertiesPatch({ torrentFileAllocation: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentFileAllocation');
expect(copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'require-crypto' }, torrentItem)).toMatchObject({ torrentEncryptionPolicy: 'require-crypto' });
expect(copyEditablePropertiesPatch({ torrentFileAllocation: 'prealloc' }, torrentItem)).toMatchObject({ torrentFileAllocation: 'prealloc' });
});
});
+21
View File
@@ -331,6 +331,27 @@ describe('useDownloadStore', () => {
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
});
it('discards legacy cookies and sensitive headers for explicit media and media domains', () => {
useDownloadStore.getState().toggleAddModal(false);
useDownloadStore.getState().openAddModalWithUrls(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com',
'video.mp4',
'Authorization: Bearer secret\nUser-Agent: FirelinkTest',
'session=leak',
true,
[{ url: 'https://www.youtube.com', cookies: 'session=leak' }]
);
const state = useDownloadStore.getState();
expect(state.pendingAddCookies).toBe('');
const context = state.pendingAddRequestContexts['https://www.youtube.com/watch?v=dQw4w9WgXcQ'];
expect(context?.cookies).toBe('');
expect(context?.cookieScopes).toBeUndefined();
expect(context?.headers).toBe('User-Agent: FirelinkTest');
expect(context?.media).toBe(true);
});
it('replaces a paused download URL in place and preserves its progress', async () => {
useDownloadStore.setState({
downloads: [{
+20 -14
View File
@@ -11,7 +11,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isMediaUrl, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -1844,8 +1844,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
const cleanReferer = referer?.trim() || '';
const cleanFilename = filename?.trim() || '';
const cleanHeaders = headers?.trim() || '';
const cleanCookies = cookies?.trim() || '';
const isExplicitMedia = media === true;
const cleanHeaders = isExplicitMedia
? stripSensitiveMediaHeaders(headers)
: (headers?.trim() || '');
const cleanCookies = isExplicitMedia ? '' : (cookies?.trim() || '');
// Keep the first modal request's grouping decision stable while later
// handoffs append URLs. This avoids moving an already-visible destination
// when a second request races with the user's Add-window setup.
@@ -1853,12 +1856,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const nextBatchName = nextBatch
? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '')
: '';
const cleanCookieScopes = cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const cleanCookieScopes = isExplicitMedia
? undefined
: cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const requestVersion = state.pendingAddRequestVersion + 1;
const pendingAddRequestContexts = isAppending
? { ...state.pendingAddRequestContexts }
@@ -1876,14 +1881,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} catch {
// The Add modal will mark malformed input invalid; retain its original key here.
}
const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl);
pendingAddRequestContexts[key] = {
version: requestVersion,
referer: cleanReferer,
filename: cleanFilename,
headers: cleanHeaders,
cookies: cleanCookies,
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
media,
headers: isItemMedia ? stripSensitiveMediaHeaders(cleanHeaders) : cleanHeaders,
cookies: isItemMedia ? '' : cleanCookies,
...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}),
media: isItemMedia,
...(torrent ? { torrent: true } : {})
};
}
@@ -1899,7 +1905,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingAddReferer: cleanReferer,
pendingAddFilename: cleanFilename,
pendingAddHeaders: cleanHeaders,
pendingAddCookies: cleanCookies,
pendingAddCookies: isExplicitMedia ? '' : cleanCookies,
pendingAddMediaUrls,
pendingAddTorrentUrls,
pendingAddBatch: nextBatch,
+58
View File
@@ -91,6 +91,64 @@ describe('durable main-window and sidebar preferences', () => {
});
});
it('pins volatile navigation and sanitizes scheduler and cookie source during hydration', () => {
const merge = useSettingsStore.persist.getOptions().merge;
expect(merge).toBeTypeOf('function');
const current = useSettingsStore.getState();
const result = merge?.({
activeView: 'logs' as any,
showKeychainModal: true as any,
mediaCookieSource: 'internet-explorer' as any,
lastCustomSpeedLimitUnit: 'GB/s',
scheduler: {
enabled: 'yes' as any,
postQueueAction: 'self-destruct' as any,
selectedDays: [10, -1, 3],
selectedQueueIds: [' ', 'queue-1']
},
schedulerRunning: 'running' as any,
schedulerActiveDownloadIds: 'all' as any
}, current);
expect(result?.activeView).toBe(current.activeView);
expect(result?.showKeychainModal).toBe(false);
expect(result?.mediaCookieSource).toBe('none');
expect(result?.lastCustomSpeedLimitUnit).toBe(current.lastCustomSpeedLimitUnit);
expect(result?.scheduler.enabled).toBe(current.scheduler.enabled);
expect(result?.scheduler.postQueueAction).toBe('none');
expect(result?.scheduler.selectedDays).toEqual([3]);
expect(result?.scheduler.selectedQueueIds).toEqual(['queue-1']);
expect(result?.schedulerRunning).toBe(current.schedulerRunning);
expect(result?.schedulerActiveDownloadIds).toEqual(current.schedulerActiveDownloadIds);
const fallbackResult = merge?.({
scheduler: {
selectedDays: [-5, 99] as any,
selectedQueueIds: [' ', ''] as any
}
}, current);
expect(fallbackResult?.scheduler.selectedDays).toEqual(current.scheduler.selectedDays);
expect(fallbackResult?.scheduler.selectedQueueIds).toEqual(current.scheduler.selectedQueueIds);
});
it('sanitizes setter calls for enum and numeric settings', () => {
useSettingsStore.getState().setMediaCookieSource('invalid-browser' as any);
expect(useSettingsStore.getState().mediaCookieSource).toBe('none');
useSettingsStore.getState().setTheme('invalid-theme' as any);
expect(useSettingsStore.getState().theme).toBe('system');
useSettingsStore.getState().setLastCustomSpeedLimitKiB(-500);
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(1);
useSettingsStore.getState().setLastCustomSpeedLimitKiB(20_000_000);
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(10_485_760);
useSettingsStore.getState().setLastCustomSpeedLimitUnit('PB/s' as any);
expect(useSettingsStore.getState().lastCustomSpeedLimitUnit).toBe('MB/s');
});
it('uses the legacy localStorage value only when durable state is absent', () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
+80 -18
View File
@@ -126,6 +126,7 @@ const MEDIA_COOKIE_SOURCE_VALUES = [
const SETTINGS_TAB_VALUES = [
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
] as const;
const POST_QUEUE_ACTION_VALUES = ['none', 'sleep', 'restart', 'shutdown'] as const;
type PersistedSettingsSnapshot = PersistedSettings & {
language: AppLocalePreference;
@@ -498,20 +499,31 @@ export const useSettingsStore = create<SettingsState>()(
autoCheckUpdates: true,
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setTheme: (theme) => {
const safe = isAllowedSetting(THEME_VALUES, theme) ? theme : 'system';
info('Settings updated: theme');
set({ theme: safe });
},
setFontFamily: (fontFamily) => {
const safe = isAllowedSetting(FONT_FAMILY_VALUES, fontFamily) ? fontFamily : 'system';
info('Settings updated: fontFamily');
set({ fontFamily });
set({ fontFamily: safe });
},
setWindowControlStyle: (windowControlStyle) => {
const safe = isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, windowControlStyle) ? windowControlStyle : 'auto';
info('Settings updated: windowControlStyle');
set({ windowControlStyle });
set({ windowControlStyle: safe });
},
setCalendarPreference: (calendarPreference) => {
const safe = isCalendarPreference(calendarPreference) ? calendarPreference : DEFAULT_CALENDAR_PREFERENCE;
info('Settings updated: calendarPreference');
set({ calendarPreference });
set({ calendarPreference: safe });
},
setLanguage: (language) => {
const safe = isAppLocalePreference(language) ? language : 'system';
info('Settings updated: language');
set({ language: safe });
},
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
@@ -561,7 +573,10 @@ export const useSettingsStore = create<SettingsState>()(
},
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
setSidebarPosition: (sidebarPosition) => {
const safe = isAllowedSetting(SIDEBAR_POSITION_VALUES, sidebarPosition) ? sidebarPosition : 'auto';
set({ sidebarPosition: safe });
},
setFoldersCollapsed: (isFoldersCollapsed) => set({ isFoldersCollapsed }),
toggleFoldersCollapsed: () => set(state => ({ isFoldersCollapsed: !state.isFoldersCollapsed })),
setMainWindowSize: (size) => {
@@ -569,14 +584,23 @@ export const useSettingsStore = create<SettingsState>()(
if (normalized) set({ mainWindowSize: normalized });
},
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setActiveSettingsTab: (activeSettingsTab) => {
const safe = isAllowedSetting(SETTINGS_TAB_VALUES, activeSettingsTab) ? activeSettingsTab : 'downloads';
set({ activeSettingsTab: safe });
},
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({
lastCustomSpeedLimitKiB: clampSettingInteger(lastCustomSpeedLimitKiB, 1, 10_485_760, 1024)
}),
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({
lastCustomSpeedLimitUnit: lastCustomSpeedLimitUnit === 'KB/s' || lastCustomSpeedLimitUnit === 'MB/s'
? lastCustomSpeedLimitUnit
: 'MB/s'
}),
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
setPerServerConnections: (perServerConnections) => set({
@@ -598,8 +622,14 @@ export const useSettingsStore = create<SettingsState>()(
setShowNotifications: (showNotifications) => set({ showNotifications }),
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
setAppFontSize: (appFontSize) => set({ appFontSize }),
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
setAppFontSize: (appFontSize) => {
const safe = isAllowedSetting(APP_FONT_SIZE_VALUES, appFontSize) ? appFontSize : 'standard';
set({ appFontSize: safe });
},
setListRowDensity: (listRowDensity) => {
const safe = isAllowedSetting(LIST_ROW_DENSITY_VALUES, listRowDensity) ? listRowDensity : 'standard';
set({ listRowDensity: safe });
},
setShowDockBadge: (showDockBadge) => {
set(state => ({
showDockBadge,
@@ -607,7 +637,10 @@ export const useSettingsStore = create<SettingsState>()(
}));
},
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
setProxyMode: (proxyMode) => set({ proxyMode }),
setProxyMode: (proxyMode) => {
const safe = isAllowedSetting(PROXY_MODE_VALUES, proxyMode) ? proxyMode : 'none';
set({ proxyMode: safe });
},
setProxyHost: (proxyHost) => set({ proxyHost }),
setProxyPort: (proxyPort) => set({
proxyPort: Number.isFinite(proxyPort)
@@ -687,7 +720,11 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: preventsDisplaySleepWhileDownloading');
set({ preventsDisplaySleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setMediaCookieSource: (mediaCookieSource) => {
const safe = isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, mediaCookieSource) ? mediaCookieSource : 'none';
info('Settings updated: mediaCookieSource');
set({ mediaCookieSource: safe });
},
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
set({
@@ -924,6 +961,9 @@ export const useSettingsStore = create<SettingsState>()(
// Never hydrate the remembered Add-window path from persisted data.
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
keychainAccessReady: currentState.keychainAccessReady,
activeView: currentState.activeView,
showKeychainModal: currentState.showKeychainModal,
dockBadgeSyncVersion: currentState.dockBadgeSyncVersion,
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
@@ -1118,12 +1158,34 @@ export const useSettingsStore = create<SettingsState>()(
: currentState.approvedDownloadRoots,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: currentState.scheduler.selectedQueueIds
...(isRecord(persisted.scheduler) ? persisted.scheduler : {}),
enabled: persistedBoolean(persisted.scheduler?.enabled, currentState.scheduler.enabled),
startTime: persistedString(persisted.scheduler?.startTime, currentState.scheduler.startTime),
stopTimeEnabled: persistedBoolean(persisted.scheduler?.stopTimeEnabled, currentState.scheduler.stopTimeEnabled),
stopTime: persistedString(persisted.scheduler?.stopTime, currentState.scheduler.stopTime),
everyday: persistedBoolean(persisted.scheduler?.everyday, currentState.scheduler.everyday),
selectedDays: (() => {
const days = Array.isArray(persisted.scheduler?.selectedDays)
? persisted.scheduler.selectedDays.filter((day): day is number => typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6)
: [];
return days.length > 0 ? days : currentState.scheduler.selectedDays;
})(),
selectedQueueIds: (() => {
const queues = Array.isArray(persisted.scheduler?.selectedQueueIds)
? persisted.scheduler.selectedQueueIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
: [];
return queues.length > 0 ? queues : currentState.scheduler.selectedQueueIds;
})(),
postQueueAction: isAllowedSetting(POST_QUEUE_ACTION_VALUES, persisted.scheduler?.postQueueAction)
? persisted.scheduler.postQueueAction
: currentState.scheduler.postQueueAction
},
schedulerRunning: persistedBoolean(persisted.schedulerRunning, currentState.schedulerRunning),
schedulerActiveDownloadIds: Array.isArray(persisted.schedulerActiveDownloadIds)
? persisted.schedulerActiveDownloadIds.filter((id): id is string => typeof id === 'string')
: currentState.schedulerActiveDownloadIds,
schedulerLastStartKey: persistedString(persisted.schedulerLastStartKey, currentState.schedulerLastStartKey),
schedulerLastStopKey: persistedString(persisted.schedulerLastStopKey, currentState.schedulerLastStopKey),
siteLogins: Array.isArray(persisted.siteLogins)
? sanitizeSiteLogins(persisted.siteLogins)
: currentState.siteLogins
+27
View File
@@ -336,6 +336,33 @@ describe('add download metadata workflow', () => {
expect(rows.some(item => item.isPlaylist)).toBe(false);
});
it('retains the playlist row when expansion has no valid video entries', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL_EMPTY';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Empty playlist',
playlist_id: 'PL_EMPTY',
entry_count: 0,
skipped_entries: 0,
truncated: false,
entries: []
}
}
);
expect(rows).toHaveLength(1);
expect(rows[0].sourceUrl).toBe(playlistUrl);
expect(rows[0].isPlaylist).toBe(true);
});
it('forces explicit extension media fetches through media metadata for any http page', () => {
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
+5 -1
View File
@@ -212,6 +212,7 @@ const parseInputLines = (
const expansion = playlistExpansions[sourceUrl];
if (expansion) {
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
let validEntryCount = 0;
for (const [position, entry] of expansion.entries.entries()) {
let entryUrl: string;
try {
@@ -221,6 +222,7 @@ const parseInputLines = (
} catch {
continue;
}
validEntryCount++;
if (seen.has(entryUrl)) continue;
seen.add(entryUrl);
parsed.push({
@@ -239,7 +241,9 @@ const parseInputLines = (
// The playlist has been successfully discovered even when every
// entry was already represented by another input row. Do not put the
// source playlist back into loading state in that case.
continue;
if (validEntryCount > 0) {
continue;
}
}
}
+21
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
formatDownloadBytes,
formatDownloadTotal,
formatTorrentRatio,
resolveDownloadFraction,
resolveDownloadSizeDisplay,
} from './downloadProgress';
@@ -108,3 +109,23 @@ describe('resolveDownloadFraction', () => {
})).toBe(1);
});
});
describe('formatTorrentRatio', () => {
it('formats positive torrent ratio with two decimal places', () => {
expect(formatTorrentRatio(1500, 1000, 'en-US')).toBe('1.50');
expect(formatTorrentRatio(2345, 1000, 'en-US')).toBe('2.35');
});
it('formats torrent ratio using the specified locale', () => {
const formatted = formatTorrentRatio(1500, 1000, 'fa');
expect(formatted).toBe(new Intl.NumberFormat('fa', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1.5));
});
it('returns a dash when ratio cannot be calculated', () => {
expect(formatTorrentRatio(0, 0, 'en-US')).toBe('—');
expect(formatTorrentRatio(-10, 1000, 'en-US')).toBe('—');
expect(formatTorrentRatio(1000, -10, 'en-US')).toBe('—');
expect(formatTorrentRatio(1000, 0, 'en-US')).toBe('—');
expect(formatTorrentRatio(Number.NaN, 1000, 'en-US')).toBe('—');
});
});
+60
View File
@@ -57,4 +57,64 @@ describe('download table sorting', () => {
expect(persisted.speed).toBeUndefined();
expect(persisted.eta).toBeUndefined();
});
it('parses estimated sizes with ~ or ≈ prefixes and sorts them accurately', () => {
expect(parseDownloadSize('~1.2 GB')).toBe(1.2 * 1024 ** 3);
expect(parseDownloadSize('~ 500 MB')).toBe(500 * 1024 ** 2);
expect(parseDownloadSize('≈250 KB')).toBe(250 * 1024);
expect(sortedIds([
item('missing', { size: '-' }),
item('est-1g', { size: '~1 GB' }),
item('final-500m', { size: '500 MB' }),
item('exact-bytes', { totalBytes: 2 * 1024 ** 3 }),
], { column: 'Size', direction: 'asc' })).toEqual([
'final-500m',
'est-1g',
'exact-bytes',
'missing'
]);
});
it('keeps unknown and missing values last in descending sort for Size, Speed, ETA, and Date Added', () => {
expect(sortedIds([
item('unknown-size', { size: '-' }),
item('small-size', { size: '100 MB' }),
item('large-size', { size: '1 GB' }),
], { column: 'Size', direction: 'desc' })).toEqual([
'large-size',
'small-size',
'unknown-size'
]);
expect(sortedIds([
item('unknown-speed', { speed: '-' }),
item('fast-speed', { speed: '10 MB/s' }),
item('slow-speed', { speed: '1 MB/s' }),
], { column: 'Speed', direction: 'desc' })).toEqual([
'fast-speed',
'slow-speed',
'unknown-speed'
]);
expect(sortedIds([
item('unknown-eta', { eta: '-' }),
item('long-eta', { eta: '2h' }),
item('short-eta', { eta: '5m' }),
], { column: 'ETA', direction: 'desc' })).toEqual([
'long-eta',
'short-eta',
'unknown-eta'
]);
expect(sortedIds([
item('unknown-date', { dateAdded: '' }),
item('newer-date', { dateAdded: '2026-08-01T00:00:00.000Z' }),
item('older-date', { dateAdded: '2026-01-01T00:00:00.000Z' }),
], { column: 'Date Added', direction: 'desc' })).toEqual([
'newer-date',
'older-date',
'unknown-date'
]);
});
});
+31 -11
View File
@@ -21,7 +21,11 @@ const SIZE_UNITS: Record<string, number> = {
};
const valueOrNull = (value?: string): string | null => {
const trimmed = value?.trim();
let trimmed = value?.trim();
if (!trimmed) return null;
if (trimmed.startsWith('~') || trimmed.startsWith('≈')) {
trimmed = trimmed.slice(1).trim();
}
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
};
@@ -41,6 +45,16 @@ const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
export const resolveSortableSize = (download: DownloadItem): number | null => {
if (typeof download.totalBytes === 'number' && Number.isFinite(download.totalBytes) && download.totalBytes > 0) {
return download.totalBytes;
}
if (download.status === 'completed' && typeof download.downloadedBytes === 'number' && Number.isFinite(download.downloadedBytes) && download.downloadedBytes > 0) {
return download.downloadedBytes;
}
return parseDownloadSize(download.size);
};
export const parseDownloadSpeed = (value?: string): number | null =>
parseUnitValue(value, SIZE_UNITS);
@@ -65,12 +79,18 @@ export const parseDownloadEta = (value?: string): number | null => {
return matched && Number.isFinite(seconds) ? seconds : null;
};
const compareValues = (left: string | number | null, right: string | number | null): number => {
const compareValues = (
left: string | number | null,
right: string | number | null,
direction: DownloadSortDirection
): number => {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
const comparison = typeof left === 'number' && typeof right === 'number'
? left - right
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
return direction === 'asc' ? comparison : -comparison;
};
const parseDownloadDate = (value?: string): number | null => {
@@ -84,27 +104,27 @@ export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortCon
let comparison: number;
switch (config.column) {
case 'File Name':
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
comparison = compareValues(left.fileName || left.url, right.fileName || right.url, config.direction);
break;
case 'Size':
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
comparison = compareValues(resolveSortableSize(left), resolveSortableSize(right), config.direction);
break;
case 'Status':
comparison = compareValues(left.status, right.status);
comparison = compareValues(left.status, right.status, config.direction);
break;
case 'Speed':
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed), config.direction);
break;
case 'ETA':
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta), config.direction);
break;
case 'Date Added':
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded), config.direction);
break;
}
if (comparison === 0) comparison = left.id.localeCompare(right.id);
return config.direction === 'asc' ? comparison : -comparison;
return comparison;
});
return sorted;
};
+20 -1
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
beginSchedulerControl,
cancelPendingPostAction,
consumeSchedulerHandoffIds,
handoffSupersededSchedulerIds,
isSchedulerControlCurrent
isSchedulerControlCurrent,
registerPostActionCanceller
} from './schedulerControl';
describe('scheduler control generation', () => {
@@ -34,4 +36,21 @@ describe('scheduler control generation', () => {
expect(handoffSupersededSchedulerIds(['download-a'], () => 'queue-a')).toEqual(new Set());
expect(consumeSchedulerHandoffIds(pause)).toEqual(new Set());
});
it('cancels pending post actions when a new control generation begins', () => {
let cancelled = 0;
const unregister = registerPostActionCanceller(() => {
cancelled += 1;
});
beginSchedulerControl();
expect(cancelled).toBe(1);
cancelPendingPostAction();
expect(cancelled).toBe(2);
unregister();
beginSchedulerControl();
expect(cancelled).toBe(2);
});
});
+17 -1
View File
@@ -2,16 +2,32 @@ let schedulerControlGeneration = 0;
let latestRunQueueIds: ReadonlySet<string> | null = null;
const schedulerHandoffs = new Map<number, Set<string>>();
let postActionCanceller: (() => void) | null = null;
export const registerPostActionCanceller = (canceller: () => void): (() => void) => {
postActionCanceller = canceller;
return () => {
if (postActionCanceller === canceller) {
postActionCanceller = null;
}
};
};
export const cancelPendingPostAction = (): void => {
postActionCanceller?.();
};
/**
* Start a new scheduler control lifecycle. A later manual pause or scheduler
* event invalidates earlier asynchronous queue operations before they can
* publish stale running state.
* publish stale running state, and cancels any pending post-queue action.
*/
export const beginSchedulerControl = (runQueueIds?: readonly string[]): number => {
schedulerControlGeneration += 1;
latestRunQueueIds = runQueueIds ? new Set(runQueueIds) : null;
schedulerHandoffs.clear();
if (runQueueIds) schedulerHandoffs.set(schedulerControlGeneration, new Set());
postActionCanceller?.();
return schedulerControlGeneration;
};
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
resolveFallbackFilter,
shouldRestoreSidebarRevealFocus,
shouldRestoreSidebarToggleFocus,
} from './sidebarFocus';
describe('sidebar focus restoration', () => {
it('restores focus to reveal button when activeElement was inside the sidebar shell', () => {
const sidebarShell = {
contains: (el: unknown) => el === buttonInside,
};
const buttonInside = {
closest: (selector: string) => (selector === '.app-sidebar-shell' ? sidebarShell : null),
};
expect(shouldRestoreSidebarRevealFocus(buttonInside, sidebarShell)).toBe(true);
});
it('does not restore focus to reveal button when activeElement was outside the sidebar shell', () => {
const sidebarShell = {
contains: () => false,
};
const tableButton = {
closest: () => null,
};
expect(shouldRestoreSidebarRevealFocus(tableButton, sidebarShell)).toBe(false);
expect(shouldRestoreSidebarRevealFocus(null, sidebarShell)).toBe(false);
});
it('restores focus to sidebar toggle button when reveal button was activated', () => {
const revealButton = {
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
};
expect(shouldRestoreSidebarToggleFocus(revealButton, revealButton)).toBe(true);
const iconInside = {
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
};
expect(shouldRestoreSidebarToggleFocus(iconInside, revealButton)).toBe(true);
});
it('does not restore focus to sidebar toggle button when reveal was not focused', () => {
const revealButton = {
closest: () => null,
};
const otherButton = {
closest: () => null,
};
expect(shouldRestoreSidebarToggleFocus(otherButton, revealButton)).toBe(false);
expect(shouldRestoreSidebarToggleFocus(null, revealButton)).toBe(false);
});
});
describe('sidebar queue filter fallback', () => {
it('falls back to all downloads when the active filtered queue is removed', () => {
const activeFilter = 'queue:custom-queue-1';
const remainingQueues = ['00000000-0000-0000-0000-000000000001', 'custom-queue-2'];
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('all');
});
it('preserves the active filter when the filtered queue remains present', () => {
const activeFilter = 'queue:custom-queue-1';
const remainingQueues = ['custom-queue-1', '00000000-0000-0000-0000-000000000001'];
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('queue:custom-queue-1');
});
it('preserves queue filter while queues are not yet hydrated', () => {
const activeFilter = 'queue:custom-queue-1';
expect(resolveFallbackFilter(activeFilter, [], false)).toBe('queue:custom-queue-1');
});
it('never modifies non-queue category or status filters', () => {
expect(resolveFallbackFilter('all', [], true)).toBe('all');
expect(resolveFallbackFilter('active', [], true)).toBe('active');
expect(resolveFallbackFilter('completed', [], true)).toBe('completed');
expect(resolveFallbackFilter('unfinished', [], true)).toBe('unfinished');
expect(resolveFallbackFilter('Musics', [], true)).toBe('Musics');
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Helpers for accessible focus preservation during sidebar collapse/reveal
* and active filter recovery when queues are deleted.
*/
export type FocusableTarget = {
contains?: (other: any) => boolean;
closest?: (selector: string) => any;
} | null;
export const shouldRestoreSidebarRevealFocus = (
activeElement: FocusableTarget,
sidebarShell: FocusableTarget,
): boolean => {
if (!activeElement || !sidebarShell) return false;
if (typeof activeElement.closest === 'function') {
return Boolean(activeElement.closest('.app-sidebar-shell'));
}
return Boolean(sidebarShell.contains?.(activeElement));
};
export const shouldRestoreSidebarToggleFocus = (
activeElement: FocusableTarget,
revealButton: FocusableTarget,
): boolean => {
if (!activeElement || !revealButton) return false;
if (activeElement === revealButton) return true;
if (typeof activeElement.closest === 'function') {
return Boolean(activeElement.closest('.app-sidebar-reveal-button'));
}
return Boolean(revealButton.contains?.(activeElement));
};
export const resolveFallbackFilter = (
filter: string,
availableQueueIds: Iterable<string>,
queuesHydrated: boolean,
): string => {
if (!filter.startsWith('queue:') || !queuesHydrated) return filter;
const queueId = filter.slice(6);
const queueSet = availableQueueIds instanceof Set
? availableQueueIds
: new Set(availableQueueIds);
return queueSet.has(queueId) ? filter : 'all';
};