feat(properties): harden standalone properties lifecycle

- synchronize child appearance and hidden-window readiness
- serialize native Torrent mutations transactionally without double-encoded rows
- preserve native lifecycle markers and fence stale or duplicate actions
- remove the obsolete modal surface and ignore implementation_plan.md
This commit is contained in:
NimBold
2026-08-04 12:02:30 +03:30
parent c342bcd347
commit 2ab292dd5d
20 changed files with 1230 additions and 2765 deletions
+230
View File
@@ -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<R, F>(
connection: &mut Connection,
id: &str,
portable: bool,
mutate: F,
) -> Result<R, String>
where
F: FnOnce(&mut serde_json::Map<String, Value>) -> Result<R, String>,
{
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::<Result<Vec<_>, _>>()
.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();
+269 -319
View File
@@ -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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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::<Option<Vec<_>>>()
.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::<Option<Vec<_>>>()
.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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, 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::<Vec<_>>();
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<Option<serde_json::Value>, 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<serde_json::Value>,
) -> 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<String, String> {
let mut native_state: HashMap<
String,
(u64, u64, Option<String>, Option<serde_json::Value>),
(
u64,
u64,
Option<String>,
bool,
Option<serde_json::Value>,
Option<String>,
),
> = HashMap::new();
for record in existing {
let Ok(value) = serde_json::from_str::<serde_json::Value>(record) else {
@@ -9917,7 +9769,15 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result<St
.get("torrentMoveDestination")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
object
.get("torrentRelocationCheckPending")
.and_then(serde_json::Value::as_bool)
== Some(true),
object.get("torrentWebSeedsNative").cloned(),
object
.get("torrentVerifyNative")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
),
);
}
@@ -9934,8 +9794,14 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result<St
let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
continue;
};
let Some((existing_uploaded, existing_seeded, native_destination, native_web_seeds)) =
native_state.get(id).cloned()
let Some((
existing_uploaded,
existing_seeded,
native_destination,
relocation_check_pending,
native_web_seeds,
native_verify_restore_status,
)) = native_state.get(id).cloned()
else {
continue;
};
@@ -9958,6 +9824,12 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result<St
);
}
}
if relocation_check_pending {
object.insert(
"torrentRelocationCheckPending".to_string(),
serde_json::Value::Bool(true),
);
}
if let Some(native_web_seeds) = native_web_seeds {
if object.get("torrentWebSeeds") == Some(&native_web_seeds) {
object.remove("torrentWebSeedsNative");
@@ -9966,6 +9838,32 @@ fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result<St
object.insert("torrentWebSeedsNative".to_string(), native_web_seeds);
}
}
if let Some(restore_status) = native_verify_restore_status {
let incoming_acknowledges_marker = object
.get("torrentVerifyOnly")
.and_then(serde_json::Value::as_bool)
== Some(true)
&& object
.get("torrentVerifyRestoreStatus")
.and_then(serde_json::Value::as_str)
== Some(restore_status.as_str());
object.insert(
"torrentVerifyOnly".to_string(),
serde_json::Value::Bool(true),
);
object.insert(
"torrentVerifyRestoreStatus".to_string(),
serde_json::Value::String(restore_status.clone()),
);
if incoming_acknowledges_marker {
object.remove("torrentVerifyNative");
} else {
object.insert(
"torrentVerifyNative".to_string(),
serde_json::Value::String(restore_status),
);
}
}
let uploaded = object
.get("torrentUploadedBytes")
.and_then(serde_json::Value::as_u64)
@@ -10541,6 +10439,57 @@ mod tests {
assert!(acknowledged[0].get("torrentMoveDestination").is_none());
}
#[test]
fn renderer_download_snapshots_cannot_clear_native_relocation_or_verification_markers() {
let existing = vec![
json!({
"id": "torrent-1",
"status": "queued",
"destination": "/downloads/new",
"torrentMoveDestination": "/downloads/new",
"torrentRelocationCheckPending": true,
"torrentVerifyOnly": true,
"torrentVerifyRestoreStatus": "paused",
"torrentVerifyNative": "paused"
})
.to_string(),
];
let merged = merge_durable_torrent_telemetry(
&existing,
&json!([{
"id": "torrent-1",
"status": "paused",
"destination": "/downloads/old"
}])
.to_string(),
)
.unwrap();
let records: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert_eq!(records[0]["destination"], "/downloads/new");
assert_eq!(records[0]["torrentRelocationCheckPending"], true);
assert_eq!(records[0]["torrentVerifyOnly"], true);
assert_eq!(records[0]["torrentVerifyRestoreStatus"], "paused");
assert_eq!(records[0]["torrentVerifyNative"], "paused");
let acknowledged = merge_durable_torrent_telemetry(
&existing,
&json!([{
"id": "torrent-1",
"status": "queued",
"destination": "/downloads/new",
"torrentVerifyOnly": true,
"torrentVerifyRestoreStatus": "paused"
}])
.to_string(),
)
.unwrap();
let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap();
assert_eq!(acknowledged[0]["torrentRelocationCheckPending"], true);
assert!(acknowledged[0].get("torrentMoveDestination").is_none());
assert!(acknowledged[0].get("torrentVerifyNative").is_none());
assert_eq!(acknowledged[0]["torrentVerifyOnly"], true);
}
#[test]
fn renderer_download_snapshots_cannot_roll_back_native_web_seed_changes() {
let native_seeds = json!([{ "fileIndex": 0, "uri": "https://mirror.example/file" }]);
@@ -14499,6 +14448,7 @@ pub fn run() {
properties_window::open_download_properties_window,
properties_window::get_properties_window_download_id,
properties_window::properties_window_send_ready,
properties_window::properties_window_reveal,
properties_window::properties_window_send_action,
properties_window::validate_properties_window_request,
properties_window::close_download_properties_window,
+66 -3
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Mutex;
use serde::Serialize;
@@ -21,6 +22,7 @@ pub struct PropertiesWindowRegistry {
struct RegistryState {
by_download: HashMap<String, String>,
by_window: HashMap<String, String>,
ready_windows: HashSet<String>,
}
#[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<bool, String> {
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, &registry)?;
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(""));