diff --git a/.gitignore b/.gitignore index 59b45e4..9585f67 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ build/ # Local secrets and signing material .env .env.* +implementation_plan.md *.key *.pem !src-tauri/binaries/_internal/certifi/cacert.pem diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 984fd2d..dac5d13 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -938,6 +938,91 @@ pub fn replace_downloads( .map_err(|error| format!("failed to commit download save: {error}")) } +/// Mutate exactly one persisted download inside a database transaction. +/// +/// Native lifecycle code must not rebuild the renderer-owned download array: +/// doing so can overwrite a newer renderer snapshot, and encoding the loaded +/// JSON strings as an array produces double-encoded records. Validate the +/// complete persisted set before changing the target, then update only that +/// row while keeping the indexed columns in sync with its JSON document. +pub fn mutate_download( + connection: &mut Connection, + id: &str, + portable: bool, + mutate: F, +) -> Result +where + F: FnOnce(&mut serde_json::Map) -> Result, +{ + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin download mutation: {error}"))?; + let records = { + let mut statement = transaction + .prepare("SELECT id, data FROM downloads ORDER BY rowid") + .map_err(|error| format!("failed to prepare download mutation: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| format!("failed to read downloads for mutation: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("failed to read download for mutation: {error}"))? + }; + + let mut target = None; + for (stored_id, data) in records { + let value: Value = serde_json::from_str(&data) + .map_err(|error| format!("persisted download '{stored_id}' is malformed: {error}"))?; + let document_id = required_string(&value, "id")?; + required_string(&value, "status")?; + if document_id != stored_id { + return Err(format!( + "persisted download '{stored_id}' has mismatched document id" + )); + } + if stored_id == id { + target = Some(value); + } + } + + let mut value = target.ok_or_else(|| "download is no longer persisted".to_string())?; + let original_value = value.clone(); + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + let result = mutate(object)?; + if object.get("id").and_then(Value::as_str) != Some(id) { + return Err("persisted download mutation cannot change its id".to_string()); + } + if portable { + remove_persisted_transfer_secrets(&mut value); + } + if value == original_value { + return Ok(result); + } + let document_id = required_string(&value, "id")?; + let status = required_string(&value, "status")?; + let queue_id = value.get("queueId").and_then(Value::as_str); + let data = serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}"))?; + let changed = transaction + .execute( + "UPDATE downloads + SET status = ?1, queue_id = ?2, data = ?3 + WHERE id = ?4", + params![status, queue_id, data, document_id], + ) + .map_err(|error| format!("failed to mutate download '{id}': {error}"))?; + if changed != 1 { + return Err("download is no longer persisted".to_string()); + } + transaction + .commit() + .map_err(|error| format!("failed to commit download mutation: {error}"))?; + Ok(result) +} + fn remove_persisted_transfer_secrets(value: &mut Value) { let Some(object) = value.as_object_mut() else { return; @@ -2260,6 +2345,151 @@ mod tests { assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude"); } + #[test] + fn native_download_mutation_keeps_object_records_and_unrelated_rows_unchanged() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + replace_downloads( + &mut connection, + &json!([ + { + "id": "torrent-1", + "status": "paused", + "queueId": "main", + "torrentUploadedBytes": 1 + }, + { + "id": "unrelated", + "status": "queued", + "queueId": "secondary", + "customMarker": {"revision": 7} + } + ]) + .to_string(), + false, + ) + .unwrap(); + let unrelated_before = load_downloads(&connection).unwrap()[1].clone(); + + mutate_download(&mut connection, "torrent-1", false, |object| { + object.insert("torrentUploadedBytes".to_string(), json!(99)); + Ok(()) + }) + .unwrap(); + + let saved = load_downloads(&connection).unwrap(); + assert_eq!(saved[1], unrelated_before); + for record in &saved { + let value: Value = serde_json::from_str(record).unwrap(); + assert!(value.is_object()); + assert!(required_string(&value, "id").is_ok()); + assert!(required_string(&value, "status").is_ok()); + } + let target: Value = serde_json::from_str(&saved[0]).unwrap(); + assert_eq!(target["torrentUploadedBytes"], 99); + + let changes_before = connection.total_changes(); + mutate_download(&mut connection, "torrent-1", false, |object| { + object.insert("torrentUploadedBytes".to_string(), json!(99)); + Ok(()) + }) + .unwrap(); + assert_eq!(connection.total_changes(), changes_before); + } + + #[test] + fn native_download_mutation_rolls_back_on_malformed_unrelated_row() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + replace_downloads( + &mut connection, + &json!([{ + "id": "torrent-1", + "status": "paused", + "torrentUploadedBytes": 1 + }]) + .to_string(), + false, + ) + .unwrap(); + connection + .execute( + "INSERT INTO downloads (id, status, data) VALUES (?1, ?2, ?3)", + params!["broken", "queued", "\"double-encoded\""], + ) + .unwrap(); + let target_before = load_downloads(&connection).unwrap()[0].clone(); + + let error = mutate_download(&mut connection, "torrent-1", false, |object| { + object.insert("torrentUploadedBytes".to_string(), json!(99)); + Ok(()) + }) + .unwrap_err(); + + assert!(error.contains("persisted item is missing 'id'")); + assert_eq!(load_downloads(&connection).unwrap()[0], target_before); + } + + #[test] + fn native_download_mutation_rolls_back_closure_errors_and_rejects_missing_targets() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + replace_downloads( + &mut connection, + &json!([{"id": "torrent-1", "status": "paused"}]).to_string(), + false, + ) + .unwrap(); + let before = load_downloads(&connection).unwrap()[0].clone(); + + let error = mutate_download(&mut connection, "torrent-1", false, |object| { + object.insert("status".to_string(), json!("queued")); + Err::<(), _>("mutation rejected".to_string()) + }) + .unwrap_err(); + assert_eq!(error, "mutation rejected"); + assert_eq!(load_downloads(&connection).unwrap()[0], before); + + let missing = mutate_download(&mut connection, "missing", false, |_| Ok(())) + .unwrap_err(); + assert_eq!(missing, "download is no longer persisted"); + } + + #[test] + fn native_download_mutation_applies_portable_redaction_at_commit() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + replace_downloads( + &mut connection, + &json!([{ + "id": "torrent-1", + "status": "paused", + "url": "https://example.test/file", + "password": "secret" + }]) + .to_string(), + false, + ) + .unwrap(); + + mutate_download(&mut connection, "torrent-1", true, |object| { + object.insert("torrentUploadedBytes".to_string(), json!(9)); + Ok(()) + }) + .unwrap(); + + let saved: Value = + serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["torrentUploadedBytes"], 9); + assert!(saved.get("password").is_none()); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + } + #[test] fn portable_download_persistence_drops_malformed_tracker_fields() { let temp = TempDir::new().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0b0e25..c87dafc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6890,45 +6890,30 @@ fn persist_torrent_destination( relocation_check_pending: bool, ) -> Result<(), String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; - let mut changed = false; - let next_records = records - .into_iter() - .map(|record| { - let mut value: serde_json::Value = serde_json::from_str(&record) - .map_err(|error| format!("persisted download is malformed: {error}"))?; - if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { + object.insert( + "destination".to_string(), + serde_json::Value::String(destination.to_string()), + ); + if relocation_check_pending { object.insert( - "destination".to_string(), - serde_json::Value::String(destination.to_string()), + "torrentRelocationCheckPending".to_string(), + serde_json::Value::Bool(true), ); - if relocation_check_pending { - object.insert( - "torrentRelocationCheckPending".to_string(), - serde_json::Value::Bool(true), - ); - } else { - object.remove("torrentRelocationCheckPending"); - } - object.insert( - "torrentMoveDestination".to_string(), - serde_json::Value::String(destination.to_string()), - ); - changed = true; + } else { + object.remove("torrentRelocationCheckPending"); } - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}")) - }) - .collect::, _>>()?; - if !changed { - return Err("download is no longer persisted".to_string()); - } - let data = serde_json::to_string(&next_records) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &data, database.is_portable()) + object.insert( + "torrentMoveDestination".to_string(), + serde_json::Value::String(destination.to_string()), + ); + Ok(()) + }, + ) } fn persist_torrent_telemetry( @@ -6937,38 +6922,22 @@ fn persist_torrent_telemetry( snapshot: crate::queue::TorrentTelemetrySnapshot, ) -> Result<(), String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; - let mut changed = false; - let next_records = records - .into_iter() - .map(|record| { - let mut value: serde_json::Value = serde_json::from_str(&record) - .map_err(|error| format!("persisted download is malformed: {error}"))?; - if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - let uploaded = serde_json::Value::from(snapshot.uploaded_bytes); - let seeded = serde_json::Value::from(snapshot.seeded_seconds); - if object.get("torrentUploadedBytes") != Some(&uploaded) { - object.insert("torrentUploadedBytes".to_string(), uploaded); - changed = true; - } - if object.get("torrentSeededSeconds") != Some(&seeded) { - object.insert("torrentSeededSeconds".to_string(), seeded); - changed = true; - } - } - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}")) - }) - .collect::, _>>()?; - if !changed { - return Ok(()); - } - let data = serde_json::to_string(&next_records) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &data, database.is_portable()) + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { + object.insert( + "torrentUploadedBytes".to_string(), + serde_json::Value::from(snapshot.uploaded_bytes), + ); + object.insert( + "torrentSeededSeconds".to_string(), + serde_json::Value::from(snapshot.seeded_seconds), + ); + Ok(()) + }, + ) } fn persist_torrent_relocation_check( @@ -6977,41 +6946,22 @@ fn persist_torrent_relocation_check( pending: bool, ) -> Result<(), String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; - let mut changed = false; - let next_records = records - .into_iter() - .map(|record| { - let mut value: serde_json::Value = serde_json::from_str(&record) - .map_err(|error| format!("persisted download is malformed: {error}"))?; - if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - if pending { - if object.get("torrentRelocationCheckPending") - != Some(&serde_json::Value::Bool(true)) - { - object.insert( - "torrentRelocationCheckPending".to_string(), - serde_json::Value::Bool(true), - ); - changed = true; - } - } else if object.remove("torrentRelocationCheckPending").is_some() { - changed = true; - } + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { + if pending { + object.insert( + "torrentRelocationCheckPending".to_string(), + serde_json::Value::Bool(true), + ); + } else { + object.remove("torrentRelocationCheckPending"); } - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}")) - }) - .collect::, _>>()?; - if !changed { - return Ok(()); - } - let data = serde_json::to_string(&next_records) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &data, database.is_portable()) + Ok(()) + }, + ) } fn persist_torrent_file_selection( @@ -7021,63 +6971,44 @@ fn persist_torrent_file_selection( selected: Option<&[u32]>, ) -> Result<(), String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; - let mut changed = false; - let next = records - .into_iter() - .map(|record| { - let mut value: serde_json::Value = match serde_json::from_str(&record) { - Ok(value) => value, - Err(_) => return Ok(record), - }; - if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { - let current = match value.get("torrentFileIndices") { - None => None, - Some(serde_json::Value::Array(indices)) => Some( - indices - .iter() - .map(serde_json::Value::as_u64) - .collect::>>() - .ok_or_else(|| { - "persisted Torrent file selection is malformed".to_string() - })? - .into_iter() - .map(|index| { - u32::try_from(index).map_err(|_| { - "persisted Torrent file selection is out of range".to_string() - }) + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { + let current = match object.get("torrentFileIndices") { + None => None, + Some(serde_json::Value::Array(indices)) => Some( + indices + .iter() + .map(serde_json::Value::as_u64) + .collect::>>() + .ok_or_else(|| { + "persisted Torrent file selection is malformed".to_string() + })? + .into_iter() + .map(|index| { + u32::try_from(index).map_err(|_| { + "persisted Torrent file selection is out of range".to_string() }) - .collect::, String>>()?, - ), - Some(_) => { - return Err("persisted Torrent file selection is malformed".to_string()) - } - }; - if current.as_deref() != expected { - return Err("Torrent file selection changed; reload before applying".to_string()); + }) + .collect::, String>>()?, + ), + Some(_) => { + return Err("persisted Torrent file selection is malformed".to_string()) } - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - if let Some(selected) = selected { - object.insert("torrentFileIndices".to_string(), serde_json::json!(selected)); - } else { - object.remove("torrentFileIndices"); - } - changed = true; - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}")) - } else { - Ok(record) + }; + if current.as_deref() != expected { + return Err("Torrent file selection changed; reload before applying".to_string()); } - }) - .collect::, String>>()?; - if !changed { - return Err("download is no longer persisted".to_string()); - } - let next_data = serde_json::to_string(&next) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &next_data, database.is_portable()) + if let Some(selected) = selected { + object.insert("torrentFileIndices".to_string(), serde_json::json!(selected)); + } else { + object.remove("torrentFileIndices"); + } + Ok(()) + }, + ) } #[tauri::command] @@ -8508,46 +8439,27 @@ async fn verify_torrent_data( lifecycle_generation: None, }; - let original_records = { - let connection = database.lock()?; - crate::db::load_downloads(&connection)? - }; - let mut next_records = Vec::with_capacity(original_records.len()); - let mut changed = false; - for record in &original_records { - let mut value: serde_json::Value = match serde_json::from_str(record) { - Ok(value) => value, - Err(_) => { - next_records.push(record.clone()); - continue; - } - }; - if value.get("id").and_then(serde_json::Value::as_str) == Some(id.as_str()) { - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - object.insert("status".to_string(), serde_json::json!("queued")); - object.insert("hasBeenDispatched".to_string(), serde_json::json!(false)); - object.insert("torrentVerifyOnly".to_string(), serde_json::json!(true)); - object.insert( - "torrentVerifyRestoreStatus".to_string(), - serde_json::json!(restore_status), - ); - changed = true; - } - next_records.push( - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}"))?, - ); - } - if !changed { - return Err("download is no longer persisted".to_string()); - } { let mut connection = database.lock()?; - let next_data = serde_json::to_string(&next_records) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?; + crate::db::mutate_download( + &mut connection, + &id, + database.is_portable(), + |object| { + object.insert("status".to_string(), serde_json::json!("queued")); + object.insert("hasBeenDispatched".to_string(), serde_json::json!(false)); + object.insert("torrentVerifyOnly".to_string(), serde_json::json!(true)); + object.insert( + "torrentVerifyRestoreStatus".to_string(), + serde_json::json!(restore_status), + ); + object.insert( + "torrentVerifyNative".to_string(), + serde_json::json!(restore_status), + ); + Ok(()) + }, + )?; } if let Err(error) = @@ -8557,56 +8469,47 @@ async fn verify_torrent_data( // still the queued verification lifecycle. Never restore the stale // full array captured before enqueue: frontend persistence or another // command may have changed unrelated rows in the meantime. - if let Ok(mut connection) = database.lock() { - if let Ok(records) = crate::db::load_downloads(&connection) { - let mut changed = false; - let next = records - .into_iter() - .map(|record| { - let mut value: serde_json::Value = match serde_json::from_str(&record) { - Ok(value) => value, - Err(_) => return record, - }; - let is_target = value - .get("id") + let rollback_result = (|| { + let mut connection = database.lock()?; + crate::db::mutate_download( + &mut connection, + &id, + database.is_portable(), + |object| { + let is_verification_marker = object + .get("status") + .and_then(serde_json::Value::as_str) + == Some("queued") + && object + .get("torrentVerifyOnly") + .and_then(serde_json::Value::as_bool) + == Some(true) + && object + .get("torrentVerifyRestoreStatus") .and_then(serde_json::Value::as_str) - == Some(id.as_str()); - let is_verification_marker = value - .get("status") - .and_then(serde_json::Value::as_str) - == Some("queued") - && value - .get("torrentVerifyOnly") - .and_then(serde_json::Value::as_bool) - == Some(true) - && value - .get("torrentVerifyRestoreStatus") - .and_then(serde_json::Value::as_str) - == Some(restore_status.as_str()); - if is_target && is_verification_marker { - if let Some(object) = value.as_object_mut() { - object.insert( - "status".to_string(), - serde_json::json!(restore_status), - ); - object.remove("torrentVerifyOnly"); - object.remove("torrentVerifyRestoreStatus"); - changed = true; - } - } - serde_json::to_string(&value).unwrap_or(record) - }) - .collect::>(); - if changed { - if let Ok(data) = serde_json::to_string(&next) { - let _ = crate::db::replace_downloads( - &mut connection, - &data, - database.is_portable(), + == Some(restore_status.as_str()); + // The native marker is an additional persistence fence, + // not a prerequisite for rollback. A renderer snapshot + // may have already acknowledged and removed that native + // marker while enqueue is still in flight; the failed + // operation must still clear its own queued lifecycle. + if is_verification_marker { + object.insert( + "status".to_string(), + serde_json::json!(restore_status), ); + object.remove("torrentVerifyOnly"); + object.remove("torrentVerifyRestoreStatus"); + object.remove("torrentVerifyNative"); } - } - } + Ok(()) + }, + ) + })(); + if let Err(rollback_error) = rollback_result { + return Err(format!( + "{error}; failed to roll back Torrent verification state: {rollback_error}" + )); } return Err(error.to_string()); } @@ -8619,48 +8522,19 @@ fn replace_persisted_torrent_web_seeds( seeds: &[crate::ipc::TorrentWebSeed], ) -> Result, String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; let next_seeds = serde_json::to_value(seeds) .map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?; - let mut previous_seeds = None; - let mut changed = false; - let mut next = Vec::with_capacity(records.len()); - for record in records { - let mut value: serde_json::Value = match serde_json::from_str(&record) { - Ok(value) => value, - Err(_) => { - // Preserve unrelated legacy/corrupt rows byte-for-byte. A - // web-seed update must not fail its own transaction merely - // because another download cannot be decoded. - next.push(record); - continue; - } - }; - if value - .get("id") - .and_then(serde_json::Value::as_str) - == Some(id) - { - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; - previous_seeds = object.get("torrentWebSeeds").cloned(); + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { + let previous_seeds = object.get("torrentWebSeeds").cloned(); object.insert("torrentWebSeeds".to_string(), next_seeds.clone()); object.insert("torrentWebSeedsNative".to_string(), next_seeds.clone()); - changed = true; - } - next.push( - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}"))?, - ); - } - if !changed { - return Err("download is not persisted".to_string()); - } - let next_data = serde_json::to_string(&next) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?; - Ok(previous_seeds) + Ok(previous_seeds) + }, + ) } fn restore_persisted_torrent_web_seeds( @@ -8670,29 +8544,13 @@ fn restore_persisted_torrent_web_seeds( previous_seeds: Option, ) -> Result<(), String> { let mut connection = database.lock()?; - let records = crate::db::load_downloads(&connection)?; let expected_value = serde_json::to_value(expected_seeds) .map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?; - let mut found = false; - let mut changed = false; - let mut next = Vec::with_capacity(records.len()); - for record in records { - let mut value: serde_json::Value = match serde_json::from_str(&record) { - Ok(value) => value, - Err(_) => { - next.push(record); - continue; - } - }; - if value - .get("id") - .and_then(serde_json::Value::as_str) - == Some(id) - { - found = true; - let object = value - .as_object_mut() - .ok_or_else(|| "persisted download is not an object".to_string())?; + crate::db::mutate_download( + &mut connection, + id, + database.is_portable(), + |object| { if object.get("torrentWebSeeds") == Some(&expected_value) { match previous_seeds.clone() { Some(previous) => { @@ -8703,28 +8561,15 @@ fn restore_persisted_torrent_web_seeds( } } object.remove("torrentWebSeedsNative"); - changed = true; } else { log::warn!( "Torrent web-seed rollback [{}] skipped because persisted state changed concurrently", id ); } - } - next.push( - serde_json::to_string(&value) - .map_err(|error| format!("failed to encode persisted download: {error}"))?, - ); - } - if !found { - return Err("download is no longer persisted".to_string()); - } - if changed { - let next_data = serde_json::to_string(&next) - .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; - crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?; - } - Ok(()) + Ok(()) + }, + ) } async fn normalize_persisted_torrent_web_seeds( @@ -9888,7 +9733,14 @@ fn persisted_destinations_equal(left: &str, right: &str) -> bool { fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result { let mut native_state: HashMap< String, - (u64, u64, Option, Option), + ( + u64, + u64, + Option, + bool, + Option, + Option, + ), > = HashMap::new(); for record in existing { let Ok(value) = serde_json::from_str::(record) else { @@ -9917,7 +9769,15 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result Result Result Result, by_window: HashMap, + ready_windows: HashSet, } #[derive(Clone, Serialize)] @@ -72,6 +74,7 @@ impl PropertiesWindowRegistry { .lock() .map_err(|_| "Properties window registry is unavailable".to_string())?; let download_id = state.by_window.remove(label); + state.ready_windows.remove(label); if let Some(download_id) = &download_id { state.by_download.remove(download_id); } @@ -86,6 +89,7 @@ impl PropertiesWindowRegistry { let label = state.by_download.remove(download_id); if let Some(label) = &label { state.by_window.remove(label); + state.ready_windows.remove(label); } Ok(label) } @@ -99,6 +103,36 @@ impl PropertiesWindowRegistry { .get(download_id) .cloned()) } + + pub fn mark_ready(&self, label: &str) -> Result<(), String> { + let mut state = self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())?; + if !state.by_window.contains_key(label) { + return Err("Properties window is no longer registered".to_string()); + } + state.ready_windows.insert(label.to_string()); + Ok(()) + } + + pub fn is_ready(&self, label: &str) -> Result { + Ok(self + .state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())? + .ready_windows + .contains(label)) + } + + pub fn clear_ready(&self, label: &str) -> Result<(), String> { + self.state + .lock() + .map_err(|_| "Properties window registry is unavailable".to_string())? + .ready_windows + .remove(label); + Ok(()) + } } pub fn is_properties_window_label(label: &str) -> bool { @@ -152,6 +186,7 @@ fn is_properties_action(action: &str) -> bool { action, "apply-properties" | "pause-resume" + | "verify-torrent" | "set-download-limit" | "set-torrent-upload-limit" | "set-torrent-peer-options" @@ -194,18 +229,25 @@ pub fn open_download_properties_window( let label = registry.allocate(&id)?; if let Some(window) = app.get_webview_window(&label) { + if !registry.is_ready(&label)? { + return Ok(label); + } let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); return Ok(label); } + // If the native window disappeared without delivering Destroyed, discard + // the old readiness bit before constructing a fresh hidden webview. + registry.clear_ready(&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) + .visible(false) .build(); if let Err(error) = build_result { // Two rapid main-window requests can race between the native lookup @@ -213,9 +255,11 @@ pub fn open_download_properties_window( // 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(); + if registry.is_ready(&label)? { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } return Ok(label); } let _ = registry.remove_window(&label); @@ -251,6 +295,17 @@ pub fn properties_window_send_ready( .map_err(|error| error.to_string()) } +#[tauri::command] +pub fn properties_window_reveal( + caller: tauri::WebviewWindow, + registry: tauri::State<'_, PropertiesWindowRegistry>, +) -> Result<(), String> { + registered_download_for_caller(&caller, ®istry)?; + registry.mark_ready(caller.label())?; + caller.show().map_err(|error| error.to_string())?; + caller.set_focus().map_err(|error| error.to_string()) +} + #[tauri::command] pub fn properties_window_send_action( caller: tauri::WebviewWindow, @@ -368,10 +423,17 @@ mod tests { fn registry_reuses_one_label_per_download_and_cleans_both_indexes() { let registry = PropertiesWindowRegistry::default(); let first = registry.allocate("download-a").unwrap(); + assert!(!registry.is_ready(&first).unwrap()); + registry.mark_ready(&first).unwrap(); + assert!(registry.is_ready(&first).unwrap()); + registry.clear_ready(&first).unwrap(); + assert!(!registry.is_ready(&first).unwrap()); + registry.mark_ready(&first).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!(!registry.is_ready(&first).unwrap()); assert_ne!(registry.allocate("download-a").unwrap(), first); } @@ -385,6 +447,7 @@ mod tests { #[test] fn child_actions_are_allowlisted() { assert!(is_properties_action("apply-properties")); + assert!(is_properties_action("verify-torrent")); 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 eec176a..5891766 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n'; import { useTranslation } from 'react-i18next'; import { formatDownloadBytes } from './utils/downloadProgress'; +import { synchronizeDocumentAppearance } from './utils/documentAppearance'; const loadSettingsView = () => import('./components/SettingsView'); const loadSchedulerView = () => import('./components/SchedulerView'); @@ -669,17 +670,13 @@ function App() { }); }, [addToast, coreReady, showKeychainModal]); - useEffect(() => { - window.document.documentElement.setAttribute('data-font-family', fontFamily); - }, [fontFamily]); - - useEffect(() => { - window.document.documentElement.setAttribute('data-font-size', appFontSize); - }, [appFontSize]); - - useEffect(() => { - window.document.documentElement.setAttribute('data-list-density', listRowDensity); - }, [listRowDensity]); + useEffect(() => synchronizeDocumentAppearance(window, { + theme, + fontFamily, + appFontSize, + listRowDensity, + locale: resolveAppLocale(i18n.language), + }), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]); useEffect(() => { const checkForUpdate = () => { @@ -1025,39 +1022,6 @@ function App() { }; }, [autoAddClipboardLinks, coreReady, showKeychainModal]); - useEffect(() => { - const root = window.document.documentElement; - - const applyTheme = () => { - // Remove all theme classes first - root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'); - - if (theme === 'system') { - const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - root.classList.add(systemDark ? 'theme-dark' : 'theme-light'); - root.dataset.resolvedTheme = systemDark ? 'dark' : 'light'; - root.style.colorScheme = systemDark ? 'dark' : 'light'; - if (systemDark) root.classList.add('dark'); - } else { - root.classList.add(`theme-${theme}`); - if (['dark', 'dracula', 'nord'].includes(theme)) { - root.classList.add('dark'); - } - root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; - root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; - } - }; - - applyTheme(); - - if (theme === 'system') { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const listener = () => applyTheme(); - mediaQuery.addEventListener('change', listener); - return () => mediaQuery.removeEventListener('change', listener); - } - }, [theme]); - return (
{ - if (!value) return '-'; - return formatDateTime(value, { - locale, - calendar, - options: { dateStyle: 'medium', timeStyle: 'short' } - }); -}; - -const isPeerDiagnosticsStatus = (status: string): boolean => - ['downloading', 'verifying', 'seeding', 'retrying'].includes(status); - -const isTorrentFileProgressStatus = (status: string): boolean => - ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); - -const isTorrentAvailabilityStatus = (status: string): boolean => - ['downloading', 'verifying', 'seeding', 'retrying', 'paused'].includes(status); - -const formatPeerSpeed = (bytesPerSecond: number): string => - `${formatDownloadBytes(bytesPerSecond)}/s`; - -export const PropertiesModal = () => { - const { t, i18n } = useTranslation(); - const categoryLabel = (category: string) => { - switch (category) { - case 'Musics': return t($ => $.navigation.categories.musics); - case 'Movies': return t($ => $.navigation.categories.movies); - case 'Compressed': return t($ => $.navigation.categories.compressed); - case 'Documents': return t($ => $.navigation.categories.documents); - case 'Pictures': return t($ => $.navigation.categories.pictures); - case 'Applications': return t($ => $.navigation.categories.applications); - default: return t($ => $.navigation.categories.other); - } - }; - const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); - const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId); - const item = useDownloadStore(useShallow(state => - selectedPropertiesDownloadId - ? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null - : null - )); - const liveProgress = useDownloadProgressStore(useShallow(state => - selectedPropertiesDownloadId - ? state.progressMap[selectedPropertiesDownloadId] - : undefined - )); - const moveProgress = useDownloadProgressStore(state => - selectedPropertiesDownloadId ? state.moveProgressMap[selectedPropertiesDownloadId] : undefined - ); - - const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore(); - - // Form states - const [url, setUrl] = useState(''); - const [fileName, setFileName] = useState(''); - const [saveLocation, setSaveLocation] = useState(''); - const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections)); - const [connectionsDirty, setConnectionsDirty] = useState(false); - - const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); - const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s - const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState(''); - const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState(''); - const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState(''); - const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState(''); - const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); - const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); - const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); - const [torrentFileAllocation, setTorrentFileAllocation] = useState('prealloc'); - const [torrentTrackers, setTorrentTrackers] = useState(''); - const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); - const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState(''); - const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState(''); - const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0'); - const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); - const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false); - const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M'); - const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false); - const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M'); - const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState(null); - const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false); - const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false); - const [torrentFileProgress, setTorrentFileProgress] = useState(null); - const [torrentFileSelection, setTorrentFileSelection] = useState(null); - const [torrentDetails, setTorrentDetails] = useState(null); - const [torrentDetailsError, setTorrentDetailsError] = useState(false); - const [isTorrentDetailsPending, setIsTorrentDetailsPending] = useState(false); - const [isTorrentVerifyPending, setIsTorrentVerifyPending] = useState(false); - const [torrentFileProgressError, setTorrentFileProgressError] = useState(false); - const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false); - const [torrentPieceProgress, setTorrentPieceProgress] = useState(null); - const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false); - const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false); - const [torrentAvailability, setTorrentAvailability] = useState(null); - const [torrentAvailabilityError, setTorrentAvailabilityError] = useState(false); - const [isTorrentAvailabilityPending, setIsTorrentAvailabilityPending] = useState(false); - const [torrentShareMessage, setTorrentShareMessage] = useState(''); - const [isTorrentMovePending, setIsTorrentMovePending] = useState(false); - const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); - const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false); - const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false); - const [torrentWebSeedRows, setTorrentWebSeedRows] = useState([]); - const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false); - const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false); - - const [loginMode, setLoginMode] = useState('matching'); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - - const [advancedExpanded, setAdvancedExpanded] = useState(false); - const [checksumEnabled, setChecksumEnabled] = useState(false); - const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256'); - const [checksumValue, setChecksumValue] = useState(''); - const [cookies, setCookies] = useState(''); - const [headers, setHeaders] = useState(''); - const [mirrors, setMirrors] = useState(''); - - const [errorMessage, setErrorMessage] = useState(''); - const [isPauseResumePending, setIsPauseResumePending] = useState(false); - const torrentFileProgressByIndex = new Map( - (torrentFileProgress?.files ?? []).map(file => [file.index, file]) - ); - const actionRequestRef = useRef(0); - const peerDiagnosticsRequestRef = useRef(0); - const torrentFileProgressRequestRef = useRef(0); - const torrentFileSelectionRequestRef = useRef(0); - const torrentDetailsRequestRef = useRef(0); - const torrentPieceProgressRequestRef = useRef(0); - const torrentAvailabilityRequestRef = useRef(0); - const torrentWebSeedsRequestRef = useRef(0); - const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item)); - - useEffect(() => { - // Invalidate native pickers and transfer-control results when the modal - // switches items, closes, or reopens for the same download. - actionRequestRef.current += 1; - setIsLiveSpeedLimitPending(false); - setIsLiveTorrentUploadLimitPending(false); - setIsLiveTorrentPeerOptionsPending(false); - peerDiagnosticsRequestRef.current += 1; - setTorrentPeerDiagnostics(null); - setTorrentPeerDiagnosticsError(false); - setIsTorrentPeerDiagnosticsPending(false); - torrentFileProgressRequestRef.current += 1; - setTorrentFileProgress(null); - setTorrentFileProgressError(false); - setIsTorrentFileProgressPending(false); - torrentDetailsRequestRef.current += 1; - setTorrentDetails(null); - setTorrentDetailsError(false); - setIsTorrentDetailsPending(false); - torrentPieceProgressRequestRef.current += 1; - setTorrentPieceProgress(null); - setTorrentPieceProgressError(false); - setIsTorrentPieceProgressPending(false); - torrentAvailabilityRequestRef.current += 1; - setTorrentAvailability(null); - setTorrentAvailabilityError(false); - setIsTorrentAvailabilityPending(false); - setTorrentShareMessage(''); - setIsTorrentMovePending(false); - torrentWebSeedsRequestRef.current += 1; - setTorrentWebSeedsError(false); - setIsTorrentWebSeedsPending(false); - setTorrentWebSeedRows([]); - }, [selectedPropertiesDownloadId]); - - useEffect(() => { - if (selectedPropertiesDownloadId) { - const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); - if (activeItem) { - setUrl(activeItem.url); - setFileName(activeItem.fileName); - if (activeItem.destination) { - setSaveLocation(activeItem.destination); - } else { - const propertiesDownloadId = selectedPropertiesDownloadId; - const requestId = actionRequestRef.current; - void resolveCategoryDestination( - useSettingsStore.getState(), - activeItem.category - ).then(location => { - if ( - requestId === actionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setSaveLocation(location); - } - }); - } - setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections)); - setConnectionsDirty(false); - - if (activeItem.speedLimit) { - setSpeedLimitEnabled(true); - setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, '')); - } else { - setSpeedLimitEnabled(false); - } - - if (activeItem.username || activeItem.password) { - setLoginMode('custom'); - setUsername(activeItem.username || ''); - setPassword(activeItem.password || ''); - } else { - setLoginMode('matching'); - setUsername(''); - setPassword(''); - } - - setHeaders(activeItem.headers || ''); - setChecksumEnabled(!!activeItem.checksum); - if (activeItem.checksum) { - const [algo, val] = activeItem.checksum.split('='); - if (val) { - setChecksumAlgorithm(algo); - setChecksumValue(val); - } - } else { - setChecksumAlgorithm('SHA-256'); - setChecksumValue(''); - } - setCookies(activeItem.cookies || ''); - setMirrors(activeItem.mirrors || ''); - setLiveTorrentMaxPeersValue( - activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers) - ); - setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || ''); - setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true); - setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true); - setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED); - setTorrentFileAllocation(normalizeTorrentFileAllocation(activeItem.torrentFileAllocation) || 'prealloc'); - setTorrentTrackers(activeItem.torrentTrackers || ''); - setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || ''); - setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout)); - setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout)); - setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval)); - setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout)); - const previewPriority = parseTorrentPreviewPriority(activeItem.torrentPrioritizePiece); - setTorrentPreviewHeadEnabled(Boolean(previewPriority.head)); - setTorrentPreviewHeadSize(previewPriority.head || '1M'); - setTorrentPreviewTailEnabled(Boolean(previewPriority.tail)); - setTorrentPreviewTailSize(previewPriority.tail || '1M'); - setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(activeItem.torrentWebSeeds)); - setErrorMessage(''); - } else { - setSelectedPropertiesDownloadId(null); - } - } - }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); - - useEffect(() => { - torrentFileSelectionRequestRef.current += 1; - setTorrentFileSelection(null); - if (!selectedPropertiesDownloadId || !item?.isTorrent) return; - const requestId = torrentFileSelectionRequestRef.current; - const propertiesDownloadId = item.id; - void invoke('get_torrent_file_selection', { id: propertiesDownloadId }) - .then(snapshot => { - if ( - requestId === torrentFileSelectionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentFileSelection(snapshot); - } - }) - .catch(() => { - if (requestId === torrentFileSelectionRequestRef.current) setTorrentFileSelection(null); - }); - }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); - - useEffect(() => { - torrentDetailsRequestRef.current += 1; - setTorrentDetails(null); - setTorrentDetailsError(false); - setIsTorrentDetailsPending(false); - if (!selectedPropertiesDownloadId || !item?.isTorrent || !item.torrentPath) return; - - const requestId = torrentDetailsRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentDetailsPending(true); - void invoke('get_torrent_details', { id: propertiesDownloadId }) - .then(details => { - if ( - requestId === torrentDetailsRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentDetails(details); - } - }) - .catch(() => { - if ( - requestId === torrentDetailsRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentDetailsError(true); - } - }) - .finally(() => { - if (requestId === torrentDetailsRequestRef.current) setIsTorrentDetailsPending(false); - }); - }, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]); - - useEffect(() => { - const activeLimit = item?.speedLimit?.trim(); - setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); - }, [item?.speedLimit, selectedPropertiesDownloadId]); - - useEffect(() => { - const activeLimit = item?.torrentUploadLimit?.trim(); - setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); - }, [item?.torrentUploadLimit, selectedPropertiesDownloadId]); - - useEffect(() => { - peerDiagnosticsRequestRef.current += 1; - setTorrentPeerDiagnostics(null); - setTorrentPeerDiagnosticsError(false); - setIsTorrentPeerDiagnosticsPending(false); - }, [item?.id, item?.isTorrent, item?.lastTry, item?.status]); - - useEffect(() => { - torrentFileProgressRequestRef.current += 1; - setTorrentFileProgress(null); - setTorrentFileProgressError(false); - setIsTorrentFileProgressPending(false); - if ( - !selectedPropertiesDownloadId - || !item?.isTorrent - || !isTorrentFileProgressStatus(item.status) - ) return; - - const requestId = torrentFileProgressRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentFileProgressPending(true); - void invoke('get_torrent_file_progress', { id: propertiesDownloadId }) - .then(snapshot => { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentFileProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentFileProgress(snapshot); - } - }) - .catch(() => { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentFileProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentFileProgressError(true); - } - }) - .finally(() => { - if (requestId === torrentFileProgressRequestRef.current) { - setIsTorrentFileProgressPending(false); - } - }); - }, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]); - - useEffect(() => { - torrentWebSeedsRequestRef.current += 1; - setTorrentWebSeedsError(false); - setIsTorrentWebSeedsPending(false); - if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return; - const requestId = torrentWebSeedsRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentWebSeedsPending(true); - void invoke('get_torrent_web_seeds', { id: propertiesDownloadId }) - .then(seeds => { - if ( - requestId === torrentWebSeedsRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(seeds)); - useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds }); - } - }) - .catch(() => { - if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true); - }) - .finally(() => { - if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false); - }); - }, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]); - - useEffect(() => { - torrentPieceProgressRequestRef.current += 1; - setTorrentPieceProgress(null); - setTorrentPieceProgressError(false); - setIsTorrentPieceProgressPending(false); - if ( - !selectedPropertiesDownloadId - || !item?.isTorrent - || !isTorrentFileProgressStatus(item.status) - ) return; - - const requestId = torrentPieceProgressRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentPieceProgressPending(true); - void invoke('get_torrent_piece_progress', { id: propertiesDownloadId }) - .then(snapshot => { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentPieceProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentPieceProgress(snapshot); - } - }) - .catch(() => { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentPieceProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentPieceProgressError(true); - } - }) - .finally(() => { - if (requestId === torrentPieceProgressRequestRef.current) { - setIsTorrentPieceProgressPending(false); - } - }); - }, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]); - - useEffect(() => { - setLiveTorrentMaxPeersValue( - item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers) - ); - setLiveTorrentPeerSpeedLimitValue(item?.torrentPeerSpeedLimit || ''); - }, [item?.torrentMaxPeers, item?.torrentPeerSpeedLimit, selectedPropertiesDownloadId]); - - useEffect(() => { - if (!selectedPropertiesDownloadId || connectionsDirty) return; - const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); - if (activeItem && activeItem.connections === undefined) { - setConnections(resolveDownloadConnections(undefined, perServerConnections)); - } - }, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]); - - useEffect(() => { - if (!selectedPropertiesDownloadId) return; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isTopmostModal(modalRef.current)) { - event.preventDefault(); - setSelectedPropertiesDownloadId(null); - } - }; - window.addEventListener('keydown', handleEscape); - return () => window.removeEventListener('keydown', handleEscape); - }, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]); - - if (!selectedPropertiesDownloadId || !item) return null; - - const handleBrowse = async () => { - if (identityLocked) return; - const requestId = ++actionRequestRef.current; - const propertiesDownloadId = item.id; - try { - const selected = await open({ - directory: true, - multiple: false, - defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation - }); - if ( - selected - && typeof selected === 'string' - && requestId === actionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setSaveLocation(selected); - } - } catch (e) { - console.error("Failed to select folder:", e); - } - }; - - const handleRefreshTorrentPeers = async () => { - if ( - isTorrentPeerDiagnosticsPending - || !item.isTorrent - || !isPeerDiagnosticsStatus(item.status) - ) return; - - const requestId = ++peerDiagnosticsRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentPeerDiagnosticsPending(true); - setTorrentPeerDiagnosticsError(false); - try { - const diagnostics = await invoke('get_torrent_peers', { id: propertiesDownloadId }); - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === peerDiagnosticsRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isPeerDiagnosticsStatus(currentItem.status) - ) { - setTorrentPeerDiagnostics(diagnostics); - } - } catch { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === peerDiagnosticsRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isPeerDiagnosticsStatus(currentItem.status) - ) { - setTorrentPeerDiagnosticsError(true); - setTorrentPeerDiagnostics(null); - } - } finally { - if (requestId === peerDiagnosticsRequestRef.current) { - setIsTorrentPeerDiagnosticsPending(false); - } - } - }; - - const handleRefreshTorrentFileProgress = async () => { - if ( - isTorrentFileProgressPending - || !item.isTorrent - || !isTorrentFileProgressStatus(item.status) - ) return; - - const requestId = ++torrentFileProgressRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentFileProgressPending(true); - setTorrentFileProgressError(false); - try { - const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId }); - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentFileProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentFileProgress(snapshot); - } - } catch { - if ( - requestId === torrentFileProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentFileProgressError(true); - setTorrentFileProgress(null); - } - } finally { - if (requestId === torrentFileProgressRequestRef.current) { - setIsTorrentFileProgressPending(false); - } - } - }; - - const handleRefreshTorrentPieceProgress = async () => { - if ( - isTorrentPieceProgressPending - || !item.isTorrent - || !isTorrentFileProgressStatus(item.status) - ) return; - - const requestId = ++torrentPieceProgressRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentPieceProgressPending(true); - setTorrentPieceProgressError(false); - try { - const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId }); - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentPieceProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentFileProgressStatus(currentItem.status) - ) { - setTorrentPieceProgress(snapshot); - } - } catch { - if ( - requestId === torrentPieceProgressRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentPieceProgressError(true); - setTorrentPieceProgress(null); - } - } finally { - if (requestId === torrentPieceProgressRequestRef.current) { - setIsTorrentPieceProgressPending(false); - } - } - }; - - const handleRefreshTorrentAvailability = async () => { - if ( - isTorrentAvailabilityPending - || !item.isTorrent - || !isTorrentAvailabilityStatus(item.status) - ) return; - const requestId = ++torrentAvailabilityRequestRef.current; - const propertiesDownloadId = item.id; - setIsTorrentAvailabilityPending(true); - setTorrentAvailabilityError(false); - try { - const snapshot = await invoke('get_torrent_availability', { id: propertiesDownloadId }); - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId); - if ( - requestId === torrentAvailabilityRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - && currentItem?.isTorrent - && isTorrentAvailabilityStatus(currentItem.status) - ) { - setTorrentAvailability(snapshot); - } - } catch { - if ( - requestId === torrentAvailabilityRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId - ) { - setTorrentAvailabilityError(true); - setTorrentAvailability(null); - } - } finally { - if (requestId === torrentAvailabilityRequestRef.current) setIsTorrentAvailabilityPending(false); - } - }; - - const handleCopyTorrentMagnet = async () => { - if (!item.isTorrent) return; - setTorrentShareMessage(''); - try { - const magnet = await invoke('get_torrent_magnet_link', { id: item.id }); - await writeClipboardText(magnet); - setTorrentShareMessage(t($ => $.properties.torrentMagnetCopied)); - } catch { - setTorrentShareMessage(t($ => $.properties.torrentMagnetCopyFailed)); - } - }; - - const handleExportTorrentMetadata = async () => { - if (!item.isTorrent) return; - setTorrentShareMessage(''); - try { - const destination = await save({ - defaultPath: `${item.fileName.replace(/\.torrent$/i, '')}.torrent`, - filters: [{ name: 'Torrent metadata', extensions: ['torrent'] }] - }); - if (!destination) return; - await invoke('export_torrent_metadata', { id: item.id, destination }); - setTorrentShareMessage(t($ => $.properties.torrentMetadataExported)); - } catch { - setTorrentShareMessage(t($ => $.properties.torrentMetadataExportFailed)); - } - }; - - const handleMoveTorrentData = async () => { - if (!item.isTorrent || isTorrentMovePending || !['paused', 'completed', 'failed'].includes(item.status)) return; - const propertiesDownloadId = item.id; - const selected = await open({ - directory: true, - multiple: false, - defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation - }); - if (!selected || typeof selected !== 'string') return; - if (!window.confirm(t($ => $.properties.torrentMoveConfirm))) return; - setIsTorrentMovePending(true); - setTorrentShareMessage(''); - try { - await invoke('move_torrent_data', { id: propertiesDownloadId, destination: selected }); - useDownloadStore.getState().updateDownload(propertiesDownloadId, { - destination: selected, - torrentRelocationCheckPending: item.torrentRelocationCheckPending === true - || item.status === 'paused' - || item.status === 'failed' - ? true - : undefined - }); - setSaveLocation(selected); - setTorrentShareMessage(t($ => $.properties.torrentMoveCompleted)); - } catch { - setTorrentShareMessage(t($ => $.properties.torrentMoveFailed)); - } finally { - setIsTorrentMovePending(false); - } - }; - - const handleCancelTorrentMove = async () => { - if (!item?.isTorrent || !isTorrentMovePending) return; - try { - await invoke('cancel_torrent_move_data', { id: item.id }); - setTorrentShareMessage(t($ => $.properties.torrentMoveCancelRequested)); - } catch { - setTorrentShareMessage(t($ => $.properties.torrentMoveFailed)); - } - }; - - const torrentWebSeedFiles = (torrentFileSelection?.files ?? torrentFileProgress?.files ?? []) - .map(file => ({ index: file.index, relativePath: file.relativePath })); - const torrentWebSeedsMetadataPending = torrentWebSeedRows.length > 0 && torrentWebSeedFiles.length === 0; - - const handleTorrentWebSeedsSave = async () => { - if (!item?.isTorrent || isTorrentWebSeedsPending) return; - const files = torrentWebSeedFiles.map(file => ({ index: file.index })); - const seeds = normalizeTorrentWebSeedDrafts(torrentWebSeedRows, files); - if (!seeds) { - setTorrentWebSeedsError(true); - return; - } - setIsTorrentWebSeedsPending(true); - setTorrentWebSeedsError(false); - try { - const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds }); - setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(normalized)); - useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized }); - } catch { - setTorrentWebSeedsError(true); - } finally { - setIsTorrentWebSeedsPending(false); - } - }; - - const handleVerifyTorrentData = async () => { - if (!item?.isTorrent || isTorrentVerifyPending) return; - const restoreStatus = item.status; - const previousVerifyOnly = item.torrentVerifyOnly; - const previousRestoreStatus = item.torrentVerifyRestoreStatus; - // Mark the maintenance lifecycle before invoking the command so a very - // fast queued/completed event cannot be mistaken for the normal download - // lifecycle. The backend persists the same markers before dispatching. - useDownloadStore.getState().updateDownload(item.id, { - torrentVerifyOnly: true, - torrentVerifyRestoreStatus: restoreStatus - }); - setIsTorrentVerifyPending(true); - try { - await invoke('verify_torrent_data', { id: item.id }); - } catch (error) { - useDownloadStore.getState().updateDownload(item.id, { - torrentVerifyOnly: previousVerifyOnly, - torrentVerifyRestoreStatus: previousRestoreStatus - }); - setErrorMessage(error instanceof Error ? error.message : String(error)); - } finally { - setIsTorrentVerifyPending(false); - } - }; - - const handleSave = async () => { - if (!url.trim()) { - setErrorMessage(t($ => $.properties.enterValidUrl)); - return; - } - if (!fileName.trim()) { - setErrorMessage(t($ => $.properties.fileNameEmpty)); - return; - } - - const normalizedMaxPeers = liveTorrentMaxPeersValue.trim() - ? Number(liveTorrentMaxPeersValue) - : undefined; - if ( - item.isTorrent - && normalizedMaxPeers !== undefined - && (!Number.isInteger(normalizedMaxPeers) || normalizedMaxPeers < 0 || normalizedMaxPeers > 1000) - ) { - setErrorMessage(t($ => $.properties.torrentMaxPeersInvalid)); - return; - } - const normalizedPeerSpeedLimit = normalizeSpeedLimitForBackend(liveTorrentPeerSpeedLimitValue); - if (item.isTorrent && liveTorrentPeerSpeedLimitValue.trim() && !normalizedPeerSpeedLimit) { - setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid)); - return; - } - if (item.isTorrent && !isValidTorrentTrackerList(torrentTrackers)) { - setErrorMessage(t($ => $.properties.torrentTrackersInvalid)); - return; - } - if (item.isTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) { - setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid)); - return; - } - if (item.isTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) { - setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid)); - return; - } - if (item.isTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) { - setErrorMessage(t($ => $.properties.torrentTrackerTimeoutInvalid)); - return; - } - if (item.isTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) { - setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid)); - return; - } - const torrentPrioritizePiece = serializeTorrentPreviewPriority( - torrentPreviewHeadEnabled, - torrentPreviewHeadSize, - torrentPreviewTailEnabled, - torrentPreviewTailSize - ); - if (item.isTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPrioritizePiece) { - setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid)); - return; - } - const normalizedStopTimeout = torrentStopTimeout.trim() - ? Number(torrentStopTimeout) - : undefined; - if ( - item.isTorrent - && normalizedStopTimeout !== undefined - && (!Number.isInteger(normalizedStopTimeout) || normalizedStopTimeout < 0 || normalizedStopTimeout > MAX_TORRENT_STOP_TIMEOUT) - ) { - setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid)); - return; - } - if (item.isTorrent && torrentRemoveUnselectedFile && !item.torrentFileIndices?.length) { - setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); - return; - } - if (item.isTorrent && !normalizeTorrentEncryptionPolicy(torrentEncryptionPolicy)) { - setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid)); - return; - } - const selectedTorrentIndices = torrentFileSelection - ? torrentFileSelection.files.filter(file => file.selected).map(file => file.index) - : []; - const allTorrentFilesSelected = Boolean( - torrentFileSelection - && selectedTorrentIndices?.length === torrentFileSelection.files.length - ); - if (torrentFileSelection && selectedTorrentIndices?.length === 0) { - setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); - return; - } - if (item.isTorrent && torrentRemoveUnselectedFile && torrentFileSelection && allTorrentFilesSelected) { - setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); - return; - } - if ( - item.isTorrent - && torrentRemoveUnselectedFile - && !item.torrentRemoveUnselectedFile - && !window.confirm(t($ => $.properties.torrentRemoveUnselectedFileConfirm)) - ) { - return; - } - - const updates: Partial = { - url, - fileName, - destination: saveLocation, - speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined, - username: loginMode === 'custom' ? username.trim() : undefined, - password: loginMode === 'custom' ? password.trim() : undefined, - headers: headers.trim() || undefined, - checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined, - cookies: cookies.trim() || undefined, - mirrors: mirrors.trim() || undefined, - ...(item.isTorrent - ? { - torrentMaxPeers: normalizedMaxPeers, - torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined, - torrentCheckIntegrity, - torrentTrackers: torrentTrackers.trim() || undefined, - torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined, - torrentTrackerConnectTimeout: torrentTrackerConnectTimeout.trim() - ? Number(torrentTrackerConnectTimeout) - : undefined, - torrentTrackerTimeout: torrentTrackerTimeout.trim() - ? Number(torrentTrackerTimeout) - : undefined, - torrentTrackerInterval: torrentTrackerInterval.trim() - ? Number(torrentTrackerInterval) - : undefined, - torrentStopTimeout: normalizedStopTimeout, - torrentPrioritizePiece: torrentPrioritizePiece || undefined, - torrentFileIndices: torrentFileSelection - ? (allTorrentFilesSelected ? undefined : selectedTorrentIndices) - : item.torrentFileIndices, - torrentRemoveUnselectedFile: (torrentFileSelection - ? !allTorrentFilesSelected - : item.torrentFileIndices !== undefined) - ? torrentRemoveUnselectedFile - : undefined, - torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED - ? torrentEncryptionPolicy - : undefined, - torrentFileAllocation, - } - : {}), - ...(connectionsDirty - ? { connections: resolveDownloadConnections(connections, perServerConnections) } - : {}), - }; - - const requestId = ++actionRequestRef.current; - try { - setErrorMessage(''); - await useDownloadStore.getState().applyProperties(item.id, updates); - if ( - requestId === actionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === item.id - ) { - setSelectedPropertiesDownloadId(null); - } - } catch (e) { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setErrorMessage(e instanceof Error ? e.message : String(e)); - } - } - }; - - const handlePauseResume = async () => { - const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id); - const action = currentItem ? getPauseResumeAction(currentItem.status) : null; - if (!currentItem || !action || isPauseResumePending) return; - - if (action === 'pause' && currentItem.resumable === false) { - const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne)); - if (!confirmPause) return; - } - - setErrorMessage(''); - const requestId = ++actionRequestRef.current; - setIsPauseResumePending(true); - try { - if (action === 'pause') { - await useDownloadStore.getState().pauseDownload(currentItem.id); - } else { - const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id); - if (!resumed) { - throw new Error(t($ => $.downloadTable.backendRejectedStart)); - } - } - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - const message = action === 'pause' - ? t($ => $.downloadTable.pauseFailed) - : t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName }); - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail })); - } - } finally { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setIsPauseResumePending(false); - } - } - }; - - const handleLiveSpeedLimit = async (limit: string | null) => { - if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return; - - setErrorMessage(''); - const requestId = ++actionRequestRef.current; - setIsLiveSpeedLimitPending(true); - try { - await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit); - if ( - limit === null - && requestId === actionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === item.id - ) { - setLiveSpeedLimitValue(''); - } - } catch (error) { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, { - detail: error instanceof Error ? error.message : String(error) - })); - } - } finally { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setIsLiveSpeedLimitPending(false); - } - } - }; - - const handleLiveTorrentUploadLimit = async (limit: string | null) => { - if ( - isLiveTorrentUploadLimitPending - || !item.isTorrent - || !['downloading', 'seeding', 'retrying'].includes(item.status) - ) return; - - setErrorMessage(''); - const requestId = ++actionRequestRef.current; - setIsLiveTorrentUploadLimitPending(true); - try { - await useDownloadStore.getState().setTorrentUploadLimit(item.id, limit); - if ( - limit === null - && requestId === actionRequestRef.current - && useDownloadStore.getState().selectedPropertiesDownloadId === item.id - ) { - setLiveTorrentUploadLimitValue(''); - } - } catch (error) { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setErrorMessage(t($ => $.properties.liveTorrentUploadLimitFailed, { - detail: error instanceof Error ? error.message : String(error) - })); - } - } finally { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setIsLiveTorrentUploadLimitPending(false); - } - } - }; - - const handleLiveTorrentPeerOptions = async () => { - if ( - isLiveTorrentPeerOptionsPending - || !item.isTorrent - || !['downloading', 'seeding', 'retrying'].includes(item.status) - ) return; - - setErrorMessage(''); - const requestId = ++actionRequestRef.current; - setIsLiveTorrentPeerOptionsPending(true); - try { - await useDownloadStore.getState().setTorrentPeerOptions( - item.id, - liveTorrentMaxPeersValue, - liveTorrentPeerSpeedLimitValue - ); - } catch (error) { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setErrorMessage(t($ => $.properties.liveTorrentPeerOptionsFailed, { - detail: error instanceof Error ? error.message : String(error) - })); - } - } finally { - if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { - setIsLiveTorrentPeerOptionsPending(false); - } - } - }; - - const identityLocked = getIdentityLocked(item.status); - const transferLocked = getTransferLocked(item.status); - const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status); - const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status); - const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); - const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status); - const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status); - const torrentFileSelectionIsEmpty = item.isTorrent - && torrentFileSelection !== null - && !torrentFileSelection.files.some(file => file.selected); - const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); - const observedConnectionTotal = Math.max( - 1, - liveProgress?.requested_connections ?? configuredConnections - ); - const observedActiveConnections = liveProgress?.active_connections; - const connectionTelemetryActive = item.status === 'downloading' || - item.status === 'processing' || - item.status === 'seeding' || - item.status === 'retrying'; - const connectionStatus = (() => { - if (!connectionTelemetryActive) return String(configuredConnections); - // yt-dlp exposes the configured fragment limit through Firelink, but its - // progress stream does not expose a reliable active-worker count. Keep - // the selected limit visible without presenting it as an active count. - if (item.isMedia) { - return t($ => $.properties.connectionCountUnknown, { - total: configuredConnections, - }); - } - if (typeof observedActiveConnections === 'number') { - return t($ => $.properties.connectionCount, { - active: observedActiveConnections, - total: observedConnectionTotal, - }); - } - if (item.status === 'downloading') { - return t($ => $.properties.connectionCountUnknown, { total: observedConnectionTotal }); - } - return t($ => $.properties.connectionCount, { - active: 0, - total: observedConnectionTotal, - }); - })(); - const displayedFraction = item.status === 'completed' - ? 1 - : liveProgress?.fraction ?? item.fraction ?? 0; - const displayedSpeed = item.status === 'completed' - ? '-' - : item.status === 'seeding' - ? liveProgress?.upload_speed ?? '-' - : liveProgress?.speed ?? item.speed ?? '-'; - const displayedEta = item.status === 'completed' - ? '-' - : item.status === 'seeding' - ? '-' - : liveProgress?.eta ?? item.eta ?? '-'; - const sizeDisplay = resolveDownloadSizeDisplay({ - downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes, - totalBytes: liveProgress?.total_bytes ?? item.totalBytes, - totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate, - fallbackSize: item.size - }); - const hasDownloadedAmount = item.status !== 'completed' && - Boolean(sizeDisplay.downloaded && sizeDisplay.total); - const completedSizeLabel = (() => { - const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback; - return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; - })(); - const torrentUploadedBytes = item.isTorrent - ? liveProgress?.uploaded_bytes ?? item.torrentUploadedBytes ?? 0 - : 0; - const torrentSeededSeconds = item.isTorrent - ? liveProgress?.torrent_seeded_seconds ?? item.torrentSeededSeconds ?? 0 - : 0; - const torrentRatioDenominator = item.isTorrent - ? torrentFileSelection?.files.length - ? torrentFileSelection.files.reduce((total, file) => total + (file.selected ? file.length : 0), 0) - : torrentDetails?.totalBytes ?? item.totalBytes ?? 0 - : 0; - const torrentRatio = formatTorrentRatio(torrentUploadedBytes, torrentRatioDenominator, i18n.language); - const torrentSeederCount = liveProgress?.num_seeders ?? torrentPeerDiagnostics?.totalSeeders; - const torrentConnectedPeerCount = torrentPeerDiagnostics?.totalPeers; - const statusLabel = t($ => $.downloads.status[item.status]); - const pauseResumeAction = getPauseResumeAction(item.status); - const pauseResumeLabel = pauseResumeAction === 'pause' - ? t($ => $.downloadTable.pause) - : t($ => $.downloadTable.resume); - const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play; - const sizeDescription = sizeDisplay.totalIsEstimate - ? t($ => $.downloads.size.downloadedOfApproximate, { - downloaded: sizeDisplay.downloaded ?? '', - total: sizeDisplay.total ?? '', - unit: sizeDisplay.unit ?? '', - }) - : t($ => $.downloads.size.downloadedOf, { - downloaded: sizeDisplay.downloaded ?? '', - total: sizeDisplay.total ?? '', - unit: sizeDisplay.unit ?? '', - }); - - let statusColor = 'text-text-secondary'; - let StatusIcon = Info; - if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } - else if (item.status === 'downloading' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } - else if (item.status === 'processing' || item.status === 'moving') { statusColor = 'text-sky-500'; StatusIcon = Play; } - else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } - else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } - - return ( -
{ - if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null); - }} - role="dialog" - aria-modal="true" - aria-labelledby="properties-modal-title" - > -
- - {/* Header Summary */} -
-
-

{item.fileName}

- - - {statusLabel} - -
- -
-
-
- -
-
{t($ => $.properties.progress)}{`${(displayedFraction * 100).toFixed(0)}%`}
-
- {t($ => $.properties.size)} - - {hasDownloadedAmount ? ( - <> - {sizeDisplay.downloaded} - / - - {sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit} - - - ) : completedSizeLabel} - -
-
{t($ => $.properties.speed)}{displayedSpeed}
-
{t($ => $.properties.eta)}{displayedEta}
- -
{t($ => $.properties.connections)} $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}>{connectionStatus}
-
{t($ => $.properties.speedCap)}{item.speedLimit || '-'}
-
{t($ => $.properties.category)}{categoryLabel(item.category)}
-
{t($ => $.properties.lastTry)}{formatLastTry(item.lastTry, i18n.language, calendarPreference)}
- -
{t($ => $.properties.dateAdded)}{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}
-
{t($ => $.properties.destination)}{saveLocation || baseDownloadFolder}
- {item.lastError && (item.status === 'failed' || item.status === 'retrying') && ( -
- {t($ => $.properties.lastError)} - {item.lastError} -
- )} -
- {item.isTorrent && ( -
$.properties.torrentStatistics)}> -
{t($ => $.properties.torrentStatistics)}
-
-
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(torrentUploadedBytes)}
-
{t($ => $.properties.torrentRatio)}{torrentRatio}
-
{t($ => $.properties.torrentSeededDuration)}{formatTorrentDuration(torrentSeededSeconds, i18n.language)}
-
{t($ => $.properties.torrentConnectedPeers)}{torrentConnectedPeerCount ?? '—'}
-
{t($ => $.properties.torrentSeeders)}{torrentSeederCount ?? '—'}
-
{t($ => $.properties.torrentUploadSpeed)}{liveProgress?.upload_speed ?? '—'}
-
-
- )} -
- -
- - {/* Scrollable Form Content */} -
- - {identityLocked && ( -
- {item.status === 'completed' ? : } - - {item.status === 'completed' - ? t($ => $.properties.identityReadOnly) - : t($ => $.properties.transferSettings)} - -
- )} - - {/* Download Section */} -
-

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

-
- - setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> - - - setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> - - -
- - -
- - -
- { setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> - {t($ => $.properties.perFile)} - {connectionStatus} - {!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && ( - - )} -
-
- {t($ => $.properties.savedPerDownload)} -
- - -
- - {speedLimitEnabled && ( -
- setSpeedLimitValue(e.target.value)} - disabled={transferLocked} - className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" - /> - KiB/s -
- )} -
-
- {t($ => $.properties.savedPerDownload)} -
- {item.isTorrent && ( - <> - - setLiveTorrentMaxPeersValue(event.currentTarget.value)} - placeholder="55" - disabled={transferLocked} - className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" - /> - - setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)} - placeholder="50K" - disabled={transferLocked} - className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" - /> -
- {t($ => $.properties.torrentPeerOptionsSavedHint)} -
- {torrentFileSelection && ( -
-
-
- {t($ => $.properties.torrentFileSelection)} -
-
- - -
-
-

- {t($ => $.properties.torrentFileSelectionHint)} -

-
- {torrentFileSelection.files.map(file => ( - - ))} -
-
- )} -
-
-
- {t($ => $.properties.torrentPieceProgress)} -
- -
-

- {t($ => $.properties.torrentPieceProgressHint)} -

- {!isTorrentFileProgressStatus(item.status) && ( -

- {t($ => $.properties.torrentPieceProgressUnavailable)} -

- )} - {torrentPieceProgressError && ( -

- {t($ => $.properties.torrentPieceProgressFailed)} -

- )} - {torrentPieceProgress && ( - <> -
- {t($ => $.properties.torrentPieceProgressSummary, { - completed: torrentPieceProgress.completedPieces, - total: torrentPieceProgress.numPieces, - size: formatDownloadBytes(torrentPieceProgress.pieceLength), - })} -
-
$.properties.torrentPieceProgressMap)} - > - {torrentPieceProgress.buckets.map((percentage, index) => ( - - ))} -
- - )} -
-
-
-
{t($ => $.properties.torrentAvailability)}
- -
-

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

- {!isTorrentAvailabilityStatus(item.status) && ( -

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

- )} - {torrentAvailabilityError && ( -

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

- )} - {torrentAvailability && ( - <> -
- {t($ => $.properties.torrentAvailabilitySummary, { - availability: new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 2 }).format(torrentAvailability.availability), - peers: torrentAvailability.connectedPeers, - pieces: torrentAvailability.pieceCount - })} -
-
$.properties.torrentAvailabilityMap)} - > - {torrentAvailability.buckets.map((bucket, index) => ( - $.properties.torrentAvailabilityBucket, { copies: bucket.minimumCopies })} - /> - ))} -
- - )} -
-
-
-
- {t($ => $.properties.torrentFileProgress)} -
- -
-

- {t($ => $.properties.torrentFileProgressHint)} -

- {!isTorrentFileProgressStatus(item.status) && ( -

- {t($ => $.properties.torrentFileProgressUnavailable)} -

- )} - {torrentFileProgressError && ( -

- {t($ => $.properties.torrentFileProgressFailed)} -

- )} - {torrentFileProgress && ( -
- - - - - - - - - - - {torrentFileProgress.files.map(file => { - const percentage = file.length === 0 - ? 100 - : Math.round((file.completedLength / file.length) * 100); - return ( - - - - - - - ); - })} - -
#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.torrentFileProgressCompleted)}{t($ => $.properties.torrentFileProgressSelected)}
{file.index}{file.relativePath} - {formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%) - - {file.selected - ? t($ => $.properties.torrentFileProgressSelected) - : t($ => $.properties.torrentFileProgressUnselected)} -
-
- )} -
-
-
-
- {t($ => $.properties.torrentPeerDiagnostics)} -
- -
-

- {t($ => $.properties.torrentPeerDiagnosticsHint)} -

- {!torrentPeerDiagnosticsAvailable && ( -

- {t($ => $.properties.torrentPeerDiagnosticsUnavailable)} -

- )} - {torrentPeerDiagnosticsError && ( -

- {t($ => $.properties.torrentPeerDiagnosticsFailed)} -

- )} - {torrentPeerDiagnostics && ( - <> -
- {t($ => $.properties.torrentPeerCount, { - total: torrentPeerDiagnostics.totalPeers, - seeders: torrentPeerDiagnostics.totalSeeders - })} -
-
- - - - - - - - - - - - - {torrentPeerDiagnostics.peers.map((peer, index) => ( - - - - - - - - - ))} - -
#{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerAmChoking)}{t($ => $.properties.torrentPeerChoking)}
{index + 1}{formatPeerSpeed(peer.downloadSpeed)}{formatPeerSpeed(peer.uploadSpeed)}{peer.seeder ? '✓' : '—'}{peer.amChoking ? '✓' : '—'}{peer.peerChoking ? '✓' : '—'}
-
- {torrentPeerDiagnostics.truncated && ( -

- {t($ => $.properties.torrentPeerShowing, { - shown: torrentPeerDiagnostics.peers.length, - total: torrentPeerDiagnostics.totalPeers - })} -

- )} - - )} -
- -
- -
- -
- - -
-
- )} -
- -
- -
- - {/* Footer */} -
-
- {errorMessage} -
-
- - {pauseResumeAction && ( - - )} - -
-
- -
-
- ); -}; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index bd423cd..7399665 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -14,6 +14,7 @@ import { PROPERTIES_WINDOW_ACTION_RESULT, PROPERTIES_WINDOW_REMOVED, PROPERTIES_WINDOW_SNAPSHOT, + getPropertiesLifecycleAction, sendPropertiesActionRequest, sendPropertiesReady, type PropertiesAction, @@ -24,6 +25,8 @@ import { type PropertiesSnapshotEvent, } from '../propertiesBridge'; import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; +import { changeAppLocale } from '../i18n'; +import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced'; @@ -73,7 +76,11 @@ export const PropertiesWindowApp = () => { const closeAfterSaveRef = useRef(false); const switchAfterSaveRef = useRef(null); const requestIdRef = useRef(0); + const pendingActionRef = useRef(null); const latestSnapshotRevisionRef = useRef(0); + const appearanceCleanupRef = useRef<(() => void) | null>(null); + const hasRevealedWindowRef = useRef(false); + const revealInFlightRef = useRef(false); const diagnosticsInFlightRef = useRef(new Set()); const snapshotRef = useRef(snapshot); const activeTabRef = useRef(activeTab); @@ -140,6 +147,7 @@ export const PropertiesWindowApp = () => { useEffect(() => { let cancelled = false; + let readyRetryTimer: number | undefined; let unlistenSnapshot: UnlistenFn | undefined; let unlistenResult: UnlistenFn | undefined; let unlistenRemoved: UnlistenFn | undefined; @@ -148,18 +156,40 @@ export const PropertiesWindowApp = () => { const id = await invoke('get_properties_window_download_id'); if (cancelled) return; setDownloadId(id); - unlistenSnapshot = await listen(PROPERTIES_WINDOW_SNAPSHOT, event => { + unlistenSnapshot = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return; if (event.payload.revision <= latestSnapshotRevisionRef.current) return; latestSnapshotRevisionRef.current = event.payload.revision; + await changeAppLocale(event.payload.snapshot.appearance.locale); + if (event.payload.revision !== latestSnapshotRevisionRef.current) return; + appearanceCleanupRef.current?.(); + appearanceCleanupRef.current = synchronizeDocumentAppearance( + window, + event.payload.snapshot.appearance, + ); setSnapshot(event.payload.snapshot); if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot); void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); + if (!hasRevealedWindowRef.current && !revealInFlightRef.current) { + revealInFlightRef.current = true; + try { + await invoke('properties_window_reveal'); + hasRevealedWindowRef.current = true; + if (readyRetryTimer !== undefined) { + window.clearInterval(readyRetryTimer); + readyRetryTimer = undefined; + } + } finally { + revealInFlightRef.current = false; + } + } }); 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); + const completedAction = pendingActionRef.current; + pendingActionRef.current = null; if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed'); else { const nextTab = switchAfterSaveRef.current; @@ -167,7 +197,7 @@ export const PropertiesWindowApp = () => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setErrorMessage(''); - setNotice(t($ => $.properties.saved)); + setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : ''); draftTabRef.current = null; setDraftTab(null); if (nextTab) { @@ -182,11 +212,24 @@ export const PropertiesWindowApp = () => { }); unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => { if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) { + if (readyRetryTimer !== undefined) { + window.clearInterval(readyRetryTimer); + readyRetryTimer = undefined; + } setSnapshot(null); setNotice(t($ => $.downloadTable.noDownloads)); } }); await sendPropertiesReady(); + if (cancelled) return; + // Tauri event listeners are registered asynchronously. If the main + // bridge was still installing its listener, the first ready event can + // legitimately be missed; retry until the first snapshot confirms + // the handshake rather than leaving a permanently hidden window. + readyRetryTimer = window.setInterval(() => { + if (cancelled || hasRevealedWindowRef.current) return; + void sendPropertiesReady().catch(() => undefined); + }, 500); } catch (error) { if (!cancelled) setErrorMessage(errorText(error)); } @@ -194,9 +237,12 @@ export const PropertiesWindowApp = () => { void start(); return () => { cancelled = true; + if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer); unlistenSnapshot?.(); unlistenResult?.(); unlistenRemoved?.(); + appearanceCleanupRef.current?.(); + appearanceCleanupRef.current = null; }; }, [currentWindow, hydrateDraft, t, windowLabel]); @@ -228,8 +274,10 @@ export const PropertiesWindowApp = () => { payload?: PropertiesActionRequest['payload'], ) => { if (!downloadId) return; + if (pendingActionRef.current !== null) return; const requestId = ++requestIdRef.current; - setIsSaving(action === 'apply-properties'); + pendingActionRef.current = action; + setIsSaving(true); try { await sendPropertiesActionRequest({ windowLabel, @@ -240,6 +288,7 @@ export const PropertiesWindowApp = () => { }); } catch (error) { setIsSaving(false); + pendingActionRef.current = null; closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(errorText(error)); @@ -328,8 +377,7 @@ export const PropertiesWindowApp = () => { setNotice(t($ => $.properties.torrentMoveCompleted)); } } else { - await invoke('verify_torrent_data', { id: downloadId }); - setNotice(t($ => $.properties.torrentVerifyIntegrity)); + await requestAction('verify-torrent'); } } catch (error) { setErrorMessage(errorText(error)); @@ -344,6 +392,7 @@ export const PropertiesWindowApp = () => { } const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0)); + const lifecycleAction = getPropertiesLifecycleAction(snapshot.status); const total = snapshot.size || (snapshot.totalBytes === undefined ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); @@ -369,14 +418,32 @@ export const PropertiesWindowApp = () => {

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

$.actions.continue)}> - + {lifecycleAction && } {isTorrent && <> - - - + + + }
@@ -387,6 +454,7 @@ export const PropertiesWindowApp = () => { {formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total} {snapshot.speed || '—'} {snapshot.eta || '—'} + {snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'} {t($ => $.properties.connections)} {isTorrent && {formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}} @@ -432,7 +500,7 @@ export const PropertiesWindowApp = () => { {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)} } - {isTorrent &&
} + {isTorrent &&
} } {activeTab === 'files' && isTorrent &&
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index a7449f6..d8c0de0 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -2,7 +2,8 @@ import { useEffect } from 'react'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { useDownloadStore } from '../store/useDownloadStore'; import type { DownloadItem } from '../store/useDownloadStore'; -import { getPauseResumeAction } from '../utils/downloadActions'; +import { useSettingsStore } from '../store/useSettingsStore'; +import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, @@ -13,6 +14,9 @@ import { PROPERTIES_WINDOW_CLOSED, PROPERTIES_WINDOW_READY, applySecretPatch, + beginExclusivePropertiesAction, + createFrameCoalescer, + getPropertiesLifecycleAction, sanitizePropertiesSnapshot, sendPropertiesActionResult, sendPropertiesRemoved, @@ -22,6 +26,7 @@ import { type PropertiesWindowReady, } from '../propertiesBridge'; import { invokeCommand as invoke } from '../ipc'; +import i18n, { resolveAppLocale } from '../i18n'; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -105,21 +110,41 @@ export const PropertiesWindowBridgeHost = () => { useEffect(() => { const windows = new Map(); const snapshotRevisions = new Map(); + const actionsInFlight = new Set(); let disposed = false; let unlistenReady: UnlistenFn | undefined; let unlistenAction: UnlistenFn | undefined; let unlistenClosed: UnlistenFn | undefined; + const snapshotCoalescer = createFrameCoalescer( + windowLabel => { + const downloadId = windows.get(windowLabel); + if (downloadId) void sendFor(windowLabel, downloadId).catch(() => undefined); + }, + callback => window.requestAnimationFrame(callback), + handle => window.cancelAnimationFrame(handle), + ); const sendFor = async (windowLabel: string, downloadId: string) => { const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId); if (!item || disposed) return false; + const settings = useSettingsStore.getState(); + const progress = useDownloadProgressStore.getState(); const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1; snapshotRevisions.set(windowLabel, revision); await sendPropertiesSnapshot(windowLabel, { windowLabel, downloadId, revision, - snapshot: sanitizePropertiesSnapshot(item), + snapshot: sanitizePropertiesSnapshot(item, { + theme: settings.theme, + fontFamily: settings.fontFamily, + appFontSize: settings.appFontSize, + listRowDensity: settings.listRowDensity, + locale: resolveAppLocale(i18n.language), + }, { + progress: progress.progressMap[downloadId], + moveProgress: progress.moveProgressMap[downloadId], + }), }); return true; }; @@ -144,11 +169,14 @@ export const PropertiesWindowBridgeHost = () => { const handleAction = async (request: PropertiesActionRequest) => { let ok = false; let error: string | undefined; + const actionKey = `${request.windowLabel}:${request.downloadId}`; + let releaseAction: (() => void) | undefined; try { await invoke('validate_properties_window_request', request); if (windows.get(request.windowLabel) !== request.downloadId) { throw new Error('Properties window is no longer registered'); } + releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey); const store = useDownloadStore.getState(); const item = store.downloads.find(download => download.id === request.downloadId); if (!item) throw new Error('Download no longer exists'); @@ -172,10 +200,57 @@ export const PropertiesWindowBridgeHost = () => { await store.applyProperties(request.downloadId, safePatch); break; } - case 'pause-resume': - if (getPauseResumeAction(item.status) === 'pause') await store.pauseDownload(request.downloadId); - else await store.resumeDownload(request.downloadId); + case 'pause-resume': { + const lifecycleAction = getPropertiesLifecycleAction(item.status); + if (!lifecycleAction) { + throw new Error('This download has no available lifecycle action'); + } + if (lifecycleAction === 'pause') { + await store.pauseDownload(request.downloadId); + const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId); + if (!current) throw new Error('Download was removed while pausing'); + if (!['paused', 'completed', 'failed'].includes(current.status)) { + throw new Error('The download did not reach a paused or terminal state'); + } + } else { + const resumed = await store.resumeDownload(request.downloadId); + if (!resumed) { + throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart)); + } + const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId); + if (!current) throw new Error('Download was removed while starting'); + // A fast completion is a valid outcome of a successful resume; + // only a status that proves the request never left its + // pre-action state is a rejected start. Preserve failed as an + // error so a real backend failure is not reported as success. + if (['paused', 'ready', 'staged', 'failed'].includes(current.status)) { + throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart)); + } + } break; + } + case 'verify-torrent': { + if (item.isTorrent !== true + || !['paused', 'completed', 'failed'].includes(item.status)) { + throw new Error('Pause the Torrent before verifying its data'); + } + const previousVerifyOnly = item.torrentVerifyOnly; + const previousRestoreStatus = item.torrentVerifyRestoreStatus; + store.updateDownload(request.downloadId, { + torrentVerifyOnly: true, + torrentVerifyRestoreStatus: item.status, + }); + try { + await invoke('verify_torrent_data', { id: request.downloadId }); + } catch (verifyError) { + useDownloadStore.getState().updateDownload(request.downloadId, { + torrentVerifyOnly: previousVerifyOnly, + torrentVerifyRestoreStatus: previousRestoreStatus, + }); + throw verifyError; + } + break; + } case 'set-download-limit': await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null); break; @@ -190,9 +265,21 @@ export const PropertiesWindowBridgeHost = () => { default: throw new Error('Invalid Properties action'); } + if (!useDownloadStore.getState().downloads.some(download => download.id === request.downloadId)) { + throw new Error('Download was removed while applying the action'); + } ok = true; } catch (caught) { error = errorText(caught); + } finally { + releaseAction?.(); + } + if (ok) { + try { + await sendFor(request.windowLabel, request.downloadId); + } catch { + // Snapshot delivery is best effort across a close/reopen race. + } } try { await sendPropertiesActionResult(request.windowLabel, { @@ -206,20 +293,16 @@ export const PropertiesWindowBridgeHost = () => { // The window may have closed between the request and its result. return; } - if (ok) { - try { - await sendFor(request.windowLabel, request.downloadId); - } catch { - // Snapshot delivery is best effort across a close/reopen race. - } - } }; void listen(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; }); void listen(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; }); void listen(PROPERTIES_WINDOW_CLOSED, event => { + const downloadId = windows.get(event.payload); windows.delete(event.payload); snapshotRevisions.delete(event.payload); + snapshotCoalescer.cancel(event.payload); + if (downloadId) actionsInFlight.delete(`${event.payload}:${downloadId}`); }).then(value => { unlistenClosed = value; }); const unsubscribeStore = useDownloadStore.subscribe((state, previous) => { @@ -227,19 +310,50 @@ export const PropertiesWindowBridgeHost = () => { const next = state.downloads.find(download => download.id === downloadId); const before = previous.downloads.find(download => download.id === downloadId); if (!next) { + snapshotCoalescer.cancel(windowLabel); void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined); windows.delete(windowLabel); snapshotRevisions.delete(windowLabel); void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined); } else if (next !== before) { - void sendFor(windowLabel, downloadId).catch(() => undefined); + snapshotCoalescer.schedule(windowLabel); } } }); + const unsubscribeProgress = useDownloadProgressStore.subscribe((state, previous) => { + for (const [windowLabel, downloadId] of windows) { + if (state.progressMap[downloadId] !== previous.progressMap[downloadId] + || state.moveProgressMap[downloadId] !== previous.moveProgressMap[downloadId]) { + snapshotCoalescer.schedule(windowLabel); + } + } + }); + const unsubscribeSettings = useSettingsStore.subscribe((state, previous) => { + if (state.theme === previous.theme + && state.fontFamily === previous.fontFamily + && state.appFontSize === previous.appFontSize + && state.listRowDensity === previous.listRowDensity + && state.language === previous.language) { + return; + } + for (const windowLabel of windows.keys()) { + snapshotCoalescer.schedule(windowLabel); + } + }); + const handleLanguageChanged = () => { + for (const windowLabel of windows.keys()) { + snapshotCoalescer.schedule(windowLabel); + } + }; + i18n.on('languageChanged', handleLanguageChanged); return () => { disposed = true; + snapshotCoalescer.cancelAll(); unsubscribeStore(); + unsubscribeProgress(); + unsubscribeSettings(); + i18n.off('languageChanged', handleLanguageChanged); unlistenReady?.(); unlistenAction?.(); unlistenClosed?.(); diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 4128416..a588a99 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -77,6 +77,7 @@ const common = { pause: 'Pause', start: 'Start', resume: 'Resume', + retry: 'Retry', options: 'Options', }, size: { diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index a1a3bbf..50fa687 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -77,6 +77,7 @@ const fa = { pause: 'توقف', start: 'شروع', resume: 'ادامه', + retry: 'تلاش مجدد', options: 'گزینه‌ها', }, size: { diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 5059068..ebf7622 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -77,6 +77,7 @@ const he = { pause: 'השהייה', start: 'הפעלה', resume: 'חידוש', + retry: 'ניסיון חוזר', options: 'אפשרויות', }, size: { diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index b48ba8d..4a6fc6f 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -77,6 +77,7 @@ const ru = { pause: 'Приостановить', start: 'Запустить', resume: 'Возобновить', + retry: 'Повторить', options: 'Параметры', }, size: { diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index adccd64..a516987 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -77,6 +77,7 @@ const uk = { pause: 'Призупинити', start: 'Запустити', resume: 'Відновити', + retry: 'Повторити', options: 'Опції', }, size: { diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index a53167a..9dc0728 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -77,6 +77,7 @@ const zhCN = { pause: '暂停', start: '开始', resume: '恢复', + retry: '重试', options: '选项', }, size: { diff --git a/src/index.css b/src/index.css index 05f00bb..656ad9d 100644 --- a/src/index.css +++ b/src/index.css @@ -643,11 +643,6 @@ html[data-list-density="relaxed"] { max-height: min(680px, 100%); } - .properties-modal { - max-width: 100%; - max-height: 100%; - } - /* Add Download window */ .add-download-modal { width: min(900px, 100%); diff --git a/src/ipc.ts b/src/ipc.ts index 232818d..e8e2f99 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -158,6 +158,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: undefined; result: void }; + properties_window_reveal: { args: undefined; result: void }; properties_window_send_action: { args: { requestId: number; action: string; payload?: unknown }; result: void }; validate_properties_window_request: { args: { windowLabel: string; downloadId: string }; result: void }; close_download_properties_window: { args: { id: string }; result: void }; diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index 437f807..aee21b0 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -10,7 +10,13 @@ vi.mock('@tauri-apps/api/event', () => ({ emitTo: vi.fn(), })); -import { applySecretPatch, sanitizePropertiesSnapshot } from './propertiesBridge'; +import { + applySecretPatch, + beginExclusivePropertiesAction, + createFrameCoalescer, + getPropertiesLifecycleAction, + sanitizePropertiesSnapshot, +} from './propertiesBridge'; describe('Properties window bridge', () => { it('sanitizes transfer secrets while preserving presence flags', () => { @@ -22,26 +28,154 @@ describe('Properties window bridge', () => { cookies: 'sid=secret', headers: 'Authorization: Bearer secret', username: 'user', + mirrors: 'https://user:secret@example.test/mirror', } as DownloadItem; - const snapshot = sanitizePropertiesSnapshot(item); + const snapshot = sanitizePropertiesSnapshot(item, { + theme: 'nord', + fontFamily: 'inter', + appFontSize: 'large', + listRowDensity: 'compact', + locale: 'fa', + }); expect(snapshot).not.toHaveProperty('password'); expect(snapshot).not.toHaveProperty('cookies'); expect(snapshot).not.toHaveProperty('headers'); expect(snapshot).not.toHaveProperty('username'); + expect(snapshot).not.toHaveProperty('mirrors'); expect(snapshot.hasPassword).toBe(true); expect(snapshot.hasCookies).toBe(true); expect(snapshot.hasHeaders).toBe(true); expect(snapshot.hasUsername).toBe(true); + expect(snapshot.hasMirrors).toBe(true); + expect(snapshot.appearance).toEqual({ + theme: 'nord', + fontFamily: 'inter', + appFontSize: 'large', + listRowDensity: 'compact', + locale: 'fa', + }); + }); + + it('projects the latest live telemetry without exposing secrets', () => { + const snapshot = sanitizePropertiesSnapshot({ + id: 'torrent-1', + fileName: 'example', + url: 'https://example.test/file', + status: 'seeding', + category: 'Other', + dateAdded: '', + speed: '-', + eta: '-', + fraction: 0, + uploadedBytes: 1, + password: 'secret', + } as DownloadItem, { + theme: 'dark', + fontFamily: 'system', + appFontSize: 'standard', + listRowDensity: 'standard', + locale: 'en', + }, { + progress: { + id: 'torrent-1', + fraction: 0.75, + speed: '2 MiB/s', + eta: '10s', + size: '4 MiB', + size_is_final: true, + downloaded_bytes: 3, + total_bytes: 4, + total_is_estimate: false, + active_connections: 4, + requested_connections: 8, + uploaded_bytes: 9, + upload_speed: '1 MiB/s', + num_seeders: 6, + torrent_seeded_seconds: 12, + }, + moveProgress: 0.5, + }); + + expect(snapshot).not.toHaveProperty('password'); + expect(snapshot).toMatchObject({ + fraction: 0.75, + speed: '1 MiB/s', + eta: '-', + downloadedBytes: 3, + totalBytes: 4, + totalIsEstimate: false, + activeConnections: 4, + requestedConnections: 8, + torrentUploadedBytes: 9, + uploadSpeed: '1 MiB/s', + torrentSeeders: 6, + torrentSeededSeconds: 12, + moveProgress: 0.5, + }); }); it('applies explicit secret changes without conflating unchanged fields', () => { expect(applySecretPatch(undefined, 'existing')).toBe('existing'); expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing'); - expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new'); - expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined(); - expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value'); - expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch'); -}); + expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new'); + expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined(); + expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value'); + expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch'); + }); + + it('derives truthful lifecycle commands from the current status', () => { + expect(getPropertiesLifecycleAction('downloading')).toBe('pause'); + expect(getPropertiesLifecycleAction('retrying')).toBe('pause'); + expect(getPropertiesLifecycleAction('paused')).toBe('resume'); + expect(getPropertiesLifecycleAction('ready')).toBe('start'); + expect(getPropertiesLifecycleAction('staged')).toBe('start'); + expect(getPropertiesLifecycleAction('failed')).toBe('retry'); + expect(getPropertiesLifecycleAction('completed')).toBeNull(); + }); + + it('keeps the first action locked when a duplicate request is rejected', () => { + const inFlight = new Set(); + const release = beginExclusivePropertiesAction(inFlight, 'window:download'); + + expect(() => beginExclusivePropertiesAction(inFlight, 'window:download')) + .toThrow('Another Properties action is still in progress'); + expect(inFlight.has('window:download')).toBe(true); + + release(); + release(); + expect(inFlight.has('window:download')).toBe(false); + }); + + it('coalesces repeated snapshot requests to one callback per animation frame', () => { + const frames = new Map(); + const delivered: string[] = []; + let nextHandle = 0; + const coalescer = createFrameCoalescer( + key => delivered.push(key), + callback => { + const handle = ++nextHandle; + frames.set(handle, callback); + return handle; + }, + handle => { + frames.delete(handle); + }, + ); + + coalescer.schedule('properties-1'); + coalescer.schedule('properties-1'); + coalescer.schedule('properties-2'); + expect(frames.size).toBe(2); + for (const [handle, callback] of [...frames]) { + frames.delete(handle); + callback(0); + } + expect(delivered).toEqual(['properties-1', 'properties-2']); + + coalescer.schedule('properties-1'); + coalescer.cancelAll(); + expect(frames.size).toBe(0); + }); }); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 2be143a..94e2a49 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -1,5 +1,9 @@ import { emitTo } from '@tauri-apps/api/event'; +import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; +import type { DownloadStatus } from './bindings/DownloadStatus'; import type { DownloadItem } from './store/useDownloadStore'; +import { canPauseDownload } from './utils/downloadActions'; +import type { DocumentAppearance } from './utils/documentAppearance'; import { invokeCommand as invoke } from './ipc'; export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const; @@ -9,11 +13,77 @@ export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result' export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const; export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const; -export type PropertiesSnapshot = Omit & { +const PROPERTIES_SNAPSHOT_KEYS = [ + 'id', + 'url', + 'fileName', + 'status', + 'fraction', + 'speed', + 'eta', + 'size', + 'downloadedBytes', + 'totalBytes', + 'totalIsEstimate', + 'category', + 'dateAdded', + 'resumable', + 'connections', + 'speedLimit', + 'checksum', + 'destination', + 'isMedia', + 'mediaFormatSelector', + 'mediaQuality', + 'queueId', + 'queuePosition', + 'hasBeenDispatched', + 'lastError', + 'lastTry', + 'isTorrent', + 'torrentFileIndices', + 'torrentInfoHash', + 'torrentSeedTime', + 'torrentSeedRatio', + 'torrentSeedRemaining', + 'torrentUploadedBytes', + 'torrentSeededSeconds', + 'torrentRelocationCheckPending', + 'torrentMoveDestination', + 'torrentMoveRestoreStatus', + 'torrentWebSeeds', + 'torrentUploadLimit', + 'torrentMaxPeers', + 'torrentPeerSpeedLimit', + 'torrentCheckIntegrity', + 'torrentTrackers', + 'torrentExcludeTrackers', + 'torrentTrackerConnectTimeout', + 'torrentTrackerTimeout', + 'torrentTrackerInterval', + 'torrentStopTimeout', + 'torrentPrioritizePiece', + 'torrentRemoveUnselectedFile', + 'torrentEncryptionPolicy', + 'torrentFileAllocation', + 'torrentVerifyOnly', + 'torrentVerifyRestoreStatus', +] as const satisfies readonly (keyof DownloadItem)[]; + +type SafePropertiesFields = Pick; + +export type PropertiesSnapshot = SafePropertiesFields & { + appearance: DocumentAppearance; + activeConnections?: number; + requestedConnections?: number; + uploadSpeed?: string; + torrentSeeders?: number; + moveProgress?: number; hasPassword: boolean; hasCookies: boolean; hasHeaders: boolean; hasUsername: boolean; + hasMirrors: boolean; }; export type SecretPatch = @@ -31,10 +101,39 @@ export type PropertiesPatch = Partial { + if (status === 'ready' || status === 'staged') return 'start'; + if (canPauseDownload(status)) return 'pause'; + if (status === 'paused') return 'resume'; + if (status === 'failed') return 'retry'; + return null; +}; + +export const beginExclusivePropertiesAction = ( + inFlight: Set, + key: string, +): (() => void) => { + if (inFlight.has(key)) { + throw new Error('Another Properties action is still in progress'); + } + inFlight.add(key); + let released = false; + return () => { + if (released) return; + released = true; + inFlight.delete(key); + }; +}; + export type PropertiesWindowReady = { windowLabel: string; downloadId: string; @@ -63,25 +162,96 @@ export type PropertiesSnapshotEvent = { snapshot: PropertiesSnapshot; }; -const copyWithoutSecrets = (item: DownloadItem): PropertiesSnapshot => { - const { - password, - cookies, - headers, - username, - ...safeItem - } = item; +const copyWithoutSecrets = ( + item: DownloadItem, + appearance: DocumentAppearance, + live?: { + progress?: DownloadProgressEvent; + moveProgress?: number; + }, +): PropertiesSnapshot => { + const safeItem = Object.fromEntries( + PROPERTIES_SNAPSHOT_KEYS.flatMap(key => ( + Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : [] + )), + ) as SafePropertiesFields; return { ...safeItem, - hasPassword: Boolean(password), - hasCookies: Boolean(cookies), - hasHeaders: Boolean(headers), - hasUsername: Boolean(username), + appearance, + ...(live?.progress ? { + fraction: live.progress.fraction, + speed: item.status === 'seeding' + ? live.progress.upload_speed ?? live.progress.speed + : live.progress.speed, + eta: item.status === 'seeding' ? '-' : live.progress.eta, + ...(live.progress.size ? { size: live.progress.size } : {}), + ...(live.progress.downloaded_bytes !== undefined + ? { downloadedBytes: live.progress.downloaded_bytes } + : {}), + ...(live.progress.total_bytes !== undefined + ? { totalBytes: live.progress.total_bytes } + : {}), + ...(live.progress.total_is_estimate !== undefined + ? { totalIsEstimate: live.progress.total_is_estimate } + : {}), + ...(live.progress.active_connections !== undefined + ? { activeConnections: live.progress.active_connections } + : {}), + ...(live.progress.requested_connections !== undefined + ? { requestedConnections: live.progress.requested_connections } + : {}), + ...(live.progress.uploaded_bytes !== undefined + ? { torrentUploadedBytes: live.progress.uploaded_bytes } + : {}), + ...(live.progress.upload_speed !== undefined + ? { uploadSpeed: live.progress.upload_speed } + : {}), + ...(live.progress.num_seeders !== undefined + ? { torrentSeeders: live.progress.num_seeders } + : {}), + ...(live.progress.torrent_seeded_seconds !== undefined + ? { torrentSeededSeconds: live.progress.torrent_seeded_seconds } + : {}), + } : {}), + ...(live?.moveProgress !== undefined ? { moveProgress: live.moveProgress } : {}), + hasPassword: Boolean(item.password), + hasCookies: Boolean(item.cookies), + hasHeaders: Boolean(item.headers), + hasUsername: Boolean(item.username), + hasMirrors: Boolean(item.mirrors), }; }; export const sanitizePropertiesSnapshot = copyWithoutSecrets; +export const createFrameCoalescer = ( + callback: (key: string) => void, + requestFrame: (callback: FrameRequestCallback) => number, + cancelFrame: (handle: number) => void, +) => { + const pending = new Map(); + return { + schedule(key: string) { + if (pending.has(key)) return; + const handle = requestFrame(() => { + pending.delete(key); + callback(key); + }); + pending.set(key, handle); + }, + cancel(key: string) { + const handle = pending.get(key); + if (handle === undefined) return; + pending.delete(key); + cancelFrame(handle); + }, + cancelAll() { + for (const handle of pending.values()) cancelFrame(handle); + pending.clear(); + }, + }; +}; + export const openPropertiesWindow = (downloadId: string): Promise => invoke('open_download_properties_window', { id: downloadId }); diff --git a/src/utils/documentAppearance.test.ts b/src/utils/documentAppearance.test.ts new file mode 100644 index 0000000..12660b5 --- /dev/null +++ b/src/utils/documentAppearance.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { applyDocumentAppearance } from './documentAppearance'; + +const fakeDocument = () => { + const classes = new Set(['theme-light', 'dark']); + const root = { + classList: { + add: (...values: string[]) => values.forEach(value => classes.add(value)), + remove: (...values: string[]) => values.forEach(value => classes.delete(value)), + }, + dataset: {} as Record, + style: {} as Record, + lang: '', + dir: '', + }; + return { + classes, + root, + document: { documentElement: root } as unknown as Document, + }; +}; + +describe('document appearance synchronization', () => { + it('applies a complete dark RTL projection without retaining stale theme classes', () => { + const target = fakeDocument(); + applyDocumentAppearance(target.document, { + theme: 'nord', + fontFamily: 'vazirmatn', + appFontSize: 'large', + listRowDensity: 'compact', + locale: 'fa', + }, false); + + expect([...target.classes].sort()).toEqual(['dark', 'theme-nord']); + expect(target.root.dataset).toEqual({ + resolvedTheme: 'dark', + fontFamily: 'vazirmatn', + fontSize: 'large', + listDensity: 'compact', + }); + expect(target.root.style.colorScheme).toBe('dark'); + expect(target.root.lang).toBe('fa'); + expect(target.root.dir).toBe('rtl'); + }); + + it('resolves system appearance while preserving an LTR locale', () => { + const target = fakeDocument(); + applyDocumentAppearance(target.document, { + theme: 'system', + fontFamily: 'system', + appFontSize: 'standard', + listRowDensity: 'standard', + locale: 'en', + }, false); + + expect([...target.classes]).toEqual(['theme-light']); + expect(target.root.dataset.resolvedTheme).toBe('light'); + expect(target.root.style.colorScheme).toBe('light'); + expect(target.root.lang).toBe('en'); + expect(target.root.dir).toBe('ltr'); + }); +}); diff --git a/src/utils/documentAppearance.ts b/src/utils/documentAppearance.ts new file mode 100644 index 0000000..e2f480f --- /dev/null +++ b/src/utils/documentAppearance.ts @@ -0,0 +1,55 @@ +import type { AppFontSize } from '../bindings/AppFontSize'; +import type { FontFamily } from '../bindings/FontFamily'; +import type { ListRowDensity } from '../bindings/ListRowDensity'; +import type { Theme } from '../bindings/Theme'; +import { localeDirection, resolveAppLocale, type AppLocale } from '../i18n/locales'; + +export type DocumentAppearance = { + theme: Theme; + fontFamily: FontFamily; + appFontSize: AppFontSize; + listRowDensity: ListRowDensity; + locale: AppLocale; +}; + +const DARK_THEMES: ReadonlySet = new Set(['dark', 'dracula', 'nord']); +const THEME_CLASSES = ['theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'] as const; + +export const applyDocumentAppearance = ( + document: Document, + appearance: DocumentAppearance, + systemDark: boolean, +): void => { + const root = document.documentElement; + const resolvedTheme = appearance.theme === 'system' + ? (systemDark ? 'dark' : 'light') + : (DARK_THEMES.has(appearance.theme) ? 'dark' : 'light'); + const themeClass = appearance.theme === 'system' + ? `theme-${resolvedTheme}` + : `theme-${appearance.theme}`; + + root.classList.remove(...THEME_CLASSES); + root.classList.add(themeClass); + if (resolvedTheme === 'dark') root.classList.add('dark'); + root.dataset.resolvedTheme = resolvedTheme; + root.style.colorScheme = resolvedTheme; + root.dataset.fontFamily = appearance.fontFamily; + root.dataset.fontSize = appearance.appFontSize; + root.dataset.listDensity = appearance.listRowDensity; + + const locale = resolveAppLocale(appearance.locale); + root.lang = locale; + root.dir = localeDirection(locale); +}; + +export const synchronizeDocumentAppearance = ( + window: Window, + appearance: DocumentAppearance, +): (() => void) => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const apply = () => applyDocumentAppearance(window.document, appearance, media.matches); + apply(); + if (appearance.theme !== 'system') return () => undefined; + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); +};