diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c2d6080..c1296b3 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -807,6 +807,9 @@ pub struct DownloadStateEvent { pub resolver_fallback: Option, #[ts(optional)] pub file_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub destination: Option, #[ts(optional)] pub torrent_seed_remaining: Option, } @@ -820,6 +823,7 @@ impl DownloadStateEvent { error_kind: None, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: None, } } @@ -833,6 +837,7 @@ impl DownloadStateEvent { error_kind, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: None, } } @@ -846,6 +851,7 @@ impl DownloadStateEvent { error_kind, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: None, } } @@ -858,6 +864,7 @@ impl DownloadStateEvent { error_kind: None, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: remaining, } } @@ -870,6 +877,7 @@ impl DownloadStateEvent { error_kind: None, resolver_fallback: None, file_name: Some(file_name.into()), + destination: None, torrent_seed_remaining: None, } } @@ -885,6 +893,7 @@ impl DownloadStateEvent { error_kind, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: None, } } @@ -897,6 +906,7 @@ impl DownloadStateEvent { error_kind: None, resolver_fallback: None, file_name: None, + destination: None, torrent_seed_remaining: remaining, } } @@ -910,6 +920,11 @@ impl DownloadStateEvent { event } + pub fn with_destination(mut self, destination: impl Into) -> Self { + self.destination = Some(destination.into()); + self + } + fn safe_error(error: impl Into) -> (String, Option) { let error = crate::redact_sensitive_text(&error.into()); let error_kind = crate::retry::is_aria2_name_resolution_error(&error) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 387b9f4..f6b4fe8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8426,9 +8426,24 @@ async fn move_torrent_data( database: tauri::State<'_, crate::db::DbState>, id: String, destination: String, + session_id: Option, ) -> Result<(), String> { properties_window::ensure_properties_or_main(&caller, &properties, &id)?; + let properties_session_id = if caller.label() == "main" { + None + } else { + let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; + if !properties.session_matches(caller.label(), &session_id)? { + return Err("Properties window session is no longer current".to_string()); + } + Some(session_id) + }; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + if let Some(session_id) = properties_session_id.as_deref() { + if !properties.session_matches(caller.label(), session_id)? { + return Err("Properties window session is no longer current".to_string()); + } + } let item = load_persisted_torrent_item(database.inner(), &id)?; if item.is_torrent != Some(true) { return Err("data relocation is available only for Torrent downloads".to_string()); @@ -8576,7 +8591,14 @@ async fn move_torrent_data( .await .map_err(|_| "could not prepare Torrent move recovery".to_string())?; } - state.queue_manager.begin_torrent_move(&id).await; + if let Some(session_id) = properties_session_id.as_deref() { + properties.with_current_session(caller.label(), session_id, || { + state.queue_manager.begin_torrent_move(&id); + Ok(()) + })?; + } else { + state.queue_manager.begin_torrent_move(&id); + } if let Err(error) = write_torrent_move_journal( &journal, "reserved", @@ -8595,12 +8617,12 @@ async fn move_torrent_data( ) .await { - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); return Err(error); } if let Err(error) = tokio::fs::create_dir(&staging_root).await { let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); return Err(format!("could not prepare Torrent move staging: {error}")); } if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal( @@ -8611,28 +8633,32 @@ async fn move_torrent_data( &new_removal_paths, ) { let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); return Err(error); } use tauri::Emitter; let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Moving)); + let move_restore_event = || { + crate::ipc::DownloadStateEvent::new(&id, item.status) + .with_destination(old_destination.to_string_lossy()) + }; let mut copied_bytes = 0u64; for (source, target) in move_old_paths.iter().zip(staging_paths.iter()) { - if state.queue_manager.torrent_move_cancelled(&id).await { + if state.queue_manager.torrent_move_cancelled(&id) { cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent move canceled".to_string()); } if let Err(error) = copy_torrent_move_file(source, target).await { cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit("download-state", move_restore_event()); return Err(error); } let source_size = match tokio::fs::metadata(source).await { @@ -8641,10 +8667,10 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + move_restore_event(), ); return Err("Torrent source changed during relocation".to_string()); } @@ -8655,10 +8681,10 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + move_restore_event(), ); return Err("Torrent destination could not be verified".to_string()); } @@ -8667,7 +8693,7 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent data changed during relocation".to_string()); } let source_digest = digest_torrent_move_file(source).await; @@ -8676,7 +8702,7 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent data changed during relocation".to_string()); } copied_bytes = copied_bytes.saturating_add(target_size); @@ -8687,12 +8713,12 @@ async fn move_torrent_data( total_bytes, }); } - if state.queue_manager.torrent_move_cancelled(&id).await { + if state.queue_manager.torrent_move_cancelled(&id) { cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent move canceled".to_string()); } for (staged, target) in staging_paths.iter().zip(move_new_paths.iter()) { @@ -8700,10 +8726,10 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + move_restore_event(), ); return Err("Torrent move destination could not be published".to_string()); } @@ -8712,10 +8738,10 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + move_restore_event(), ); return Err("Torrent move staging could not be finalized".to_string()); } @@ -8740,10 +8766,10 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + move_restore_event(), ); return Err(error); } @@ -8761,8 +8787,8 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit("download-state", move_restore_event()); return Err(error); } if let Err(error) = write_torrent_move_journal( @@ -8787,10 +8813,11 @@ async fn move_torrent_data( // let startup recovery finish old-source cleanup from the committed // destination rather than rolling the row back after commit. let _ = persist_torrent_relocation_check(database.inner(), &id, true); - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + crate::ipc::DownloadStateEvent::new(&id, item.status) + .with_destination(new_destination.to_string_lossy()), ); return Err(format!("Torrent data moved; cleanup recovery remains pending: {error}")); } @@ -8828,23 +8855,32 @@ async fn move_torrent_data( &move_new_paths, total_bytes, ).await { - state.queue_manager.finish_torrent_move(&id).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit( "download-state", - crate::ipc::DownloadStateEvent::new(&id, item.status), + crate::ipc::DownloadStateEvent::new(&id, item.status) + .with_destination(new_destination.to_string_lossy()), ); return Err(format!( "Torrent data moved, but cleanup recovery could not be recorded: {journal_error}" )); } - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status) + .with_destination(new_destination.to_string_lossy()), + ); drop(control_guard); return Err(format!("Torrent data moved, but old files need cleanup: {error}")); } let _ = tokio::fs::remove_file(&journal).await; - state.queue_manager.finish_torrent_move(&id).await; - let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + state.queue_manager.finish_torrent_move(&id); + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status) + .with_destination(new_destination.to_string_lossy()), + ); drop(control_guard); Ok(()) } @@ -8852,14 +8888,26 @@ async fn move_torrent_data( #[tauri::command] async fn cancel_torrent_move_data( caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, id: String, + session_id: Option, ) -> Result<(), String> { - properties_window::ensure_main_window(&caller)?; if id.trim().is_empty() { return Err("invalid Torrent download id".to_string()); } - state.queue_manager.cancel_torrent_move(&id).await; + if caller.label() == "main" { + properties_window::ensure_main_window(&caller)?; + } else { + let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; + properties.with_current_session(caller.label(), &session_id, || { + state.queue_manager.cancel_torrent_move(&id); + Ok(()) + })?; + return Ok(()); + } + state.queue_manager.cancel_torrent_move(&id); Ok(()) } diff --git a/src-tauri/src/properties_window.rs b/src-tauri/src/properties_window.rs index 211abfe..bbae442 100644 --- a/src-tauri/src/properties_window.rs +++ b/src-tauri/src/properties_window.rs @@ -238,6 +238,26 @@ impl PropertiesWindowRegistry { Ok(self.session_for_window(label)?.as_deref() == Some(session_id)) } + /// Validate a session and perform a short synchronous mutation while the + /// registry lock is held. Callers use this for cancellation flags so a + /// stale session cannot pass validation and then race a replacement + /// session before its mutation is recorded. + pub fn with_current_session( + &self, + label: &str, + session_id: &str, + mutation: impl FnOnce() -> Result, + ) -> Result { + let state = self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())?; + if state.sessions_by_window.get(label).map(String::as_str) != Some(session_id) { + return Err("Properties window session is no longer current".to_string()); + } + mutation() + } + #[cfg(test)] pub fn is_ready(&self, label: &str) -> Result { Ok(self @@ -478,8 +498,16 @@ pub fn properties_window_send_ready( pub fn properties_window_reveal( caller: tauri::WebviewWindow, registry: tauri::State<'_, PropertiesWindowRegistry>, + session_id: Option, ) -> Result<(), String> { registered_download_for_caller(&caller, ®istry)?; + if caller.label() != MAIN_WINDOW_LABEL { + let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?; + validate_properties_session_id(&session_id)?; + if !registry.session_matches(caller.label(), &session_id)? { + return Err("Properties window session is no longer current".to_string()); + } + } registry.mark_ready(caller.label())?; caller.show().map_err(|error| error.to_string())?; caller.set_focus().map_err(|error| error.to_string()) @@ -722,6 +750,28 @@ mod tests { assert!(!registry.session_matches(&label, "session-new").unwrap()); } + #[test] + fn current_session_mutation_is_fenced_from_retired_sessions() { + let registry = PropertiesWindowRegistry::default(); + let label = registry.allocate("download-a").unwrap(); + registry.register_session(&label, "session-old").unwrap(); + + let mut mutations = 0; + let stale = registry.with_current_session(&label, "session-old", || { + mutations += 1; + Ok(()) + }); + assert!(stale.is_ok()); + + registry.register_session(&label, "session-new").unwrap(); + let rejected = registry.with_current_session(&label, "session-old", || { + mutations += 1; + Ok(()) + }); + assert!(rejected.is_err()); + assert_eq!(mutations, 1); + } + #[test] fn child_actions_are_allowlisted() { assert!(is_properties_action("apply-properties")); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index be25e52..e206914 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -942,7 +942,7 @@ pub struct QueueManager { /// are scoped to the current GID and control epoch and never leave this /// process as durable state. torrent_telemetry: Mutex>, - torrent_move_cancellations: Mutex>, + torrent_move_cancellations: StdMutex>, /// aria2 gid -> download id map (shared with the WS poller). pub aria2_gids: Arc>>, @@ -1045,7 +1045,7 @@ impl QueueManager { }), seed_budgets: StdMutex::new(HashMap::new()), torrent_telemetry: Mutex::new(HashMap::new()), - torrent_move_cancellations: Mutex::new(HashSet::new()), + torrent_move_cancellations: StdMutex::new(HashSet::new()), aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), aria2_payloads: Mutex::new(HashMap::new()), @@ -1135,23 +1135,32 @@ impl QueueManager { true } - pub async fn begin_torrent_move(&self, id: &str) { - self.torrent_move_cancellations.lock().await.remove(id); - } - - pub async fn cancel_torrent_move(&self, id: &str) { + pub fn begin_torrent_move(&self, id: &str) { self.torrent_move_cancellations .lock() - .await + .expect("Torrent move cancellation lock poisoned") + .remove(id); + } + + pub fn cancel_torrent_move(&self, id: &str) { + self.torrent_move_cancellations + .lock() + .expect("Torrent move cancellation lock poisoned") .insert(id.to_string()); } - pub async fn torrent_move_cancelled(&self, id: &str) -> bool { - self.torrent_move_cancellations.lock().await.contains(id) + pub fn torrent_move_cancelled(&self, id: &str) -> bool { + self.torrent_move_cancellations + .lock() + .expect("Torrent move cancellation lock poisoned") + .contains(id) } - pub async fn finish_torrent_move(&self, id: &str) { - self.torrent_move_cancellations.lock().await.remove(id); + pub fn finish_torrent_move(&self, id: &str) { + self.torrent_move_cancellations + .lock() + .expect("Torrent move cancellation lock poisoned") + .remove(id); } /// Drop counters after terminal cleanup/removal. Persisted lifetime diff --git a/src/bindings/DownloadStateEvent.ts b/src/bindings/DownloadStateEvent.ts index 21acf19..4d2ae67 100644 --- a/src/bindings/DownloadStateEvent.ts +++ b/src/bindings/DownloadStateEvent.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 { DownloadErrorKind } from "./DownloadErrorKind"; -export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, torrentSeedRemaining?: number, }; +export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, }; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 039450f..29f2d4a 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; @@ -27,6 +27,7 @@ import { propertiesDiagnosticPhase, propertiesActionRequestKey, propertiesWindowEventTarget, + redactPropertiesError, resetPropertiesActionState, type PropertiesAction, type PropertiesActionRequest, @@ -36,7 +37,7 @@ import { type PropertiesSnapshot, type PropertiesSnapshotEvent, } from '../propertiesBridge'; -import { formatDownloadBytes, formatTorrentRatio, resolveDownloadFraction } from '../utils/downloadProgress'; +import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; import { getWindowControlRailWidth } from '../utils/windowControlStyle'; @@ -49,7 +50,7 @@ import { } from '../utils/propertiesDiagnostics'; import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl'; import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; -import { getPropertiesConnectionPresentation } from '../utils/propertiesPresentation'; +import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation'; import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, @@ -78,7 +79,7 @@ const safeTitle = (name: string) => { return `${bounded || 'Download'} - Properties - Firelink`; }; -const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); +const errorText = redactPropertiesError; const PropertiesHelp = ({ text }: { text: string }) => ( } {isTorrent && <>
- - - + + + {snapshot.status === 'moving' ? : }
$.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}>
- - - + + + {snapshot.status === 'moving' ? : }
} @@ -1255,9 +1282,10 @@ export const PropertiesWindowApp = () => {
{activeTab === 'overview' &&
- - + +
+ {!identityEditingEnabled &&

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

}
@@ -1314,7 +1342,7 @@ export const PropertiesWindowApp = () => { {t($ => $.properties.torrentDetailsCreator)}{details.creator || '—'} {t($ => $.properties.torrentDetailsComment)}{details.comment || '—'}
} - {isTorrent &&
} + {isTorrent &&
}
} {activeTab === 'files' && isTorrent &&
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 3a31028..8507273 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -31,6 +31,7 @@ import { propertiesActionRequestKey, PROPERTIES_PATCH_CLEARABLE_KEYS, sanitizePropertiesSnapshot, + redactPropertiesError, sendPropertiesActionResult, sendPropertiesRemoved, sendPropertiesSnapshot, @@ -46,7 +47,7 @@ import { getPlatformInfo } from '../utils/platform'; import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle'; import i18n, { localeDirection, resolveAppLocale } from '../i18n'; -const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); +const errorText = redactPropertiesError; let lastPropertiesBridgeGeneration = 0; const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => { @@ -58,7 +59,10 @@ const normalizeOptionalSpeed = (value: unknown, label: string): string | undefin return normalized; }; -const copyEditablePatch = (rawPatch: PropertiesPatch): Partial => { +export const copyEditablePropertiesPatch = ( + rawPatch: PropertiesPatch, + item?: Pick, +): Partial => { const safePatch: Partial = {}; const copy = (key: keyof PropertiesPatch) => { if (Object.prototype.hasOwnProperty.call(rawPatch, key)) { @@ -68,6 +72,7 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial => for (const key of [ 'fileName', 'destination', + 'sftpHostKeyMd', 'connections', 'speedLimit', 'torrentTrackers', @@ -88,6 +93,13 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial => 'torrentFileAllocation', ] as const) copy(key); + if (item && (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) { + if (Object.prototype.hasOwnProperty.call(rawPatch, 'fileName') + || Object.prototype.hasOwnProperty.call(rawPatch, 'destination')) { + throw new Error('File identity and destination are read-only for this download state'); + } + } + for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) { if (Object.prototype.hasOwnProperty.call(rawPatch, key)) { const value = (rawPatch as Record)[key]; @@ -383,7 +395,7 @@ export const PropertiesWindowBridgeHost = () => { if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) { throw new Error('Torrent file selection requires the dedicated selection action'); } - const safePatch = copyEditablePatch(rawPatch); + const safePatch = copyEditablePropertiesPatch(rawPatch, item); if ('password' in rawPatch) { safePatch.password = applySecretPatch(rawPatch.password, item.password); } @@ -421,6 +433,7 @@ export const PropertiesWindowBridgeHost = () => { if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) { throw new Error('Generic connection settings are not available for Torrent downloads'); } + await assertCurrentAction(request); await store.applyProperties(request.downloadId, safePatch); break; } @@ -437,12 +450,14 @@ export const PropertiesWindowBridgeHost = () => { || selectedIndices.some(index => !Number.isInteger(index) || index < 1))) { throw new Error('Torrent file selection must contain at least one valid file'); } + await assertCurrentAction(request); const selection = await invoke('set_torrent_file_selection', { id: request.downloadId, selected_indices: selectedIndices, }); const selected = selection.files.filter(file => file.selected).map(file => file.index); const allSelected = selection.files.length > 0 && selected.length === selection.files.length; + await assertCurrentAction(request); store.updateDownload(request.downloadId, { torrentFileIndices: allSelected ? undefined : selected, }); @@ -484,29 +499,40 @@ export const PropertiesWindowBridgeHost = () => { } const previousVerifyOnly = item.torrentVerifyOnly; const previousRestoreStatus = item.torrentVerifyRestoreStatus; + await assertCurrentAction(request); store.updateDownload(request.downloadId, { torrentVerifyOnly: true, torrentVerifyRestoreStatus: item.status, }); try { + await assertCurrentAction(request); await invoke('verify_torrent_data', { id: request.downloadId }); } catch (verifyError) { - useDownloadStore.getState().updateDownload(request.downloadId, { - torrentVerifyOnly: previousVerifyOnly, - torrentVerifyRestoreStatus: previousRestoreStatus, - }); + try { + await assertCurrentAction(request); + useDownloadStore.getState().updateDownload(request.downloadId, { + torrentVerifyOnly: previousVerifyOnly, + torrentVerifyRestoreStatus: previousRestoreStatus, + }); + } catch { + // A newer Properties session owns the row now. Do not let a + // late verification failure roll back its marker. + } throw verifyError; } break; } case 'set-download-limit': + await assertCurrentAction(request); await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null); break; case 'set-torrent-upload-limit': + await assertCurrentAction(request); await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null); break; case 'set-torrent-peer-options': { if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options'); + await assertCurrentAction(request); await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit); break; } diff --git a/src/index.css b/src/index.css index a272520..73f97f8 100644 --- a/src/index.css +++ b/src/index.css @@ -23,6 +23,7 @@ --bg-input: 0 0% 100%; --border-modal: 0 0% 85%; --surface-raised: 0 0% 100%; + --properties-header-surface: hsl(var(--bg-modal)); /* Keep this token alpha-free because some consumers apply their own /alpha. */ --surface-overlay: 0 0% 100%; --shadow-color: 220 10% 20% / 0.1; @@ -69,6 +70,7 @@ --bg-input: 0 0% 100%; --border-modal: 0 0% 85%; --surface-raised: 0 0% 100%; + --properties-header-surface: hsl(var(--bg-modal)); --surface-overlay: 0 0% 100%; --shadow-color: 220 10% 20% / 0.1; --sidebar-shell-bg: 0 0% 92%; @@ -99,6 +101,7 @@ --text-muted: 0 0% 60%; --bg-modal: 0 0% 13%; --bg-input: 0 0% 16%; + --properties-header-surface: hsl(0 0% 10%); --shadow-color: 0 0% 0% / 0.30; --status-completed: 136 62% 48%; --status-paused: 0 0% 56%; @@ -145,6 +148,7 @@ --text-muted: 229 12% 66%; --bg-modal: 232 14% 23%; --bg-input: 231 15% 20%; + --properties-header-surface: hsl(231 15% 17%); --shadow-color: 231 20% 8% / 0.35; --status-completed: 135 94% 65%; --status-paused: 65 92% 76%; @@ -191,6 +195,7 @@ --text-muted: 218 17% 70%; --bg-modal: 220 17% 27%; --bg-input: 220 16% 24%; + --properties-header-surface: hsl(220 16% 19%); --shadow-color: 220 25% 10% / 0.34; --status-completed: 92 28% 65%; --status-paused: 40 71% 73%; @@ -578,7 +583,6 @@ html[data-list-density="relaxed"] { .properties-window-shell { --properties-body-surface: hsl(var(--main-bg)); - --properties-header-surface: hsl(var(--bg-modal)); --properties-card-surface: hsl(var(--bg-input)); --properties-card-border: hsl(var(--border-modal)); --properties-live-value: hsl(var(--properties-live-value-color)); @@ -622,8 +626,7 @@ html[data-list-density="relaxed"] { .properties-window-header { background: var(--properties-header-surface); box-shadow: - inset 0 -1px 0 var(--properties-card-border), - 0 8px 24px hsl(var(--shadow-color)); + inset 0 -1px 0 hsl(var(--text-primary) / 0.04); } .properties-window-hero-top { diff --git a/src/ipc.ts b/src/ipc.ts index 335d417..a7c6df5 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -103,8 +103,8 @@ type CommandMap = { verify_torrent_data: { args: { id: string }; result: void }; get_torrent_magnet_link: { args: { id: string }; result: string }; export_torrent_metadata: { args: { id: string; destination: string }; result: void }; - move_torrent_data: { args: { id: string; destination: string }; result: void }; - cancel_torrent_move_data: { args: { id: string }; result: void }; + move_torrent_data: { args: { id: string; destination: string; sessionId?: string }; result: void }; + cancel_torrent_move_data: { args: { id: string; sessionId?: string }; result: void }; get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] }; set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] }; set_torrent_max_open_files: { args: { max_open_files: number }; result: void }; @@ -172,7 +172,7 @@ type CommandMap = { open_download_properties_window: { args: { id: string }; result: string }; get_properties_window_download_id: { args: undefined; result: string }; properties_window_send_ready: { args: { sessionId: string }; result: void }; - properties_window_reveal: { args: undefined; result: void }; + properties_window_reveal: { args: { sessionId?: string }; result: void }; properties_window_send_action: { args: { sessionId: string; requestId: number; action: string; payload?: unknown }; result: void }; validate_properties_window_request: { args: { windowLabel: string; downloadId: string; sessionId: string; requestId?: number }; result: void }; close_download_properties_window: { args: { id: string }; result: void }; diff --git a/src/main.tsx b/src/main.tsx index c452506..b060cfe 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -101,9 +101,12 @@ const renderPropertiesApp = async () => { // reveal command as the normal child path. console.error('Failed to initialize the Properties window:', error); renderRoot(PropertiesStartupFailure); - void invoke('properties_window_reveal').catch(revealError => { - console.error('Failed to reveal the Properties startup error:', revealError); - }); + const fallbackSessionId = crypto.randomUUID(); + void invoke('properties_window_send_ready', { sessionId: fallbackSessionId }) + .then(() => invoke('properties_window_reveal', { sessionId: fallbackSessionId })) + .catch(revealError => { + console.error('Failed to reveal the Properties startup error:', revealError); + }); } }; diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index 14ed585..3b1245e 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -29,10 +29,12 @@ import { propertiesTorrentPeerLimit, propertiesWindowEventTarget, resetPropertiesActionState, + redactPropertiesError, sanitizePropertiesSnapshot, sendPropertiesSnapshot, shouldAcceptPropertiesActionRequest, } from './propertiesBridge'; +import { copyEditablePropertiesPatch } from './components/PropertiesWindowBridgeHost'; describe('Properties window bridge', () => { it('keeps optional override resets explicit across the JSON IPC boundary', () => { @@ -129,6 +131,16 @@ describe('Properties window bridge', () => { expect(snapshot).not.toHaveProperty('aria2ResolverMode'); }); + it('redacts credentials from Properties errors at the renderer boundary', () => { + const error = redactPropertiesError(new Error( + 'GET https://user:pa@ss@example.test/file?token=secret&x=1 Authorization: Bearer bearer-secret', + )); + expect(error).not.toContain('pa@ss@example'); + expect(error).not.toContain('token=secret'); + expect(error).not.toContain('bearer-secret'); + expect(error).toContain('[redacted]'); + }); + it('projects the latest live telemetry without exposing secrets', () => { const snapshot = sanitizePropertiesSnapshot({ id: 'torrent-1', @@ -353,11 +365,31 @@ describe('Properties window bridge', () => { expect(getPropertiesLifecycleAction('retrying')).toBe('pause'); expect(getPropertiesLifecycleAction('paused')).toBe('resume'); expect(getPropertiesLifecycleAction('ready')).toBe('start'); - expect(getPropertiesLifecycleAction('staged')).toBe('start'); + expect(getPropertiesLifecycleAction('staged')).toBe('pause'); expect(getPropertiesLifecycleAction('failed')).toBe('retry'); expect(getPropertiesLifecycleAction('completed')).toBeNull(); }); + it('preserves validated SFTP fingerprints and rejects identity edits after dispatch', () => { + expect(copyEditablePropertiesPatch({ sftpHostKeyMd: 'MD5=0123456789abcdef0123456789abcdef' }, { + isTorrent: false, + status: 'ready', + })).toMatchObject({ sftpHostKeyMd: 'md5=0123456789abcdef0123456789abcdef' }); + + expect(() => copyEditablePropertiesPatch({ fileName: 'renamed.bin' }, { + isTorrent: false, + status: 'completed', + })).toThrow('read-only'); + expect(() => copyEditablePropertiesPatch({ destination: '/new/path' }, { + isTorrent: true, + status: 'paused', + })).toThrow('read-only'); + expect(() => copyEditablePropertiesPatch({ fileName: 'queued.bin' }, { + isTorrent: false, + status: 'queued', + })).toThrow('read-only'); + }); + it('keeps Torrent peer-cap telemetry distinct from generic connections', () => { expect(propertiesTorrentPeerLimit(undefined)).toBe(55); expect(propertiesTorrentPeerLimit(120)).toBe(120); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 1791f23..cbfe348 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -190,6 +190,16 @@ export type PropertiesSnapshot = SafePropertiesFields & { hasMirrors: boolean; }; +export const redactPropertiesError = (error: unknown): string => { + const text = error instanceof Error ? error.message : String(error); + return text + .replace(/(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+/gi, '$1[redacted]') + .replace(/((?:cookie|set-cookie|proxy-authorization)\s*:\s*)[^\r\n]+/gi, '$1[redacted]') + .replace(/((?:https?|sftp|ftp):\/\/)[^\s]*@/gi, '$1[redacted]@') + .replace(/([?&](?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential|code)=)[^&#\s]*/gi, '$1[redacted]') + .replace(/\b(?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential)=\S+/gi, match => `${match.slice(0, match.indexOf('=') + 1)}[redacted]`); +}; + export type SecretPatch = | { kind: 'unchanged' } | { kind: 'replace'; value: string } @@ -252,7 +262,7 @@ export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry'; export const getPropertiesLifecycleAction = ( status: DownloadStatus, ): PropertiesLifecycleAction | null => { - if (status === 'ready' || status === 'staged') return 'start'; + if (status === 'ready') return 'start'; if (canPauseDownload(status)) return 'pause'; if (status === 'paused') return 'resume'; if (status === 'failed') return 'retry'; @@ -388,6 +398,9 @@ const copyWithoutSecrets = ( Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : [] )), ) as SafePropertiesFields; + if (typeof safeItem.lastError === 'string') { + safeItem.lastError = redactPropertiesError(safeItem.lastError); + } if (item.isTorrent === true) delete safeItem.connections; const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError); return { diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index 91de03f..35b8dad 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -92,6 +92,49 @@ describe('useDownloadProgressStore', () => { release(); }); + it('applies the authoritative destination carried by Torrent move completion', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'moving-torrent', + url: 'magnet:?xt=urn:btih:test', + fileName: 'data', + destination: '/old-root', + status: 'moving', + category: 'Other', + dateAdded: '', + isTorrent: true, + }], + }); + useDownloadProgressStore.getState().setMoveProgress('moving-torrent', 0.8); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'moving-torrent', + status: 'paused', + error: 'stale pause from the previous lifecycle', + } }); + + expect(useDownloadStore.getState().downloads[0].status).toBe('moving'); + expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBe(0.8); + + handlers['download-state']({ payload: { + id: 'moving-torrent', + status: 'completed', + error: null, + destination: '/new-root', + } }); + + expect(useDownloadStore.getState().downloads[0].destination).toBe('/new-root'); + expect(useDownloadStore.getState().downloads[0].status).toBe('completed'); + expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBeUndefined(); + release(); + }); + it('keeps Aria2 connection telemetry from the live progress event', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 509e61a..2d9b787 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -121,6 +121,12 @@ const startDownloadListeners = async () => { return; } const status = payload.status as DownloadStatus; + // A move terminal event carries its authoritative destination. Older + // lifecycle events do not, so they must not overwrite an active move or + // clear its progress while the native relocation still owns the row. + if (current.status === 'moving' && status !== 'moving' && payload.destination == null) { + return; + } if (status !== 'moving') { useDownloadProgressStore.getState().clearMoveProgress(payload.id); } @@ -229,6 +235,9 @@ const startDownloadListeners = async () => { current.category ); } + if (payload.destination && payload.destination !== current.destination) { + updates.destination = payload.destination; + } if (status !== 'downloading' && status !== 'verifying') { updates.speed = '-'; updates.eta = '-'; diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 5d13b8e..6c9a91d 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -141,6 +141,28 @@ describe('useDownloadStore', () => { expect(fileName.endsWith('.mp4')).toBe(true); }); + it('rejects queued identity edits before invalidating their dispatch', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'queued-identity', + url: 'https://example.com/file', + fileName: 'file.bin', + destination: '/tmp', + status: 'queued', + category: 'Other', + dateAdded: '', + }] as any[], + }); + + await expect(useDownloadStore.getState().applyProperties('queued-identity', { + fileName: 'renamed.bin', + })).rejects.toThrow('read-only'); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith( + 'cancel_enqueue_generation', + expect.anything(), + ); + }); + it('keeps the credential-required marker when the last secret is cleared', async () => { useDownloadStore.setState({ downloads: [{ @@ -1228,10 +1250,10 @@ describe('useDownloadStore', () => { ).toHaveLength(2); }); - it('re-enqueues the edited values only after an obsolete queued dispatch is removed', async () => { + it('re-enqueues queued transfer edits only after an obsolete dispatch is removed', async () => { useDownloadStore.setState({ downloads: [ - { id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false }, + { id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false, speedLimit: '128K' }, ] as any[], }); @@ -1245,7 +1267,7 @@ describe('useDownloadStore', () => { enqueueCount += 1; return (enqueueCount === 1 ? firstEnqueue - : Promise.resolve({ id: 'edited', filename: 'new.bin' })) as never; + : Promise.resolve({ id: 'edited', filename: 'old.bin' })) as never; } if (command === 'get_pending_order') return Promise.resolve(['edited']) as never; return Promise.resolve(undefined) as never; @@ -1253,7 +1275,7 @@ describe('useDownloadStore', () => { const start = useDownloadStore.getState().startQueue('MAIN'); await vi.waitFor(() => expect(enqueueCount).toBe(1)); - const update = useDownloadStore.getState().applyProperties('edited', { fileName: 'new.bin' }); + const update = useDownloadStore.getState().applyProperties('edited', { speedLimit: '512K' }); resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' }); await expect(update).resolves.toBeUndefined(); @@ -1264,7 +1286,8 @@ describe('useDownloadStore', () => { { queueId: 'MAIN' } ); expect(useDownloadStore.getState().downloads[0]).toMatchObject({ - fileName: 'new.bin', + fileName: 'old.bin', + speedLimit: '512K', hasBeenDispatched: true, }); }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 09f470c..bdf1b7f 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1060,23 +1060,53 @@ interface DownloadState { export const useDownloadStore = create((set, get) => { const applyPropertiesInternal = async (id: string, updates: Partial): Promise => { await waitForPendingStartupResume(); + const initialItem = get().downloads.find(download => download.id === id); + if (!initialItem) return; + // Reject immutable identity edits before invalidating a live queued + // dispatch. Validation after invalidation would cancel a legitimate + // enqueue even though no mutation was accepted. + if ((updates.fileName !== undefined || updates.destination !== undefined) + && (initialItem.isTorrent === true || !['ready', 'staged'].includes(initialItem.status))) { + throw new Error(i18n.t($ => $.properties.identityReadOnly)); + } const wasDispatching = await invalidateAndWaitForDispatch(id); const state = get(); const item = state.downloads.find(d => d.id === id); if (!item) return; const previousItem = item; + const previousPendingOrder = [...state.pendingOrder]; + const wasBackendRegistered = state.backendRegisteredIds.has(id); const commitProperties = async (): Promise => { try { await commitDownloadState(); } catch (error) { - // Do not leave a renderer-only Properties edit that will disappear on - // restart. Restore the prior row while retaining the native lifecycle - // fencing already performed for this operation. + // A queued item may already have been detached before persistence + // failed. Restore both projections, then rebuild the backend lifecycle + // when the previous row owned one; restoring only the React object + // would leave a visible queued row that can never dispatch. set(current => ({ - downloads: current.downloads.map(download => - download.id === id ? previousItem : download - ) + downloads: current.downloads.map(download => download.id === id ? previousItem : download), + pendingOrder: previousPendingOrder, + backendRegisteredIds: new Set( + [...current.backendRegisteredIds].filter(registeredId => registeredId !== id) + ), })); + if ((wasBackendRegistered || wasDispatching) && previousItem.status === 'queued') { + const restored = await dispatchItemInternal(id); + if (!restored) { + set(current => ({ + downloads: current.downloads.map(download => + download.id === id + ? { ...previousItem, status: 'failed' as const, hasBeenDispatched: false, lastError: errorMessage(error) } + : download + ), + pendingOrder: current.pendingOrder.filter(value => value !== id), + backendRegisteredIds: new Set( + [...current.backendRegisteredIds].filter(registeredId => registeredId !== id) + ), + })); + } + } throw error; } }; @@ -1100,6 +1130,11 @@ export const useDownloadStore = create((set, get) => { && normalizedUpdates.torrentRemoveUnselectedFile === false && item.torrentRemoveUnselectedFile !== false; + if ((normalizedUpdates.fileName !== undefined || normalizedUpdates.destination !== undefined) + && (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) { + throw new Error(i18n.t($ => $.properties.identityReadOnly)); + } + if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { throw new Error(i18n.t($ => $.downloadTable.transferActive)); } diff --git a/src/utils/downloadActions.test.ts b/src/utils/downloadActions.test.ts index 3964d14..a4ecfb7 100644 --- a/src/utils/downloadActions.test.ts +++ b/src/utils/downloadActions.test.ts @@ -72,7 +72,7 @@ describe('download action policy', () => { { status: 'completed' }, ]); - expect(counts).toEqual({ pause: 3, resume: 4 }); + expect(counts).toEqual({ pause: 3, resume: 3 }); }); it('keeps large action badges compact without changing the accessible count', () => { diff --git a/src/utils/downloadActions.ts b/src/utils/downloadActions.ts index cc02a9b..3b685fb 100644 --- a/src/utils/downloadActions.ts +++ b/src/utils/downloadActions.ts @@ -45,7 +45,10 @@ export const countDownloadActions = ( downloads: ReadonlyArray<{ status: DownloadStatus }> ): DownloadActionCounts => downloads.reduce((counts, download) => { if (canPauseDownload(download.status)) counts.pause += 1; - if (canStartDownload(download.status)) counts.resume += 1; + if (download.status === 'paused' + || (canStartDownload(download.status) && !canPauseDownload(download.status))) { + counts.resume += 1; + } return counts; }, { pause: 0, resume: 0 }); diff --git a/src/utils/propertiesPresentation.test.ts b/src/utils/propertiesPresentation.test.ts index dc105f7..f59e5aa 100644 --- a/src/utils/propertiesPresentation.test.ts +++ b/src/utils/propertiesPresentation.test.ts @@ -1,7 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { getPropertiesConnectionPresentation } from './propertiesPresentation'; +import { getPropertiesConnectionPresentation, getPropertiesProgress } from './propertiesPresentation'; describe('Properties connection presentation', () => { + it('uses move progress instead of the completed download fraction during relocation', () => { + expect(getPropertiesProgress({ + status: 'moving', + moveProgress: 0.42, + fraction: 1, + downloadedBytes: 100, + totalBytes: 100, + totalIsEstimate: false, + isMedia: false, + size: '100 B', + })).toBe(0.42); + expect(getPropertiesProgress({ + status: 'moving', + moveProgress: 4, + fraction: 0, + isMedia: false, + })).toBe(1); + expect(getPropertiesProgress({ + status: 'moving', + fraction: 1, + downloadedBytes: 100, + totalBytes: 100, + isMedia: false, + })).toBe(0); + }); + it('keeps media concurrency out of the live header metrics', () => { expect(getPropertiesConnectionPresentation({ isMedia: true, diff --git a/src/utils/propertiesPresentation.ts b/src/utils/propertiesPresentation.ts index 7436527..42232d2 100644 --- a/src/utils/propertiesPresentation.ts +++ b/src/utils/propertiesPresentation.ts @@ -1,4 +1,5 @@ import type { PropertiesSnapshot } from '../propertiesBridge'; +import { resolveDownloadFraction } from './downloadProgress'; export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2'; export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections'; @@ -12,6 +13,12 @@ export type PropertiesConnectionPresentation = { const displayCount = (value: number | undefined): string => value == null ? '—' : String(value); +export const getPropertiesProgress = ( + snapshot: Pick, +): number => snapshot.status === 'moving' + ? Math.max(0, Math.min(1, snapshot.moveProgress ?? 0)) + : resolveDownloadFraction(snapshot); + export const getPropertiesConnectionPresentation = ( snapshot: Pick, ): PropertiesConnectionPresentation => {