From c342bcd34717d708b6af7b2a8ed026d38c857855 Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 4 Aug 2026 09:26:38 +0330 Subject: [PATCH] feat(torrent): add standalone properties windows - add caller-bound native properties bridge and lifecycle guards - split Network settings into accessible secondary tabs - add tabbed Torrent and generic Properties surfaces - harden runtime validation and ignore the implementation plan --- .gitignore | 1 + src-tauri/capabilities/properties.json | 15 + src-tauri/src/commands.rs | 4 + src-tauri/src/lib.rs | 317 ++++++++++-- src-tauri/src/parity.rs | 7 +- src-tauri/src/properties_window.rs | 392 +++++++++++++++ src/App.tsx | 16 +- src/components/DownloadTable.tsx | 10 +- src/components/PropertiesWindowApp.tsx | 472 ++++++++++++++++++ src/components/PropertiesWindowBridgeHost.tsx | 250 ++++++++++ src/components/SettingsView.tsx | 130 ++++- src/i18n/catalogs/en.ts | 1 + src/i18n/catalogs/fa.ts | 1 + src/i18n/catalogs/he.ts | 1 + src/i18n/catalogs/ru.ts | 1 + src/i18n/catalogs/uk.ts | 1 + src/i18n/catalogs/zh-CN.ts | 1 + src/ipc.ts | 7 + src/main.tsx | 6 +- src/propertiesBridge.test.ts | 47 ++ src/propertiesBridge.ts | 127 +++++ src/store/useDownloadStore.ts | 59 ++- 22 files changed, 1778 insertions(+), 88 deletions(-) create mode 100644 src-tauri/capabilities/properties.json create mode 100644 src-tauri/src/properties_window.rs create mode 100644 src/components/PropertiesWindowApp.tsx create mode 100644 src/components/PropertiesWindowBridgeHost.tsx create mode 100644 src/propertiesBridge.test.ts create mode 100644 src/propertiesBridge.ts diff --git a/.gitignore b/.gitignore index 22b7c49..59b45e4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ AGENT.md AGENTS.md TORRENT_FEATURES.md torrent_features.md +TORRENT_UI_IMPLEMENTATION_PLAN.md CLAUDE.md GEMINI.md implementation_plan.md diff --git a/src-tauri/capabilities/properties.json b/src-tauri/capabilities/properties.json new file mode 100644 index 0000000..7701e20 --- /dev/null +++ b/src-tauri/capabilities/properties.json @@ -0,0 +1,15 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "properties-window", + "description": "Minimal capability for Firelink Properties windows", + "windows": ["properties-*"], + "permissions": [ + "core:window:allow-close", + "core:window:allow-set-title", + "core:event:allow-listen", + "core:event:allow-unlisten", + "dialog:default", + "clipboard-manager:allow-write-text", + "log:default" + ] +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bc0f085..d13d4b7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,9 +4,11 @@ use tauri_plugin_opener::OpenerExt; #[tauri::command] pub async fn reveal_in_file_manager( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String, ) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; let primary = authorize_reveal_path(&app_handle, &path)?; let path = existing_download_asset(&primary).ok_or_else(|| { format!( @@ -29,9 +31,11 @@ pub async fn reveal_in_file_manager( #[tauri::command] pub async fn open_downloaded_file( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String, ) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; let path = authorize_download_path(&app_handle, &path)?; if !path.exists() { return Err(format!("Downloaded file is missing: {}", path.display())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a84c441..b0b0e25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1919,6 +1919,7 @@ fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) #[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed. #[tauri::command] async fn fetch_metadata( + caller: tauri::WebviewWindow, url: String, user_agent: Option, username: Option, @@ -1929,6 +1930,7 @@ async fn fetch_metadata( proxy: Option, defer_cookies: Option, ) -> Result { + properties_window::ensure_main_window(&caller)?; ensure_reqwest_crypto_provider(); let mut current_url = url.clone(); @@ -2363,6 +2365,7 @@ fn build_ytdlp_config_content( #[tauri::command] #[allow(clippy::too_many_arguments)] // Keep the generated TypeScript IPC contract flat and stable. async fn fetch_media_metadata( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, url: String, cookie_browser: Option, @@ -2373,6 +2376,7 @@ async fn fetch_media_metadata( cookies: Option, proxy: Option, ) -> Result { + properties_window::ensure_main_window(&caller)?; validate_url_ssrf(&url).await?; let cache_key = media_metadata_cache_key( &url, @@ -2498,6 +2502,7 @@ async fn fetch_media_metadata( #[tauri::command] #[allow(clippy::too_many_arguments)] async fn fetch_media_playlist_metadata( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, url: String, cookie_browser: Option, @@ -2508,6 +2513,7 @@ async fn fetch_media_playlist_metadata( cookies: Option, proxy: Option, ) -> Result { + properties_window::ensure_main_window(&caller)?; validate_url_ssrf(&url).await?; let result = fetch_media_playlist_metadata_uncached( @@ -2800,7 +2806,8 @@ async fn fetch_media_metadata_uncached( } #[tauri::command] -async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { +async fn test_ytdlp(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { + properties_window::ensure_main_window(&caller)?; let (version, error, _) = run_sidecar_version(&app_handle, "yt-dlp", &["--version"]).await; match (version, error) { (Some(version), None) => Ok(version), @@ -2810,7 +2817,8 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { } #[tauri::command] -async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { +async fn test_ffmpeg(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { + properties_window::ensure_main_window(&caller)?; let (version, error, _) = run_sidecar_version(&app_handle, "ffmpeg", &["-version"]).await; version .as_deref() @@ -2819,7 +2827,8 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { } #[tauri::command] -async fn test_deno(app_handle: tauri::AppHandle) -> Result { +async fn test_deno(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result { + properties_window::ensure_main_window(&caller)?; let (version, error, _) = run_sidecar_version(&app_handle, "deno", &["--version"]).await; if let Some(text) = version { let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").unwrap(); @@ -2945,7 +2954,12 @@ fn approved_download_roots(app_handle: &tauri::AppHandle) } #[tauri::command] -fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result { +fn approve_download_root( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, + path: String, +) -> Result { + properties_window::ensure_main_window(&caller)?; let resolved = resolve_path(path.trim(), &app_handle); if !resolved.is_absolute() { return Err("Download root must be an absolute path".to_string()); @@ -3039,6 +3053,7 @@ pub mod ipc; mod parity; mod power; mod platform; +mod properties_window; pub mod queue; pub mod process; pub mod retry; @@ -3450,9 +3465,11 @@ impl crate::torrent_probe::RpcClient for Aria2RpcClient { #[tauri::command] async fn test_aria2c( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let guard = app_handle.state::(); let startup_err = guard .startup_error @@ -3988,9 +4005,11 @@ async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem { #[tauri::command] async fn get_engine_status( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let secret = state.aria2_secret.clone(); @@ -4008,9 +4027,11 @@ async fn get_engine_status( #[tauri::command] async fn get_aria2_engine_status( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; Ok(check_aria2( &app_handle, state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -4020,19 +4041,29 @@ async fn get_aria2_engine_status( } #[tauri::command] -async fn get_ytdlp_engine_status(app_handle: tauri::AppHandle) -> Result { +async fn get_ytdlp_engine_status( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, +) -> Result { + properties_window::ensure_main_window(&caller)?; Ok(check_ytdlp(&app_handle).await) } #[tauri::command] async fn get_ffmpeg_engine_status( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, ) -> Result { + properties_window::ensure_main_window(&caller)?; Ok(check_ffmpeg(&app_handle).await) } #[tauri::command] -async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result { +async fn get_deno_engine_status( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, +) -> Result { + properties_window::ensure_main_window(&caller)?; Ok(check_deno(&app_handle).await) } @@ -4491,10 +4522,12 @@ pub(crate) async fn start_media_download_internal( #[tauri::command] async fn pause_download( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; log::info!("pause_download called for id: {}", id); let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; @@ -4718,11 +4751,13 @@ async fn pause_download( #[tauri::command] async fn resume_download( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, queue_id: String, ) -> Result { + properties_window::ensure_main_window(&caller)?; let queue_id = queue_id.trim().to_string(); if queue_id.is_empty() { return Err("Queue id cannot be empty".to_string()); @@ -5077,12 +5112,14 @@ async fn resume_download( #[tauri::command] async fn remove_download( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, delete_assets: bool, preserve_resumable: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); let control_guard = state.queue_manager.acquire_aria2_control(&id).await; @@ -5270,9 +5307,11 @@ async fn remove_download( #[tauri::command] fn get_download_primary_path( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, id: String, ) -> Result, String> { + properties_window::ensure_main_window(&caller)?; crate::download_ownership::primary_path_for_id(&app_handle, &id) .map(|path| path.map(|path| path.to_string_lossy().to_string())) } @@ -5346,10 +5385,12 @@ pub(crate) async fn remove_download_assets( #[tauri::command] async fn detach_download_for_reconfigure( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; log::info!("detach_download_for_reconfigure called for id: {}", id); let control_guard = state.queue_manager.acquire_aria2_control(&id).await; detach_download_for_reconfigure_locked( @@ -5677,11 +5718,13 @@ fn begin_dock_badge_session() -> u64 { #[tauri::command] #[allow(unused_variables)] fn update_dock_badge( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, count: i32, generation: u64, session: u64, -) { +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; #[cfg(target_os = "macos")] { use objc::runtime::Object; @@ -5719,29 +5762,41 @@ fn update_dock_badge( } }); } + Ok(()) } #[tauri::command] -fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo { - crate::ipc::PlatformInfo { +fn get_platform_info( + caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Result { + properties_window::ensure_main_window(&caller)?; + Ok(crate::ipc::PlatformInfo { os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), target_triple: crate::platform::target_triple(), portable: state.storage_layout.is_portable(), - } + }) } #[tauri::command] -fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { +fn set_prevent_sleep( + caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + prevent: bool, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state.power_manager.set_system_prevention(prevent) } #[tauri::command] fn set_power_preferences( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, prevent_system_sleep: bool, prevent_display_sleep: bool, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .power_manager .set_preferences(prevent_system_sleep, prevent_display_sleep) @@ -5761,17 +5816,23 @@ pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Resu } #[tauri::command] -fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { +fn perform_system_action( + caller: tauri::WebviewWindow, + action: crate::ipc::PostQueueAction, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; execute_system_action(action) } #[tauri::command] fn ack_schedule_trigger( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, action: String, key: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; crate::settings::update_settings_state(&app_handle, |state| match action.as_str() { "start" => { state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key)); @@ -5800,9 +5861,11 @@ fn ack_schedule_trigger( #[tauri::command] async fn get_pending_order( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, queue_id: Option, ) -> Result, AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(state.queue_manager.pending_order(queue_id.as_deref()).await) } @@ -6272,6 +6335,7 @@ async fn resolve_magnet_metadata( #[tauri::command] async fn inspect_torrent( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, source: String, @@ -6279,6 +6343,7 @@ async fn inspect_torrent( cache: Option, proxy: Option, ) -> Result { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { return resolve_magnet_metadata( &app_handle, @@ -6325,10 +6390,12 @@ async fn inspect_torrent( #[tauri::command] async fn rekey_torrent_metadata( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, source_id: String, target_id: String, ) -> Result { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let source = crate::torrent::managed_torrent_path(&app_handle, &source_id) .map_err(AppError::Internal)?; let source = crate::torrent::validate_managed_torrent_path( @@ -6365,19 +6432,23 @@ async fn rekey_torrent_metadata( #[tauri::command] async fn remove_torrent_metadata( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, id: String, ) -> Result<(), AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; crate::torrent::remove_managed_torrent(&app_handle, &id).await; Ok(()) } #[tauri::command] async fn enqueue_download( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, item: queue::EnqueueItem, ) -> Result { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let id = item.id.clone(); let control_guard = state.queue_manager.acquire_aria2_control(&id).await; enqueue_download_locked(&app_handle, state.inner(), item, &control_guard).await @@ -6457,10 +6528,12 @@ async fn enqueue_download_locked( #[tauri::command] async fn cancel_enqueue_generation( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, generation: String, ) -> Result<(), AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let generation = generation .parse::() .map_err(|_| AppError::Internal("Invalid enqueue lifecycle generation".to_string()))?; @@ -6473,10 +6546,12 @@ async fn cancel_enqueue_generation( #[tauri::command] async fn enqueue_many( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, items: Vec, ) -> Result, AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let mut results = Vec::with_capacity(items.len()); for mut item in items { let id = item.id.clone(); @@ -6601,11 +6676,13 @@ async fn enqueue_many( #[tauri::command] async fn move_in_queue( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, queue_id: String, direction: crate::ipc::QueueDirection, ) -> Result, AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(state .queue_manager .move_in_queue(&id, &queue_id, direction) @@ -6614,12 +6691,14 @@ async fn move_in_queue( #[tauri::command] async fn move_many_in_queue( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, ids: Vec, queue_id: String, direction: crate::ipc::QueueDirection, target_index: Option, ) -> Result, AppError> { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; Ok(match target_index { Some(target_index) => state .queue_manager @@ -6634,10 +6713,12 @@ async fn move_many_in_queue( #[tauri::command] async fn remove_from_queue( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_main_window(&caller).map_err(AppError::Internal)?; let removed = state.queue_manager.remove_from_pending(&id).await; if removed { let _ = crate::download_ownership::remove(&app_handle, &id); @@ -6648,18 +6729,22 @@ async fn remove_from_queue( #[tauri::command] async fn set_concurrent_limit( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: usize, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state.queue_manager.set_capacity(limit.clamp(1, 12)); Ok(()) } #[tauri::command] async fn set_queue_concurrency_limits( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limits: Vec, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .queue_manager .replace_queue_limits( @@ -6673,10 +6758,12 @@ async fn set_queue_concurrency_limits( #[tauri::command] async fn set_download_speed_limit( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, limit: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .queue_manager .set_aria2_download_speed_limit(&id, limit) @@ -6685,10 +6772,12 @@ async fn set_download_speed_limit( #[tauri::command] async fn set_torrent_upload_limit( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, limit: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .queue_manager .set_aria2_torrent_upload_limit(&id, limit) @@ -6697,11 +6786,13 @@ async fn set_torrent_upload_limit( #[tauri::command] async fn set_torrent_peer_options( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, max_peers: Option, peer_speed_limit: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .queue_manager .set_aria2_torrent_peer_options(&id, max_peers, peer_speed_limit) @@ -6710,25 +6801,34 @@ async fn set_torrent_peer_options( #[tauri::command] async fn get_torrent_peers( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; state.queue_manager.get_aria2_torrent_peers(&id).await } #[tauri::command] async fn get_torrent_availability( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; state.queue_manager.get_aria2_torrent_availability(&id).await } #[tauri::command] async fn get_torrent_file_progress( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; state .queue_manager .get_aria2_torrent_file_progress(&id) @@ -6737,9 +6837,12 @@ async fn get_torrent_file_progress( #[tauri::command] async fn get_torrent_piece_progress( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; state .queue_manager .get_aria2_torrent_piece_progress(&id) @@ -6979,10 +7082,13 @@ fn persist_torrent_file_selection( #[tauri::command] async fn get_torrent_file_selection( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let item = load_persisted_torrent_item(state.inner(), &id)?; if item.is_torrent != Some(true) { return Err("file selection is available only for Torrent downloads".to_string()); @@ -7005,12 +7111,15 @@ async fn get_torrent_file_selection( #[tauri::command] async fn set_torrent_file_selection( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, AppState>, database: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle, id: String, selected_indices: Option>, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let item = load_persisted_torrent_item(database.inner(), &id)?; if item.is_torrent != Some(true) { @@ -7155,10 +7264,13 @@ async fn set_torrent_file_selection( #[tauri::command] async fn get_torrent_details( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let item = load_persisted_torrent_item(state.inner(), &id)?; if item.is_torrent != Some(true) { return Err("details are available only for Torrent downloads".to_string()); @@ -7190,10 +7302,13 @@ fn torrent_identity_magnet(details: &crate::ipc::TorrentDetails) -> Result, state: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle, id: String, ) -> Result { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let item = load_persisted_torrent_item(state.inner(), &id)?; if item.is_torrent != Some(true) { return Err("magnet links are available only for Torrent downloads".to_string()); @@ -7213,11 +7328,14 @@ async fn get_torrent_magnet_link( #[tauri::command] async fn export_torrent_metadata( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, state: tauri::State<'_, crate::db::DbState>, app_handle: tauri::AppHandle, id: String, destination: String, ) -> Result<(), String> { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let destination = std::path::PathBuf::from(destination.trim()); if !destination.is_absolute() || destination.extension().and_then(|value| value.to_str()) @@ -7797,12 +7915,15 @@ fn torrent_move_path_pair( #[tauri::command] async fn move_torrent_data( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, database: tauri::State<'_, crate::db::DbState>, id: String, destination: String, ) -> Result<(), String> { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let item = load_persisted_torrent_item(database.inner(), &id)?; if item.is_torrent != Some(true) { @@ -8226,9 +8347,11 @@ async fn move_torrent_data( #[tauri::command] async fn cancel_torrent_move_data( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if id.trim().is_empty() { return Err("invalid Torrent download id".to_string()); } @@ -8282,11 +8405,14 @@ fn verification_destination( #[tauri::command] async fn verify_torrent_data( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, database: tauri::State<'_, crate::db::DbState>, id: String, ) -> Result<(), String> { + properties_window::ensure_properties_or_main(&caller, &properties, &id)?; // Verification replaces the current Aria2 lifecycle. Serialize that // replacement with pause/resume/remove so a paused GID cannot reject the // maintenance enqueue as a duplicate task or race it with a late event. @@ -8632,10 +8758,17 @@ async fn normalize_persisted_torrent_web_seeds( #[tauri::command] async fn get_torrent_web_seeds( + caller: tauri::WebviewWindow, + properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, ) -> Result, String> { + properties_window::ensure_properties_or_main( + &caller, + &properties, + &id, + )?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; if state.queue_manager.is_registered(&id).await && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)) @@ -8674,11 +8807,13 @@ async fn get_torrent_web_seeds( #[tauri::command] async fn set_torrent_web_seeds( + caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, seeds: Vec, ) -> Result, String> { + properties_window::ensure_main_window(&caller)?; let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active = state.queue_manager.is_registered(&id).await && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)); @@ -8912,9 +9047,11 @@ fn apply_aria2_torrent_global_options( #[tauri::command(rename_all = "snake_case")] async fn set_torrent_max_open_files( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, max_open_files: u32, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)?; rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), @@ -8929,9 +9066,11 @@ async fn set_torrent_max_open_files( #[tauri::command] async fn set_torrent_overall_upload_limit( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let normalized_limit = normalize_torrent_overall_upload_limit(limit.as_deref())?; let limit_str = normalized_limit.as_deref().unwrap_or("0"); rpc_call( @@ -8947,9 +9086,11 @@ async fn set_torrent_overall_upload_limit( #[tauri::command] async fn set_global_speed_limit( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: Option, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let normalized_limit = limit .as_deref() .and_then(normalize_speed_limit_for_aria2); @@ -8973,7 +9114,8 @@ async fn set_global_speed_limit( } #[tauri::command] -fn check_automation_permission() -> Result<(), String> { +fn check_automation_permission(caller: tauri::WebviewWindow) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; #[cfg(target_os = "macos")] { use objc::runtime::Object; @@ -9011,7 +9153,8 @@ fn check_automation_permission() -> Result<(), String> { } #[tauri::command] -fn request_automation_permission() -> Result<(), String> { +fn request_automation_permission(caller: tauri::WebviewWindow) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; #[cfg(target_os = "macos")] { system_shutdown::request_permission_dialog() @@ -9024,7 +9167,11 @@ fn request_automation_permission() -> Result<(), String> { #[tauri::command] #[allow(unused_variables)] -fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> { +fn open_automation_settings( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; #[cfg(target_os = "macos")] { use tauri_plugin_opener::OpenerExt; @@ -9043,7 +9190,12 @@ fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> } #[tauri::command] -fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result { +fn get_free_space( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, + path: String, +) -> Result { + properties_window::ensure_main_window(&caller)?; use sysinfo::Disks; let disks = Disks::new_with_refreshed_list(); @@ -9081,11 +9233,13 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result, state: tauri::State<'_, AppState>, id: String, password: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -9099,9 +9253,11 @@ fn set_keychain_password( #[tauri::command(async)] fn get_keychain_password( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result { + properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -9113,9 +9269,11 @@ fn get_keychain_password( #[tauri::command(async)] fn delete_keychain_password( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { @@ -9127,6 +9285,7 @@ fn delete_keychain_password( #[tauri::command(async)] fn save_site_login( + caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, @@ -9134,6 +9293,7 @@ fn save_site_login( username: String, password: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; require_keychain_access(&state)?; let connection = database.lock()?; crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) @@ -9141,10 +9301,12 @@ fn save_site_login( #[tauri::command(async)] fn delete_site_login( + caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; require_keychain_access(&state)?; let connection = database.lock()?; crate::db::delete_site_login(&connection, &id) @@ -9154,7 +9316,11 @@ fn delete_site_login( /// decision. The process-local gate prevents any stale or early IPC caller /// from producing a native OS prompt before the explanation is visible. #[tauri::command] -fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> { +fn authorize_keychain_access( + caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if state.storage_layout.is_portable() { return Ok(()); } @@ -9197,9 +9363,11 @@ struct KeychainGrantStatus { /// token with the portable settings folder. #[tauri::command(async)] fn hydrate_extension_pairing_token( + caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let connection = database.lock()?; if app_state.storage_layout.is_portable() { @@ -9249,8 +9417,10 @@ fn hydrate_extension_pairing_token( /// waits for an explicit user decision. #[tauri::command] fn get_session_pairing_token( + caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let token = app_state .extension_pairing_token .read() @@ -9269,9 +9439,11 @@ fn get_session_pairing_token( #[tauri::command(async)] fn regenerate_pairing_token( + caller: tauri::WebviewWindow, database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let connection = database.lock()?; let generated = crate::db::generate_pairing_token(); @@ -9329,9 +9501,11 @@ fn regenerate_pairing_token( // also perform synchronous IPC to their desktop credential service. #[tauri::command] async fn grant_keychain_access( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, request_id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let app_state = app_handle.state::(); let grant_guard = begin_keychain_grant(app_state.inner(), &request_id)?; @@ -9361,9 +9535,11 @@ async fn grant_keychain_access( #[tauri::command] fn get_keychain_grant_status( + caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result { + properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let grant = app_state .keychain_grant @@ -9395,9 +9571,11 @@ fn get_keychain_grant_status( #[tauri::command] fn accept_keychain_grant( + caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result { + properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let mut grant = app_state .keychain_grant @@ -9428,9 +9606,11 @@ fn accept_keychain_grant( #[tauri::command] fn abandon_keychain_grant( + caller: tauri::WebviewWindow, app_state: tauri::State<'_, AppState>, request_id: String, ) -> Result, String> { + properties_window::ensure_main_window(&caller)?; validate_keychain_grant_request_id(&request_id)?; let mut grant = app_state .keychain_grant @@ -9537,18 +9717,22 @@ fn grant_keychain_access_blocking( #[tauri::command] fn acknowledge_pairing_token_change( + caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::acknowledge_pairing_token_notice(&connection) } #[tauri::command] fn db_save_settings( + caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, data: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; let existing = crate::db::load_settings(&connection)?; let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?; @@ -9580,7 +9764,11 @@ fn db_save_settings( } #[tauri::command] -fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { +fn db_load_settings( + caller: tauri::WebviewWindow, + state: tauri::State<'_, crate::db::DbState>, +) -> Result, String> { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; let settings = crate::db::load_settings(&connection)?; if state.is_portable() { @@ -9597,18 +9785,22 @@ fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result, ) -> Result, String> { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::load_downloads(&connection) } #[tauri::command] async fn clear_torrent_removal_paths( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; // This command is reached from the trusted renderer, but it still owns a // destructive reservation boundary. Serialize it with terminal/control // transitions and refuse to clear a lifecycle that the backend still @@ -9627,8 +9819,10 @@ async fn clear_torrent_removal_paths( #[tauri::command] fn reconcile_torrent_removal_reservations( + caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, ) -> Result { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::reconcile_torrent_removal_paths_after_restart(&connection) } @@ -9665,9 +9859,11 @@ fn retained_torrent_info_hash_from_persisted_record(record: &str) -> Option, data: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let portable = state.is_portable(); let mut connection = state.lock()?; let existing = crate::db::load_downloads(&connection)?; @@ -9798,22 +9994,31 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result) -> Result, String> { +fn db_get_all_queues( + caller: tauri::WebviewWindow, + state: tauri::State<'_, crate::db::DbState>, +) -> Result, String> { + properties_window::ensure_main_window(&caller)?; let connection = state.lock()?; crate::db::load_queues(&connection) } #[tauri::command] fn db_replace_queues( + caller: tauri::WebviewWindow, state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; let mut connection = state.lock()?; crate::db::replace_queues(&mut connection, &data) } #[tauri::command] -fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { +fn check_file_exists(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, path: String) -> bool { + if properties_window::ensure_main_window(&caller).is_err() { + return false; + } let resolved_dest = resolve_path(&path, &app_handle); if !is_safe_path(&resolved_dest, &app_handle) { return false; @@ -9964,7 +10169,12 @@ fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String } #[tauri::command] -async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result, String> { +async fn read_logs( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, + limit: usize, +) -> Result, String> { + properties_window::ensure_main_window(&caller)?; let mut lines = Vec::new(); for file in log_files(&app_handle).await? { let content = tokio::fs::read_to_string(&file) @@ -9984,7 +10194,8 @@ async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result Result<(), String> { +async fn clear_logs(caller: tauri::WebviewWindow, app_handle: tauri::AppHandle) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; for file in log_files(&app_handle).await? { tokio::fs::write(&file, "") .await @@ -9995,10 +10206,12 @@ async fn clear_logs(app_handle: tauri::AppHandle) -> Result<(), String> { #[tauri::command] async fn export_logs( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, destination: Option, ) -> Result { + properties_window::ensure_main_window(&caller)?; let mut output = format!( "Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n", env!("CARGO_PKG_VERSION"), @@ -10072,7 +10285,12 @@ async fn export_logs( } #[tauri::command] -fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { +fn toggle_tray_icon( + caller: tauri::WebviewWindow, + app_handle: tauri::AppHandle, + show: bool, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if show { build_main_tray(&app_handle) } else { @@ -10150,9 +10368,11 @@ fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> { #[tauri::command] fn set_extension_pairing_token( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, token: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if token.is_empty() || token.len() > 512 { return Err("Invalid extension pairing token".to_string()); } @@ -10166,7 +10386,13 @@ fn set_extension_pairing_token( } #[tauri::command] -fn get_extension_server_port(state: tauri::State<'_, AppState>) -> Option { +fn get_extension_server_port( + caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Option { + if properties_window::ensure_main_window(&caller).is_err() { + return None; + } state .extension_server_port .read() @@ -10175,7 +10401,12 @@ fn get_extension_server_port(state: tauri::State<'_, AppState>) -> Option { } #[tauri::command] -fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) { +fn set_extension_frontend_ready( + caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + ready: bool, +) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; state .extension_frontend_ready .store(ready, Ordering::Release); @@ -10185,13 +10416,16 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) .send(download::DownloadCmd::FrontendReady(ready)) .await; }); + Ok(()) } #[tauri::command] fn ack_extension_download( + caller: tauri::WebviewWindow, state: tauri::State<'_, AppState>, request_id: String, ) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; if request_id.len() != 32 || !request_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("Invalid extension request id".to_string()); } @@ -12955,18 +13189,25 @@ static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[tauri::command] -fn toggle_log_pause(pause: bool) { +fn toggle_log_pause(caller: tauri::WebviewWindow, pause: bool) -> Result<(), String> { + properties_window::ensure_main_window(&caller)?; LOG_PAUSED.store(pause, std::sync::atomic::Ordering::Relaxed); + Ok(()) } #[tauri::command] -fn is_log_paused() -> bool { - LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) +fn is_log_paused(caller: tauri::WebviewWindow) -> bool { + properties_window::ensure_main_window(&caller).is_ok() + && LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) } #[tauri::command] -fn set_log_stream_active(active: bool) { +fn set_log_stream_active(caller: tauri::WebviewWindow, active: bool) -> Result<(), String> { + if let Err(error) = properties_window::ensure_main_window(&caller) { + return Err(error); + } LOG_STREAM_ACTIVE.store(active, std::sync::atomic::Ordering::Release); + Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -13004,6 +13245,7 @@ pub fn run() { let aria2_secret = uuid::Uuid::new_v4().to_string(); tauri::Builder::default() .manage(MainWindowRestoreState::default()) + .manage(properties_window::PropertiesWindowRegistry::default()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { restore_main_window(app); })) @@ -14219,6 +14461,16 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_clipboard_manager::init()) .on_window_event(|window, event| { + if matches!(event, tauri::WindowEvent::Destroyed) + && properties_window::is_properties_window_label(window.label()) + { + if let Some(registry) = window.app_handle().try_state::() { + let _ = registry.remove_window(window.label()); + } + let _ = window + .app_handle() + .emit_to("main", "properties-window-closed", window.label().to_string()); + } if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -14244,6 +14496,13 @@ pub fn run() { detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, + properties_window::open_download_properties_window, + properties_window::get_properties_window_download_id, + properties_window::properties_window_send_ready, + properties_window::properties_window_send_action, + properties_window::validate_properties_window_request, + properties_window::close_download_properties_window, + properties_window::properties_window_registry_remove_for_download, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, parity::create_category_directories, db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index d90cb59..abcc795 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -6,7 +6,8 @@ use ts_rs::TS; use crate::ipc::DownloadCategory; #[tauri::command] -pub async fn get_system_proxy() -> Result, String> { +pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result, String> { + crate::properties_window::ensure_main_window(&caller)?; match native_system_proxy() { Ok(Some(proxy)) => Ok(Some(proxy)), Ok(None) => Ok(proxy_from_environment()), @@ -539,8 +540,10 @@ struct GitHubRelease { #[tauri::command] pub async fn check_for_updates( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, ) -> Result { + crate::properties_window::ensure_main_window(&caller)?; let current_version = app_handle.package_info().version.to_string(); crate::ensure_reqwest_crypto_provider(); @@ -606,10 +609,12 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering { #[tauri::command] pub async fn create_category_directories( + caller: tauri::WebviewWindow, app_handle: tauri::AppHandle, base_folder: String, subfolders: std::collections::HashMap, ) -> Result<(), String> { + crate::properties_window::ensure_main_window(&caller)?; let base = crate::resolve_path(&base_folder, &app_handle); let mut errors = Vec::new(); diff --git a/src-tauri/src/properties_window.rs b/src-tauri/src/properties_window.rs new file mode 100644 index 0000000..010dba4 --- /dev/null +++ b/src-tauri/src/properties_window.rs @@ -0,0 +1,392 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use uuid::Uuid; + +const MAIN_WINDOW_LABEL: &str = "main"; +const PROPERTIES_LABEL_PREFIX: &str = "properties-"; +const PROPERTIES_WINDOW_TITLE: &str = "Properties - Firelink"; +const PROPERTIES_WINDOW_READY_EVENT: &str = "properties-window-ready"; +const PROPERTIES_WINDOW_ACTION_REQUEST_EVENT: &str = "properties-window-action-request"; +const MAX_PROPERTIES_ACTION_PAYLOAD_BYTES: usize = 64 * 1024; + +#[derive(Default)] +pub struct PropertiesWindowRegistry { + state: Mutex, +} + +#[derive(Default)] +struct RegistryState { + by_download: HashMap, + by_window: HashMap, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PropertiesWindowReadyEvent { + window_label: String, + download_id: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PropertiesWindowActionEvent { + window_label: String, + download_id: String, + request_id: u64, + action: String, + payload: Option, +} + +impl PropertiesWindowRegistry { + pub fn allocate(&self, download_id: &str) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())?; + if let Some(label) = state.by_download.get(download_id) { + return Ok(label.clone()); + } + + let label = format!("{PROPERTIES_LABEL_PREFIX}{}", Uuid::new_v4().simple()); + state.by_download.insert(download_id.to_string(), label.clone()); + state.by_window.insert(label.clone(), download_id.to_string()); + Ok(label) + } + + pub fn download_for_window(&self, label: &str) -> Result, String> { + Ok(self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())? + .by_window + .get(label) + .cloned()) + } + + pub fn remove_window(&self, label: &str) -> Result, String> { + let mut state = self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())?; + let download_id = state.by_window.remove(label); + if let Some(download_id) = &download_id { + state.by_download.remove(download_id); + } + Ok(download_id) + } + + pub fn remove_download(&self, download_id: &str) -> Result, String> { + let mut state = self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())?; + let label = state.by_download.remove(download_id); + if let Some(label) = &label { + state.by_window.remove(label); + } + Ok(label) + } + + pub fn window_for_download(&self, download_id: &str) -> Result, String> { + Ok(self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())? + .by_download + .get(download_id) + .cloned()) + } +} + +pub fn is_properties_window_label(label: &str) -> bool { + label.starts_with(PROPERTIES_LABEL_PREFIX) + && label.len() > PROPERTIES_LABEL_PREFIX.len() + && label[PROPERTIES_LABEL_PREFIX.len()..] + .chars() + .all(|character| character.is_ascii_hexdigit()) +} + +/// Custom Tauri commands are not automatically narrowed by a capability's +/// window list. Commands that a Properties child may call must therefore +/// validate the invoking webview and its registered download explicitly. +pub fn ensure_properties_or_main( + caller: &tauri::WebviewWindow, + registry: &PropertiesWindowRegistry, + download_id: &str, +) -> Result<(), String> { + if caller.label() == MAIN_WINDOW_LABEL { + return Ok(()); + } + if !is_properties_window_label(caller.label()) + || registry.download_for_window(caller.label())?.as_deref() != Some(download_id) + { + return Err("This window is not authorized for the requested download".to_string()); + } + Ok(()) +} + +pub fn ensure_main_window(caller: &tauri::WebviewWindow) -> Result<(), String> { + (caller.label() == MAIN_WINDOW_LABEL) + .then_some(()) + .ok_or_else(|| "This command is available only to the main window".to_string()) +} + +fn registered_download_for_caller( + caller: &tauri::WebviewWindow, + registry: &PropertiesWindowRegistry, +) -> Result { + let label = caller.label(); + if !is_properties_window_label(label) { + return Err("This window is not a Properties window".to_string()); + } + registry + .download_for_window(label)? + .ok_or_else(|| "Properties window is no longer registered".to_string()) +} + +fn is_properties_action(action: &str) -> bool { + matches!( + action, + "apply-properties" + | "pause-resume" + | "set-download-limit" + | "set-torrent-upload-limit" + | "set-torrent-peer-options" + ) +} + +fn download_exists(db: &crate::db::DbState, download_id: &str) -> Result { + let connection = db.lock()?; + Ok(crate::db::load_downloads(&connection)?.into_iter().any(|record| { + serde_json::from_str::(&record) + .ok() + .and_then(|value| value.get("id").and_then(serde_json::Value::as_str).map(str::to_owned)) + .is_some_and(|id| id == download_id) + })) +} + +fn validate_download_id(download_id: &str) -> Result<(), String> { + let trimmed = download_id.trim(); + if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) { + return Err("Invalid download ID".to_string()); + } + Ok(()) +} + +#[tauri::command] +pub fn open_download_properties_window( + app: tauri::AppHandle, + caller: tauri::WebviewWindow, + db: tauri::State<'_, crate::db::DbState>, + registry: tauri::State<'_, PropertiesWindowRegistry>, + id: String, +) -> Result { + if caller.label() != MAIN_WINDOW_LABEL { + return Err("Only the main window can open Properties windows".to_string()); + } + validate_download_id(&id)?; + if !download_exists(&db, &id)? { + return Err("Download no longer exists".to_string()); + } + + let label = registry.allocate(&id)?; + if let Some(window) = app.get_webview_window(&label) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(label); + } + + let build_result = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into())) + .title(PROPERTIES_WINDOW_TITLE) + .inner_size(1000.0, 720.0) + .min_inner_size(760.0, 560.0) + .resizable(true) + .always_on_top(false) + .build(); + if let Err(error) = build_result { + // Two rapid main-window requests can race between the native lookup + // above and builder creation. If the first request won, retain the + // registry entry and focus its window instead of treating the second + // request as a failed open. + if let Some(window) = app.get_webview_window(&label) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(label); + } + let _ = registry.remove_window(&label); + return Err(format!("Could not open Properties window: {error}")); + } + + Ok(label) +} + +#[tauri::command] +pub fn get_properties_window_download_id( + caller: tauri::WebviewWindow, + registry: tauri::State<'_, PropertiesWindowRegistry>, +) -> Result { + registered_download_for_caller(&caller, ®istry) +} + +#[tauri::command] +pub fn properties_window_send_ready( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + registry: tauri::State<'_, PropertiesWindowRegistry>, +) -> Result<(), String> { + let download_id = registered_download_for_caller(&caller, ®istry)?; + app.emit_to( + MAIN_WINDOW_LABEL, + PROPERTIES_WINDOW_READY_EVENT, + PropertiesWindowReadyEvent { + window_label: caller.label().to_string(), + download_id, + }, + ) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn properties_window_send_action( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + registry: tauri::State<'_, PropertiesWindowRegistry>, + request_id: u64, + action: String, + payload: Option, +) -> Result<(), String> { + if !is_properties_action(&action) + || action.len() > 64 + || action.chars().any(char::is_control) + { + return Err("Invalid Properties action".to_string()); + } + if let Some(payload) = payload.as_ref() { + let payload_size = serde_json::to_vec(payload) + .map_err(|_| "Invalid Properties action payload".to_string())? + .len(); + if payload_size > MAX_PROPERTIES_ACTION_PAYLOAD_BYTES { + return Err("Properties action payload is too large".to_string()); + } + } + let download_id = registered_download_for_caller(&caller, ®istry)?; + app.emit_to( + MAIN_WINDOW_LABEL, + PROPERTIES_WINDOW_ACTION_REQUEST_EVENT, + PropertiesWindowActionEvent { + window_label: caller.label().to_string(), + download_id, + request_id, + action, + payload, + }, + ) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn validate_properties_window_request( + caller: tauri::WebviewWindow, + registry: tauri::State<'_, PropertiesWindowRegistry>, + window_label: String, + download_id: String, +) -> Result<(), String> { + if caller.label() != MAIN_WINDOW_LABEL { + return Err("Only the main window can validate Properties requests".to_string()); + } + validate_download_id(&download_id)?; + if !is_properties_window_label(&window_label) { + return Err("Invalid Properties window label".to_string()); + } + if registry.download_for_window(&window_label)?.as_deref() != Some(download_id.as_str()) { + return Err("Properties window request does not match its registered download".to_string()); + } + Ok(()) +} + +#[tauri::command] +pub fn close_download_properties_window( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + registry: tauri::State<'_, PropertiesWindowRegistry>, + id: String, +) -> Result<(), String> { + let label = caller.label(); + let registered_id = if label == MAIN_WINDOW_LABEL { + registry.window_for_download(&id)?.map(|_| id.clone()) + } else { + registry.download_for_window(label)? + }; + if registered_id.as_deref() != Some(id.as_str()) { + return Err("Properties window close request is not registered".to_string()); + } + if let Some(label) = registry.window_for_download(&id)? { + if let Some(window) = app.get_webview_window(&label) { + window.close().map_err(|error| error.to_string())?; + } + } + let _ = registry.remove_download(&id); + Ok(()) +} + +#[tauri::command] +pub fn properties_window_registry_remove_for_download( + caller: tauri::WebviewWindow, + app: tauri::AppHandle, + registry: tauri::State<'_, PropertiesWindowRegistry>, + id: String, +) -> Result<(), String> { + if caller.label() != MAIN_WINDOW_LABEL { + return Err("Only the main window can remove a Properties window".to_string()); + } + if let Some(label) = registry.remove_download(&id)? { + if let Some(window) = app.get_webview_window(&label) { + let _ = window.close(); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_are_opaque_and_strictly_scoped() { + assert!(is_properties_window_label("properties-0123456789abcdef")); + assert!(!is_properties_window_label("properties-download-id")); + assert!(!is_properties_window_label("main")); + assert!(!is_properties_window_label("properties-")); + } + + #[test] + fn registry_reuses_one_label_per_download_and_cleans_both_indexes() { + let registry = PropertiesWindowRegistry::default(); + let first = registry.allocate("download-a").unwrap(); + assert_eq!(registry.allocate("download-a").unwrap(), first); + assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string())); + assert_eq!(registry.remove_window(&first).unwrap(), Some("download-a".to_string())); + assert_eq!(registry.download_for_window(&first).unwrap(), None); + assert_ne!(registry.allocate("download-a").unwrap(), first); + } + + #[test] + fn invalid_ids_are_rejected() { + assert!(validate_download_id("").is_err()); + assert!(validate_download_id("\n").is_err()); + assert!(validate_download_id("valid-id").is_ok()); + } + + #[test] + fn child_actions_are_allowlisted() { + assert!(is_properties_action("apply-properties")); + assert!(is_properties_action("set-torrent-peer-options")); + assert!(!is_properties_action("get_keychain_password")); + assert!(!is_properties_action("")); + } +} diff --git a/src/App.tsx b/src/App.tsx index a65014f..eec176a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,11 +10,13 @@ import { KeychainPermissionModal } from './components/KeychainPermissionModal'; import { extractValidDownloadUrls } from './utils/url'; import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; -import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; +import { initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; +import { getCurrentWindow } from '@tauri-apps/api/window'; import { initDownloadListener } from './store/downloadStore'; import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import { WindowControls } from "./components/WindowControls"; +import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { updateDockBadge } from './utils/dockBadge'; @@ -49,9 +51,6 @@ const SettingsView = lazy(loadSettingsView); const SchedulerView = lazy(loadSchedulerView); const SpeedLimiterView = lazy(loadSpeedLimiterView); const LogsView = lazy(loadLogsView); -const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({ - default: module.PropertiesModal, -}))); const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({ default: module.DeleteConfirmationModal, }))); @@ -226,7 +225,6 @@ function App() { const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const showKeychainModal = useSettingsStore(state => state.showKeychainModal); const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen); - const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; @@ -409,6 +407,7 @@ function App() { }, [sidebarWidth]); useEffect(() => { + const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label); let active = true; let cleanupListeners: (() => void) | null = null; const initialize = async () => { @@ -624,6 +623,7 @@ function App() { pendingStartupInputs.current = []; cleanupListeners?.(); cleanupListeners = null; + disposePersistence(); }; }, [addToast, queueFrontendReadyUpdate]); @@ -1166,11 +1166,7 @@ function App() { {isAddModalOpen && } - {selectedPropertiesDownloadId !== null && ( - - - - )} + {isDeleteModalOpen && ( diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 2ade306..962a779 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -56,6 +56,7 @@ import { import { updateDownloadSelection } from '../utils/downloadSelection'; import { clampFloatingPosition } from '../utils/floatingPosition'; import { FloatingQueueSubmenu } from './FloatingQueueSubmenu'; +import { openPropertiesWindow } from '../propertiesBridge'; export interface DownloadTableStatusSummary { summary: DownloadSummary; @@ -1383,8 +1384,13 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }, []); const openProperties = useCallback((id: string) => { - useDownloadStore.getState().setSelectedPropertiesDownloadId(id); - }, []); + void openPropertiesWindow(id).catch(error => { + showInteractionError(t($ => $.downloadTable.interactionError, { + message: t($ => $.downloadTable.properties), + detail: error instanceof Error ? error.message : String(error) + }), error); + }); + }, [showInteractionError, t]); const revealDownloadFile = useCallback(async (item: DownloadItem) => { const pathToReveal = await getDownloadPath(item); diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx new file mode 100644 index 0000000..bd423cd --- /dev/null +++ b/src/components/PropertiesWindowApp.tsx @@ -0,0 +1,472 @@ +import { useCallback, useEffect, useMemo, useRef, useState } 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'; +import { open, save } from '@tauri-apps/plugin-dialog'; +import { Copy, FileDown, FolderOpen, Pause, Play, RefreshCw, Save, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; +import type { TorrentDetails } from '../bindings/TorrentDetails'; +import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; +import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; +import { invokeCommand as invoke } from '../ipc'; +import { + PROPERTIES_WINDOW_ACTION_RESULT, + PROPERTIES_WINDOW_REMOVED, + PROPERTIES_WINDOW_SNAPSHOT, + sendPropertiesActionRequest, + sendPropertiesReady, + type PropertiesAction, + type PropertiesActionRequest, + type PropertiesActionResult, + type PropertiesPatch, + type PropertiesSnapshot, + type PropertiesSnapshotEvent, +} from '../propertiesBridge'; +import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; + +type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced'; + +const isTorrentStatus = (status: string) => + ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); + +const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'retrying', 'moving'].includes(status); + +const safeTitle = (name: string) => { + const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160); + return `${bounded || 'Download'} - Properties - Firelink`; +}; + +const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); + +export const PropertiesWindowApp = () => { + const { t } = useTranslation(); + const currentWindow = useMemo(() => getCurrentWindow(), []); + const windowLabel = currentWindow.label; + const [downloadId, setDownloadId] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [pendingTab, setPendingTab] = useState(null); + const [closePrompt, setClosePrompt] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [notice, setNotice] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [fileProgress, setFileProgress] = useState(null); + const [peers, setPeers] = useState(null); + const [availability, setAvailability] = useState(null); + const [details, setDetails] = useState(null); + const [diagnosticError, setDiagnosticError] = useState(''); + // null means the Files tab has no local selection draft yet; [] is an + // explicit user choice to clear every file and must remain visually empty. + const [selectedFiles, setSelectedFiles] = useState(null); + const [fileName, setFileName] = useState(''); + const [destination, setDestination] = useState(''); + const [connections, setConnections] = useState(''); + const [trackers, setTrackers] = useState(''); + const [excludedTrackers, setExcludedTrackers] = useState(''); + const [downloadLimit, setDownloadLimit] = useState(''); + const [uploadLimit, setUploadLimit] = useState(''); + const [maxPeers, setMaxPeers] = useState(''); + const [peerSpeedLimit, setPeerSpeedLimit] = useState(''); + const [draftTab, setDraftTab] = useState(null); + const draftTabRef = useRef(null); + const closeAfterSaveRef = useRef(false); + const switchAfterSaveRef = useRef(null); + const requestIdRef = useRef(0); + const latestSnapshotRevisionRef = useRef(0); + const diagnosticsInFlightRef = useRef(new Set()); + const snapshotRef = useRef(snapshot); + const activeTabRef = useRef(activeTab); + const downloadIdRef = useRef(downloadId); + snapshotRef.current = snapshot; + activeTabRef.current = activeTab; + downloadIdRef.current = downloadId; + + const isTorrent = snapshot?.isTorrent === true; + const tabs = useMemo(() => isTorrent + ? ['overview', 'files', 'trackers', 'peers', 'options'] + : ['overview', 'transfer', 'advanced'], [isTorrent]); + const isDirty = draftTab !== null; + + useEffect(() => { + draftTabRef.current = draftTab; + }, [draftTab]); + + const hydrateDraft = useCallback((next: PropertiesSnapshot) => { + setFileName(next.fileName); + setDestination(next.destination ?? ''); + setConnections(next.connections === undefined ? '' : String(next.connections)); + setTrackers(next.torrentTrackers ?? ''); + setExcludedTrackers(next.torrentExcludeTrackers ?? ''); + setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null); + setDownloadLimit(next.speedLimit ?? ''); + setUploadLimit(next.torrentUploadLimit ?? ''); + setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers)); + setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? ''); + }, []); + + const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => { + if (!isTorrentStatus(snapshotRef.current?.status ?? '')) return; + const requestKey = `${id}:${tab}`; + if (diagnosticsInFlightRef.current.has(requestKey)) return; + diagnosticsInFlightRef.current.add(requestKey); + const isCurrent = () => downloadIdRef.current === id + && activeTabRef.current === tab + && isTorrentStatus(snapshotRef.current?.status ?? ''); + if (isCurrent()) setDiagnosticError(''); + try { + if (tab === 'overview') { + const nextDetails = await invoke('get_torrent_details', { id }); + if (isCurrent()) setDetails(nextDetails); + } else if (tab === 'files') { + const nextProgress = await invoke('get_torrent_file_progress', { id }); + if (isCurrent()) setFileProgress(nextProgress); + } else if (tab === 'peers') { + const [nextPeers, nextAvailability] = await Promise.all([ + invoke('get_torrent_peers', { id }), + invoke('get_torrent_availability', { id }), + ]); + if (isCurrent()) { + setPeers(nextPeers); + setAvailability(nextAvailability); + } + } + } catch (error) { + if (isCurrent()) setDiagnosticError(errorText(error)); + } finally { + diagnosticsInFlightRef.current.delete(requestKey); + } + }, []); + + useEffect(() => { + let cancelled = false; + let unlistenSnapshot: UnlistenFn | undefined; + let unlistenResult: UnlistenFn | undefined; + let unlistenRemoved: UnlistenFn | undefined; + const start = async () => { + try { + const id = await invoke('get_properties_window_download_id'); + if (cancelled) return; + setDownloadId(id); + unlistenSnapshot = await listen(PROPERTIES_WINDOW_SNAPSHOT, event => { + if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return; + if (event.payload.revision <= latestSnapshotRevisionRef.current) return; + latestSnapshotRevisionRef.current = event.payload.revision; + setSnapshot(event.payload.snapshot); + if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot); + void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); + }); + unlistenResult = await listen(PROPERTIES_WINDOW_ACTION_RESULT, event => { + if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return; + if (event.payload.requestId !== requestIdRef.current) return; + setIsSaving(false); + if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed'); + else { + const nextTab = switchAfterSaveRef.current; + const shouldClose = closeAfterSaveRef.current; + switchAfterSaveRef.current = null; + closeAfterSaveRef.current = false; + setErrorMessage(''); + setNotice(t($ => $.properties.saved)); + draftTabRef.current = null; + setDraftTab(null); + if (nextTab) { + setActiveTab(nextTab); + setPendingTab(null); + } + if (shouldClose) { + setClosePrompt(false); + void currentWindow.close().catch(error => setErrorMessage(errorText(error))); + } + } + }); + unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => { + if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) { + setSnapshot(null); + setNotice(t($ => $.downloadTable.noDownloads)); + } + }); + await sendPropertiesReady(); + } catch (error) { + if (!cancelled) setErrorMessage(errorText(error)); + } + }; + void start(); + return () => { + cancelled = true; + unlistenSnapshot?.(); + unlistenResult?.(); + unlistenRemoved?.(); + }; + }, [currentWindow, hydrateDraft, t, windowLabel]); + + useEffect(() => { + if (!snapshot || draftTab !== null) return; + hydrateDraft(snapshot); + }, [draftTab, hydrateDraft, snapshot]); + + useEffect(() => { + if (!downloadId || !snapshot || !isTorrent) return; + void refreshDiagnostics(activeTab, downloadId); + if (!['files', 'peers'].includes(activeTab)) return; + const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), activeTab === 'peers' ? 3000 : 2000); + return () => window.clearInterval(interval); + }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot]); + + useEffect(() => { + if (!isDirty) return; + let unlisten: UnlistenFn | undefined; + void currentWindow.onCloseRequested(event => { + event.preventDefault(); + setClosePrompt(true); + }).then(value => { unlisten = value; }); + return () => unlisten?.(); + }, [currentWindow, isDirty]); + + const requestAction = useCallback(async ( + action: PropertiesAction, + payload?: PropertiesActionRequest['payload'], + ) => { + if (!downloadId) return; + const requestId = ++requestIdRef.current; + setIsSaving(action === 'apply-properties'); + try { + await sendPropertiesActionRequest({ + windowLabel, + downloadId, + requestId, + action, + payload, + }); + } catch (error) { + setIsSaving(false); + closeAfterSaveRef.current = false; + switchAfterSaveRef.current = null; + setErrorMessage(errorText(error)); + } + }, [downloadId, windowLabel]); + + const applyActiveTab = useCallback(async () => { + if (!snapshot || !isEditableStatus(snapshot.status)) { + closeAfterSaveRef.current = false; + switchAfterSaveRef.current = null; + setErrorMessage(t($ => $.properties.editingUnavailable)); + return; + } + const patch: PropertiesPatch = {}; + if (activeTab === 'overview') { + patch.fileName = fileName; + patch.destination = destination || undefined; + if (connections.trim()) patch.connections = Number(connections); + } else if (activeTab === 'files' && isTorrent) { + const nextSelectedFiles = selectedFiles + ?? fileProgress?.files.filter(file => file.selected).map(file => file.index) + ?? []; + if (nextSelectedFiles.length === 0) { + setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); + closeAfterSaveRef.current = false; + switchAfterSaveRef.current = null; + return; + } + patch.torrentFileIndices = nextSelectedFiles; + } else if (activeTab === 'trackers') { + patch.torrentTrackers = trackers; + patch.torrentExcludeTrackers = excludedTrackers; + } else if (activeTab === 'options' || activeTab === 'transfer') { + if (downloadLimit !== snapshot.speedLimit) patch.speedLimit = downloadLimit; + if (isTorrent) { + patch.torrentUploadLimit = uploadLimit; + patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined; + patch.torrentPeerSpeedLimit = peerSpeedLimit; + } + } + await requestAction('apply-properties', patch); + }, [activeTab, connections, destination, downloadLimit, excludedTrackers, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, requestAction, selectedFiles, snapshot, t, trackers, uploadLimit]); + + const chooseTab = (tab: PropertiesTab) => { + if (tab === activeTab) return; + if (isDirty) setPendingTab(tab); + else setActiveTab(tab); + }; + + const discardDraft = () => { + const shouldClose = closePrompt; + if (snapshot) hydrateDraft(snapshot); + draftTabRef.current = null; + setDraftTab(null); + if (pendingTab) setActiveTab(pendingTab); + setPendingTab(null); + setClosePrompt(false); + if (shouldClose) void currentWindow.close().catch(error => setErrorMessage(errorText(error))); + }; + + const closeWindow = async () => { + if (!downloadId) return; + try { + await invoke('close_download_properties_window', { id: downloadId }); + } catch (error) { + setErrorMessage(errorText(error)); + } + }; + + const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => { + if (!downloadId) return; + try { + if (action === 'magnet') { + await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId })); + setNotice(t($ => $.properties.torrentMagnetCopied)); + } else if (action === 'export') { + const destinationPath = await save({ defaultPath: `${snapshot?.fileName || 'download'}.torrent` }); + if (destinationPath) { + await invoke('export_torrent_metadata', { id: downloadId, destination: destinationPath }); + setNotice(t($ => $.properties.torrentMetadataExported)); + } + } else if (action === 'move') { + const selected = await open({ directory: true, multiple: false }); + if (selected && typeof selected === 'string') { + await invoke('move_torrent_data', { id: downloadId, destination: selected }); + setNotice(t($ => $.properties.torrentMoveCompleted)); + } + } else { + await invoke('verify_torrent_data', { id: downloadId }); + setNotice(t($ => $.properties.torrentVerifyIntegrity)); + } + } catch (error) { + setErrorMessage(errorText(error)); + } + }; + + if (!downloadId) { + return
{errorMessage || t($ => $.app.loading)}
; + } + if (!snapshot) { + return
{errorMessage || t($ => $.app.loading)}
; + } + + const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0)); + const total = snapshot.size || (snapshot.totalBytes === undefined + ? t($ => $.addDownloads.unknownSize) + : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); + const statusLabel = t($ => $.downloads.status[snapshot.status]); + const tabLabel = (tab: PropertiesTab) => { + switch (tab) { + case 'overview': return t($ => $.properties.torrentDetails); + case 'files': return t($ => $.properties.torrentFileProgress); + case 'trackers': return t($ => $.properties.torrentTrackers); + case 'peers': return t($ => $.properties.torrentPeerDiagnostics); + case 'options': return t($ => $.properties.advancedTransfer); + case 'transfer': return t($ => $.properties.connections); + case 'advanced': return t($ => $.properties.advancedTransfer); + } + }; + + return ( +
+
+
+
+

{snapshot.fileName}

+

{statusLabel} · {Math.round(progress * 100)}% · {total}

+
+
$.actions.continue)}> + + {isTorrent && <> + + + + } +
+
+
$.properties.progress)}> +
+
+
+ {formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total} + {snapshot.speed || '—'} + {snapshot.eta || '—'} + {isTorrent && {formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}} +
+
+ + + +
+ {activeTab === 'overview' &&
+
+ + +
+
+
{t($ => $.properties.url)}

{snapshot.url}

+
{t($ => $.properties.category)}

{snapshot.category}

+
+ {isTorrent && details &&
+ {t($ => $.properties.torrentDetailsInfoHash)}{details.infoHash} + {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} + {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)} +
} + {isTorrent &&
} +
} + + {activeTab === 'files' && isTorrent &&
+
+
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} />{file.index + 1}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
+ {diagnosticError &&

{diagnosticError}

} +
} + + {activeTab === 'trackers' && isTorrent &&
+