diff --git a/Extensions/Browser b/Extensions/Browser index 6f1b50b..64a8c3c 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 6f1b50bee4d9d74be70243c5175bddaec07c0733 +Subproject commit 64a8c3c5a2c35b6621ac4f8e10d8a915253af8c8 diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 5f3033c..a533ca7 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -36,7 +36,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof"; const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; -const PROTOCOL_VERSION: &str = "4"; +const PROTOCOL_VERSION: &str = "5"; const MAX_PENDING_EXTENSION_ACKS: usize = 64; const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); @@ -75,6 +75,8 @@ struct ExtensionRequest { #[serde(default)] media: bool, #[serde(default)] + torrent: bool, + #[serde(default)] batch: bool, #[serde(default)] batch_name: Option, @@ -100,6 +102,7 @@ pub struct ExtensionDownload { cookies: Option, cookie_scopes: Option>, media: bool, + torrent: bool, batch: bool, batch_name: Option, } @@ -428,6 +431,20 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option Option, media: bool) -> Option { fn normalize_url(raw_url: &str) -> Option { let url = Url::parse(raw_url.trim()).ok()?; - matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string()) + matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") + .then(|| url.to_string()) +} + +fn filename_is_torrent(filename: Option<&str>) -> bool { + filename + .and_then(|value| Path::new(value.trim()).file_name()) + .and_then(|value| value.to_str()) + .is_some_and(|value| value.to_ascii_lowercase().ends_with(".torrent")) } fn sanitize_filename(filename: &str) -> Option { @@ -752,7 +778,7 @@ mod tests { assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1"); assert_eq!( response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(), - "4" + "5" ); server.abort(); @@ -816,6 +842,7 @@ mod tests { cookies: None, cookie_scopes: None, media: true, + torrent: false, batch: false, batch_name: None, }); @@ -836,6 +863,7 @@ mod tests { cookies: None, cookie_scopes: None, media: false, + torrent: false, batch: false, batch_name: None, }); @@ -897,6 +925,7 @@ mod tests { cookies: Some(format!("large={}", "x".repeat(64 * 1024))), cookie_scopes: None, media: true, + torrent: false, batch: false, batch_name: None, }) @@ -918,6 +947,7 @@ mod tests { cookies: Some("session=browser-cookie-header".to_string()), cookie_scopes: None, media: false, + torrent: false, batch: false, batch_name: None, }) @@ -930,6 +960,63 @@ mod tests { ); } + #[test] + fn torrent_handoff_accepts_magnets_and_preserves_the_intent() { + let download = normalize_download(ExtensionRequest { + urls: vec![ + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(), + ], + referer: None, + silent: false, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + torrent: true, + batch: false, + batch_name: None, + }) + .expect("valid magnet torrent handoff"); + + assert!(download.torrent); + assert_eq!(download.urls[0], "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"); + + let opaque = normalize_download(ExtensionRequest { + urls: vec!["https://example.com/download?id=opaque".to_string()], + referer: None, + silent: true, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + torrent: true, + batch: false, + batch_name: None, + }) + .expect("explicit opaque torrent handoff"); + assert!(opaque.torrent); + + let legacy_magnet = normalize_download(ExtensionRequest { + urls: vec![ + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(), + ], + referer: None, + silent: false, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + torrent: false, + batch: false, + batch_name: None, + }) + .expect("legacy magnet handoff"); + assert!(legacy_magnet.torrent); + } + #[test] fn regular_capture_normalizes_host_scoped_cookie_headers() { let download = normalize_download(ExtensionRequest { @@ -954,6 +1041,7 @@ mod tests { }, ]), media: false, + torrent: false, batch: false, batch_name: None, }) @@ -984,6 +1072,7 @@ mod tests { cookies: Some("session=secret".to_string()), cookie_scopes: None, media: false, + torrent: false, batch: false, batch_name: None, }) @@ -1007,6 +1096,7 @@ mod tests { cookies: None, cookie_scopes: None, media: false, + torrent: false, batch: true, batch_name: Some("Example Gallery / Chapter: 1".to_string()), }) @@ -1030,6 +1120,7 @@ mod tests { cookies: None, cookie_scopes: None, media: false, + torrent: false, batch: true, batch_name: Some("Example Gallery".to_string()), }) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52849dd..0ad4b37 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use regex::Regex; use serde::Serialize; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::hash::{Hash, Hasher}; +use std::ffi::OsStr; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{Duration, Instant}; @@ -1747,12 +1748,27 @@ async fn validate_url_ssrf(url: &str) -> Result bool { + if crate::torrent::is_remote_torrent_url(source) { + return true; + } + if !explicitly_torrent { + return false; + } + reqwest::Url::parse(source.trim()) + .ok() + .is_some_and(|url| matches!(url.scheme(), "http" | "https")) +} + /// Fetch remote `.torrent` metadata through the same SSRF, redirect, proxy, /// timeout, and bounded-body rules used by Firelink's metadata path. The /// bytes are parsed and cached before they can enter the Aria2 lifecycle. async fn fetch_remote_torrent_bytes( source: &str, proxy: Option<&str>, + headers: Option<&str>, + cookies: Option<&str>, + cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>, ) -> Result, String> { ensure_reqwest_crypto_provider(); let proxy = proxy @@ -1761,8 +1777,16 @@ async fn fetch_remote_torrent_bytes( .flatten(); let mut current = reqwest::Url::parse(source) .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; + let original_origin = Some(current.clone()); + let cookies_available = metadata_cookie_header_present(headers, cookies, cookie_scopes); + let mut send_cookies = !cookies_available; + let mut cookie_retry_attempted = false; - for redirect_count in 0..=MAX_REMOTE_TORRENT_REDIRECTS { + let mut redirect_count = 0; + loop { + if redirect_count > MAX_REMOTE_TORRENT_REDIRECTS { + return Err("Too many redirects while fetching torrent metadata".to_string()); + } if !matches!(current.scheme(), "http" | "https") { return Err("Remote torrent metadata must use HTTP or HTTPS".to_string()); } @@ -1785,6 +1809,27 @@ async fn fetch_remote_torrent_bytes( None => {} } builder = builder.resolve(&host, address); + let same_origin_credentials = should_send_metadata_credentials( + original_origin.as_ref(), + Some(¤t), + redirect_count, + ); + let scoped_cookie = if send_cookies { + cookie_scope_for_url(current.as_str(), cookie_scopes) + } else { + None + }; + let cookie_header = if same_origin_credentials { + scoped_cookie.or(cookies) + } else { + scoped_cookie + }; + let header_map = if same_origin_credentials { + metadata_headers(headers, cookie_header, send_cookies) + } else { + metadata_headers(None, cookie_header, send_cookies) + }; + builder = builder.default_headers(header_map); let client = builder .build() .map_err(|error| format!("could not create torrent metadata client: {error}"))?; @@ -1803,6 +1848,19 @@ async fn fetch_remote_torrent_bytes( ) })?; + if should_retry_metadata_with_cookies( + response.status(), + cookies_available && !send_cookies, + cookie_retry_attempted, + ) { + send_cookies = true; + cookie_retry_attempted = true; + current = reqwest::Url::parse(source) + .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; + redirect_count = 0; + continue; + } + if response.status().is_redirection() { if redirect_count == MAX_REMOTE_TORRENT_REDIRECTS { return Err("Too many redirects while fetching torrent metadata".to_string()); @@ -1815,6 +1873,7 @@ async fn fetch_remote_torrent_bytes( current = current .join(location) .map_err(|_| "Torrent metadata redirect URL is invalid".to_string())?; + redirect_count += 1; continue; } @@ -1861,7 +1920,6 @@ async fn fetch_remote_torrent_bytes( return Ok(bytes); } - Err("Too many redirects while fetching torrent metadata".to_string()) } fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { @@ -3263,6 +3321,20 @@ enum FirelinkDeepLink { } fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { + if deep_link.scheme() == "magnet" { + return if deep_link.query().is_some() + && deep_link.fragment().is_none() + && deep_link.username().is_empty() + && deep_link.password().is_none() + && deep_link.port().is_none() + && deep_link.to_string().chars().count() < MAX_DEEP_LINK_PAYLOAD_LEN + { + FirelinkDeepLink::Add(vec![deep_link.to_string()]) + } else { + FirelinkDeepLink::Invalid + }; + } + if deep_link.scheme() != "firelink" || !deep_link.username().is_empty() || deep_link.password().is_some() @@ -3324,6 +3396,57 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { } } +fn normalize_opened_torrent_argument(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() + || raw.starts_with('-') + || raw.contains('\r') + || raw.contains('\n') + || raw.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN + { + return None; + } + + let is_windows_absolute = cfg!(target_os = "windows") + && raw.len() >= 3 + && raw.as_bytes()[1] == b':' + && matches!(raw.as_bytes()[2], b'/' | b'\\'); + let path = if is_windows_absolute { + PathBuf::from(raw) + } else if let Ok(url) = url::Url::parse(raw) { + if url.scheme() != "file" { + return None; + } + url.to_file_path().ok()? + } else { + PathBuf::from(raw) + }; + if !path.is_absolute() && !is_windows_absolute { + return None; + } + if !path.extension().is_some_and(|extension| { + extension.to_string_lossy().eq_ignore_ascii_case("torrent") + }) { + return None; + } + let path = path.to_string_lossy().into_owned(); + (!path.contains('\r') && !path.contains('\n')).then_some(path) +} + +fn collect_opened_torrent_paths(arguments: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + let mut seen = HashSet::new(); + arguments + .into_iter() + .filter_map(|argument| argument.as_ref().to_str().and_then(normalize_opened_torrent_argument)) + .filter(|path| seen.insert(path.clone())) + .take(MAX_DEEP_LINK_URLS) + .collect() +} + fn restore_main_window(app_handle: &tauri::AppHandle) { let Some(window) = app_handle.get_webview_window("main") else { if let Some(state) = app_handle.try_state::() { @@ -3382,6 +3505,22 @@ fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec) }); } +fn dispatch_opened_torrent_paths(app_handle: tauri::AppHandle, paths: Vec) { + if paths.is_empty() { + return; + } + restore_main_window(&app_handle); + let coordinator = app_handle.state::().download_coordinator.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator + .send(download::DownloadCmd::CaptureUrls(paths)) + .await + { + eprintln!("Failed to dispatch opened torrent files: {error}"); + } + }); +} + #[doc(hidden)] pub async fn rpc_call( port: u16, @@ -6465,6 +6604,10 @@ async fn inspect_torrent( id: String, cache: Option, proxy: Option, + headers: Option, + cookies: Option, + cookie_scopes: Option>, + torrent: Option, ) -> Result { properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { @@ -6479,9 +6622,15 @@ async fn inspect_torrent( .await .map_err(AppError::Internal); } - if crate::torrent::is_remote_torrent_url(&source) { + if is_remote_torrent_source(&source, torrent == Some(true)) { let remote_source = source.trim(); - let bytes = fetch_remote_torrent_bytes(remote_source, proxy.as_deref()) + let bytes = fetch_remote_torrent_bytes( + remote_source, + proxy.as_deref(), + headers.as_deref(), + cookies.as_deref(), + cookie_scopes.as_deref(), + ) .await .map_err(AppError::Internal)?; let parsed = crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?; @@ -10499,6 +10648,7 @@ mod tests { media_progress_speed, cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, + is_remote_torrent_source, normalize_speed_limit_for_aria2, normalize_torrent_overall_upload_limit, apply_aria2_torrent_global_options, @@ -10509,6 +10659,8 @@ mod tests { apply_aria2_torrent_dht_options, aria2_rpc_port_is_occupied, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, + collect_opened_torrent_paths, + normalize_opened_torrent_argument, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, has_resumable_download_assets, is_media_artifact_name, should_cleanup_media_artifacts_after_failure, @@ -11029,17 +11181,33 @@ mod tests { #[tokio::test] async fn remote_torrent_fetch_rejects_non_http_and_embedded_credentials() { assert_eq!( - super::fetch_remote_torrent_bytes("ftp://example.com/sample.torrent", None).await, + super::fetch_remote_torrent_bytes( + "ftp://example.com/sample.torrent", + None, + None, + None, + None, + ) + .await, Err("Remote torrent metadata must use HTTP or HTTPS".to_string()) ); assert_eq!( - super::fetch_remote_torrent_bytes("https://user:pass@example.com/sample.torrent", None) - .await, + super::fetch_remote_torrent_bytes( + "https://user:pass@example.com/sample.torrent", + None, + None, + None, + None, + ) + .await, Err("Torrent metadata URLs must not contain credentials".to_string()) ); let proxy_error = super::fetch_remote_torrent_bytes( "https://example.com/sample.torrent", Some("socks5://127.0.0.1:1080"), + None, + None, + None, ) .await .expect_err("remote Torrent metadata must use the shared proxy policy"); @@ -11995,6 +12163,26 @@ mod tests { )); } + #[test] + fn explicit_torrent_intent_allows_opaque_http_sources() { + assert!(is_remote_torrent_source( + "https://example.com/download?id=opaque", + true + )); + assert!(!is_remote_torrent_source( + "https://example.com/download?id=opaque", + false + )); + assert!(!is_remote_torrent_source( + "ftp://example.com/download?id=opaque", + true + )); + assert!(is_remote_torrent_source( + "https://example.com/files/sample.torrent?download=1", + false + )); + } + #[test] fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() { let directory = tempfile::tempdir().unwrap(); @@ -12372,6 +12560,48 @@ mod tests { ); } + #[test] + fn accepts_direct_magnet_deep_links() { + let deep_link = url::Url::parse( + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example", + ) + .unwrap(); + assert_eq!( + parse_firelink_deep_link(&deep_link), + FirelinkDeepLink::Add(vec![deep_link.to_string()]) + ); + } + + #[test] + fn filters_opened_torrent_arguments_to_absolute_files() { + assert_eq!( + normalize_opened_torrent_argument("file:///tmp/example%20one.torrent"), + Some("/tmp/example one.torrent".to_string()) + ); + assert!(normalize_opened_torrent_argument("relative/example.torrent").is_none()); + assert!(normalize_opened_torrent_argument("--flag.torrent").is_none()); + assert!(normalize_opened_torrent_argument("/tmp/line\nbreak.torrent").is_none()); + assert!(normalize_opened_torrent_argument("file:///tmp/line%0Abreak.torrent").is_none()); + assert!(normalize_opened_torrent_argument("/tmp/example.zip").is_none()); + if cfg!(target_os = "windows") { + assert!(normalize_opened_torrent_argument("C:/example.torrent").is_some()); + } else { + assert!(normalize_opened_torrent_argument("C:/example.torrent").is_none()); + } + + let paths = collect_opened_torrent_paths([ + "--new-instance", + "/tmp/one.torrent", + "/tmp/one.torrent", + "/tmp/two.TORRENT", + "/tmp/readme.txt", + ]); + assert_eq!(paths, vec![ + "/tmp/one.torrent".to_string(), + "/tmp/two.TORRENT".to_string(), + ]); + } + #[test] fn rejects_launch_variants_and_nested_schemes() { let links = [ @@ -13349,8 +13579,10 @@ pub fn run() { tauri::Builder::default() .manage(MainWindowRestoreState::default()) .manage(properties_window::PropertiesWindowRegistry::default()) - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { restore_main_window(app); + let paths = collect_opened_torrent_paths(args); + dispatch_opened_torrent_paths(app.clone(), paths); })) .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard::new()) @@ -13603,6 +13835,12 @@ pub fn run() { .map_err(|error| format!("failed to create main window: {error}"))?; restore_pending_main_window(app.handle()); + #[cfg(any(target_os = "windows", target_os = "linux"))] + dispatch_opened_torrent_paths( + app.handle().clone(), + collect_opened_torrent_paths(std::env::args_os().skip(1)), + ); + #[cfg(target_os = "windows")] if let Some(window) = app.get_webview_window("main") { window @@ -14670,6 +14908,13 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Opened { urls } => { + let paths = collect_opened_torrent_paths( + urls.into_iter().map(|url| url.to_string()), + ); + dispatch_opened_torrent_paths(app_handle.clone(), paths); + } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { has_visible_windows, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bd23836..555b5ba 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,12 +38,26 @@ "resources": { "engine-dist/": "engine-dist/", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" - } + }, + "fileAssociations": [ + { + "ext": ["torrent"], + "mimeType": "application/x-bittorrent", + "name": "BitTorrent file", + "description": "BitTorrent metadata file", + "role": "Viewer", + "rank": "Alternate", + "exportedType": { + "identifier": "com.nimbold.firelink.torrent", + "conformsTo": ["public.data"] + } + } + ] }, "plugins": { "deep-link": { "desktop": { - "schemes": ["firelink"] + "schemes": ["firelink", "magnet"] } } } diff --git a/src/bindings/ExtensionDownload.ts b/src/bindings/ExtensionDownload.ts index ac0c762..91e0b5f 100644 --- a/src/bindings/ExtensionDownload.ts +++ b/src/bindings/ExtensionDownload.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExtensionCookieScope } from "./ExtensionCookieScope"; -export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, batch: boolean, batch_name: string | null, }; +export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 48fbc5a..32327a5 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -62,10 +62,12 @@ const formatBytes = (bytes: number) => { }; const normalizeComparableUrl = (rawUrl: string) => { + const trimmed = rawUrl.trim(); + if (trimmed.startsWith('/') || /^[a-z]:[\\/]/i.test(trimmed)) return trimmed; try { - return new URL(rawUrl).href; + return new URL(trimmed).href; } catch { - return rawUrl.trim(); + return trimmed; } }; @@ -141,6 +143,7 @@ export const AddDownloadsModal = () => { pendingAddHeaders, pendingAddCookies, pendingAddMediaUrls, + pendingAddTorrentUrls, pendingAddBatchName, pendingAddRequestContexts, pendingAddRequestVersion, @@ -530,13 +533,8 @@ export const AddDownloadsModal = () => { return Object.keys(retained).length === Object.keys(current).length ? current : retained; }); - const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => { - try { - return new URL(url).href; - } catch { - return url; - } - })); + const forcedMediaUrls = new Set(pendingAddMediaUrls.map(normalizeComparableUrl)); + const forcedTorrentUrls = new Set(pendingAddTorrentUrls.map(normalizeComparableUrl)); const requestFilenames = Object.fromEntries( Object.entries(pendingAddRequestContexts) .filter(([, context]) => Boolean(context.filename)) @@ -564,13 +562,15 @@ export const AddDownloadsModal = () => { requestFilenames, requestContextVersions, playlistExpansions, - selectedBySourceUrl + selectedBySourceUrl, + forcedTorrentUrls ); }); }, [ urls, pendingAddFilename, pendingAddMediaUrls, + pendingAddTorrentUrls, pendingAddRequestContexts, hasExtensionRequestContext, playlistExpansions @@ -608,7 +608,11 @@ export const AddDownloadsModal = () => { source: row.sourceUrl, id: torrentCacheId, cache: true, - proxy: proxy ?? undefined + proxy: proxy ?? undefined, + headers: headersForRow(contextUrl) || undefined, + cookies: cookiesForRow(contextUrl, row.sourceUrl) || undefined, + cookieScopes: requestContext?.cookieScopes || undefined, + torrent: true }); const isCurrentTorrentDraft = addModalOpenRef.current && parsedItemsRef.current.some(currentRow => @@ -1465,6 +1469,7 @@ export const AddDownloadsModal = () => { try { const id = crypto.randomUUID(); allocatedId = id; + const contextUrl = requestContextUrlForRow(item); let torrentPath = item.torrentPath; if (item.isTorrent) { if (item.torrentPath) { @@ -1483,7 +1488,11 @@ export const AddDownloadsModal = () => { source: item.sourceUrl, id, cache: true, - proxy: proxy ?? undefined + proxy: proxy ?? undefined, + headers: headersForRow(contextUrl) || undefined, + cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined, + cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined, + torrent: true }); torrentPath = torrentData.torrentPath; } @@ -1492,8 +1501,6 @@ export const AddDownloadsModal = () => { ? mediaFileNameForSelectedFormat(item.file, item) : canonicalizeDownloadFileName(item.file); let formatSelector = mediaFormatSelectorForRow(item); - const contextUrl = requestContextUrlForRow(item); - const category = categoryForFileName(finalFile); const added = await addDownload({ id, diff --git a/src/ipc.ts b/src/ipc.ts index 42983be..4cd0fe8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -41,7 +41,16 @@ type CommandMap = { result: MediaPlaylistMetadata; }; inspect_torrent: { - args: { source: string; id: string; cache?: boolean; proxy?: string }; + args: { + source: string; + id: string; + cache?: boolean; + proxy?: string; + headers?: string; + cookies?: string; + cookieScopes?: Array; + torrent?: boolean; + }; result: TorrentMetadata; }; rekey_torrent_metadata: { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 985c502..95147bf 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -2683,6 +2683,7 @@ describe('useDownloadStore', () => { { url: 'https://accounts.google.com/', cookies: 'SID=account-session' } ], media: false, + torrent: false, batch: false, batch_name: null }); @@ -2721,6 +2722,7 @@ describe('useDownloadStore', () => { cookies: null, cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2744,6 +2746,7 @@ describe('useDownloadStore', () => { cookies: 'session=secret', cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2768,6 +2771,7 @@ describe('useDownloadStore', () => { cookies: null, cookie_scopes: null, media: false, + torrent: false, batch: true, batch_name: 'Example Gallery' }); @@ -2792,6 +2796,7 @@ describe('useDownloadStore', () => { cookies: 'first=session', cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2804,6 +2809,7 @@ describe('useDownloadStore', () => { cookies: 'second=session', cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2843,6 +2849,7 @@ describe('useDownloadStore', () => { cookies: `oversized=${'x'.repeat(64 * 1024)}`, cookie_scopes: null, media: true, + torrent: false, batch: false, batch_name: null }); @@ -2865,6 +2872,7 @@ describe('useDownloadStore', () => { cookies: 'session=secret', cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2884,6 +2892,7 @@ describe('useDownloadStore', () => { { url: 'https://media.example/', cookies: 'session=secret' } ], media: true, + torrent: false, batch: false, batch_name: null }); @@ -2903,6 +2912,7 @@ describe('useDownloadStore', () => { cookies: 'session=secret', cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2915,6 +2925,7 @@ describe('useDownloadStore', () => { cookies: null, cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); @@ -2945,6 +2956,7 @@ describe('useDownloadStore', () => { cookies: 'session=secret', cookie_scopes: null, media: true, + torrent: false, batch: false, batch_name: null }); @@ -2967,6 +2979,7 @@ describe('useDownloadStore', () => { cookies: null, cookie_scopes: null, media: false, + torrent: false, batch: false, batch_name: null }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 7359169..38232cb 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -924,6 +924,7 @@ export type PendingAddRequestContext = { cookies: string; cookieScopes?: ExtensionCookieScope[]; media: boolean; + torrent?: boolean; }; export type DeleteModalState = { @@ -956,6 +957,7 @@ interface DownloadState { pendingAddHeaders: string; pendingAddCookies: string; pendingAddMediaUrls: string[]; + pendingAddTorrentUrls: string[]; pendingAddBatch: boolean; pendingAddBatchName: string; pendingAddRequestContexts: Record; @@ -971,7 +973,8 @@ interface DownloadState { media?: boolean, cookieScopes?: ExtensionCookieScope[] | null, batch?: boolean, - batchName?: string | null + batchName?: string | null, + torrent?: boolean ) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise; deleteModalState: DeleteModalState; @@ -1399,6 +1402,7 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], + pendingAddTorrentUrls: [], pendingAddBatch: false, pendingAddBatchName: '', pendingAddRequestContexts: {}, @@ -1420,6 +1424,7 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: '', pendingAddCookies: '', pendingAddMediaUrls: [], + pendingAddTorrentUrls: [], pendingAddBatch: false, pendingAddBatchName: '', pendingAddRequestContexts: {}, @@ -1436,7 +1441,8 @@ export const useDownloadStore = create((set, get) => { media = false, cookieScopes, batch = false, - batchName + batchName, + torrent = false ) => set((state) => { const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls); const existingUrls = isAppending ? state.pendingAddUrls : ''; @@ -1482,12 +1488,16 @@ export const useDownloadStore = create((set, get) => { headers: cleanHeaders, cookies: cleanCookies, ...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}), - media + media, + ...(torrent ? { torrent: true } : {}) }; } const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts) .filter(([, context]) => context.media) .map(([url]) => url); + const pendingAddTorrentUrls = Object.entries(pendingAddRequestContexts) + .filter(([, context]) => context.torrent) + .map(([url]) => url); return { isAddModalOpen: true, pendingAddUrls: mergedUrls, @@ -1496,6 +1506,7 @@ export const useDownloadStore = create((set, get) => { pendingAddHeaders: cleanHeaders, pendingAddCookies: cleanCookies, pendingAddMediaUrls, + pendingAddTorrentUrls, pendingAddBatch: nextBatch, pendingAddBatchName: nextBatchName, pendingAddRequestContexts, @@ -1523,7 +1534,8 @@ export const useDownloadStore = create((set, get) => { request.media === true, request.media === true ? undefined : request.cookie_scopes, request.batch === true && urls.length >= 2, - request.batch_name + request.batch_name, + request.torrent === true ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index f91d667..8599cd3 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -120,6 +120,29 @@ describe('add download metadata workflow', () => { }); }); + it('preserves an explicit torrent handoff for an opaque remote URL', () => { + const sourceUrl = 'https://example.com/download?id=opaque'; + const rows = reconcileDownloadRows( + sourceUrl, + [], + 'example.torrent', + new Set(), + undefined, + {}, + {}, + {}, + {}, + new Set([sourceUrl]) + ); + + expect(rows[0]).toMatchObject({ + sourceUrl, + isTorrent: true, + torrentCacheId: `${rows[0].id}-1`, + file: 'example.torrent' + }); + }); + it('gives refreshed torrent metadata a new cache identity', () => { const existing = row({ id: 'torrent-row', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index d940dbd..f6f0195 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -235,7 +235,8 @@ export const reconcileDownloadRows = ( requestFilenames: Readonly> = {}, requestContextVersions: Readonly> = {}, playlistExpansions: PlaylistExpansions = {}, - selectedBySourceUrl: Readonly> = {} + selectedBySourceUrl: Readonly> = {}, + forceTorrentUrls: ReadonlySet = new Set() ): AddDownloadDraftRow[] => { const inputs = parseInputLines( rawText, @@ -249,6 +250,7 @@ export const reconcileDownloadRows = ( const preserved = existing.get(input.sourceUrl); if (preserved) { const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl); + const forcedTorrent = input.valid && forceTorrentUrls.has(input.sourceUrl); const requestContextVersion = input.requestContextVersion; const contextChanged = requestContextVersion !== undefined && requestContextVersion !== preserved.requestContextVersion; @@ -257,7 +259,10 @@ export const reconcileDownloadRows = ( || preserved.playlistIndex !== input.playlistIndex || preserved.playlistCount !== input.playlistCount || preserved.playlistEntryTitle !== input.playlistEntryTitle; - if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) { + if ((forcedMedia && !preserved.isMedia) + || (forcedTorrent && !preserved.isTorrent) + || contextChanged + || playlistContextChanged) { const nextGeneration = preserved.generation + 1; const requestedFilename = input.playlistSourceUrl ? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}` @@ -271,7 +276,7 @@ export const reconcileDownloadRows = ( generation: nextGeneration, requestContextVersion, isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), - isTorrent: input.isTorrent, + isTorrent: input.isTorrent || forcedTorrent, size: undefined, sizeBytes: undefined, resumable: undefined, @@ -290,7 +295,7 @@ export const reconcileDownloadRows = ( playlistError: undefined, metadataBlockedReason: undefined, torrentPath: undefined, - torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined, + torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined, torrentInfoHash: undefined, torrentFiles: undefined, selectedTorrentFileIndices: undefined @@ -323,7 +328,7 @@ export const reconcileDownloadRows = ( || forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl) ), - isTorrent: input.valid && Boolean(input.isTorrent), + isTorrent: input.valid && (Boolean(input.isTorrent) || forceTorrentUrls.has(input.sourceUrl)), isPlaylist: input.isPlaylist, playlistSourceUrl: input.playlistSourceUrl, playlistTitle: input.playlistTitle, @@ -331,7 +336,9 @@ export const reconcileDownloadRows = ( playlistCount: input.playlistCount, playlistEntryTitle: input.playlistEntryTitle, metadataBlockedReason: undefined, - torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined, + torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl)) + ? `${id}-${generation}` + : undefined, selected: input.selected !== false }; });