mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 09:16:57 +00:00
fix(extension): support browser-local torrent attachments
- Cache signed browser-local torrent bytes in the desktop handoff server.\n- Preserve managed torrent identity through Add-window inspection and rekey.\n- Advance the tested browser companion submodule to the published capture fix.
This commit is contained in:
+1
-1
Submodule Extensions/Browser updated: fbede1cdc8...84672ab6bc
@@ -7,6 +7,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,7 +28,11 @@ use ts_rs::TS;
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 6412;
|
||||
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
|
||||
const MAX_URL_COUNT: usize = 200;
|
||||
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||
const MAX_NON_TORRENT_REQUEST_BODY_BYTES: usize = 256 * 1024;
|
||||
const MAX_ENCODED_TORRENT_BYTES: usize =
|
||||
((crate::torrent::MAX_TORRENT_BYTES + 2) / 3) * 4;
|
||||
const MAX_REQUEST_BODY_BYTES: usize =
|
||||
MAX_ENCODED_TORRENT_BYTES + MAX_NON_TORRENT_REQUEST_BODY_BYTES;
|
||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||
const SERVER_HEADER: &str = "x-firelink-server";
|
||||
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
|
||||
@@ -36,7 +41,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 = "5";
|
||||
const PROTOCOL_VERSION: &str = "6";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
@@ -80,6 +85,8 @@ struct ExtensionRequest {
|
||||
batch: bool,
|
||||
#[serde(default)]
|
||||
batch_name: Option<String>,
|
||||
#[serde(default)]
|
||||
torrent_bytes_base64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, TS)]
|
||||
@@ -105,6 +112,11 @@ pub struct ExtensionDownload {
|
||||
torrent: bool,
|
||||
batch: bool,
|
||||
batch_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
torrent_path: Option<String>,
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
torrent_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn start_server(
|
||||
@@ -303,11 +315,25 @@ async fn download_handler(
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let download = match normalize_download(payload) {
|
||||
let mut download = match normalize_download(payload) {
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
if let Some(torrent_bytes) = download.torrent_bytes.take() {
|
||||
let torrent_path = crate::torrent::cache_torrent_bytes(
|
||||
&state.app_handle,
|
||||
&request_id,
|
||||
&torrent_bytes,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
download.urls = vec![torrent_path.clone()];
|
||||
download.torrent_path = Some(torrent_path);
|
||||
}
|
||||
let cached_torrent = download.torrent_path.is_some();
|
||||
|
||||
let is_hidden = state
|
||||
.app_handle
|
||||
.get_webview_window("main")
|
||||
@@ -321,13 +347,19 @@ async fn download_handler(
|
||||
}
|
||||
|
||||
if !wait_for_frontend(&state.frontend_ready).await {
|
||||
if cached_torrent {
|
||||
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||
}
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let mut download = download;
|
||||
let Some(ack_receiver) = register_extension_ack(&state.extension_acks, request_id.clone())
|
||||
else {
|
||||
if cached_torrent {
|
||||
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||
}
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
};
|
||||
download.request_id = Some(request_id.clone());
|
||||
|
||||
if state
|
||||
@@ -336,6 +368,9 @@ async fn download_handler(
|
||||
.is_err()
|
||||
{
|
||||
remove_extension_ack(&state.extension_acks, &request_id);
|
||||
if cached_torrent {
|
||||
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
|
||||
}
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
@@ -405,11 +440,33 @@ fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_torrent_bytes(encoded: &str) -> Option<Vec<u8>> {
|
||||
if encoded.is_empty()
|
||||
|| encoded.len() > MAX_ENCODED_TORRENT_BYTES
|
||||
|| encoded.len() % 4 != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()?;
|
||||
if bytes.is_empty() || bytes.len() > crate::torrent::MAX_TORRENT_BYTES {
|
||||
return None;
|
||||
}
|
||||
crate::torrent::parse_torrent_bytes(&bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
||||
if payload.urls.len() > MAX_URL_COUNT {
|
||||
return None;
|
||||
}
|
||||
|
||||
let torrent_bytes = match payload.torrent_bytes_base64.as_deref() {
|
||||
Some(encoded) => Some(decode_torrent_bytes(encoded)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let urls = payload
|
||||
.urls
|
||||
@@ -420,6 +477,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
if urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if torrent_bytes.is_some()
|
||||
&& (payload.media
|
||||
|| !payload.torrent
|
||||
|| urls.len() != 1
|
||||
|| Url::parse(&urls[0])
|
||||
.ok()
|
||||
.is_none_or(|url| !matches!(url.scheme(), "http" | "https")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if payload.media
|
||||
&& urls.iter().any(|url| {
|
||||
Url::parse(url)
|
||||
@@ -437,6 +504,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
}
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& (payload.torrent
|
||||
|| torrent_bytes.is_some()
|
||||
|| filename_is_torrent(payload.filename.as_deref())
|
||||
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
||||
});
|
||||
@@ -500,6 +568,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
torrent,
|
||||
batch,
|
||||
batch_name,
|
||||
torrent_path: None,
|
||||
torrent_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -730,7 +800,7 @@ fn is_allowed_origin(origin: &str) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||
decode_torrent_bytes, has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
|
||||
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
@@ -741,6 +811,7 @@ mod tests {
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
@@ -767,7 +838,7 @@ mod tests {
|
||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||
assert_eq!(
|
||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||
"5"
|
||||
"6"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
@@ -834,6 +905,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
@@ -855,6 +927,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
});
|
||||
|
||||
assert!(download.is_none());
|
||||
@@ -917,6 +990,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid media handoff");
|
||||
|
||||
@@ -942,6 +1016,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
@@ -972,6 +1047,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("batch".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
@@ -999,6 +1075,7 @@ mod tests {
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid magnet torrent handoff");
|
||||
|
||||
@@ -1017,6 +1094,7 @@ mod tests {
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("explicit opaque torrent handoff");
|
||||
assert!(opaque.torrent);
|
||||
@@ -1035,11 +1113,78 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("legacy magnet handoff");
|
||||
assert!(legacy_magnet.torrent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_local_torrent_bytes_are_normalized_as_a_single_http_sourced_torrent() {
|
||||
let bytes = b"d4:infod6:lengthi5e4:name4:testee".to_vec();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://privatebin.example/paste".to_string()],
|
||||
referer: Some("https://privatebin.example/paste".to_string()),
|
||||
silent: true,
|
||||
filename: Some("TerraScape.TORRENT".to_string()),
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: Some(encoded),
|
||||
})
|
||||
.expect("browser-local torrent bytes should be accepted");
|
||||
|
||||
assert!(download.torrent);
|
||||
assert_eq!(download.urls, vec!["https://privatebin.example/paste"]);
|
||||
assert_eq!(download.filename.as_deref(), Some("TerraScape.TORRENT"));
|
||||
assert_eq!(download.torrent_bytes.as_deref(), Some(bytes.as_slice()));
|
||||
assert!(download.torrent_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_local_torrent_bytes_require_valid_bencoded_metadata_and_http_source() {
|
||||
let valid = base64::engine::general_purpose::STANDARD
|
||||
.encode(b"d4:infod6:lengthi5e4:name4:testee");
|
||||
let invalid = base64::engine::general_purpose::STANDARD.encode(b"not a torrent");
|
||||
|
||||
assert!(decode_torrent_bytes(&invalid).is_none());
|
||||
assert!(normalize_download(ExtensionRequest {
|
||||
urls: vec!["blob:https://privatebin.example/attachment".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: Some("download.torrent".to_string()),
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: Some(valid.clone()),
|
||||
})
|
||||
.is_none());
|
||||
assert!(normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://privatebin.example/paste".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: Some("download.torrent".to_string()),
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: Some(valid),
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -1067,6 +1212,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid download handoff");
|
||||
|
||||
@@ -1098,6 +1244,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
@@ -1122,6 +1269,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid selected-link batch");
|
||||
|
||||
@@ -1146,6 +1294,7 @@ mod tests {
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery".to_string()),
|
||||
torrent_bytes_base64: None,
|
||||
})
|
||||
.expect("valid single-link handoff");
|
||||
|
||||
|
||||
@@ -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<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, torrent_path?: string, };
|
||||
|
||||
@@ -215,11 +215,18 @@ export const AddDownloadsModal = () => {
|
||||
if (!row.isTorrent) continue;
|
||||
activeDraftIds.add(row.torrentCacheId || row.id);
|
||||
activeDraftIds.add(`${row.id}-${row.generation}`);
|
||||
const requestContext = pendingAddRequestContexts[normalizeComparableUrl(row.sourceUrl)];
|
||||
if (requestContext?.torrentPath
|
||||
&& requestContext.torrentCacheId
|
||||
&& requestContext.torrentPath === row.torrentPath
|
||||
&& requestContext?.torrentCacheId === row.torrentCacheId) {
|
||||
cachedTorrentDraftIdsRef.current.add(requestContext.torrentCacheId);
|
||||
}
|
||||
}
|
||||
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
|
||||
.filter(id => !activeDraftIds.has(id));
|
||||
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
|
||||
}, [cleanupDraftTorrentCache, parsedItems]);
|
||||
}, [cleanupDraftTorrentCache, parsedItems, pendingAddRequestContexts]);
|
||||
|
||||
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
|
||||
|
||||
@@ -579,6 +586,16 @@ export const AddDownloadsModal = () => {
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.map(([url, context]) => [url, context.version])
|
||||
);
|
||||
const requestTorrentPaths = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.filter(([, context]) => Boolean(context.torrentPath))
|
||||
.map(([url, context]) => [url, context.torrentPath as string])
|
||||
);
|
||||
const requestTorrentCacheIds = Object.fromEntries(
|
||||
Object.entries(pendingAddRequestContexts)
|
||||
.filter(([, context]) => Boolean(context.torrentCacheId))
|
||||
.map(([url, context]) => [url, context.torrentCacheId as string])
|
||||
);
|
||||
setParsedItems(current => {
|
||||
const selectedBySourceUrl = Object.fromEntries(
|
||||
current.map(row => [row.sourceUrl, row.selected !== false])
|
||||
@@ -598,7 +615,9 @@ export const AddDownloadsModal = () => {
|
||||
requestContextVersions,
|
||||
playlistExpansions,
|
||||
selectedBySourceUrl,
|
||||
forcedTorrentUrls
|
||||
forcedTorrentUrls,
|
||||
requestTorrentPaths,
|
||||
requestTorrentCacheIds
|
||||
);
|
||||
});
|
||||
}, [
|
||||
|
||||
@@ -4591,6 +4591,61 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes a browser-local torrent handoff with its managed cache identity', async () => {
|
||||
const torrentPath = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent';
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
request_id: 'request-id',
|
||||
urls: [torrentPath],
|
||||
torrent_path: torrentPath,
|
||||
referer: 'https://example.com/page',
|
||||
silent: true,
|
||||
filename: 'sample.torrent',
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddUrls).toBe(torrentPath);
|
||||
expect(state.pendingAddTorrentUrls).toEqual([torrentPath]);
|
||||
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
|
||||
media: false,
|
||||
torrent: true,
|
||||
torrentPath,
|
||||
torrentCacheId: 'request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('retains a Windows managed torrent path as the request context key', async () => {
|
||||
const torrentPath = 'C:\\Users\\test\\AppData\\Roaming\\Firelink\\torrents\\request-id.torrent';
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
request_id: 'request-id',
|
||||
urls: [torrentPath],
|
||||
torrent_path: torrentPath,
|
||||
referer: 'https://example.com/page',
|
||||
silent: true,
|
||||
filename: 'sample.torrent',
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: null
|
||||
});
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
|
||||
torrentPath,
|
||||
torrentCacheId: 'request-id'
|
||||
});
|
||||
expect(state.pendingAddRequestContexts).not.toHaveProperty(`c:${torrentPath.slice(1)}`);
|
||||
});
|
||||
|
||||
it('does not reuse stale extension metadata for a later single-link handoff', async () => {
|
||||
useDownloadStore.setState({
|
||||
isAddModalOpen: true,
|
||||
|
||||
@@ -1122,6 +1122,8 @@ export type PendingAddRequestContext = {
|
||||
cookieScopes?: ExtensionCookieScope[];
|
||||
media: boolean;
|
||||
torrent?: boolean;
|
||||
torrentPath?: string;
|
||||
torrentCacheId?: string;
|
||||
};
|
||||
|
||||
export type DeleteModalState = {
|
||||
@@ -1178,7 +1180,9 @@ interface DownloadState {
|
||||
cookieScopes?: ExtensionCookieScope[] | null,
|
||||
batch?: boolean,
|
||||
batchName?: string | null,
|
||||
torrent?: boolean
|
||||
torrent?: boolean,
|
||||
torrentPath?: string,
|
||||
torrentCacheId?: string
|
||||
) => void;
|
||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
||||
deleteModalState: DeleteModalState;
|
||||
@@ -1928,7 +1932,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
cookieScopes,
|
||||
batch = false,
|
||||
batchName,
|
||||
torrent = false
|
||||
torrent = false,
|
||||
torrentPath,
|
||||
torrentCacheId
|
||||
) => set((state) => {
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||
@@ -1967,10 +1973,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl) continue;
|
||||
let key = trimmedUrl;
|
||||
try {
|
||||
key = new URL(trimmedUrl).href;
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
const isLocalPath = trimmedUrl.startsWith('/') || /^[a-z]:[\\/]/i.test(trimmedUrl);
|
||||
if (!isLocalPath) {
|
||||
try {
|
||||
key = new URL(trimmedUrl).href;
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
}
|
||||
}
|
||||
const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl);
|
||||
pendingAddRequestContexts[key] = {
|
||||
@@ -1981,7 +1990,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
cookies: isItemMedia ? '' : cleanCookies,
|
||||
...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}),
|
||||
media: isItemMedia,
|
||||
...(torrent ? { torrent: true } : {})
|
||||
...(torrent ? { torrent: true } : {}),
|
||||
...(torrentPath ? { torrentPath } : {}),
|
||||
...(torrentCacheId ? { torrentCacheId } : {})
|
||||
};
|
||||
}
|
||||
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
|
||||
@@ -2027,7 +2038,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
request.media === true ? undefined : request.cookie_scopes,
|
||||
request.batch === true && urls.length >= 2,
|
||||
request.batch_name,
|
||||
request.torrent === true
|
||||
request.torrent === true,
|
||||
request.torrent_path || undefined,
|
||||
request.request_id || undefined
|
||||
);
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
|
||||
@@ -152,6 +152,32 @@ describe('add download metadata workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retains the native-managed path and cache identity for a browser-local torrent', () => {
|
||||
const source = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent';
|
||||
const rows = reconcileDownloadRows(
|
||||
source,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{ [source]: 1 },
|
||||
{},
|
||||
{},
|
||||
new Set(),
|
||||
{ [source]: source },
|
||||
{ [source]: 'request-id' }
|
||||
);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
sourceUrl: source,
|
||||
isTorrent: true,
|
||||
status: 'loading',
|
||||
torrentPath: source,
|
||||
torrentCacheId: 'request-id'
|
||||
});
|
||||
});
|
||||
|
||||
it('gives refreshed torrent metadata a new cache identity', () => {
|
||||
const existing = row({
|
||||
id: 'torrent-row',
|
||||
|
||||
@@ -272,7 +272,9 @@ export const reconcileDownloadRows = (
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {},
|
||||
forceTorrentUrls: ReadonlySet<string> = new Set()
|
||||
forceTorrentUrls: ReadonlySet<string> = new Set(),
|
||||
requestTorrentPaths: Readonly<Record<string, string>> = {},
|
||||
requestTorrentCacheIds: Readonly<Record<string, string>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(
|
||||
rawText,
|
||||
@@ -331,8 +333,9 @@ export const reconcileDownloadRows = (
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined,
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||
torrentPath: requestTorrentPaths[input.sourceUrl],
|
||||
torrentCacheId: requestTorrentCacheIds[input.sourceUrl]
|
||||
|| (input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined),
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
@@ -400,9 +403,11 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
|
||||
? `${id}-${generation}`
|
||||
: undefined,
|
||||
torrentPath: requestTorrentPaths[input.sourceUrl],
|
||||
torrentCacheId: requestTorrentCacheIds[input.sourceUrl]
|
||||
|| (input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
|
||||
? `${id}-${generation}`
|
||||
: undefined),
|
||||
torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl)
|
||||
? 'loading'
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user