mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-31 13:08:17 +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:
@@ -64,6 +64,7 @@ build/
|
|||||||
# Local secrets and signing material
|
# Local secrets and signing material
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
implementation_plan.md
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
!src-tauri/binaries/_internal/certifi/cacert.pem
|
!src-tauri/binaries/_internal/certifi/cacert.pem
|
||||||
|
|||||||
@@ -938,6 +938,91 @@ pub fn replace_downloads(
|
|||||||
.map_err(|error| format!("failed to commit download save: {error}"))
|
.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) {
|
fn remove_persisted_transfer_secrets(value: &mut Value) {
|
||||||
let Some(object) = value.as_object_mut() else {
|
let Some(object) = value.as_object_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -2260,6 +2345,151 @@ mod tests {
|
|||||||
assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude");
|
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]
|
#[test]
|
||||||
fn portable_download_persistence_drops_malformed_tracker_fields() {
|
fn portable_download_persistence_drops_malformed_tracker_fields() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
+269
-319
@@ -6890,45 +6890,30 @@ fn persist_torrent_destination(
|
|||||||
relocation_check_pending: bool,
|
relocation_check_pending: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let next_records = records
|
id,
|
||||||
.into_iter()
|
database.is_portable(),
|
||||||
.map(|record| {
|
|object| {
|
||||||
let mut value: serde_json::Value = serde_json::from_str(&record)
|
object.insert(
|
||||||
.map_err(|error| format!("persisted download is malformed: {error}"))?;
|
"destination".to_string(),
|
||||||
if value.get("id").and_then(serde_json::Value::as_str) == Some(id) {
|
serde_json::Value::String(destination.to_string()),
|
||||||
let object = value
|
);
|
||||||
.as_object_mut()
|
if relocation_check_pending {
|
||||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
|
||||||
object.insert(
|
object.insert(
|
||||||
"destination".to_string(),
|
"torrentRelocationCheckPending".to_string(),
|
||||||
serde_json::Value::String(destination.to_string()),
|
serde_json::Value::Bool(true),
|
||||||
);
|
);
|
||||||
if relocation_check_pending {
|
} else {
|
||||||
object.insert(
|
object.remove("torrentRelocationCheckPending");
|
||||||
"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;
|
|
||||||
}
|
}
|
||||||
serde_json::to_string(&value)
|
object.insert(
|
||||||
.map_err(|error| format!("failed to encode persisted download: {error}"))
|
"torrentMoveDestination".to_string(),
|
||||||
})
|
serde_json::Value::String(destination.to_string()),
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
);
|
||||||
if !changed {
|
Ok(())
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_torrent_telemetry(
|
fn persist_torrent_telemetry(
|
||||||
@@ -6937,38 +6922,22 @@ fn persist_torrent_telemetry(
|
|||||||
snapshot: crate::queue::TorrentTelemetrySnapshot,
|
snapshot: crate::queue::TorrentTelemetrySnapshot,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let next_records = records
|
id,
|
||||||
.into_iter()
|
database.is_portable(),
|
||||||
.map(|record| {
|
|object| {
|
||||||
let mut value: serde_json::Value = serde_json::from_str(&record)
|
object.insert(
|
||||||
.map_err(|error| format!("persisted download is malformed: {error}"))?;
|
"torrentUploadedBytes".to_string(),
|
||||||
if value.get("id").and_then(serde_json::Value::as_str) == Some(id) {
|
serde_json::Value::from(snapshot.uploaded_bytes),
|
||||||
let object = value
|
);
|
||||||
.as_object_mut()
|
object.insert(
|
||||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
"torrentSeededSeconds".to_string(),
|
||||||
let uploaded = serde_json::Value::from(snapshot.uploaded_bytes);
|
serde_json::Value::from(snapshot.seeded_seconds),
|
||||||
let seeded = serde_json::Value::from(snapshot.seeded_seconds);
|
);
|
||||||
if object.get("torrentUploadedBytes") != Some(&uploaded) {
|
Ok(())
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_torrent_relocation_check(
|
fn persist_torrent_relocation_check(
|
||||||
@@ -6977,41 +6946,22 @@ fn persist_torrent_relocation_check(
|
|||||||
pending: bool,
|
pending: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let next_records = records
|
id,
|
||||||
.into_iter()
|
database.is_portable(),
|
||||||
.map(|record| {
|
|object| {
|
||||||
let mut value: serde_json::Value = serde_json::from_str(&record)
|
if pending {
|
||||||
.map_err(|error| format!("persisted download is malformed: {error}"))?;
|
object.insert(
|
||||||
if value.get("id").and_then(serde_json::Value::as_str) == Some(id) {
|
"torrentRelocationCheckPending".to_string(),
|
||||||
let object = value
|
serde_json::Value::Bool(true),
|
||||||
.as_object_mut()
|
);
|
||||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
} else {
|
||||||
if pending {
|
object.remove("torrentRelocationCheckPending");
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
serde_json::to_string(&value)
|
Ok(())
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_torrent_file_selection(
|
fn persist_torrent_file_selection(
|
||||||
@@ -7021,63 +6971,44 @@ fn persist_torrent_file_selection(
|
|||||||
selected: Option<&[u32]>,
|
selected: Option<&[u32]>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let next = records
|
id,
|
||||||
.into_iter()
|
database.is_portable(),
|
||||||
.map(|record| {
|
|object| {
|
||||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
let current = match object.get("torrentFileIndices") {
|
||||||
Ok(value) => value,
|
None => None,
|
||||||
Err(_) => return Ok(record),
|
Some(serde_json::Value::Array(indices)) => Some(
|
||||||
};
|
indices
|
||||||
if value.get("id").and_then(serde_json::Value::as_str) == Some(id) {
|
.iter()
|
||||||
let current = match value.get("torrentFileIndices") {
|
.map(serde_json::Value::as_u64)
|
||||||
None => None,
|
.collect::<Option<Vec<_>>>()
|
||||||
Some(serde_json::Value::Array(indices)) => Some(
|
.ok_or_else(|| {
|
||||||
indices
|
"persisted Torrent file selection is malformed".to_string()
|
||||||
.iter()
|
})?
|
||||||
.map(serde_json::Value::as_u64)
|
.into_iter()
|
||||||
.collect::<Option<Vec<_>>>()
|
.map(|index| {
|
||||||
.ok_or_else(|| {
|
u32::try_from(index).map_err(|_| {
|
||||||
"persisted Torrent file selection is malformed".to_string()
|
"persisted Torrent file selection is out of range".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>>()?,
|
})
|
||||||
),
|
.collect::<Result<Vec<_>, String>>()?,
|
||||||
Some(_) => {
|
),
|
||||||
return Err("persisted Torrent file selection is malformed".to_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());
|
|
||||||
}
|
}
|
||||||
let object = value
|
};
|
||||||
.as_object_mut()
|
if current.as_deref() != expected {
|
||||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
return Err("Torrent file selection changed; reload before applying".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 let Some(selected) = selected {
|
||||||
.collect::<Result<Vec<_>, String>>()?;
|
object.insert("torrentFileIndices".to_string(), serde_json::json!(selected));
|
||||||
if !changed {
|
} else {
|
||||||
return Err("download is no longer persisted".to_string());
|
object.remove("torrentFileIndices");
|
||||||
}
|
}
|
||||||
let next_data = serde_json::to_string(&next)
|
Ok(())
|
||||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
},
|
||||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -8508,46 +8439,27 @@ async fn verify_torrent_data(
|
|||||||
lifecycle_generation: None,
|
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 mut connection = database.lock()?;
|
||||||
let next_data = serde_json::to_string(&next_records)
|
crate::db::mutate_download(
|
||||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
&mut connection,
|
||||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
|
&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) =
|
if let Err(error) =
|
||||||
@@ -8557,56 +8469,47 @@ async fn verify_torrent_data(
|
|||||||
// still the queued verification lifecycle. Never restore the stale
|
// still the queued verification lifecycle. Never restore the stale
|
||||||
// full array captured before enqueue: frontend persistence or another
|
// full array captured before enqueue: frontend persistence or another
|
||||||
// command may have changed unrelated rows in the meantime.
|
// command may have changed unrelated rows in the meantime.
|
||||||
if let Ok(mut connection) = database.lock() {
|
let rollback_result = (|| {
|
||||||
if let Ok(records) = crate::db::load_downloads(&connection) {
|
let mut connection = database.lock()?;
|
||||||
let mut changed = false;
|
crate::db::mutate_download(
|
||||||
let next = records
|
&mut connection,
|
||||||
.into_iter()
|
&id,
|
||||||
.map(|record| {
|
database.is_portable(),
|
||||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
|object| {
|
||||||
Ok(value) => value,
|
let is_verification_marker = object
|
||||||
Err(_) => return record,
|
.get("status")
|
||||||
};
|
.and_then(serde_json::Value::as_str)
|
||||||
let is_target = value
|
== Some("queued")
|
||||||
.get("id")
|
&& object
|
||||||
|
.get("torrentVerifyOnly")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
== Some(true)
|
||||||
|
&& object
|
||||||
|
.get("torrentVerifyRestoreStatus")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
== Some(id.as_str());
|
== Some(restore_status.as_str());
|
||||||
let is_verification_marker = value
|
// The native marker is an additional persistence fence,
|
||||||
.get("status")
|
// not a prerequisite for rollback. A renderer snapshot
|
||||||
.and_then(serde_json::Value::as_str)
|
// may have already acknowledged and removed that native
|
||||||
== Some("queued")
|
// marker while enqueue is still in flight; the failed
|
||||||
&& value
|
// operation must still clear its own queued lifecycle.
|
||||||
.get("torrentVerifyOnly")
|
if is_verification_marker {
|
||||||
.and_then(serde_json::Value::as_bool)
|
object.insert(
|
||||||
== Some(true)
|
"status".to_string(),
|
||||||
&& value
|
serde_json::json!(restore_status),
|
||||||
.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(),
|
|
||||||
);
|
);
|
||||||
|
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());
|
return Err(error.to_string());
|
||||||
}
|
}
|
||||||
@@ -8619,48 +8522,19 @@ fn replace_persisted_torrent_web_seeds(
|
|||||||
seeds: &[crate::ipc::TorrentWebSeed],
|
seeds: &[crate::ipc::TorrentWebSeed],
|
||||||
) -> Result<Option<serde_json::Value>, String> {
|
) -> Result<Option<serde_json::Value>, String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
|
||||||
let next_seeds = serde_json::to_value(seeds)
|
let next_seeds = serde_json::to_value(seeds)
|
||||||
.map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?;
|
.map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?;
|
||||||
let mut previous_seeds = None;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let mut next = Vec::with_capacity(records.len());
|
id,
|
||||||
for record in records {
|
database.is_portable(),
|
||||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
|object| {
|
||||||
Ok(value) => value,
|
let previous_seeds = object.get("torrentWebSeeds").cloned();
|
||||||
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();
|
|
||||||
object.insert("torrentWebSeeds".to_string(), next_seeds.clone());
|
object.insert("torrentWebSeeds".to_string(), next_seeds.clone());
|
||||||
object.insert("torrentWebSeedsNative".to_string(), next_seeds.clone());
|
object.insert("torrentWebSeedsNative".to_string(), next_seeds.clone());
|
||||||
changed = true;
|
Ok(previous_seeds)
|
||||||
}
|
},
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_persisted_torrent_web_seeds(
|
fn restore_persisted_torrent_web_seeds(
|
||||||
@@ -8670,29 +8544,13 @@ fn restore_persisted_torrent_web_seeds(
|
|||||||
previous_seeds: Option<serde_json::Value>,
|
previous_seeds: Option<serde_json::Value>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
let records = crate::db::load_downloads(&connection)?;
|
|
||||||
let expected_value = serde_json::to_value(expected_seeds)
|
let expected_value = serde_json::to_value(expected_seeds)
|
||||||
.map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?;
|
.map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?;
|
||||||
let mut found = false;
|
crate::db::mutate_download(
|
||||||
let mut changed = false;
|
&mut connection,
|
||||||
let mut next = Vec::with_capacity(records.len());
|
id,
|
||||||
for record in records {
|
database.is_portable(),
|
||||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
|object| {
|
||||||
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())?;
|
|
||||||
if object.get("torrentWebSeeds") == Some(&expected_value) {
|
if object.get("torrentWebSeeds") == Some(&expected_value) {
|
||||||
match previous_seeds.clone() {
|
match previous_seeds.clone() {
|
||||||
Some(previous) => {
|
Some(previous) => {
|
||||||
@@ -8703,28 +8561,15 @@ fn restore_persisted_torrent_web_seeds(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
object.remove("torrentWebSeedsNative");
|
object.remove("torrentWebSeedsNative");
|
||||||
changed = true;
|
|
||||||
} else {
|
} else {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Torrent web-seed rollback [{}] skipped because persisted state changed concurrently",
|
"Torrent web-seed rollback [{}] skipped because persisted state changed concurrently",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
Ok(())
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn normalize_persisted_torrent_web_seeds(
|
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> {
|
fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result<String, String> {
|
||||||
let mut native_state: HashMap<
|
let mut native_state: HashMap<
|
||||||
String,
|
String,
|
||||||
(u64, u64, Option<String>, Option<serde_json::Value>),
|
(
|
||||||
|
u64,
|
||||||
|
u64,
|
||||||
|
Option<String>,
|
||||||
|
bool,
|
||||||
|
Option<serde_json::Value>,
|
||||||
|
Option<String>,
|
||||||
|
),
|
||||||
> = HashMap::new();
|
> = HashMap::new();
|
||||||
for record in existing {
|
for record in existing {
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(record) else {
|
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")
|
.get("torrentMoveDestination")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToString::to_string),
|
.map(ToString::to_string),
|
||||||
|
object
|
||||||
|
.get("torrentRelocationCheckPending")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
== Some(true),
|
||||||
object.get("torrentWebSeedsNative").cloned(),
|
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 {
|
let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some((existing_uploaded, existing_seeded, native_destination, native_web_seeds)) =
|
let Some((
|
||||||
native_state.get(id).cloned()
|
existing_uploaded,
|
||||||
|
existing_seeded,
|
||||||
|
native_destination,
|
||||||
|
relocation_check_pending,
|
||||||
|
native_web_seeds,
|
||||||
|
native_verify_restore_status,
|
||||||
|
)) = native_state.get(id).cloned()
|
||||||
else {
|
else {
|
||||||
continue;
|
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 let Some(native_web_seeds) = native_web_seeds {
|
||||||
if object.get("torrentWebSeeds") == Some(&native_web_seeds) {
|
if object.get("torrentWebSeeds") == Some(&native_web_seeds) {
|
||||||
object.remove("torrentWebSeedsNative");
|
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);
|
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
|
let uploaded = object
|
||||||
.get("torrentUploadedBytes")
|
.get("torrentUploadedBytes")
|
||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64)
|
||||||
@@ -10541,6 +10439,57 @@ mod tests {
|
|||||||
assert!(acknowledged[0].get("torrentMoveDestination").is_none());
|
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]
|
#[test]
|
||||||
fn renderer_download_snapshots_cannot_roll_back_native_web_seed_changes() {
|
fn renderer_download_snapshots_cannot_roll_back_native_web_seed_changes() {
|
||||||
let native_seeds = json!([{ "fileIndex": 0, "uri": "https://mirror.example/file" }]);
|
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::open_download_properties_window,
|
||||||
properties_window::get_properties_window_download_id,
|
properties_window::get_properties_window_download_id,
|
||||||
properties_window::properties_window_send_ready,
|
properties_window::properties_window_send_ready,
|
||||||
|
properties_window::properties_window_reveal,
|
||||||
properties_window::properties_window_send_action,
|
properties_window::properties_window_send_action,
|
||||||
properties_window::validate_properties_window_request,
|
properties_window::validate_properties_window_request,
|
||||||
properties_window::close_download_properties_window,
|
properties_window::close_download_properties_window,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -21,6 +22,7 @@ pub struct PropertiesWindowRegistry {
|
|||||||
struct RegistryState {
|
struct RegistryState {
|
||||||
by_download: HashMap<String, String>,
|
by_download: HashMap<String, String>,
|
||||||
by_window: HashMap<String, String>,
|
by_window: HashMap<String, String>,
|
||||||
|
ready_windows: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -72,6 +74,7 @@ impl PropertiesWindowRegistry {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||||
let download_id = state.by_window.remove(label);
|
let download_id = state.by_window.remove(label);
|
||||||
|
state.ready_windows.remove(label);
|
||||||
if let Some(download_id) = &download_id {
|
if let Some(download_id) = &download_id {
|
||||||
state.by_download.remove(download_id);
|
state.by_download.remove(download_id);
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,7 @@ impl PropertiesWindowRegistry {
|
|||||||
let label = state.by_download.remove(download_id);
|
let label = state.by_download.remove(download_id);
|
||||||
if let Some(label) = &label {
|
if let Some(label) = &label {
|
||||||
state.by_window.remove(label);
|
state.by_window.remove(label);
|
||||||
|
state.ready_windows.remove(label);
|
||||||
}
|
}
|
||||||
Ok(label)
|
Ok(label)
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,36 @@ impl PropertiesWindowRegistry {
|
|||||||
.get(download_id)
|
.get(download_id)
|
||||||
.cloned())
|
.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 {
|
pub fn is_properties_window_label(label: &str) -> bool {
|
||||||
@@ -152,6 +186,7 @@ fn is_properties_action(action: &str) -> bool {
|
|||||||
action,
|
action,
|
||||||
"apply-properties"
|
"apply-properties"
|
||||||
| "pause-resume"
|
| "pause-resume"
|
||||||
|
| "verify-torrent"
|
||||||
| "set-download-limit"
|
| "set-download-limit"
|
||||||
| "set-torrent-upload-limit"
|
| "set-torrent-upload-limit"
|
||||||
| "set-torrent-peer-options"
|
| "set-torrent-peer-options"
|
||||||
@@ -194,18 +229,25 @@ pub fn open_download_properties_window(
|
|||||||
|
|
||||||
let label = registry.allocate(&id)?;
|
let label = registry.allocate(&id)?;
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
if !registry.is_ready(&label)? {
|
||||||
|
return Ok(label);
|
||||||
|
}
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
return Ok(label);
|
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()))
|
let build_result = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
|
||||||
.title(PROPERTIES_WINDOW_TITLE)
|
.title(PROPERTIES_WINDOW_TITLE)
|
||||||
.inner_size(1000.0, 720.0)
|
.inner_size(1000.0, 720.0)
|
||||||
.min_inner_size(760.0, 560.0)
|
.min_inner_size(760.0, 560.0)
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.always_on_top(false)
|
.always_on_top(false)
|
||||||
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
if let Err(error) = build_result {
|
if let Err(error) = build_result {
|
||||||
// Two rapid main-window requests can race between the native lookup
|
// 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
|
// registry entry and focus its window instead of treating the second
|
||||||
// request as a failed open.
|
// request as a failed open.
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
let _ = window.unminimize();
|
if registry.is_ready(&label)? {
|
||||||
let _ = window.show();
|
let _ = window.unminimize();
|
||||||
let _ = window.set_focus();
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
return Ok(label);
|
return Ok(label);
|
||||||
}
|
}
|
||||||
let _ = registry.remove_window(&label);
|
let _ = registry.remove_window(&label);
|
||||||
@@ -251,6 +295,17 @@ pub fn properties_window_send_ready(
|
|||||||
.map_err(|error| error.to_string())
|
.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]
|
#[tauri::command]
|
||||||
pub fn properties_window_send_action(
|
pub fn properties_window_send_action(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
@@ -368,10 +423,17 @@ mod tests {
|
|||||||
fn registry_reuses_one_label_per_download_and_cleans_both_indexes() {
|
fn registry_reuses_one_label_per_download_and_cleans_both_indexes() {
|
||||||
let registry = PropertiesWindowRegistry::default();
|
let registry = PropertiesWindowRegistry::default();
|
||||||
let first = registry.allocate("download-a").unwrap();
|
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.allocate("download-a").unwrap(), first);
|
||||||
assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string()));
|
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.remove_window(&first).unwrap(), Some("download-a".to_string()));
|
||||||
assert_eq!(registry.download_for_window(&first).unwrap(), None);
|
assert_eq!(registry.download_for_window(&first).unwrap(), None);
|
||||||
|
assert!(!registry.is_ready(&first).unwrap());
|
||||||
assert_ne!(registry.allocate("download-a").unwrap(), first);
|
assert_ne!(registry.allocate("download-a").unwrap(), first);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +447,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn child_actions_are_allowlisted() {
|
fn child_actions_are_allowlisted() {
|
||||||
assert!(is_properties_action("apply-properties"));
|
assert!(is_properties_action("apply-properties"));
|
||||||
|
assert!(is_properties_action("verify-torrent"));
|
||||||
assert!(is_properties_action("set-torrent-peer-options"));
|
assert!(is_properties_action("set-torrent-peer-options"));
|
||||||
assert!(!is_properties_action("get_keychain_password"));
|
assert!(!is_properties_action("get_keychain_password"));
|
||||||
assert!(!is_properties_action(""));
|
assert!(!is_properties_action(""));
|
||||||
|
|||||||
+8
-44
@@ -35,6 +35,7 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
|||||||
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||||
|
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||||
|
|
||||||
const loadSettingsView = () => import('./components/SettingsView');
|
const loadSettingsView = () => import('./components/SettingsView');
|
||||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||||
@@ -669,17 +670,13 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, [addToast, coreReady, showKeychainModal]);
|
}, [addToast, coreReady, showKeychainModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => synchronizeDocumentAppearance(window, {
|
||||||
window.document.documentElement.setAttribute('data-font-family', fontFamily);
|
theme,
|
||||||
}, [fontFamily]);
|
fontFamily,
|
||||||
|
appFontSize,
|
||||||
useEffect(() => {
|
listRowDensity,
|
||||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
locale: resolveAppLocale(i18n.language),
|
||||||
}, [appFontSize]);
|
}), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.document.documentElement.setAttribute('data-list-density', listRowDensity);
|
|
||||||
}, [listRowDensity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkForUpdate = () => {
|
const checkForUpdate = () => {
|
||||||
@@ -1025,39 +1022,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
}, [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 (
|
return (
|
||||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||||
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
|||||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||||
PROPERTIES_WINDOW_REMOVED,
|
PROPERTIES_WINDOW_REMOVED,
|
||||||
PROPERTIES_WINDOW_SNAPSHOT,
|
PROPERTIES_WINDOW_SNAPSHOT,
|
||||||
|
getPropertiesLifecycleAction,
|
||||||
sendPropertiesActionRequest,
|
sendPropertiesActionRequest,
|
||||||
sendPropertiesReady,
|
sendPropertiesReady,
|
||||||
type PropertiesAction,
|
type PropertiesAction,
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
type PropertiesSnapshotEvent,
|
type PropertiesSnapshotEvent,
|
||||||
} from '../propertiesBridge';
|
} from '../propertiesBridge';
|
||||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||||
|
import { changeAppLocale } from '../i18n';
|
||||||
|
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||||
|
|
||||||
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
|
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
|
||||||
|
|
||||||
@@ -73,7 +76,11 @@ export const PropertiesWindowApp = () => {
|
|||||||
const closeAfterSaveRef = useRef(false);
|
const closeAfterSaveRef = useRef(false);
|
||||||
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
|
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
||||||
const latestSnapshotRevisionRef = useRef(0);
|
const latestSnapshotRevisionRef = useRef(0);
|
||||||
|
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const hasRevealedWindowRef = useRef(false);
|
||||||
|
const revealInFlightRef = useRef(false);
|
||||||
const diagnosticsInFlightRef = useRef(new Set<string>());
|
const diagnosticsInFlightRef = useRef(new Set<string>());
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
const activeTabRef = useRef(activeTab);
|
const activeTabRef = useRef(activeTab);
|
||||||
@@ -140,6 +147,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let readyRetryTimer: number | undefined;
|
||||||
let unlistenSnapshot: UnlistenFn | undefined;
|
let unlistenSnapshot: UnlistenFn | undefined;
|
||||||
let unlistenResult: UnlistenFn | undefined;
|
let unlistenResult: UnlistenFn | undefined;
|
||||||
let unlistenRemoved: UnlistenFn | undefined;
|
let unlistenRemoved: UnlistenFn | undefined;
|
||||||
@@ -148,18 +156,40 @@ export const PropertiesWindowApp = () => {
|
|||||||
const id = await invoke('get_properties_window_download_id');
|
const id = await invoke('get_properties_window_download_id');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDownloadId(id);
|
setDownloadId(id);
|
||||||
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, event => {
|
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
|
||||||
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
|
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
|
||||||
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
|
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
|
||||||
latestSnapshotRevisionRef.current = event.payload.revision;
|
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);
|
setSnapshot(event.payload.snapshot);
|
||||||
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
|
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
|
||||||
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
|
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<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
unlistenResult = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
||||||
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
|
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
|
||||||
if (event.payload.requestId !== requestIdRef.current) return;
|
if (event.payload.requestId !== requestIdRef.current) return;
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
const completedAction = pendingActionRef.current;
|
||||||
|
pendingActionRef.current = null;
|
||||||
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
||||||
else {
|
else {
|
||||||
const nextTab = switchAfterSaveRef.current;
|
const nextTab = switchAfterSaveRef.current;
|
||||||
@@ -167,7 +197,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setNotice(t($ => $.properties.saved));
|
setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : '');
|
||||||
draftTabRef.current = null;
|
draftTabRef.current = null;
|
||||||
setDraftTab(null);
|
setDraftTab(null);
|
||||||
if (nextTab) {
|
if (nextTab) {
|
||||||
@@ -182,11 +212,24 @@ export const PropertiesWindowApp = () => {
|
|||||||
});
|
});
|
||||||
unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
||||||
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
|
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
|
||||||
|
if (readyRetryTimer !== undefined) {
|
||||||
|
window.clearInterval(readyRetryTimer);
|
||||||
|
readyRetryTimer = undefined;
|
||||||
|
}
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
setNotice(t($ => $.downloadTable.noDownloads));
|
setNotice(t($ => $.downloadTable.noDownloads));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await sendPropertiesReady();
|
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) {
|
} catch (error) {
|
||||||
if (!cancelled) setErrorMessage(errorText(error));
|
if (!cancelled) setErrorMessage(errorText(error));
|
||||||
}
|
}
|
||||||
@@ -194,9 +237,12 @@ export const PropertiesWindowApp = () => {
|
|||||||
void start();
|
void start();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer);
|
||||||
unlistenSnapshot?.();
|
unlistenSnapshot?.();
|
||||||
unlistenResult?.();
|
unlistenResult?.();
|
||||||
unlistenRemoved?.();
|
unlistenRemoved?.();
|
||||||
|
appearanceCleanupRef.current?.();
|
||||||
|
appearanceCleanupRef.current = null;
|
||||||
};
|
};
|
||||||
}, [currentWindow, hydrateDraft, t, windowLabel]);
|
}, [currentWindow, hydrateDraft, t, windowLabel]);
|
||||||
|
|
||||||
@@ -228,8 +274,10 @@ export const PropertiesWindowApp = () => {
|
|||||||
payload?: PropertiesActionRequest['payload'],
|
payload?: PropertiesActionRequest['payload'],
|
||||||
) => {
|
) => {
|
||||||
if (!downloadId) return;
|
if (!downloadId) return;
|
||||||
|
if (pendingActionRef.current !== null) return;
|
||||||
const requestId = ++requestIdRef.current;
|
const requestId = ++requestIdRef.current;
|
||||||
setIsSaving(action === 'apply-properties');
|
pendingActionRef.current = action;
|
||||||
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await sendPropertiesActionRequest({
|
await sendPropertiesActionRequest({
|
||||||
windowLabel,
|
windowLabel,
|
||||||
@@ -240,6 +288,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
pendingActionRef.current = null;
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
setErrorMessage(errorText(error));
|
setErrorMessage(errorText(error));
|
||||||
@@ -328,8 +377,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
setNotice(t($ => $.properties.torrentMoveCompleted));
|
setNotice(t($ => $.properties.torrentMoveCompleted));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await invoke('verify_torrent_data', { id: downloadId });
|
await requestAction('verify-torrent');
|
||||||
setNotice(t($ => $.properties.torrentVerifyIntegrity));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(errorText(error));
|
setErrorMessage(errorText(error));
|
||||||
@@ -344,6 +392,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
||||||
|
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||||
const total = snapshot.size || (snapshot.totalBytes === undefined
|
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||||
? t($ => $.addDownloads.unknownSize)
|
? t($ => $.addDownloads.unknownSize)
|
||||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||||
@@ -369,14 +418,32 @@ export const PropertiesWindowApp = () => {
|
|||||||
<p className="mt-1 text-xs text-text-muted" role="status">{statusLabel} · {Math.round(progress * 100)}% · {total}</p>
|
<p className="mt-1 text-xs text-text-muted" role="status">{statusLabel} · {Math.round(progress * 100)}% · {total}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2" aria-label={t($ => $.actions.continue)}>
|
<div className="flex flex-wrap items-center gap-2" aria-label={t($ => $.actions.continue)}>
|
||||||
<button type="button" className="app-button px-3 text-xs" onClick={() => void requestAction('pause-resume')}>
|
{lifecycleAction && <button
|
||||||
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? <Play size={14} /> : <Pause size={14} />}
|
type="button"
|
||||||
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.pause)}
|
className="app-button px-3 text-xs"
|
||||||
</button>
|
disabled={isSaving}
|
||||||
|
onClick={() => {
|
||||||
|
if (lifecycleAction === 'pause'
|
||||||
|
&& snapshot.resumable === false
|
||||||
|
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void requestAction('pause-resume');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
|
||||||
|
{lifecycleAction === 'pause'
|
||||||
|
? t($ => $.downloads.actions.pause)
|
||||||
|
: lifecycleAction === 'resume'
|
||||||
|
? t($ => $.downloads.actions.resume)
|
||||||
|
: lifecycleAction === 'retry'
|
||||||
|
? t($ => $.downloads.actions.retry)
|
||||||
|
: t($ => $.downloads.actions.start)}
|
||||||
|
</button>}
|
||||||
{isTorrent && <>
|
{isTorrent && <>
|
||||||
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
||||||
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
||||||
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,6 +454,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
|
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
|
||||||
<span>{snapshot.speed || '—'}</span>
|
<span>{snapshot.speed || '—'}</span>
|
||||||
<span>{snapshot.eta || '—'}</span>
|
<span>{snapshot.eta || '—'}</span>
|
||||||
|
<span>{snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'} {t($ => $.properties.connections)}</span>
|
||||||
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
|
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -432,7 +500,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPieces)}</span><span>{details.pieceCount} × {formatDownloadBytes(details.pieceLength)}</span>
|
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPieces)}</span><span>{details.pieceCount} × {formatDownloadBytes(details.pieceLength)}</span>
|
||||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPrivate)}</span><span>{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}</span>
|
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPrivate)}</span><span>{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}</span>
|
||||||
</div>}
|
</div>}
|
||||||
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
|
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={isSaving || !['paused', 'completed', 'failed'].includes(snapshot.status)} onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect } from 'react';
|
|||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import type { DownloadItem } 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 {
|
import {
|
||||||
isValidTorrentExcludeTrackerList,
|
isValidTorrentExcludeTrackerList,
|
||||||
isValidTorrentTrackerList,
|
isValidTorrentTrackerList,
|
||||||
@@ -13,6 +14,9 @@ import {
|
|||||||
PROPERTIES_WINDOW_CLOSED,
|
PROPERTIES_WINDOW_CLOSED,
|
||||||
PROPERTIES_WINDOW_READY,
|
PROPERTIES_WINDOW_READY,
|
||||||
applySecretPatch,
|
applySecretPatch,
|
||||||
|
beginExclusivePropertiesAction,
|
||||||
|
createFrameCoalescer,
|
||||||
|
getPropertiesLifecycleAction,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
sendPropertiesActionResult,
|
sendPropertiesActionResult,
|
||||||
sendPropertiesRemoved,
|
sendPropertiesRemoved,
|
||||||
@@ -22,6 +26,7 @@ import {
|
|||||||
type PropertiesWindowReady,
|
type PropertiesWindowReady,
|
||||||
} from '../propertiesBridge';
|
} from '../propertiesBridge';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
|
import i18n, { resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
@@ -105,21 +110,41 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const windows = new Map<string, string>();
|
const windows = new Map<string, string>();
|
||||||
const snapshotRevisions = new Map<string, number>();
|
const snapshotRevisions = new Map<string, number>();
|
||||||
|
const actionsInFlight = new Set<string>();
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let unlistenReady: UnlistenFn | undefined;
|
let unlistenReady: UnlistenFn | undefined;
|
||||||
let unlistenAction: UnlistenFn | undefined;
|
let unlistenAction: UnlistenFn | undefined;
|
||||||
let unlistenClosed: 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 sendFor = async (windowLabel: string, downloadId: string) => {
|
||||||
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
|
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
|
||||||
if (!item || disposed) return false;
|
if (!item || disposed) return false;
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const progress = useDownloadProgressStore.getState();
|
||||||
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
|
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
|
||||||
snapshotRevisions.set(windowLabel, revision);
|
snapshotRevisions.set(windowLabel, revision);
|
||||||
await sendPropertiesSnapshot(windowLabel, {
|
await sendPropertiesSnapshot(windowLabel, {
|
||||||
windowLabel,
|
windowLabel,
|
||||||
downloadId,
|
downloadId,
|
||||||
revision,
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -144,11 +169,14 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const handleAction = async (request: PropertiesActionRequest) => {
|
const handleAction = async (request: PropertiesActionRequest) => {
|
||||||
let ok = false;
|
let ok = false;
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||||
|
let releaseAction: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
await invoke('validate_properties_window_request', request);
|
await invoke('validate_properties_window_request', request);
|
||||||
if (windows.get(request.windowLabel) !== request.downloadId) {
|
if (windows.get(request.windowLabel) !== request.downloadId) {
|
||||||
throw new Error('Properties window is no longer registered');
|
throw new Error('Properties window is no longer registered');
|
||||||
}
|
}
|
||||||
|
releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey);
|
||||||
const store = useDownloadStore.getState();
|
const store = useDownloadStore.getState();
|
||||||
const item = store.downloads.find(download => download.id === request.downloadId);
|
const item = store.downloads.find(download => download.id === request.downloadId);
|
||||||
if (!item) throw new Error('Download no longer exists');
|
if (!item) throw new Error('Download no longer exists');
|
||||||
@@ -172,10 +200,57 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
await store.applyProperties(request.downloadId, safePatch);
|
await store.applyProperties(request.downloadId, safePatch);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'pause-resume':
|
case 'pause-resume': {
|
||||||
if (getPauseResumeAction(item.status) === 'pause') await store.pauseDownload(request.downloadId);
|
const lifecycleAction = getPropertiesLifecycleAction(item.status);
|
||||||
else await store.resumeDownload(request.downloadId);
|
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;
|
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':
|
case 'set-download-limit':
|
||||||
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
||||||
break;
|
break;
|
||||||
@@ -190,9 +265,21 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
default:
|
default:
|
||||||
throw new Error('Invalid Properties action');
|
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;
|
ok = true;
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
error = errorText(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 {
|
try {
|
||||||
await sendPropertiesActionResult(request.windowLabel, {
|
await sendPropertiesActionResult(request.windowLabel, {
|
||||||
@@ -206,20 +293,16 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
// The window may have closed between the request and its result.
|
// The window may have closed between the request and its result.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ok) {
|
|
||||||
try {
|
|
||||||
await sendFor(request.windowLabel, request.downloadId);
|
|
||||||
} catch {
|
|
||||||
// Snapshot delivery is best effort across a close/reopen race.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; });
|
void listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; });
|
||||||
void listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; });
|
void listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; });
|
||||||
void listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
void listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
||||||
|
const downloadId = windows.get(event.payload);
|
||||||
windows.delete(event.payload);
|
windows.delete(event.payload);
|
||||||
snapshotRevisions.delete(event.payload);
|
snapshotRevisions.delete(event.payload);
|
||||||
|
snapshotCoalescer.cancel(event.payload);
|
||||||
|
if (downloadId) actionsInFlight.delete(`${event.payload}:${downloadId}`);
|
||||||
}).then(value => { unlistenClosed = value; });
|
}).then(value => { unlistenClosed = value; });
|
||||||
|
|
||||||
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
|
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
|
||||||
@@ -227,19 +310,50 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const next = state.downloads.find(download => download.id === downloadId);
|
const next = state.downloads.find(download => download.id === downloadId);
|
||||||
const before = previous.downloads.find(download => download.id === downloadId);
|
const before = previous.downloads.find(download => download.id === downloadId);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
|
snapshotCoalescer.cancel(windowLabel);
|
||||||
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
|
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
|
||||||
windows.delete(windowLabel);
|
windows.delete(windowLabel);
|
||||||
snapshotRevisions.delete(windowLabel);
|
snapshotRevisions.delete(windowLabel);
|
||||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||||
} else if (next !== before) {
|
} 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 () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
snapshotCoalescer.cancelAll();
|
||||||
unsubscribeStore();
|
unsubscribeStore();
|
||||||
|
unsubscribeProgress();
|
||||||
|
unsubscribeSettings();
|
||||||
|
i18n.off('languageChanged', handleLanguageChanged);
|
||||||
unlistenReady?.();
|
unlistenReady?.();
|
||||||
unlistenAction?.();
|
unlistenAction?.();
|
||||||
unlistenClosed?.();
|
unlistenClosed?.();
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const common = {
|
|||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
resume: 'Resume',
|
resume: 'Resume',
|
||||||
|
retry: 'Retry',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const fa = {
|
|||||||
pause: 'توقف',
|
pause: 'توقف',
|
||||||
start: 'شروع',
|
start: 'شروع',
|
||||||
resume: 'ادامه',
|
resume: 'ادامه',
|
||||||
|
retry: 'تلاش مجدد',
|
||||||
options: 'گزینهها',
|
options: 'گزینهها',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const he = {
|
|||||||
pause: 'השהייה',
|
pause: 'השהייה',
|
||||||
start: 'הפעלה',
|
start: 'הפעלה',
|
||||||
resume: 'חידוש',
|
resume: 'חידוש',
|
||||||
|
retry: 'ניסיון חוזר',
|
||||||
options: 'אפשרויות',
|
options: 'אפשרויות',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const ru = {
|
|||||||
pause: 'Приостановить',
|
pause: 'Приостановить',
|
||||||
start: 'Запустить',
|
start: 'Запустить',
|
||||||
resume: 'Возобновить',
|
resume: 'Возобновить',
|
||||||
|
retry: 'Повторить',
|
||||||
options: 'Параметры',
|
options: 'Параметры',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const uk = {
|
|||||||
pause: 'Призупинити',
|
pause: 'Призупинити',
|
||||||
start: 'Запустити',
|
start: 'Запустити',
|
||||||
resume: 'Відновити',
|
resume: 'Відновити',
|
||||||
|
retry: 'Повторити',
|
||||||
options: 'Опції',
|
options: 'Опції',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const zhCN = {
|
|||||||
pause: '暂停',
|
pause: '暂停',
|
||||||
start: '开始',
|
start: '开始',
|
||||||
resume: '恢复',
|
resume: '恢复',
|
||||||
|
retry: '重试',
|
||||||
options: '选项',
|
options: '选项',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -643,11 +643,6 @@ html[data-list-density="relaxed"] {
|
|||||||
max-height: min(680px, 100%);
|
max-height: min(680px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-modal {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add Download window */
|
/* Add Download window */
|
||||||
.add-download-modal {
|
.add-download-modal {
|
||||||
width: min(900px, 100%);
|
width: min(900px, 100%);
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ type CommandMap = {
|
|||||||
open_download_properties_window: { args: { id: string }; result: string };
|
open_download_properties_window: { args: { id: string }; result: string };
|
||||||
get_properties_window_download_id: { args: undefined; result: string };
|
get_properties_window_download_id: { args: undefined; result: string };
|
||||||
properties_window_send_ready: { args: undefined; result: void };
|
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 };
|
properties_window_send_action: { args: { requestId: number; action: string; payload?: unknown }; result: void };
|
||||||
validate_properties_window_request: { args: { windowLabel: string; downloadId: string }; result: void };
|
validate_properties_window_request: { args: { windowLabel: string; downloadId: string }; result: void };
|
||||||
close_download_properties_window: { args: { id: string }; result: void };
|
close_download_properties_window: { args: { id: string }; result: void };
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ vi.mock('@tauri-apps/api/event', () => ({
|
|||||||
emitTo: vi.fn(),
|
emitTo: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { applySecretPatch, sanitizePropertiesSnapshot } from './propertiesBridge';
|
import {
|
||||||
|
applySecretPatch,
|
||||||
|
beginExclusivePropertiesAction,
|
||||||
|
createFrameCoalescer,
|
||||||
|
getPropertiesLifecycleAction,
|
||||||
|
sanitizePropertiesSnapshot,
|
||||||
|
} from './propertiesBridge';
|
||||||
|
|
||||||
describe('Properties window bridge', () => {
|
describe('Properties window bridge', () => {
|
||||||
it('sanitizes transfer secrets while preserving presence flags', () => {
|
it('sanitizes transfer secrets while preserving presence flags', () => {
|
||||||
@@ -22,26 +28,154 @@ describe('Properties window bridge', () => {
|
|||||||
cookies: 'sid=secret',
|
cookies: 'sid=secret',
|
||||||
headers: 'Authorization: Bearer secret',
|
headers: 'Authorization: Bearer secret',
|
||||||
username: 'user',
|
username: 'user',
|
||||||
|
mirrors: 'https://user:secret@example.test/mirror',
|
||||||
} as DownloadItem;
|
} 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('password');
|
||||||
expect(snapshot).not.toHaveProperty('cookies');
|
expect(snapshot).not.toHaveProperty('cookies');
|
||||||
expect(snapshot).not.toHaveProperty('headers');
|
expect(snapshot).not.toHaveProperty('headers');
|
||||||
expect(snapshot).not.toHaveProperty('username');
|
expect(snapshot).not.toHaveProperty('username');
|
||||||
|
expect(snapshot).not.toHaveProperty('mirrors');
|
||||||
expect(snapshot.hasPassword).toBe(true);
|
expect(snapshot.hasPassword).toBe(true);
|
||||||
expect(snapshot.hasCookies).toBe(true);
|
expect(snapshot.hasCookies).toBe(true);
|
||||||
expect(snapshot.hasHeaders).toBe(true);
|
expect(snapshot.hasHeaders).toBe(true);
|
||||||
expect(snapshot.hasUsername).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', () => {
|
it('applies explicit secret changes without conflating unchanged fields', () => {
|
||||||
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
|
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
|
||||||
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
|
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
|
||||||
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
|
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
|
||||||
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
|
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
|
||||||
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
|
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
|
||||||
expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch');
|
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<string>();
|
||||||
|
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<number, FrameRequestCallback>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+183
-13
@@ -1,5 +1,9 @@
|
|||||||
import { emitTo } from '@tauri-apps/api/event';
|
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 type { DownloadItem } from './store/useDownloadStore';
|
||||||
|
import { canPauseDownload } from './utils/downloadActions';
|
||||||
|
import type { DocumentAppearance } from './utils/documentAppearance';
|
||||||
import { invokeCommand as invoke } from './ipc';
|
import { invokeCommand as invoke } from './ipc';
|
||||||
|
|
||||||
export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const;
|
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_REMOVED = 'properties-window-removed' as const;
|
||||||
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
||||||
|
|
||||||
export type PropertiesSnapshot = Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'> & {
|
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<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
|
||||||
|
|
||||||
|
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||||
|
appearance: DocumentAppearance;
|
||||||
|
activeConnections?: number;
|
||||||
|
requestedConnections?: number;
|
||||||
|
uploadSpeed?: string;
|
||||||
|
torrentSeeders?: number;
|
||||||
|
moveProgress?: number;
|
||||||
hasPassword: boolean;
|
hasPassword: boolean;
|
||||||
hasCookies: boolean;
|
hasCookies: boolean;
|
||||||
hasHeaders: boolean;
|
hasHeaders: boolean;
|
||||||
hasUsername: boolean;
|
hasUsername: boolean;
|
||||||
|
hasMirrors: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SecretPatch =
|
export type SecretPatch =
|
||||||
@@ -31,10 +101,39 @@ export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies'
|
|||||||
export type PropertiesAction =
|
export type PropertiesAction =
|
||||||
| 'apply-properties'
|
| 'apply-properties'
|
||||||
| 'pause-resume'
|
| 'pause-resume'
|
||||||
|
| 'verify-torrent'
|
||||||
| 'set-download-limit'
|
| 'set-download-limit'
|
||||||
| 'set-torrent-upload-limit'
|
| 'set-torrent-upload-limit'
|
||||||
| 'set-torrent-peer-options';
|
| 'set-torrent-peer-options';
|
||||||
|
|
||||||
|
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
||||||
|
|
||||||
|
export const getPropertiesLifecycleAction = (
|
||||||
|
status: DownloadStatus,
|
||||||
|
): PropertiesLifecycleAction | null => {
|
||||||
|
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<string>,
|
||||||
|
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 = {
|
export type PropertiesWindowReady = {
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
downloadId: string;
|
downloadId: string;
|
||||||
@@ -63,25 +162,96 @@ export type PropertiesSnapshotEvent = {
|
|||||||
snapshot: PropertiesSnapshot;
|
snapshot: PropertiesSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyWithoutSecrets = (item: DownloadItem): PropertiesSnapshot => {
|
const copyWithoutSecrets = (
|
||||||
const {
|
item: DownloadItem,
|
||||||
password,
|
appearance: DocumentAppearance,
|
||||||
cookies,
|
live?: {
|
||||||
headers,
|
progress?: DownloadProgressEvent;
|
||||||
username,
|
moveProgress?: number;
|
||||||
...safeItem
|
},
|
||||||
} = item;
|
): PropertiesSnapshot => {
|
||||||
|
const safeItem = Object.fromEntries(
|
||||||
|
PROPERTIES_SNAPSHOT_KEYS.flatMap(key => (
|
||||||
|
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
||||||
|
)),
|
||||||
|
) as SafePropertiesFields;
|
||||||
return {
|
return {
|
||||||
...safeItem,
|
...safeItem,
|
||||||
hasPassword: Boolean(password),
|
appearance,
|
||||||
hasCookies: Boolean(cookies),
|
...(live?.progress ? {
|
||||||
hasHeaders: Boolean(headers),
|
fraction: live.progress.fraction,
|
||||||
hasUsername: Boolean(username),
|
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 sanitizePropertiesSnapshot = copyWithoutSecrets;
|
||||||
|
|
||||||
|
export const createFrameCoalescer = (
|
||||||
|
callback: (key: string) => void,
|
||||||
|
requestFrame: (callback: FrameRequestCallback) => number,
|
||||||
|
cancelFrame: (handle: number) => void,
|
||||||
|
) => {
|
||||||
|
const pending = new Map<string, number>();
|
||||||
|
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<string> =>
|
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
|
||||||
invoke('open_download_properties_window', { id: downloadId });
|
invoke('open_download_properties_window', { id: downloadId });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyDocumentAppearance } from './documentAppearance';
|
||||||
|
|
||||||
|
const fakeDocument = () => {
|
||||||
|
const classes = new Set<string>(['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<string, string>,
|
||||||
|
style: {} as Record<string, string>,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Theme> = 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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user