diff --git a/scripts/release-workflow.node-test.js b/scripts/release-workflow.node-test.js index 708fd78..af5073a 100644 --- a/scripts/release-workflow.node-test.js +++ b/scripts/release-workflow.node-test.js @@ -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/); +}); diff --git a/scripts/verify-companion-release.js b/scripts/verify-companion-release.js index 11f3a7d..2955dad 100644 --- a/scripts/verify-companion-release.js +++ b/scripts/verify-companion-release.js @@ -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()) diff --git a/scripts/verify-companion-release.node-test.js b/scripts/verify-companion-release.node-test.js index 37811e9..8fc62e7 100644 --- a/scripts/verify-companion-release.node-test.js +++ b/scripts/verify-companion-release.node-test.js @@ -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 }); + } +}); diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 3fd8a4c..dcb270a 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -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,

test

").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" + )); + } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index ff79b67..0c5c799 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7ef153..1a600cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2640,6 +2640,13 @@ async fn fetch_media_metadata( ) -> Result { 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 { 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, 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 { + 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() { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ad9f19d..acdf15f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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!({ diff --git a/src-tauri/tests/production_contract.rs b/src-tauri/tests/production_contract.rs index c52b169..b90a0bc 100644 --- a/src-tauri/tests/production_contract.rs +++ b/src-tauri/tests/production_contract.rs @@ -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, + peer_speed_limit: Option, +} + +#[derive(serde::Deserialize, PartialEq, Debug)] +#[serde(rename_all = "snake_case")] +struct TorrentFileSelectionArgs { + id: String, + selected_indices: Option>, +} + +#[derive(serde::Deserialize, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +struct BuggyTorrentPeerOptionsArgs { + id: String, + max_peers: Option, + peer_speed_limit: Option, +} + +#[derive(serde::Deserialize, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +struct BuggyTorrentFileSelectionArgs { + id: String, + selected_indices: Option>, +} + +#[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); +} diff --git a/src-tauri/tests/torrent_rpc.rs b/src-tauri/tests/torrent_rpc.rs index e959220..c74a12a 100644 --- a/src-tauri/tests/torrent_rpc.rs +++ b/src-tauri/tests/torrent_rpc.rs @@ -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); + 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; +} diff --git a/src/App.tsx b/src/App.tsx index cade729..8889329 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); + const sidebarToggleRef = useRef(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(null); + const pendingPostActionToastId = useRef(null); + const pendingForceActionToastId = useRef(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() { ) }); + 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() { > { setFilter(f); @@ -1234,7 +1279,10 @@ function App() { -
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ 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}`} />{file.index}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
+
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ 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}`} />{file.index}
{file.relativePath}
{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

{t($ => $.properties.torrentFileProgressLoading)}

} {diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError &&

{t($ => $.properties.torrentFileProgressUnavailable)}

} {diagnosticError &&

{diagnosticError}

} diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 085f134..f5da86e 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -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([ + ...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) diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9a3dae1..075865a 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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); 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)} />
@@ -1085,8 +1109,22 @@ runEngineChecks(false);
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" > - Regenerate + {t($ => $.settings.integrations.regenerateToken)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a3455d8..f72f496 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -21,10 +21,11 @@ interface SidebarProps { selectedFilter: SidebarFilter; onToggleSidebar?: () => void; onSelectFilter: (filter: SidebarFilter) => void; + toggleButtonRef?: React.Ref; } export const Sidebar: React.FC = (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 = (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 = (props) => {