mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 18:59:36 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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, ®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(""));
|
||||
|
||||
Reference in New Issue
Block a user