diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed68525..6f089e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6855,6 +6855,7 @@ async fn validate_torrent_enqueue( app_handle: &tauri::AppHandle, item: &mut queue::EnqueueItem, ) -> Result<(), String> { + item.strip_torrent_credentials(); if item.is_media.unwrap_or(false) { return Err("torrent transfer cannot be a media download".to_string()); } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 264e2f6..ba62cdf 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -6948,6 +6948,30 @@ fn apply_aria2_torrent_options( Ok(()) } +fn apply_aria2_header_options( + options: &mut serde_json::Map, + payload: &SpawnPayload, + credentials_allowed: bool, +) { + if !credentials_allowed { + return; + } + let mut header_list = Vec::new(); + if let Some(cookies) = &payload.cookies { + header_list.push(format!("Cookie: {cookies}")); + } + if let Some(headers) = &payload.headers { + for line in headers.lines() { + if !line.trim().is_empty() { + header_list.push(line.trim().to_string()); + } + } + } + if !header_list.is_empty() { + options.insert("header".to_string(), serde_json::json!(header_list)); + } +} + impl ProductionSpawner { pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } @@ -7055,7 +7079,10 @@ impl SidecarSpawner for ProductionSpawner { } let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) = if payload.is_torrent { - (Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, true) + // Torrent metadata acquisition credentials are never transfer + // credentials. Torrent trackers and web seeds use their own + // validated URI/options paths below. + (Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, false) } else { let requested_uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); @@ -7134,22 +7161,7 @@ impl SidecarSpawner for ProductionSpawner { if let Some(ua) = &payload.user_agent { options.insert("user-agent".to_string(), serde_json::json!(ua)); } - let mut header_list = Vec::new(); - if payload.is_torrent || credentials_allowed { - if let Some(cook) = &payload.cookies { - header_list.push(format!("Cookie: {}", cook)); - } - if let Some(hdrs) = &payload.headers { - for line in hdrs.lines() { - if !line.trim().is_empty() { - header_list.push(line.trim().to_string()); - } - } - } - } - if !header_list.is_empty() { - options.insert("header".to_string(), serde_json::json!(header_list)); - } + apply_aria2_header_options(&mut options, payload, credentials_allowed); if let Some(prox) = proxy_value { options.insert("all-proxy".to_string(), serde_json::json!(prox)); } @@ -7715,7 +7727,7 @@ impl SidecarSpawner for ProductionSpawner { } } -#[derive(Debug, Clone, Deserialize, TS)] +#[derive(Debug, Clone, Default, Deserialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct EnqueueItem { pub id: String, @@ -7827,73 +7839,84 @@ pub struct EnqueueItem { } impl EnqueueItem { + pub fn strip_torrent_credentials(&mut self) { + if self.is_torrent.unwrap_or(false) { + self.username = None; + self.password = None; + self.headers = None; + self.cookies = None; + } + } + pub fn into_task(self) -> QueuedTask { - let media = self.is_media.unwrap_or(false); + let mut item = self; + item.strip_torrent_credentials(); + let media = item.is_media.unwrap_or(false); let kind = if media { TaskKind::Media } else { TaskKind::Aria2 }; - let id = self.id.clone(); + let id = item.id.clone(); QueuedTask { id, - queue_id: self.queue_id, + queue_id: item.queue_id, kind, - lifecycle_generation: self + lifecycle_generation: item .lifecycle_generation .as_deref() .and_then(|generation| generation.parse().ok()) .unwrap_or_default(), payload: SpawnPayload { - url: self.url, - destination: self.destination, - filename: self.filename, - connections: self.connections, - speed_limit: self.speed_limit, - username: self.username, - password: self.password, - sftp_host_key_md: self.sftp_host_key_md, - headers: self.headers, - checksum: self.checksum, - cookies: self.cookies, - mirrors: self.mirrors, - user_agent: self.user_agent, - max_tries: self.max_tries, - minimum_normal_download_speed_kib: self + url: item.url, + destination: item.destination, + filename: item.filename, + connections: item.connections, + speed_limit: item.speed_limit, + username: item.username, + password: item.password, + sftp_host_key_md: item.sftp_host_key_md, + headers: item.headers, + checksum: item.checksum, + cookies: item.cookies, + mirrors: item.mirrors, + user_agent: item.user_agent, + max_tries: item.max_tries, + minimum_normal_download_speed_kib: item .minimum_normal_download_speed_kib .unwrap_or_default(), - retry_not_found_errors: self.retry_not_found_errors.unwrap_or(false), - adaptive_mirror_selection: self.adaptive_mirror_selection.unwrap_or(true), - proxy: self.proxy, + retry_not_found_errors: item.retry_not_found_errors.unwrap_or(false), + adaptive_mirror_selection: item.adaptive_mirror_selection.unwrap_or(true), + proxy: item.proxy, aria2_resolver_mode: Aria2ResolverMode::Automatic, - format_selector: self.format_selector, - cookie_source: self.cookie_source, + format_selector: item.format_selector, + cookie_source: item.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, - torrent_seed_time: self.torrent_seed_remaining.or(self.torrent_seed_time), - torrent_seed_ratio: self.torrent_seed_ratio, - torrent_seed_remaining: self.torrent_seed_remaining, - torrent_web_seeds: self.torrent_web_seeds, - torrent_upload_limit: self.torrent_upload_limit, - torrent_max_peers: self.torrent_max_peers, - torrent_peer_speed_limit: self.torrent_peer_speed_limit, - torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false), - torrent_trackers: self.torrent_trackers, - torrent_exclude_trackers: self.torrent_exclude_trackers, - torrent_tracker_connect_timeout: self.torrent_tracker_connect_timeout, - torrent_tracker_timeout: self.torrent_tracker_timeout, - torrent_tracker_interval: self.torrent_tracker_interval, - torrent_stop_timeout: self.torrent_stop_timeout, - torrent_prioritize_piece: self.torrent_prioritize_piece, - torrent_remove_unselected_file: self + is_torrent: item.is_torrent.unwrap_or(false), + torrent_path: item.torrent_path, + torrent_file_indices: item.torrent_file_indices, + torrent_seed_time: item.torrent_seed_remaining.or(item.torrent_seed_time), + torrent_seed_ratio: item.torrent_seed_ratio, + torrent_seed_remaining: item.torrent_seed_remaining, + torrent_web_seeds: item.torrent_web_seeds, + torrent_upload_limit: item.torrent_upload_limit, + torrent_max_peers: item.torrent_max_peers, + torrent_peer_speed_limit: item.torrent_peer_speed_limit, + torrent_check_integrity: item.torrent_check_integrity.unwrap_or(false), + torrent_trackers: item.torrent_trackers, + torrent_exclude_trackers: item.torrent_exclude_trackers, + torrent_tracker_connect_timeout: item.torrent_tracker_connect_timeout, + torrent_tracker_timeout: item.torrent_tracker_timeout, + torrent_tracker_interval: item.torrent_tracker_interval, + torrent_stop_timeout: item.torrent_stop_timeout, + torrent_prioritize_piece: item.torrent_prioritize_piece, + torrent_remove_unselected_file: item .torrent_remove_unselected_file .unwrap_or(false), - torrent_encryption_policy: self.torrent_encryption_policy, - torrent_file_allocation: self.torrent_file_allocation, - torrent_verify_only: self.torrent_verify_only.unwrap_or(false), - torrent_verify_restore_status: self.torrent_verify_restore_status, + torrent_encryption_policy: item.torrent_encryption_policy, + torrent_file_allocation: item.torrent_file_allocation, + torrent_verify_only: item.torrent_verify_only.unwrap_or(false), + torrent_verify_restore_status: item.torrent_verify_restore_status, torrent_verified_length: None, }, } @@ -8075,6 +8098,41 @@ mod tests { assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16"))); } + #[test] + fn torrent_payloads_do_not_emit_generic_headers_or_cookies() { + let payload = SpawnPayload { + is_torrent: true, + headers: Some("User-Agent: browser\nAuthorization: secret".to_string()), + cookies: Some("session=secret".to_string()), + ..Default::default() + }; + let mut options = serde_json::Map::new(); + + apply_aria2_header_options(&mut options, &payload, false); + + assert!(!options.contains_key("header")); + } + + #[test] + fn torrent_enqueue_items_strip_generic_credentials_before_task_creation() { + let mut item = EnqueueItem { + is_torrent: Some(true), + username: Some("browser-user".to_string()), + password: Some("secret".to_string()), + headers: Some("Authorization: secret".to_string()), + cookies: Some("session=secret".to_string()), + ..Default::default() + }; + + item.strip_torrent_credentials(); + let task = item.into_task(); + + assert!(task.payload.username.is_none()); + assert!(task.payload.password.is_none()); + assert!(task.payload.headers.is_none()); + assert!(task.payload.cookies.is_none()); + } + #[test] fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() { assert_eq!( diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 576ae8f..d0b3ca4 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -1563,16 +1563,16 @@ export const AddDownloadsModal = () => { // and must not inherit the generic 1–16 HTTP setting. connections: item.isTorrent ? undefined : Number(connections), speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, - username: useAuth ? username.trim() : undefined, - password: useAuth ? password.trim() : undefined, + username: item.isTorrent ? undefined : useAuth ? username.trim() : undefined, + password: item.isTorrent ? undefined : useAuth ? password.trim() : undefined, sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:') ? sftpHostKeyMd.trim() || undefined : undefined, - headers: headersForRow(contextUrl) || undefined, + headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` : undefined, - cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined, + cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined, mirrors: mirrors.trim() || undefined, destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex] ? await destinationForFile( diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 0ca989d..9106f69 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -993,6 +993,36 @@ describe('useDownloadStore', () => { expect(normalized.torrentEncryptionPolicy).toBeUndefined(); }); + it('migrates legacy Torrent credential context before restart resume', () => { + const normalized = normalizePersistedDownloadProgress({ + id: 'legacy-torrent-credentials', + url: 'torrent:0123456789abcdef0123456789abcdef01234567', + fileName: 'payload', + status: 'paused', + category: 'Other', + dateAdded: '', + isTorrent: true, + torrentPath: '/managed/legacy-torrent.torrent', + torrentInfoHash: '0123456789abcdef0123456789abcdef01234567', + username: 'browser-user', + password: 'secret', + headers: 'User-Agent: browser', + cookies: 'session=metadata-only', + credentialsRequired: true, + }); + + expect(normalized).toMatchObject({ + isTorrent: true, + torrentPath: '/managed/legacy-torrent.torrent', + torrentInfoHash: '0123456789abcdef0123456789abcdef01234567', + }); + expect(normalized.username).toBeUndefined(); + expect(normalized.password).toBeUndefined(); + expect(normalized.headers).toBeUndefined(); + expect(normalized.cookies).toBeUndefined(); + expect(normalized.credentialsRequired).toBeUndefined(); + }); + it('recovers an interrupted Torrent move without discarding the native destination marker', () => { const normalized = normalizePersistedDownloadProgress({ id: 'interrupted-torrent-move', @@ -1316,6 +1346,52 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false); }); + it('exposes allocation phase for a preallocated Torrent and strips metadata credentials', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'torrent-allocation-phase', + url: 'torrent:0123456789abcdef0123456789abcdef01234567', + fileName: 'payload', + destination: '/tmp', + status: 'queued', + category: 'Other', + dateAdded: '', + queueId: 'MAIN', + isTorrent: true, + torrentFileAllocation: 'prealloc', + username: 'browser-user', + password: 'secret', + headers: 'User-Agent: browser', + cookies: 'session=metadata-only', + }] as any[], + backendRegisteredIds: new Set(), + allocationPendingIds: new Set(), + }); + + let resolveEnqueue!: (value: { id: string; filename: string }) => void; + const enqueue = new Promise<{ id: string; filename: string }>(resolve => { + resolveEnqueue = resolve; + }); + vi.mocked(ipc.invokeCommand).mockImplementation((command: string, args?: unknown) => { + if (command === 'enqueue_download') { + expect((args as { item: { username: string | null; password: string | null; headers: string | null; cookies: string | null } }).item) + .toMatchObject({ username: null, password: null, headers: null, cookies: null }); + return enqueue as never; + } + if (command === 'get_pending_order') return Promise.resolve(['torrent-allocation-phase']) as never; + return Promise.resolve(undefined) as never; + }); + + const dispatch = dispatchItem('torrent-allocation-phase'); + await vi.waitFor(() => { + expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(true); + }); + + resolveEnqueue({ id: 'torrent-allocation-phase', filename: 'payload' }); + await expect(dispatch).resolves.toBe(true); + expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(false); + }); + it('clears allocation state when a terminal status wins the race', () => { useDownloadStore.setState({ downloads: [{ @@ -2450,6 +2526,61 @@ describe('useDownloadStore', () => { }); }); + it('shows and clears allocation phase for a blocked startup Torrent batch', async () => { + let releaseEnqueue!: (value: Array<{ id: string; success: boolean; filename: string }>) => void; + const enqueue = new Promise>(resolve => { + releaseEnqueue = resolve; + }); + vi.mocked(ipc.invokeCommand).mockImplementation((cmd: string) => { + if (cmd === 'db_get_all_queues') return Promise.resolve([]) as never; + if (cmd === 'db_get_all_downloads') { + return Promise.resolve([JSON.stringify({ + id: 'startup-torrent-allocation', + url: 'torrent:0123456789abcdef0123456789abcdef01234567', + fileName: 'payload', + status: 'queued', + category: 'Other', + dateAdded: '', + queueId: '00000000-0000-0000-0000-000000000001', + hasBeenDispatched: true, + isTorrent: true, + torrentFileAllocation: 'prealloc', + username: 'browser-user', + password: 'secret', + headers: 'User-Agent: browser', + cookies: 'session=metadata-only', + credentialsRequired: true, + })]) as never; + } + if (cmd === 'enqueue_many') return enqueue as never; + if (cmd === 'get_pending_order') return Promise.resolve([]) as never; + return Promise.resolve(undefined) as never; + }); + + await useDownloadStore.getState().initDB(); + const resume = useDownloadStore.getState().resumePendingDownloads(); + + await vi.waitFor(() => { + expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(true); + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_many', + expect.objectContaining({ + items: [expect.objectContaining({ + username: null, + password: null, + headers: null, + cookies: null, + })] + }) + ); + }); + + releaseEnqueue([{ id: 'startup-torrent-allocation', success: true, filename: 'payload' }]); + await resume; + expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(false); + expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBeUndefined(); + }); + it('keeps all startup items retryable when system proxy resolution fails', async () => { vi.mocked(useSettingsStore.getState).mockReturnValue({ ...useSettingsStore.getState(), diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 28ea596..dd2b962 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -339,7 +339,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): await resolveCategoryDestination(settings, item.category); if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false; - const login = getSiteLogin(item.url, settings); + const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings); if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) { settings.setShowKeychainModal(true); return false; @@ -354,7 +354,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): } if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false; - if (item.credentialsRequired === true + if (item.isTorrent !== true && item.credentialsRequired === true && !hasCredentialMaterial(item.password) && !hasCredentialMaterial(item.cookies) && !hasCredentialMaterial(item.headers) @@ -379,12 +379,12 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): ? null : resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), - username: item.username || (login ? login.username : null), - password: item.password || keychainPassword, - sftp_host_key_md: item.sftpHostKeyMd || undefined, - headers: item.headers || null, + username: item.isTorrent === true ? null : item.username || (login ? login.username : null), + password: item.isTorrent === true ? null : item.password || keychainPassword, + sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined, + headers: item.isTorrent === true ? null : item.headers || null, checksum: item.checksum || null, - cookies: item.cookies || null, + cookies: item.isTorrent === true ? null : item.cookies || null, mirrors: item.mirrors || null, user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, @@ -434,7 +434,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): ) { return false; } - const showsAllocationPhase = item.isMedia !== true && item.isTorrent !== true; + const showsAllocationPhase = isAllocationPhaseEligible(admittedItem); if (showsAllocationPhase) { useDownloadStore.getState().setAllocationPending(id, true); } @@ -834,6 +834,13 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down && ['paused', 'failed', 'completed'].includes(rawVerifyRestoreStatus) ? rawVerifyRestoreStatus : undefined; + const torrentCredentialStateChanged = download.isTorrent === true && ( + download.username !== undefined + || download.password !== undefined + || download.headers !== undefined + || download.cookies !== undefined + || download.credentialsRequired !== undefined + ); const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining || download.connections !== normalizedConnections || rawUploadedBytes !== normalizedUploadedBytes || @@ -858,7 +865,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down rawMoveDestination !== normalizedMoveDestination || rawMoveRestoreStatus !== normalizedMoveRestoreStatus || recoveredMoveStatus !== download.status || - rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus + rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus || + torrentCredentialStateChanged ? { ...download, connections: normalizedConnections, @@ -885,7 +893,16 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down torrentRelocationCheckPending: normalizedRelocationCheckPending, torrentMoveDestination: normalizedMoveDestination, torrentMoveRestoreStatus: normalizedMoveRestoreStatus, - torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus + torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus, + ...(download.isTorrent === true + ? { + username: undefined, + password: undefined, + headers: undefined, + cookies: undefined, + credentialsRequired: undefined, + } + : {}) } : recoveredMoveStatus !== download.status ? { ...download, status: recoveredMoveStatus, torrentMoveRestoreStatus: normalizedMoveRestoreStatus } @@ -1167,6 +1184,15 @@ export const useDownloadStore = create((set, get) => { : credentialsUpdated && item.credentialsRequired === true ? { credentialsRequired: true } : {}), + ...(item.isTorrent === true + ? { + username: undefined, + password: undefined, + headers: undefined, + cookies: undefined, + credentialsRequired: undefined, + } + : {}), }; const disablingTorrentRemoval = item.isTorrent === true && normalizedUpdates.torrentRemoveUnselectedFile === false @@ -1261,7 +1287,7 @@ export const useDownloadStore = create((set, get) => { if (!targetItem) return false; } - if (targetItem.credentialsRequired === true + if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true && !hasCredentialMaterial(targetItem.password) && !hasCredentialMaterial(targetItem.cookies) && !hasCredentialMaterial(targetItem.headers)) { @@ -1285,6 +1311,8 @@ export const useDownloadStore = create((set, get) => { } } clearCredentialsRequired(id); + } else if (targetItem.isTorrent === true && targetItem.credentialsRequired === true) { + clearCredentialsRequired(id); } setDownloadControlIntent(id, 'resume'); @@ -1784,6 +1812,15 @@ export const useDownloadStore = create((set, get) => { const settings = useSettingsStore.getState(); const normalizedItem = { ...item, + ...(item.isTorrent === true + ? { + username: undefined, + password: undefined, + headers: undefined, + cookies: undefined, + credentialsRequired: undefined, + } + : {}), fileName: canonicalizeDownloadFileName(item.fileName), category: categoryForFileName(item.fileName, item.isTorrent === true) }; @@ -2628,7 +2665,7 @@ export const useDownloadStore = create((set, get) => { const item = get().downloads.find(download => download.id === pendingItem.id); if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue; - const login = getSiteLogin(item.url, settings); + const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings); let keychainPassword = null; if (login && !item.password && settings.keychainAccessReady) { try { @@ -2637,7 +2674,7 @@ export const useDownloadStore = create((set, get) => { console.warn("Could not fetch keychain password for login:", e); } } - if (item.credentialsRequired === true + if (item.isTorrent !== true && item.credentialsRequired === true && !hasCredentialMaterial(item.password) && !hasCredentialMaterial(item.cookies) && !hasCredentialMaterial(item.headers) @@ -2658,12 +2695,12 @@ export const useDownloadStore = create((set, get) => { ? null : resolveDownloadConnections(item.connections, settings.perServerConnections), speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), - username: item.username || (login ? login.username : null), - password: item.password || keychainPassword, - sftp_host_key_md: item.sftpHostKeyMd || undefined, - headers: item.headers || null, + username: item.isTorrent === true ? null : item.username || (login ? login.username : null), + password: item.isTorrent === true ? null : item.password || keychainPassword, + sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined, + headers: item.isTorrent === true ? null : item.headers || null, checksum: item.checksum || null, - cookies: item.cookies || null, + cookies: item.isTorrent === true ? null : item.cookies || null, mirrors: item.mirrors || null, user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, @@ -2724,7 +2761,20 @@ export const useDownloadStore = create((set, get) => { currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation; }); if (dispatchableItems.length === 0) return; - const results = await invoke('enqueue_many', { items: dispatchableItems }); + const allocationPendingIds = dispatchableItems + .filter(item => { + const current = latestItems.get(item.id); + return current !== undefined && isAllocationPhaseEligible(current); + }) + .map(item => item.id); + allocationPendingIds.forEach(id => get().setAllocationPending(id, true)); + + let results; + try { + results = await invoke('enqueue_many', { items: dispatchableItems }); + } finally { + allocationPendingIds.forEach(id => get().setAllocationPending(id, false)); + } const registeredIds = results.filter(result => result.success).map(result => result.id); const failedErrors = new Map( results diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 1cdd13a..01345a8 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -9,6 +9,7 @@ import { categoryForDownload, categoryForFileName, isAllocationPhaseVisible, + isAllocationPhaseEligible, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, normalizeTorrentEncryptionPolicy, @@ -88,6 +89,24 @@ describe('download persistence progress snapshots', () => { expect(persisted.headers).toBeUndefined(); }); + it('does not create a credential gate for Torrent metadata context', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + isTorrent: true, + username: 'browser-user', + password: 'secret', + cookies: 'session=metadata-only', + headers: 'User-Agent: browser', + credentialsRequired: true, + }); + + expect(persisted.credentialsRequired).toBeUndefined(); + expect(persisted.username).toBeUndefined(); + expect(persisted.password).toBeUndefined(); + expect(persisted.cookies).toBeUndefined(); + expect(persisted.headers).toBeUndefined(); + }); + it.each(['queued', 'staged', 'retrying', 'processing'] as const)( 'keeps byte counters for %s snapshots', (status) => { @@ -116,6 +135,15 @@ describe('allocation phase visibility', () => { expect(isAllocationPhaseVisible(true, 'completed')).toBe(false); expect(isAllocationPhaseVisible(false, 'downloading')).toBe(false); }); + + it('uses Torrent allocation settings and excludes media and verify-only work', () => { + expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: undefined })).toBe(true); + expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'prealloc' })).toBe(true); + expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'none' })).toBe(false); + expect(isAllocationPhaseEligible({ isTorrent: true, torrentVerifyOnly: true })).toBe(false); + expect(isAllocationPhaseEligible({ isTorrent: true, isMedia: true })).toBe(false); + expect(isAllocationPhaseEligible({ isTorrent: false, isMedia: false })).toBe(true); + }); }); describe('Torrent tracker input validation', () => { diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index e006925..f854148 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -55,6 +55,20 @@ export const isAllocationPhaseVisible = ( status: DownloadStatus, ): boolean => allocationPending && status !== 'completed' && status !== 'paused'; +/** + * Allocation is a transient admission phase. Normal downloads retain the + * existing preallocation behavior; Torrent rows use Aria2's Torrent-specific + * allocation setting and never show the hint for verification-only work. + */ +export const isAllocationPhaseEligible = ( + download: Pick, +): boolean => { + if (download.isMedia === true) return false; + if (download.isTorrent !== true) return true; + return download.torrentVerifyOnly !== true + && normalizeTorrentFileAllocation(download.torrentFileAllocation) !== 'none'; +}; + export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MAX = 16; @@ -590,7 +604,13 @@ const VOLATILE_PROGRESS_STATUSES = new Set([ */ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => { const copy: DownloadItem = { ...item }; - if (item.credentialsRequired === true + if (item.isTorrent === true) { + // Torrent request credentials belong only to metadata acquisition. A + // legacy row may still carry the marker or username in memory, but neither + // may turn a cached-metadata Torrent into a credential-gated restart. + delete copy.credentialsRequired; + delete copy.username; + } else if (item.credentialsRequired === true || DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) { copy.credentialsRequired = true; }