diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b00e423..51d3a7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1369,6 +1369,7 @@ dependencies = [ "apple-native-keyring-store", "async-trait", "axum", + "base64 0.22.1", "chrono", "futures-util", "hmac 0.13.0", @@ -1383,6 +1384,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha1 0.10.7", "sha2 0.11.0", "sysinfo", "sysproxy", @@ -4281,6 +4283,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5632,7 +5645,7 @@ dependencies = [ "httparse", "log 0.4.33", "rand 0.10.2", - "sha1", + "sha1 0.11.0", "thiserror 2.0.19", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ac5acc..9990e28 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,8 @@ tauri-plugin-clipboard-manager = "2.3.2" sysinfo = "0.39.3" hmac = "0.13" sha2 = "0.11" +sha1 = "0.10" +base64 = "0.22" tauri-plugin-deep-link = "2" tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ce12a18..bc0f085 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager( app_handle: tauri::AppHandle, path: String, ) -> Result<(), String> { - let primary = authorize_download_path(&app_handle, &path)?; + let primary = authorize_reveal_path(&app_handle, &path)?; let path = existing_download_asset(&primary).ok_or_else(|| { format!( "Downloaded file or partial file is missing: {}", @@ -56,18 +56,37 @@ fn authorize_download_path( authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?) } +fn authorize_reveal_path( + app_handle: &tauri::AppHandle, + requested: &str, +) -> Result { + authorize_exact_path_with_directory( + Path::new(requested), + &known_download_paths(app_handle)?, + true, + ) +} + fn known_download_paths(app_handle: &tauri::AppHandle) -> Result, String> { crate::download_ownership::known_primary_paths(app_handle) } fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result { + authorize_exact_path_with_directory(requested, allowed_paths, false) +} + +fn authorize_exact_path_with_directory( + requested: &Path, + allowed_paths: &[PathBuf], + allow_directory: bool, +) -> Result { if crate::path_has_symlink_component(requested) { return Err("Download path may not contain symlink components".to_string()); } let requested = canonicalize_with_missing_leaf(requested)?; if let Ok(metadata) = std::fs::metadata(&requested) { - if !metadata.is_file() { + if !metadata.is_file() && !(allow_directory && metadata.is_dir()) { return Err("Download path is not a file".to_string()); } } @@ -140,8 +159,9 @@ fn existing_download_asset(primary: &Path) -> Option { ] .into_iter() .find(|candidate| { - std::fs::symlink_metadata(candidate) - .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) + std::fs::symlink_metadata(candidate).is_ok_and(|metadata| { + (metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink() + }) }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 27ccf77..98dd6a3 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; const DATABASE_NAME: &str = "firelink.sqlite"; const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; -const CURRENT_SCHEMA_VERSION: i64 = 1; +const CURRENT_SCHEMA_VERSION: i64 = 2; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; // Development builds are a different executable identity from the packaged @@ -127,6 +127,10 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), id TEXT PRIMARY KEY, primary_path TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS download_owned_paths ( + id TEXT PRIMARY KEY, + paths TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS migration_events ( key TEXT PRIMARY KEY, consumed INTEGER NOT NULL DEFAULT 0 @@ -176,6 +180,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), } } + if from_version < 2 { + transaction + .execute_batch( + " + CREATE TABLE IF NOT EXISTS download_owned_paths ( + id TEXT PRIMARY KEY, + paths TEXT NOT NULL + ); + ", + ) + .map_err(|error| format!("failed to migrate download ownership paths: {error}"))?; + } + transaction .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .map_err(|error| format!("failed to update database schema version: {error}"))?; @@ -937,20 +954,44 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { if let Some(url) = object.get("url").and_then(Value::as_str) { if let Ok(mut parsed) = url::Url::parse(url) { let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some(); - let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); - if had_userinfo || had_query_or_fragment { + if parsed.scheme() == "magnet" { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + let mut retained_info_hash = false; + let mut removed_query_context = false; + for (key, value) in parsed.query_pairs() { + if key == "xt" || key == "dn" { + retained_info_hash |= key == "xt"; + serializer.append_pair(&key, &value); + } else { + removed_query_context = true; + } + } + let removed_fragment = parsed.fragment().is_some(); + let safe_query = serializer.finish(); let _ = parsed.set_username(""); let _ = parsed.set_password(None); - parsed.set_query(None); + parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str())); parsed.set_fragment(None); object.insert("url".to_string(), Value::String(parsed.to_string())); - - // A queued transfer whose URL depended on query/fragment - // credentials must not silently auto-resume with a truncated - // URL after a portable restart. - if had_userinfo || had_query_or_fragment { + if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash { mark_portable_download_unresumable(object); } + } else { + let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some(); + if had_userinfo || had_query_or_fragment { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + object.insert("url".to_string(), Value::String(parsed.to_string())); + + // A queued transfer whose URL depended on query/fragment + // credentials must not silently auto-resume with a truncated + // URL after a portable restart. + if had_userinfo || had_query_or_fragment { + mark_portable_download_unresumable(object); + } + } } } else { object.insert("url".to_string(), Value::String(String::new())); @@ -1117,43 +1158,91 @@ fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { .ok_or_else(|| format!("persisted item is missing '{key}'")) } -pub fn load_ownership(connection: &Connection) -> Result, String> { +pub fn load_ownership(connection: &Connection) -> Result)>, String> { let mut statement = connection - .prepare("SELECT id, primary_path FROM download_ownership") + .prepare( + "SELECT ownership.id, ownership.primary_path, paths.paths + FROM download_ownership AS ownership + LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id", + ) .map_err(|error| format!("failed to prepare ownership query: {error}"))?; let rows = statement - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .query_map([], |row| { + let primary_path: String = row.get(1)?; + let owned_paths = row + .get::<_, Option>(2)? + .and_then(|paths| serde_json::from_str::>(&paths).ok()) + .filter(|paths| !paths.is_empty()) + .unwrap_or_else(|| vec![primary_path.clone()]); + Ok((row.get(0)?, primary_path, owned_paths)) + }) .map_err(|error| format!("failed to query ownership data: {error}"))?; rows.collect::, _>>() .map_err(|error| format!("failed to read ownership data: {error}")) } -pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> { - let existing_owner = connection - .query_row( - "SELECT id FROM download_ownership - WHERE primary_path = ?1 AND id <> ?2 - LIMIT 1", - params![path, id], - |row| row.get::<_, String>(0), +pub fn set_ownership_paths( + connection: &Connection, + id: &str, + primary_path: &str, + paths: &[String], +) -> Result<(), String> { + let mut statement = connection + .prepare( + "SELECT ownership.id, ownership.primary_path, paths.paths + FROM download_ownership AS ownership + LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id + WHERE ownership.id <> ?1", ) - .optional() - .map_err(|error| format!("failed to check download ownership path: {error}"))?; - if existing_owner.is_some() { - return Err("Download destination is already owned by another Firelink download".to_string()); + .map_err(|error| format!("failed to prepare download ownership check: {error}"))?; + let existing = statement + .query_map(params![id], |row| { + let primary: String = row.get(1)?; + let owned = row + .get::<_, Option>(2)? + .and_then(|value| serde_json::from_str::>(&value).ok()) + .unwrap_or_else(|| vec![primary.clone()]); + Ok((primary, owned)) + }) + .map_err(|error| format!("failed to check download ownership paths: {error}"))?; + for row in existing { + let (existing_primary, owned) = + row.map_err(|error| format!("failed to read download ownership paths: {error}"))?; + let new_paths = std::iter::once(primary_path).chain(paths.iter().map(String::as_str)); + let existing_paths = std::iter::once(existing_primary.as_str()) + .chain(owned.iter().map(String::as_str)); + if new_paths.clone().any(|new_path| { + existing_paths + .clone() + .any(|existing_path| crate::platform::paths_equal(Path::new(new_path), Path::new(existing_path))) + }) { + return Err("Download destination is already owned by another Firelink download".to_string()); + } } connection .execute( "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path", - params![id, path], + params![id, primary_path], ) .map_err(|error| format!("failed to save ownership data: {error}"))?; + let encoded_paths = serde_json::to_string(paths) + .map_err(|error| format!("failed to encode download ownership paths: {error}"))?; + connection + .execute( + "INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2) + ON CONFLICT(id) DO UPDATE SET paths = excluded.paths", + params![id, encoded_paths], + ) + .map_err(|error| format!("failed to save download ownership path list: {error}"))?; Ok(()) } pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { + connection + .execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id]) + .map_err(|error| format!("failed to delete download ownership paths: {error}"))?; connection .execute("DELETE FROM download_ownership WHERE id = ?1", params![id]) .map_err(|error| format!("failed to delete ownership data: {error}"))?; @@ -1937,6 +2026,29 @@ mod tests { assert!(saved.get("headers").is_none()); } + #[test] + fn portable_magnet_persistence_keeps_identity_but_does_not_resume_after_tracker_removal() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "magnet-download", + "status": "queued", + "queueId": "main", + "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["url"], "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=Example+Torrent"); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + assert!(!saved.to_string().contains("tracker.invalid")); + assert!(!saved.to_string().contains("secret")); + } + #[test] fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() { let temp = TempDir::new().unwrap(); @@ -2142,15 +2254,62 @@ mod tests { let state = init_at_path(temp.path()).unwrap(); let connection = state.lock().unwrap(); - set_ownership(&connection, "first", "/downloads/file.bin").unwrap(); - let error = set_ownership(&connection, "second", "/downloads/file.bin") + set_ownership_paths( + &connection, + "first", + "/downloads/file.bin", + &["/downloads/file.bin".to_string()], + ) + .unwrap(); + let error = set_ownership_paths( + &connection, + "second", + "/downloads/file.bin", + &["/downloads/file.bin".to_string()], + ) .expect_err("a primary path must have one live owner"); assert!(error.contains("already owned")); - assert_eq!(load_ownership(&connection).unwrap(), vec![( - "first".to_string(), - "/downloads/file.bin".to_string() - )]); - set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap(); + assert_eq!( + load_ownership(&connection).unwrap(), + vec![( + "first".to_string(), + "/downloads/file.bin".to_string(), + vec!["/downloads/file.bin".to_string()] + )] + ); + set_ownership_paths( + &connection, + "first", + "/downloads/renamed.bin", + &["/downloads/renamed.bin".to_string()], + ) + .unwrap(); + } + + #[test] + fn rejects_overlap_with_any_owned_torrent_path() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + + set_ownership_paths( + &connection, + "first", + "/downloads/root", + &[ + "/downloads/root/a.bin".to_string(), + "/downloads/root/b.bin".to_string(), + ], + ) + .unwrap(); + let error = set_ownership_paths( + &connection, + "second", + "/downloads/root", + &["/downloads/root/c.bin".to_string()], + ) + .expect_err("a torrent root must not be reused"); + assert!(error.contains("already owned")); } } diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 80350e1..5fd3c55 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -7,6 +7,7 @@ use tauri::Manager; struct DownloadOwnershipRecord { id: String, primary_path: String, + owned_paths: Vec, } pub fn canonical_download_filename(filename: &str) -> String { @@ -124,6 +125,63 @@ pub fn set_primary_path( id: &str, path: &Path, ) -> Result<(), String> { + set_owned_paths(app_handle, id, &[path.to_path_buf()]) +} + +pub fn set_owned_paths( + app_handle: &tauri::AppHandle, + id: &str, + paths: &[PathBuf], +) -> Result<(), String> { + let primary = paths + .first() + .ok_or_else(|| "Download ownership requires at least one path".to_string())?; + set_owned_paths_with_primary(app_handle, id, primary, paths) +} + +pub fn set_owned_paths_with_primary( + app_handle: &tauri::AppHandle, + id: &str, + primary: &Path, + paths: &[PathBuf], +) -> Result<(), String> { + if paths.is_empty() { + return Err("Download ownership requires at least one path".to_string()); + } + + let canonical_primary = canonical_owned_path(app_handle, primary)?; + let mut canonical_paths = Vec::with_capacity(paths.len()); + for path in paths { + if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) { + return Err("Download ownership file path is a directory".to_string()); + } + let canonical_path = canonical_owned_path(app_handle, path)?; + if !canonical_paths + .iter() + .any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path)) + { + canonical_paths.push(canonical_path); + } + } + + let path_strings = canonical_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::set_ownership_paths( + &connection, + id, + &canonical_primary.to_string_lossy(), + &path_strings, + ) +} + +fn canonical_owned_path( + app_handle: &tauri::AppHandle, + path: &Path, +) -> Result { if !path.is_absolute() { return Err("Download ownership path must be absolute".to_string()); } @@ -140,10 +198,10 @@ pub fn set_primary_path( } let canonical_path = crate::canonicalize_with_missing_components(path) .ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?; - - let database = app_handle.state::(); - let connection = database.lock()?; - crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy()) + if !crate::is_safe_path(&canonical_path, app_handle) { + return Err("Download ownership path is outside an allowed download location".to_string()); + } + Ok(canonical_path) } pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { @@ -162,10 +220,25 @@ pub fn primary_path_for_id( .map(|record| PathBuf::from(record.primary_path))) } +pub fn owned_paths_for_id( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result, String> { + Ok(load_records(app_handle)? + .into_iter() + .find(|record| record.id == id) + .map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect()) + .unwrap_or_default()) +} + pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result, String> { let mut paths: Vec = load_records(app_handle)? .into_iter() - .map(|record| PathBuf::from(record.primary_path)) + .flat_map(|record| { + std::iter::once(PathBuf::from(record.primary_path)).chain( + record.owned_paths.into_iter().map(PathBuf::from), + ) + }) .collect(); // One-time compatibility for downloads created before the backend-owned @@ -185,7 +258,11 @@ fn load_records(app_handle: &tauri::AppHandle) -> Result, #[ts(optional)] pub last_try: Option, + #[serde(default)] + #[ts(optional)] + pub is_torrent: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_path: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_file_indices: Option>, + #[serde(default)] + #[ts(optional)] + pub torrent_info_hash: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentFile { + pub index: u32, + pub path: String, + #[ts(type = "number")] + pub length: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentMetadata { + pub name: String, + #[ts(type = "number")] + pub total_bytes: u64, + pub files: Vec, + pub info_hash: String, + #[serde(default)] + #[ts(optional)] + pub torrent_path: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c400c7e..10b96f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2918,6 +2918,7 @@ mod platform; pub mod queue; pub mod process; pub mod retry; +pub mod torrent; mod settings; mod storage; pub use error::AppError; @@ -3156,7 +3157,7 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { let Ok(url) = url::Url::parse(raw_url) else { continue; }; - if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { + if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") { continue; } let url = url.to_string(); @@ -4883,7 +4884,7 @@ async fn remove_download( ) -> Result<(), String> { log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); - let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?; + let mut owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?; let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; @@ -4896,6 +4897,19 @@ async fn remove_download( let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { + if let Err(error) = state + .queue_manager + .reconcile_aria2_torrent_ownership(&id, gid) + .await + { + log::debug!( + "aria2 torrent ownership [{}]: could not resolve files before removal of gid {}: {}", + id, + gid, + error + ); + } + owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?; // Keep the current epoch and mapping alive until daemon removal is // confirmed. If removal fails, terminal events from this lifecycle // must still be accepted so the permit and mapping can be cleaned up. @@ -4959,16 +4973,15 @@ async fn remove_download( ); let preserve_assets = preserve_resumable - && primary_path - .as_deref() - .is_some_and(has_resumable_download_assets); + && owned_paths.iter().any(|path| has_resumable_download_assets(path)); let cleanup_result = async { if delete_assets && !preserve_assets { - if let Some(path) = primary_path.as_deref() { + for path in &owned_paths { remove_download_assets(path, &app_handle).await?; } } + crate::torrent::remove_managed_torrent(&app_handle, &id).await; crate::download_ownership::remove(&app_handle, &id)?; Ok::<(), String>(()) } @@ -4978,6 +4991,15 @@ async fn remove_download( cleanup_result } +#[tauri::command] +fn get_download_primary_path( + app_handle: tauri::AppHandle, + id: String, +) -> Result, String> { + crate::download_ownership::primary_path_for_id(&app_handle, &id) + .map(|path| path.map(|path| path.to_string_lossy().to_string())) +} + fn has_resumable_download_assets(primary: &std::path::Path) -> bool { [".aria2", ".part", ".ytdl"].iter().any(|suffix| { let mut candidate = primary.as_os_str().to_os_string(); @@ -5510,15 +5532,122 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S Ok(()) } +async fn validate_torrent_enqueue( + app_handle: &tauri::AppHandle, + item: &queue::EnqueueItem, +) -> Result<(), String> { + if item.is_media.unwrap_or(false) { + return Err("torrent transfer cannot be a media download".to_string()); + } + validate_enqueue_uris("", item.mirrors.as_deref()).await?; + if let Some(path) = item.torrent_path.as_deref() { + let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; + let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + crate::torrent::validate_selected_indices( + item.torrent_file_indices.as_deref(), + metadata.files.len(), + )?; + return Ok(()); + } + + let parsed = url::Url::parse(&item.url).map_err(|_| "invalid magnet URI".to_string())?; + if parsed.scheme() != "magnet" { + return Err("torrent transfer has no magnet URI or cached metadata".to_string()); + } + if item.torrent_file_indices.is_some() { + return Err("magnet file selection requires resolved torrent metadata".to_string()); + } + crate::torrent::inspect_source(&item.url).map(|_| ()) +} + +fn expected_torrent_output_paths( + app_handle: &tauri::AppHandle, + item: &queue::EnqueueItem, +) -> Result>, String> { + if !item.is_torrent.unwrap_or(false) { + return Ok(None); + } + let Some(torrent_path) = item.torrent_path.as_deref() else { + return Ok(None); + }; + let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, torrent_path)?; + let bytes = std::fs::read(torrent_path) + .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; + let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + let selected = crate::torrent::validate_selected_indices( + item.torrent_file_indices.as_deref(), + metadata.files.len(), + )?; + let destination = crate::resolve_path(&item.destination, app_handle); + if !crate::is_safe_path(&destination, app_handle) { + return Err("Path traversal blocked".to_string()); + } + let canonical_destination = crate::canonicalize_with_missing_components(&destination) + .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; + let mut paths = Vec::new(); + for relative in crate::torrent::aria2_output_paths(&metadata, selected.as_deref()) { + let relative = std::path::PathBuf::from(relative); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err("torrent output path is unsafe".to_string()); + } + let path = destination.join(relative); + let canonical_path = crate::canonicalize_with_missing_components(&path) + .ok_or_else(|| "torrent output path could not be canonicalized".to_string())?; + if !crate::platform::path_is_within(&canonical_path, &canonical_destination) { + return Err("torrent output path is outside its destination".to_string()); + } + paths.push(canonical_path); + } + Ok(Some(paths)) +} + +#[tauri::command] +async fn inspect_torrent( + app_handle: tauri::AppHandle, + source: String, + id: String, + cache: Option, +) -> Result { + if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { + return crate::torrent::inspect_source(&source) + .map(|parsed| crate::torrent::to_metadata(parsed, None)) + .map_err(AppError::Internal); + } + if cache == Some(false) { + return crate::torrent::inspect_source(&source) + .map(|parsed| crate::torrent::to_metadata(parsed, None)) + .map_err(AppError::Internal); + } + let (parsed, path) = crate::torrent::prepare_local_torrent(&app_handle, &source, &id) + .await + .map_err(AppError::Internal)?; + Ok(crate::torrent::to_metadata(parsed, Some(path))) +} + #[tauri::command] async fn enqueue_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, mut item: queue::EnqueueItem, ) -> Result { - validate_enqueue_uris(&item.url, item.mirrors.as_deref()) - .await - .map_err(AppError::Internal)?; + if item.is_torrent.unwrap_or(false) { + validate_torrent_enqueue(&app_handle, &item) + .await + .map_err(AppError::Internal)?; + } else { + validate_enqueue_uris(&item.url, item.mirrors.as_deref()) + .await + .map_err(AppError::Internal)?; + } let id = item.id.clone(); item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let accepted_filename = item.filename.clone(); @@ -5540,6 +5669,47 @@ async fn enqueue_download( .await; return Err(AppError::Internal(error)); } + match expected_torrent_output_paths(&app_handle, &item) { + Ok(Some(paths)) => { + let primary = match crate::download_ownership::expected_primary_path( + &app_handle, + &item.destination, + &item.filename, + ) { + Ok(primary) => primary, + Err(error) => { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + }; + if let Err(error) = crate::download_ownership::set_owned_paths_with_primary( + &app_handle, + &item.id, + &primary, + &paths, + ) { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + } + Ok(None) => {} + Err(error) => { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) @@ -5583,7 +5753,12 @@ async fn enqueue_many( let mut results = Vec::with_capacity(items.len()); for mut item in items { let id = item.id.clone(); - if let Err(error) = validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await { + let validation = if item.is_torrent.unwrap_or(false) { + validate_torrent_enqueue(&app_handle, &item).await + } else { + validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await + }; + if let Err(error) = validation { results.push(crate::ipc::EnqueueResult { id, success: false, @@ -5640,6 +5815,65 @@ async fn enqueue_many( }); continue; } + match expected_torrent_output_paths(&app_handle, &item) { + Ok(Some(paths)) => { + let primary = match crate::download_ownership::expected_primary_path( + &app_handle, + &item.destination, + &item.filename, + ) { + Ok(primary) => primary, + Err(error) => { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + }; + if let Err(error) = crate::download_ownership::set_owned_paths_with_primary( + &app_handle, + &item.id, + &primary, + &paths, + ) { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + } + Ok(None) => {} + Err(error) => { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) @@ -9979,7 +10213,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, - pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, + pause_download, resume_download, fetch_metadata, inspect_torrent, fetch_media_metadata, fetch_media_playlist_metadata, begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, @@ -9990,7 +10224,7 @@ pub fn run() { authorize_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, - get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download, + get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download, get_download_primary_path, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 3d983ef..3f9ea9a 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -123,12 +123,12 @@ pub fn path_is_within(path: &Path, root: &Path) -> bool { } pub fn paths_equal(left: &Path, right: &Path) -> bool { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] { left.to_string_lossy() .eq_ignore_ascii_case(&right.to_string_lossy()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { left == right } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 480821c..ff4fa64 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1,3 +1,4 @@ +use base64::Engine as _; use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection}; use crate::power::PowerManager; use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES}; @@ -201,6 +202,9 @@ pub struct SpawnPayload { pub format_selector: Option, pub cookie_source: Option, pub is_media: bool, + pub is_torrent: bool, + pub torrent_path: Option, + pub torrent_file_indices: Option>, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -1594,6 +1598,16 @@ impl QueueManager { /// and lets commands reconcile an Aria2 terminal status without releasing /// the lock first. pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) { + if let Some(gid) = self.aria2_gid_for_download(id) { + if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await { + log::debug!( + "aria2 torrent ownership [{}]: could not resolve files for gid {}: {}", + id, + gid, + error + ); + } + } // A terminal event invalidates every delayed retry or control worker // from the previous lifecycle before releasing its permit. self.next_aria2_control_epoch(id).await; @@ -1609,11 +1623,11 @@ impl QueueManager { PendingOutcome::Error(error) => { if error.to_ascii_lowercase().contains("checksum") { log::warn!("Checksum error detected for {}, cleaning up assets", id); - if let Ok(primary_path) = - crate::download_ownership::primary_path_for_id(&self.app_handle, id) + if let Ok(paths) = + crate::download_ownership::owned_paths_for_id(&self.app_handle, id) { - if let Some(path) = primary_path.as_deref() { - let _ = crate::remove_download_assets(path, &self.app_handle).await; + for path in paths { + let _ = crate::remove_download_assets(&path, &self.app_handle).await; } } } @@ -1741,6 +1755,92 @@ impl QueueManager { .find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone())) } + /// Refresh ownership from Aria2's resolved file list before a torrent + /// lifecycle is forgotten. Magnet metadata is not available when the row + /// is enqueued, so the user-provided display name is not a safe ownership + /// path until Aria2 reports the actual files. + pub async fn reconcile_aria2_torrent_ownership( + &self, + id: &str, + gid: &str, + ) -> Result<(), String> { + let payload = self.aria2_payloads.lock().await.get(id).cloned(); + let Some(payload) = payload.filter(|payload| payload.is_torrent) else { + return Ok(()); + }; + let mapping = self + .aria2_gid_mapping(gid) + .filter(|mapping| mapping.id == id) + .ok_or_else(|| "aria2 torrent ownership has no current gid mapping".to_string())?; + let state = self.app_handle.state::(); + let result = crate::rpc_call( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, + "aria2.getFiles", + serde_json::json!([gid]), + ) + .await?; + if !self.is_current_aria2_gid_mapping(gid, &mapping) + || !self + .is_aria2_control_epoch_current(id, mapping.epoch) + .await + { + return Err("aria2 torrent lifecycle changed while resolving output paths".to_string()); + } + + let paths = result + .as_array() + .into_iter() + .flatten() + .filter(|file| file.get("selected").and_then(|value| value.as_str()) != Some("false")) + .filter_map(|file| file.get("path").and_then(|value| value.as_str())) + .map(std::path::PathBuf::from) + .collect::>(); + if paths.is_empty() { + return Err("aria2 returned no selected torrent output paths".to_string()); + } + let destination = crate::resolve_path(&payload.destination, &self.app_handle); + let canonical_destination = crate::canonicalize_with_missing_components(&destination) + .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; + if paths.iter().any(|path| { + crate::canonicalize_with_missing_components(path) + .is_none_or(|path| !crate::platform::path_is_within(&path, &canonical_destination)) + }) { + return Err("aria2 returned a torrent output path outside its destination".to_string()); + } + let primary = if paths.len() == 1 { + paths[0].clone() + } else { + let canonical_paths = paths + .iter() + .filter_map(|path| crate::canonicalize_with_missing_components(path)) + .collect::>(); + let mut common = canonical_paths + .first() + .and_then(|path| path.parent()) + .map(std::path::Path::to_path_buf) + .ok_or_else(|| "torrent output path has no parent".to_string())?; + for path in canonical_paths.iter().skip(1) { + while !crate::platform::path_is_within(path, &common) { + let Some(parent) = common.parent() else { + return Err("torrent output paths have no common directory".to_string()); + }; + if parent == common { + return Err("torrent output paths have no common directory".to_string()); + } + common = parent.to_path_buf(); + } + } + common + }; + crate::download_ownership::set_owned_paths_with_primary( + &self.app_handle, + id, + &primary, + &paths, + ) + } + /// Capture the GID ownership token used by the poller. The mapping's /// epoch must still be current when an asynchronous status snapshot is /// emitted; otherwise a late response from an older GID can be attributed @@ -2666,9 +2766,10 @@ impl ProductionSpawner { Self { app_handle } } - async fn add_uri_rpc( + async fn add_transfer_rpc( &self, state: &crate::AppState, + method: &str, params: &serde_json::Value, ) -> Result { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); @@ -2676,7 +2777,7 @@ impl ProductionSpawner { match crate::rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, - "aria2.addUri", + method, params.clone(), ) .await @@ -2714,7 +2815,9 @@ impl SidecarSpawner for ProductionSpawner { ); let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename); - options.insert("out".to_string(), serde_json::json!(safe_filename)); + if !payload.is_torrent { + options.insert("out".to_string(), serde_json::json!(safe_filename)); + } let conn = effective_aria2_connections(id, payload).await; apply_aria2_connection_options(&mut options, conn); let mt = aria2_attempt_limit(payload.max_tries); @@ -2766,23 +2869,73 @@ impl SidecarSpawner for ProductionSpawner { if let Some(prox) = proxy_value { options.insert("all-proxy".to_string(), serde_json::json!(prox)); } - let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); - let params = serde_json::json!([uris, options]); - match self.add_uri_rpc(&state, ¶ms).await { + let (method, params) = if payload.is_torrent { + if let Some(path) = payload.torrent_path.as_deref() { + let path = crate::torrent::validate_managed_torrent_path( + &self.app_handle, + id, + path, + )?; + let bytes = tokio::fs::read(&path) + .await + .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; + let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + options.insert( + "index-out".to_string(), + serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)), + ); + let selected = crate::torrent::validate_selected_indices( + payload.torrent_file_indices.as_deref(), + metadata.files.len(), + )?; + if let Some(indices) = selected { + options.insert( + "select-file".to_string(), + serde_json::json!(indices.iter().map(u32::to_string).collect::>().join(",")), + ); + } + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let uris = payload + .mirrors + .as_deref() + .map(|mirrors| crate::collect_download_uris("", Some(mirrors))) + .unwrap_or_default(); + ("aria2.addTorrent", serde_json::json!([encoded, uris, options])) + } else { + let parsed = url::Url::parse(&payload.url) + .map_err(|_| "invalid magnet URI".to_string())?; + if parsed.scheme() != "magnet" { + return Err("torrent transfer has no magnet URI or cached metadata".to_string()); + } + let selected = crate::torrent::validate_selected_indices( + payload.torrent_file_indices.as_deref(), + usize::MAX, + )?; + if selected.is_some() { + return Err("magnet file selection requires resolved torrent metadata".to_string()); + } + ("aria2.addUri", serde_json::json!([[payload.url.clone()], options])) + } + } else { + let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); + ("aria2.addUri", serde_json::json!([uris, options])) + }; + + match self.add_transfer_rpc(&state, method, ¶ms).await { Ok(result) => { let gid = result.as_str().unwrap_or("").to_string(); if gid.is_empty() { - Err("aria2.addUri returned an empty gid".to_string()) + Err(format!("{method} returned an empty gid")) } else { - log::info!("aria2 addUri [{}]: created gid {}", id, gid); + log::info!("aria2 {} [{}]: created gid {}", method, id, gid); Ok(gid) } } Err(e) => { let safe_error = crate::redact_sensitive_text(&e); - log::error!("aria2 addUri [{}] failed: {}", id, safe_error); - Err(format!("aria2 addUri failed: {safe_error}")) + log::error!("aria2 {} [{}] failed: {}", method, id, safe_error); + Err(format!("aria2 {method} failed: {safe_error}")) } } } @@ -3108,6 +3261,18 @@ pub struct EnqueueItem { pub is_media: Option, #[serde(default)] #[ts(optional)] + pub is_torrent: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_path: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_file_indices: Option>, + #[serde(default)] + #[ts(optional)] + pub torrent_info_hash: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -3147,6 +3312,9 @@ impl EnqueueItem { format_selector: self.format_selector, cookie_source: self.cookie_source, is_media: media, + is_torrent: self.is_torrent.unwrap_or(false), + torrent_path: self.torrent_path, + torrent_file_indices: self.torrent_file_indices, }, } } diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs new file mode 100644 index 0000000..6833658 --- /dev/null +++ b/src-tauri/src/torrent.rs @@ -0,0 +1,581 @@ +use sha1::{Digest, Sha1}; +use std::collections::{BTreeMap, HashSet}; +use std::path::PathBuf; + +use tauri::Manager; + +use crate::ipc::{TorrentFile, TorrentMetadata}; + +pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct ParsedTorrent { + pub name: String, + pub total_bytes: u64, + pub files: Vec, + pub info_hash: String, +} + +#[derive(Debug, Clone)] +enum BencodeValue { + Integer(i64), + Bytes(Vec), + List(Vec), + Dict(BTreeMap, BencodeValue>), +} + +struct Parser<'a> { + input: &'a [u8], + position: usize, + depth: usize, +} + +impl<'a> Parser<'a> { + fn new(input: &'a [u8]) -> Self { + Self { input, position: 0, depth: 0 } + } + + fn parse(mut self) -> Result { + let value = self.parse_value()?; + if self.position != self.input.len() { + return Err("torrent metadata has trailing data".to_string()); + } + Ok(value) + } + + fn parse_value(&mut self) -> Result { + if self.depth >= 128 { + return Err("torrent metadata nesting is too deep".to_string()); + } + let byte = *self + .input + .get(self.position) + .ok_or_else(|| "torrent metadata is truncated".to_string())?; + match byte { + b'i' => self.parse_integer(), + b'l' => self.parse_list(), + b'd' => self.parse_dict(), + b'0'..=b'9' => self.parse_bytes(), + _ => Err("torrent metadata contains an invalid bencode value".to_string()), + } + } + + fn parse_integer(&mut self) -> Result { + self.position += 1; + let start = self.position; + while let Some(byte) = self.input.get(self.position) { + if *byte == b'e' { + break; + } + self.position += 1; + } + if self.input.get(self.position) != Some(&b'e') { + return Err("torrent integer is truncated".to_string()); + } + let raw = std::str::from_utf8(&self.input[start..self.position]) + .map_err(|_| "torrent integer is not valid UTF-8".to_string())?; + if raw.is_empty() || (raw.starts_with('0') && raw.len() > 1) || raw == "-0" { + return Err("torrent integer has invalid encoding".to_string()); + } + let value = raw + .parse::() + .map_err(|_| "torrent integer is outside the supported range".to_string())?; + self.position += 1; + Ok(BencodeValue::Integer(value)) + } + + fn parse_bytes(&mut self) -> Result { + let start = self.position; + while let Some(byte) = self.input.get(self.position) { + if *byte == b':' { + break; + } + if !byte.is_ascii_digit() { + return Err("torrent byte string has an invalid length".to_string()); + } + self.position += 1; + } + if self.input.get(self.position) != Some(&b':') { + return Err("torrent byte string is truncated".to_string()); + } + let length = std::str::from_utf8(&self.input[start..self.position]) + .map_err(|_| "torrent byte string length is invalid".to_string())? + .to_owned(); + if (length.starts_with('0') && length.len() > 1) || length.is_empty() { + return Err("torrent byte string has invalid length encoding".to_string()); + } + let length = length + .parse::() + .map_err(|_| "torrent byte string length is too large".to_string())?; + self.position += 1; + let end = self + .position + .checked_add(length) + .ok_or_else(|| "torrent byte string length overflowed".to_string())?; + if end > self.input.len() { + return Err("torrent byte string is truncated".to_string()); + } + let value = self.input[self.position..end].to_vec(); + self.position = end; + Ok(BencodeValue::Bytes(value)) + } + + fn parse_list(&mut self) -> Result { + self.position += 1; + self.depth += 1; + let mut values = Vec::new(); + while self.input.get(self.position) != Some(&b'e') { + if self.input.get(self.position).is_none() { + self.depth -= 1; + return Err("torrent list is truncated".to_string()); + } + values.push(self.parse_value()?); + } + self.position += 1; + self.depth -= 1; + Ok(BencodeValue::List(values)) + } + + fn parse_dict(&mut self) -> Result { + self.position += 1; + self.depth += 1; + let mut values = BTreeMap::new(); + let mut previous_key: Option> = None; + while self.input.get(self.position) != Some(&b'e') { + if self.input.get(self.position).is_none() { + self.depth -= 1; + return Err("torrent dictionary is truncated".to_string()); + } + let key = match self.parse_bytes()? { + BencodeValue::Bytes(key) => key, + _ => unreachable!(), + }; + if previous_key.as_ref().is_some_and(|previous| previous >= &key) { + self.depth -= 1; + return Err("torrent dictionary keys are not sorted".to_string()); + } + previous_key = Some(key.clone()); + values.insert(key, self.parse_value()?); + } + self.position += 1; + self.depth -= 1; + Ok(BencodeValue::Dict(values)) + } +} + +fn encode(value: &BencodeValue, output: &mut Vec) { + match value { + BencodeValue::Integer(value) => output.extend_from_slice(format!("i{value}e").as_bytes()), + BencodeValue::Bytes(value) => { + output.extend_from_slice(value.len().to_string().as_bytes()); + output.push(b':'); + output.extend_from_slice(value); + } + BencodeValue::List(values) => { + output.push(b'l'); + for value in values { + encode(value, output); + } + output.push(b'e'); + } + BencodeValue::Dict(values) => { + output.push(b'd'); + for (key, value) in values { + encode(&BencodeValue::Bytes(key.clone()), output); + encode(value, output); + } + output.push(b'e'); + } + } +} + +fn bytes_text(value: Option<&BencodeValue>, field: &str) -> Result { + let bytes = match value { + Some(BencodeValue::Bytes(bytes)) => bytes, + _ => return Err(format!("torrent metadata is missing {field}")), + }; + String::from_utf8(bytes.clone()).map_err(|_| format!("torrent {field} is not valid UTF-8")) +} + +fn positive_length(value: Option<&BencodeValue>, field: &str) -> Result { + match value { + Some(BencodeValue::Integer(value)) if *value >= 0 => Ok(*value as u64), + _ => Err(format!("torrent {field} is invalid")), + } +} + +fn safe_path_component(value: &str, field: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) { + return Err(format!("torrent {field} contains an unsafe path")); + } + Ok(crate::download_ownership::canonical_download_filename(value)) +} + +fn parse_info(info: &BencodeValue) -> Result { + let info_dict = match info { + BencodeValue::Dict(value) => value, + _ => return Err("torrent info dictionary is invalid".to_string()), + }; + let name = info_dict + .get(b"name.utf-8".as_slice()) + .or_else(|| info_dict.get(b"name".as_slice())) + .map(|value| bytes_text(Some(value), "name")) + .transpose()? + .ok_or_else(|| "torrent metadata is missing name".to_string())?; + let name = crate::download_ownership::canonical_download_filename(&safe_path_component(&name, "name")?); + + let mut files = Vec::new(); + let total_bytes = if let Some(files_value) = info_dict.get(b"files".as_slice()) { + let entries = match files_value { + BencodeValue::List(entries) => entries, + _ => return Err("torrent files field is invalid".to_string()), + }; + let mut total = 0u64; + let mut paths = HashSet::new(); + for (position, entry) in entries.iter().enumerate() { + let entry = match entry { + BencodeValue::Dict(value) => value, + _ => return Err(format!("torrent file entry {} is invalid", position + 1)), + }; + let path_value = entry + .get(b"path.utf-8".as_slice()) + .or_else(|| entry.get(b"path".as_slice())) + .ok_or_else(|| format!("torrent file entry {} has no path", position + 1))?; + let path_parts = match path_value { + BencodeValue::List(parts) => parts + .iter() + .map(|part| bytes_text(Some(part), "file path")) + .collect::, _>>()?, + _ => return Err(format!("torrent file entry {} path is invalid", position + 1)), + }; + if path_parts.is_empty() { + return Err(format!("torrent file entry {} path is empty", position + 1)); + } + let path = path_parts + .iter() + .map(|part| safe_path_component(part, "file path")) + .collect::, _>>()? + .join("/"); + if !paths.insert(path.clone()) { + return Err("torrent contains duplicate output paths".to_string()); + } + let length = positive_length(entry.get(b"length".as_slice()), "file length")?; + total = total + .checked_add(length) + .ok_or_else(|| "torrent total size is too large".to_string())?; + files.push(TorrentFile { + index: (position + 1) as u32, + path, + length, + }); + } + if files.is_empty() { + return Err("torrent has no files".to_string()); + } + total + } else { + let length = positive_length(info_dict.get(b"length".as_slice()), "length")?; + files.push(TorrentFile { + index: 1, + path: name.clone(), + length, + }); + length + }; + + let mut encoded = Vec::new(); + encode(info, &mut encoded); + let digest = Sha1::digest(encoded); + let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + + Ok(ParsedTorrent { name, total_bytes, files, info_hash }) +} + +pub fn parse_torrent_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { + return Err(format!("torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes")); + } + let root = Parser::new(bytes).parse()?; + let root = match root { + BencodeValue::Dict(value) => value, + _ => return Err("torrent root is not a dictionary".to_string()), + }; + let info = root + .get(b"info".as_slice()) + .ok_or_else(|| "torrent metadata is missing info".to_string())?; + parse_info(info) +} + +fn magnet_metadata(source: &str) -> Result { + let parsed = url::Url::parse(source).map_err(|_| "invalid magnet URI".to_string())?; + if parsed.scheme() != "magnet" { + return Err("unsupported torrent source".to_string()); + } + let info_hash = parsed + .query_pairs() + .find_map(|(key, value)| { + if key != "xt" { + return None; + } + let value = value.strip_prefix("urn:btih:")?; + let normalized = value.trim().to_ascii_lowercase(); + if (normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit())) + || (normalized.len() == 32 + && normalized + .bytes() + .all(|byte| byte.is_ascii_lowercase() || matches!(byte, b'2'..=b'7'))) + { + Some(normalized) + } else { + None + } + }) + .ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?; + let name = parsed + .query_pairs() + .find_map(|(key, value)| (key == "dn").then_some(value.into_owned())) + .filter(|value| !value.trim().is_empty()) + .map(|value| crate::download_ownership::canonical_download_filename(&value)) + .unwrap_or_else(|| format!("torrent-{info_hash}")); + Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash }) +} + +fn local_torrent_path(source: &str) -> Result { + let path = match url::Url::parse(source) { + Ok(parsed) if parsed.scheme() == "file" => parsed + .to_file_path() + .map_err(|_| "invalid local torrent path".to_string())?, + _ => PathBuf::from(source), + }; + if !path.is_absolute() { + return Err("torrent source is not an absolute local file".to_string()); + } + let path = std::fs::canonicalize(&path) + .map_err(|error| format!("could not resolve torrent file: {error}"))?; + if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("torrent")) != Some(true) { + return Err("torrent files must use the .torrent extension".to_string()); + } + Ok(path) +} + +pub fn inspect_source(source: &str) -> Result { + if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { + return magnet_metadata(source.trim()); + } + let path = local_torrent_path(source)?; + let bytes = std::fs::read(&path).map_err(|error| format!("could not read torrent file: {error}"))?; + parse_torrent_bytes(&bytes) +} + +pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option) -> TorrentMetadata { + TorrentMetadata { + name: parsed.name, + total_bytes: parsed.total_bytes, + files: parsed.files, + info_hash: parsed.info_hash, + torrent_path, + } +} + +/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep +/// these values derived from the validated, canonical paths so the daemon's +/// actual files stay aligned with Firelink's ownership registry. +pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec { + parsed + .files + .iter() + .map(|file| { + let output = if parsed.files.len() == 1 { + file.path.clone() + } else { + format!("{}/{}", parsed.name, file.path) + }; + format!("{}={output}", file.index) + }) + .collect() +} + +pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec { + parsed + .files + .iter() + .filter(|file| selected.is_none_or(|indices| indices.contains(&file.index))) + .map(|file| { + if parsed.files.len() == 1 { + file.path.clone() + } else { + format!("{}/{}", parsed.name, file.path) + } + }) + .collect() +} + +pub fn managed_torrent_path( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result { + if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { + return Err("invalid torrent download id".to_string()); + } + let root = app_handle + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve torrent storage: {error}"))? + .join("torrents"); + Ok(root.join(format!("{id}.torrent"))) +} + +pub fn validate_selected_indices( + selected: Option<&[u32]>, + file_count: usize, +) -> Result>, String> { + let Some(selected) = selected else { + return Ok(None); + }; + if selected.is_empty() || selected.iter().any(|index| *index == 0 || *index as usize > file_count) { + return Err("torrent file selection is invalid".to_string()); + } + let mut normalized = selected.to_vec(); + normalized.sort_unstable(); + normalized.dedup(); + Ok(Some(normalized)) +} + +pub async fn prepare_local_torrent( + app_handle: &tauri::AppHandle, + source: &str, + id: &str, +) -> Result<(ParsedTorrent, String), String> { + let source_path = local_torrent_path(source)?; + let file_metadata = tokio::fs::metadata(&source_path) + .await + .map_err(|error| format!("could not inspect torrent file: {error}"))?; + if file_metadata.len() == 0 || file_metadata.len() > MAX_TORRENT_BYTES as u64 { + return Err(format!( + "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" + )); + } + let bytes = tokio::fs::read(&source_path) + .await + .map_err(|error| format!("could not read torrent file: {error}"))?; + let parsed = parse_torrent_bytes(&bytes)?; + let destination = managed_torrent_path(app_handle, id)?; + if let Some(parent) = destination.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("could not create torrent storage: {error}"))?; + } + tokio::fs::write(&destination, &bytes) + .await + .map_err(|error| format!("could not cache torrent metadata: {error}"))?; + Ok((parsed, destination.to_string_lossy().to_string())) +} + +pub fn validate_managed_torrent_path( + app_handle: &tauri::AppHandle, + id: &str, + path: &str, +) -> Result { + let expected = managed_torrent_path(app_handle, id)?; + let candidate = std::fs::canonicalize(path) + .map_err(|error| format!("could not access cached torrent metadata: {error}"))?; + let expected_parent = expected + .parent() + .and_then(|parent| std::fs::canonicalize(parent).ok()) + .ok_or_else(|| "cached torrent storage is unavailable".to_string())?; + if candidate.parent() != Some(expected_parent.as_path()) || candidate.file_name() != expected.file_name() { + return Err("cached torrent metadata path is invalid".to_string()); + } + Ok(candidate) +} + +pub async fn remove_managed_torrent( + app_handle: &tauri::AppHandle, + id: &str, +) { + if let Ok(path) = managed_torrent_path(app_handle, id) { + let _ = tokio::fs::remove_file(path).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_single_file_torrent_and_hashes_info_dictionary() { + let parsed = parse_torrent_bytes(b"d4:infod6:lengthi5e4:name4:testee") + .expect("single-file torrent should parse"); + assert_eq!(parsed.name, "test"); + assert_eq!(parsed.total_bytes, 5); + assert_eq!(parsed.files[0].index, 1); + assert_eq!(parsed.files[0].path, "test"); + assert_eq!(parsed.info_hash.len(), 40); + } + + #[test] + fn parses_multi_file_torrent_and_rejects_traversal() { + let parsed = parse_torrent_bytes( + b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee", + ) + .expect("multi-file torrent should parse"); + assert_eq!(parsed.total_bytes, 5); + assert_eq!(parsed.files.len(), 2); + assert_eq!(parsed.files[1].path, "root/b.bin"); + + let error = parse_torrent_bytes( + b"d4:infod5:filesld6:lengthi1e4:pathl2:..4:evileee4:name4:rootee", + ) + .expect_err("path traversal must be rejected"); + assert!(error.contains("unsafe path")); + } + + #[test] + fn parses_magnet_identity_without_logging_or_persisting_tracker_data() { + let parsed = inspect_source( + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret", + ) + .expect("magnet should parse"); + assert_eq!(parsed.name, "Example Torrent"); + assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567"); + assert!(parsed.files.is_empty()); + } + + #[test] + fn rejects_malformed_base32_magnet_hashes() { + let error = inspect_source( + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcde!&dn=Invalid", + ) + .expect_err("a 32-character hash with non-base32 characters must be rejected"); + assert!(error.contains("valid BitTorrent info hash")); + } + + #[test] + fn normalizes_and_bounds_selected_file_indices() { + assert_eq!( + validate_selected_indices(Some(&[3, 1, 3]), 3).unwrap(), + Some(vec![1, 3]) + ); + assert!(validate_selected_indices(Some(&[0]), 3).is_err()); + assert!(validate_selected_indices(Some(&[4]), 3).is_err()); + } + + #[test] + fn maps_validated_torrent_files_to_aria2_index_outputs() { + let parsed = parse_torrent_bytes( + b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee", + ) + .expect("multi-file torrent should parse"); + assert_eq!( + aria2_index_outputs(&parsed), + vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()] + ); + } + + #[test] + fn rejects_noncanonical_lengths_and_invalid_files_field() { + assert!(parse_torrent_bytes(b"d4:infod6:lengthi5e4:name04:testee").is_err()); + assert!(parse_torrent_bytes(b"d4:infod5:filesi1e6:lengthi5e4:name4:testee").is_err()); + } +} diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 79992ed..233f8b7 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 77a1aff..7e5df74 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, lifecycle_generation?: string, }; diff --git a/src/bindings/TorrentFile.ts b/src/bindings/TorrentFile.ts new file mode 100644 index 0000000..b8abbc8 --- /dev/null +++ b/src/bindings/TorrentFile.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentFile = { index: number, path: string, length: number, }; diff --git a/src/bindings/TorrentMetadata.ts b/src/bindings/TorrentMetadata.ts new file mode 100644 index 0000000..fc6687e --- /dev/null +++ b/src/bindings/TorrentMetadata.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TorrentFile } from "./TorrentFile"; + +export type TorrentMetadata = { name: string, totalBytes: number, files: Array, infoHash: string, torrentPath?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index e332223..0378417 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -192,6 +192,28 @@ export const AddDownloadsModal = () => { const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); + const addTorrentFiles = async () => { + try { + const selected = await open({ + multiple: true, + directory: false, + title: 'Choose torrent files', + filters: [{ name: 'Torrent', extensions: ['torrent'] }] + }); + const paths = Array.isArray(selected) + ? selected + : selected + ? [selected] + : []; + if (paths.length === 0) return; + setUrls(current => [...current.split('\n').map(line => line.trim()).filter(Boolean), ...paths] + .filter((value, index, values) => values.indexOf(value) === index) + .join('\n')); + } catch (error) { + console.error('Failed to select torrent files:', error); + } + }; + const [useAuth, setUseAuth] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -493,10 +515,41 @@ export const AddDownloadsModal = () => { void (async () => { try { const settingsStore = useSettingsStore.getState(); - const proxy = await getProxyArgs(settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore); const contextUrl = requestContextUrlForRow(row); const requestContext = requestContextForUrl(contextUrl); + if (row.isTorrent) { + const torrentData = await invoke('inspect_torrent', { + source: row.sourceUrl, + id: row.id, + cache: false + }); + const totalBytes = torrentData.totalBytes || undefined; + setParsedItems(current => updateRowIfCurrent( + current, + row.id, + row.sourceUrl, + row.generation, + currentRow => ({ + ...currentRow, + downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:') + ? 'torrent:' + torrentData.infoHash + : row.sourceUrl, + file: canonicalizeDownloadFileName(torrentData.name), + size: totalBytes ? formatBytes(totalBytes) : undefined, + sizeBytes: totalBytes, + status: 'ready', + isTorrent: true, + torrentPath: torrentData.torrentPath, + torrentInfoHash: torrentData.infoHash, + torrentFiles: torrentData.files, + selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices + ?.filter(index => torrentData.files.some(file => file.index === index)) + }) + )); + return; + } + const proxy = await getProxyArgs(settingsStore); if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { settingsStore.setShowKeychainModal(true); return; @@ -1184,15 +1237,19 @@ export const AddDownloadsModal = () => { if (!existingItem) { throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile })); } - const incomingMediaFormat = mediaFormatSelectorForRow(item); - const mediaFormatChanged = item.isMedia - && existingItem.mediaFormatSelector !== incomingMediaFormat; - if (existingItem.status === 'completed' || mediaFormatChanged) { - // Completed replacements must remove the old file so the - // new transfer cannot be treated as an already-complete - // aria2 target. Unfinished rows use the in-place path to - // preserve their resumable assets and progress. - await store.removeDownload(existingItem.id, true, false); + 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, { @@ -1225,6 +1282,19 @@ export const AddDownloadsModal = () => { if (!item) continue; try { const id = crypto.randomUUID(); + let torrentPath = item.torrentPath; + if (item.isTorrent && !item.sourceUrl.trim().toLowerCase().startsWith('magnet:')) { + // Cached torrent metadata is deliberately keyed by the download + // identity. The metadata row ID is temporary, so re-key the + // cache after the final download ID is allocated (including + // replacement flows). + const torrentData = await invoke('inspect_torrent', { + source: item.sourceUrl, + id, + cache: true + }); + torrentPath = torrentData.torrentPath; + } let finalFile = item.isMedia ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); @@ -1260,6 +1330,10 @@ export const AddDownloadsModal = () => { resumable: item.resumable, mediaFormatSelector: formatSelector, mediaQuality: mediaQualityForRow(item), + isTorrent: item.isTorrent, + torrentPath, + torrentInfoHash: item.torrentInfoHash, + torrentFileIndices: item.selectedTorrentFileIndices, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -1369,6 +1443,23 @@ export const AddDownloadsModal = () => { )); }; + const toggleTorrentFile = (index: number) => { + if (selectedItemIndex === null) return; + setParsedItems(items => items.map((item, itemIndex) => { + if (itemIndex !== selectedItemIndex || !item.torrentFiles?.length) return item; + const allIndices = item.torrentFiles.map(file => file.index); + const selectedIndices = item.selectedTorrentFileIndices ?? allIndices; + if (selectedIndices.length === 1 && selectedIndices[0] === index) return item; + const next = selectedIndices.includes(index) + ? selectedIndices.filter(value => value !== index) + : [...selectedIndices, index].sort((left, right) => left - right); + return { + ...item, + selectedTorrentFileIndices: next.length === allIndices.length ? undefined : next + }; + })); + }; + const selectedItems = parsedItems.filter(item => item.selected !== false); const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex]; const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl; @@ -1635,6 +1726,15 @@ export const AddDownloadsModal = () => { value={urls} onChange={(e) => setUrls(e.target.value)} /> +
+ +
{playlistSummaries.map(([sourceUrl, playlist]) => { const total = playlist.entry_count || playlist.entries.length; return ( @@ -1766,6 +1866,46 @@ export const AddDownloadsModal = () => {
+ {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && ( +
+
+ {t($ => $.addDownloads.torrentFiles)} +
+ {parsedItems[selectedItemIndex].torrentFiles?.length ? ( +
$.addDownloads.torrentFiles)} + > + {parsedItems[selectedItemIndex].torrentFiles!.map(file => { + const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices; + const checked = !selectedIndices || selectedIndices.includes(file.index); + return ( + + ); + })} +
+ ) : ( +

+ {t($ => $.addDownloads.torrentMetadataPending)} +

+ )} +
+ )} + {/* Media Format (Dynamic) */} {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index e054da8..72d2a66 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -241,6 +241,11 @@ export const DownloadItem = React.memo(({ {mediaQualityLabel} ) : null} + {download.isTorrent ? ( + $.addDownloads.torrentFiles)}> + {t($ => $.addDownloads.torrentFiles)} + + ) : null}
), diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index fa35b90..1765ae9 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1365,6 +1365,14 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }, []); const getDownloadPath = useCallback(async (item: DownloadItem) => { + if (item.isTorrent) { + try { + const ownedPath = await invoke('get_download_primary_path', { id: item.id }); + if (ownedPath) return ownedPath; + } catch (error) { + console.error("Failed to resolve torrent output path:", error); + } + } const fileName = item.fileName?.trim(); if (!fileName) return null; const settings = useSettingsStore.getState(); @@ -1377,12 +1385,33 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC useDownloadStore.getState().setSelectedPropertiesDownloadId(id); }, []); + const revealDownloadFile = useCallback(async (item: DownloadItem) => { + const pathToReveal = await getDownloadPath(item); + + if (!pathToReveal) { + openProperties(item.id); + return; + } + + try { + await invoke('reveal_in_file_manager', { path: pathToReveal }); + } catch (error) { + console.error("Failed to show in Finder:", error); + showInteractionError(t($ => $.downloadTable.revealFileFailed), error); + } + }, [getDownloadPath, openProperties, showInteractionError]); + const openDownloadFile = useCallback(async (item: DownloadItem) => { if (item.status !== 'completed') { openProperties(item.id); return; } + if (item.isTorrent) { + await revealDownloadFile(item); + return; + } + const fullPath = await getDownloadPath(item); if (!fullPath) { openProperties(item.id); @@ -1395,23 +1424,7 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC console.error("Failed to open file:", error); showInteractionError(t($ => $.downloadTable.openFileFailed), error); } - }, [getDownloadPath, openProperties, showInteractionError]); - - const revealDownloadFile = async (item: DownloadItem) => { - const pathToReveal = await getDownloadPath(item); - - if (!pathToReveal) { - openProperties(item.id); - return; - } - - try { - await invoke('reveal_in_file_manager', { path: pathToReveal }); - } catch (error) { - console.error("Failed to show in Finder:", error); - showInteractionError(t($ => $.downloadTable.revealFileFailed), error); - } - }; + }, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]); const handleDownloadDoubleClick = useCallback((item: DownloadItem) => { if (item.status === 'completed') { diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index d0270f7..2da502e 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -446,7 +446,7 @@ const common = { pauseBeforeReplace: 'Pause {{file}} before replacing it.', cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.', downloadLinks: 'Download Links', - pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.', + pastePlaceholder: 'Paste HTTP, HTTPS, FTP, SFTP, or magnet URLs here... You can also choose .torrent files below.\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.', playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}', safeEntryLimit: ' (safe entry limit reached)', selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked', @@ -454,6 +454,9 @@ const common = { selectAll: 'Select all', refreshMetadata: 'Refresh Metadata', files: 'Files', + torrentFiles: 'Torrent files', + chooseTorrentFiles: 'Add .torrent files', + torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.', required: 'Required', free: 'Free', preview: 'Preview', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 205dee5..9647f5a 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -446,7 +446,7 @@ const fa = { pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.', cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.', downloadLinks: 'پیوندهای دانلود', - pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جای‌گذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.', + pastePlaceholder: 'URLهای HTTP، HTTPS، FTP، SFTP یا magnet را اینجا جای‌گذاری کنید… همچنین می‌توانید فایل‌های .torrent را از پایین انتخاب کنید.\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.', playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}', safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)', selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده', @@ -454,6 +454,9 @@ const fa = { selectAll: 'انتخاب همه', refreshMetadata: 'تازه‌سازی متادیتا', files: 'فایل‌ها', + torrentFiles: 'فایل‌های تورنت', + chooseTorrentFiles: 'افزودن فایل‌های .torrent', + torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت می‌کند.', required: 'الزامی', free: 'فضای آزاد', preview: 'پیش‌نمایش', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index b22e94b..ca6c8e2 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -446,7 +446,7 @@ const he = { pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.', cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.', downloadLinks: 'קישורי הורדה', - pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.', + pastePlaceholder: 'הדבק כאן כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069, \u2066SFTP\u2069 או magnet… אפשר גם לבחור קובצי ‎.torrent‎ למטה.\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.', playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}', safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)', selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים', @@ -454,6 +454,9 @@ const he = { selectAll: 'בחירת הכל', refreshMetadata: 'רענון מטא נתונים', files: 'קבצים', + torrentFiles: 'קובצי טורנט', + chooseTorrentFiles: 'הוספת קובצי ‎.torrent', + torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.', required: 'נדרש', free: 'פנוי', preview: 'תצוגה מקדימה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 5f3f2cc..e04c09a 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -446,7 +446,7 @@ const ru = { pauseBeforeReplace: 'Приостановите {{file}} перед заменой.', cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.', downloadLinks: 'Ссылки для скачивания', - pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.', + pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP, SFTP или magnet… Также можно выбрать файлы .torrent ниже.\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.', playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}', safeEntryLimit: ' (достигнут безопасный лимит элементов)', selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано', @@ -454,6 +454,9 @@ const ru = { selectAll: 'Выбрать все', refreshMetadata: 'Обновить метаданные', files: 'Файлы', + torrentFiles: 'Торрент-файлы', + chooseTorrentFiles: 'Добавить файлы .torrent', + torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.', required: 'Требуется', free: 'Свободно', preview: 'Предпросмотр', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 6b9bccc..d1a74a3 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -446,7 +446,7 @@ const uk = { pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.', cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.', downloadLinks: 'Посилання для завантаження', - pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.', + pastePlaceholder: 'Вставте сюди URL-адреси HTTP, HTTPS, FTP, SFTP або magnet… Також можна вибрати файли .torrent нижче.\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.', playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}', safeEntryLimit: ' (досягнуто безпечного ліміту елементів)', selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано', @@ -454,6 +454,9 @@ const uk = { selectAll: 'Вибрати всі', refreshMetadata: 'Оновити метадані', files: 'Файли', + torrentFiles: 'Торрент-файли', + chooseTorrentFiles: 'Додати файли .torrent', + torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.', required: 'Обов\'язково', free: 'Вільно', preview: 'Попередній перегляд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index db06465..368a20b 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -446,7 +446,7 @@ const zhCN = { pauseBeforeReplace: '请在替换 {{file}} 前暂停它。', cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。', downloadLinks: '下载链接', - pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。', + pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP、SFTP 或 magnet URL…也可以在下方选择 .torrent 文件。\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。', playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}', safeEntryLimit: ' (达到安全条目限制)', selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个', @@ -454,6 +454,9 @@ const zhCN = { selectAll: '全选', refreshMetadata: '刷新元数据', files: '文件', + torrentFiles: '种子文件', + chooseTorrentFiles: '添加 .torrent 文件', + torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。', required: '必需', free: '可用空间', preview: '预览', diff --git a/src/ipc.ts b/src/ipc.ts index 7d38e72..610016b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -18,6 +18,7 @@ import type { EnqueueItem } from './bindings/EnqueueItem'; import type { EnqueueAccepted } from './bindings/EnqueueAccepted'; import type { PlatformInfo } from './bindings/PlatformInfo'; import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig'; +import type { TorrentMetadata } from './bindings/TorrentMetadata'; type CommandMap = { fetch_metadata: { @@ -32,6 +33,10 @@ type CommandMap = { args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; result: MediaPlaylistMetadata; }; + inspect_torrent: { + args: { source: string; id: string; cache?: boolean }; + result: TorrentMetadata; + }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem }; get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem }; @@ -41,6 +46,7 @@ type CommandMap = { pause_download: { args: { id: string }; result: void }; resume_download: { args: { id: string; queueId: string }; result: boolean }; remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void }; + get_download_primary_path: { args: { id: string }; result: string | null }; detach_download_for_reconfigure: { args: { id: string }; result: void }; begin_dock_badge_session: { args: undefined; result: number }; update_dock_badge: { args: { count: number; generation: number; session: number }; result: void }; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 375ccc4..7cfffbf 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -341,6 +341,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): format_selector: item.mediaFormatSelector || null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, is_media: item.isMedia || false, + is_torrent: item.isTorrent || false, + torrent_path: item.torrentPath || undefined, + torrent_file_indices: item.torrentFileIndices || undefined, + torrent_info_hash: item.torrentInfoHash || undefined, lifecycle_generation: lifecycleGeneration.toString(), }; @@ -2040,6 +2044,10 @@ export const useDownloadStore = create((set, get) => { format_selector: item.mediaFormatSelector || null, cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, is_media: item.isMedia || false, + is_torrent: item.isTorrent || false, + torrent_path: item.torrentPath || undefined, + torrent_file_indices: item.torrentFileIndices || undefined, + torrent_info_hash: item.torrentInfoHash || undefined, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 1c53536..084a489 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -84,6 +84,26 @@ describe('add download metadata workflow', () => { expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false); }); + it('admits magnets and local torrent files through the Add window metadata path', () => { + const rows = reconcileDownloadRows( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example\nfile:///tmp/Example.torrent', + [] + ); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + isTorrent: true, + isMedia: false, + status: 'loading' + }); + expect(rows[1]).toMatchObject({ + isTorrent: true, + isMedia: false, + sourceUrl: 'file:///tmp/Example.torrent', + status: 'loading' + }); + }); + it('keeps a playlist as one loading row until discovery succeeds', () => { const rows = reconcileDownloadRows( 'https://www.youtube.com/playlist?list=PL123', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 0fdb05d..02901e0 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -4,6 +4,7 @@ import { isMediaUrl } from './downloads'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; +import type { TorrentFile } from '../bindings/TorrentFile'; import i18n from '../i18n'; import { localePluralVariant } from '../i18n/locales'; @@ -52,6 +53,11 @@ export interface AddDownloadDraftRow { playlistError?: string; metadataBlockedReason?: 'unsafe-url'; selected?: boolean; + isTorrent?: boolean; + torrentPath?: string; + torrentInfoHash?: string; + torrentFiles?: TorrentFile[]; + selectedTorrentFileIndices?: number[]; } /** @@ -61,12 +67,27 @@ export interface AddDownloadDraftRow { */ export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim(); -const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']); +const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:', 'magnet:']); + +const isLocalTorrentPath = (value: string): boolean => { + try { + const parsed = new URL(value); + if (parsed.protocol === 'file:') { + return parsed.pathname.toLowerCase().endsWith('.torrent'); + } + } catch { + // A native Windows path is not a URL, even though URL parsing may treat + // its drive letter as a scheme. + } + return value.toLowerCase().endsWith('.torrent') + && (value.startsWith('/') || /^[a-z]:[\\/]/i.test(value)); +}; type ParsedInput = { identity: string; sourceUrl: string; valid: boolean; + isTorrent?: boolean; isPlaylist?: boolean; playlistSourceUrl?: string; playlistTitle?: string; @@ -115,10 +136,18 @@ const parseInputLines = ( let sourceUrl = line; let valid = false; + let isTorrent = false; + if (isLocalTorrentPath(line)) { + valid = true; + isTorrent = true; + } try { - const url = new URL(line); - valid = ALLOWED_SCHEMES.has(url.protocol); - if (valid) sourceUrl = url.href; + if (!isTorrent) { + const url = new URL(line); + valid = ALLOWED_SCHEMES.has(url.protocol); + isTorrent = valid && url.protocol === 'magnet:'; + if (valid) sourceUrl = url.href; + } } catch { valid = false; } @@ -166,6 +195,7 @@ const parseInputLines = ( identity, sourceUrl, valid, + isTorrent, isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl), requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined, selected: selectedBySourceUrl[sourceUrl] !== false @@ -219,6 +249,7 @@ export const reconcileDownloadRows = ( generation: preserved.generation + 1, requestContextVersion, isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), + isTorrent: input.isTorrent, size: undefined, sizeBytes: undefined, resumable: undefined, @@ -235,7 +266,11 @@ export const reconcileDownloadRows = ( playlistCount: input.playlistCount, playlistEntryTitle: input.playlistEntryTitle, playlistError: undefined, - metadataBlockedReason: undefined + metadataBlockedReason: undefined, + torrentPath: input.isTorrent ? preserved.torrentPath : undefined, + torrentInfoHash: input.isTorrent ? preserved.torrentInfoHash : undefined, + torrentFiles: input.isTorrent ? preserved.torrentFiles : undefined, + selectedTorrentFileIndices: input.isTorrent ? preserved.selectedTorrentFileIndices : undefined }; } return preserved; @@ -263,6 +298,7 @@ export const reconcileDownloadRows = ( || forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl) ), + isTorrent: input.valid && Boolean(input.isTorrent), isPlaylist: input.isPlaylist, playlistSourceUrl: input.playlistSourceUrl, playlistTitle: input.playlistTitle, diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 50fa740..eda45ac 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -68,6 +68,7 @@ describe('download connection resolution', () => { describe('download filename matching', () => { it('matches frontend filenames to the backend Windows device-name canonicalization', () => { expect(canonicalizeDownloadFileName('CON.txt')).toBe('CON-.txt'); + expect(canonicalizeDownloadFileName('CON .txt')).toBe('CON -.txt'); expect(canonicalizeDownloadFileName('com1.archive.zip')).toBe('com1.archive-.zip'); expect(downloadFileNamesMatch('CON-.txt', 'CON.txt')).toBe(true); expect(downloadFileNamesMatch('console.txt', 'CON.txt')).toBe(false); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 5998b71..b5090f4 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -162,7 +162,7 @@ export const canonicalizeDownloadFileName = (fileName: string): string => { .trim() .replace(/[. ]+$/g, ''); let canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download'; - const reservedStem = canonical.split('.')[0]?.toUpperCase(); + const reservedStem = canonical.split('.')[0]?.trimEnd().toUpperCase(); if (reservedStem && WINDOWS_RESERVED_FILENAME_STEMS.has(reservedStem)) { const extensionStart = canonical.lastIndexOf('.'); const base = extensionStart > 0 ? canonical.slice(0, extensionStart) : canonical;