mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
fix(properties): harden lifecycle and session fencing
- Fence Properties actions and Torrent moves by current caller sessions. - Preserve move progress and authoritative destinations across stale events and recovery. - Enforce immutable identity fields and transactional queued-edit rejection. - Restore subtle theme surfaces and expand regression coverage.
This commit is contained in:
@@ -807,6 +807,9 @@ pub struct DownloadStateEvent {
|
|||||||
pub resolver_fallback: Option<bool>,
|
pub resolver_fallback: Option<bool>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub file_name: Option<String>,
|
pub file_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub destination: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub torrent_seed_remaining: Option<f64>,
|
pub torrent_seed_remaining: Option<f64>,
|
||||||
}
|
}
|
||||||
@@ -820,6 +823,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind: None,
|
error_kind: None,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -833,6 +837,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind,
|
error_kind,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,6 +851,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind,
|
error_kind,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,6 +864,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind: None,
|
error_kind: None,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: remaining,
|
torrent_seed_remaining: remaining,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,6 +877,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind: None,
|
error_kind: None,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: Some(file_name.into()),
|
file_name: Some(file_name.into()),
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -885,6 +893,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind,
|
error_kind,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -897,6 +906,7 @@ impl DownloadStateEvent {
|
|||||||
error_kind: None,
|
error_kind: None,
|
||||||
resolver_fallback: None,
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
|
destination: None,
|
||||||
torrent_seed_remaining: remaining,
|
torrent_seed_remaining: remaining,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -910,6 +920,11 @@ impl DownloadStateEvent {
|
|||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_destination(mut self, destination: impl Into<String>) -> Self {
|
||||||
|
self.destination = Some(destination.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn safe_error(error: impl Into<String>) -> (String, Option<DownloadErrorKind>) {
|
fn safe_error(error: impl Into<String>) -> (String, Option<DownloadErrorKind>) {
|
||||||
let error = crate::redact_sensitive_text(&error.into());
|
let error = crate::redact_sensitive_text(&error.into());
|
||||||
let error_kind = crate::retry::is_aria2_name_resolution_error(&error)
|
let error_kind = crate::retry::is_aria2_name_resolution_error(&error)
|
||||||
|
|||||||
+84
-36
@@ -8426,9 +8426,24 @@ async fn move_torrent_data(
|
|||||||
database: tauri::State<'_, crate::db::DbState>,
|
database: tauri::State<'_, crate::db::DbState>,
|
||||||
id: String,
|
id: String,
|
||||||
destination: String,
|
destination: String,
|
||||||
|
session_id: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
||||||
|
let properties_session_id = if caller.label() == "main" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?;
|
||||||
|
if !properties.session_matches(caller.label(), &session_id)? {
|
||||||
|
return Err("Properties window session is no longer current".to_string());
|
||||||
|
}
|
||||||
|
Some(session_id)
|
||||||
|
};
|
||||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
|
if let Some(session_id) = properties_session_id.as_deref() {
|
||||||
|
if !properties.session_matches(caller.label(), session_id)? {
|
||||||
|
return Err("Properties window session is no longer current".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
let item = load_persisted_torrent_item(database.inner(), &id)?;
|
let item = load_persisted_torrent_item(database.inner(), &id)?;
|
||||||
if item.is_torrent != Some(true) {
|
if item.is_torrent != Some(true) {
|
||||||
return Err("data relocation is available only for Torrent downloads".to_string());
|
return Err("data relocation is available only for Torrent downloads".to_string());
|
||||||
@@ -8576,7 +8591,14 @@ async fn move_torrent_data(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| "could not prepare Torrent move recovery".to_string())?;
|
.map_err(|_| "could not prepare Torrent move recovery".to_string())?;
|
||||||
}
|
}
|
||||||
state.queue_manager.begin_torrent_move(&id).await;
|
if let Some(session_id) = properties_session_id.as_deref() {
|
||||||
|
properties.with_current_session(caller.label(), session_id, || {
|
||||||
|
state.queue_manager.begin_torrent_move(&id);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
state.queue_manager.begin_torrent_move(&id);
|
||||||
|
}
|
||||||
if let Err(error) = write_torrent_move_journal(
|
if let Err(error) = write_torrent_move_journal(
|
||||||
&journal,
|
&journal,
|
||||||
"reserved",
|
"reserved",
|
||||||
@@ -8595,12 +8617,12 @@ async fn move_torrent_data(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if let Err(error) = tokio::fs::create_dir(&staging_root).await {
|
if let Err(error) = tokio::fs::create_dir(&staging_root).await {
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
return Err(format!("could not prepare Torrent move staging: {error}"));
|
return Err(format!("could not prepare Torrent move staging: {error}"));
|
||||||
}
|
}
|
||||||
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal(
|
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal(
|
||||||
@@ -8611,28 +8633,32 @@ async fn move_torrent_data(
|
|||||||
&new_removal_paths,
|
&new_removal_paths,
|
||||||
) {
|
) {
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Moving));
|
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Moving));
|
||||||
|
let move_restore_event = || {
|
||||||
|
crate::ipc::DownloadStateEvent::new(&id, item.status)
|
||||||
|
.with_destination(old_destination.to_string_lossy())
|
||||||
|
};
|
||||||
let mut copied_bytes = 0u64;
|
let mut copied_bytes = 0u64;
|
||||||
for (source, target) in move_old_paths.iter().zip(staging_paths.iter()) {
|
for (source, target) in move_old_paths.iter().zip(staging_paths.iter()) {
|
||||||
if state.queue_manager.torrent_move_cancelled(&id).await {
|
if state.queue_manager.torrent_move_cancelled(&id) {
|
||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err("Torrent move canceled".to_string());
|
return Err("Torrent move canceled".to_string());
|
||||||
}
|
}
|
||||||
if let Err(error) = copy_torrent_move_file(source, target).await {
|
if let Err(error) = copy_torrent_move_file(source, target).await {
|
||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
let source_size = match tokio::fs::metadata(source).await {
|
let source_size = match tokio::fs::metadata(source).await {
|
||||||
@@ -8641,10 +8667,10 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
move_restore_event(),
|
||||||
);
|
);
|
||||||
return Err("Torrent source changed during relocation".to_string());
|
return Err("Torrent source changed during relocation".to_string());
|
||||||
}
|
}
|
||||||
@@ -8655,10 +8681,10 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
move_restore_event(),
|
||||||
);
|
);
|
||||||
return Err("Torrent destination could not be verified".to_string());
|
return Err("Torrent destination could not be verified".to_string());
|
||||||
}
|
}
|
||||||
@@ -8667,7 +8693,7 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err("Torrent data changed during relocation".to_string());
|
return Err("Torrent data changed during relocation".to_string());
|
||||||
}
|
}
|
||||||
let source_digest = digest_torrent_move_file(source).await;
|
let source_digest = digest_torrent_move_file(source).await;
|
||||||
@@ -8676,7 +8702,7 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err("Torrent data changed during relocation".to_string());
|
return Err("Torrent data changed during relocation".to_string());
|
||||||
}
|
}
|
||||||
copied_bytes = copied_bytes.saturating_add(target_size);
|
copied_bytes = copied_bytes.saturating_add(target_size);
|
||||||
@@ -8687,12 +8713,12 @@ async fn move_torrent_data(
|
|||||||
total_bytes,
|
total_bytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if state.queue_manager.torrent_move_cancelled(&id).await {
|
if state.queue_manager.torrent_move_cancelled(&id) {
|
||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err("Torrent move canceled".to_string());
|
return Err("Torrent move canceled".to_string());
|
||||||
}
|
}
|
||||||
for (staged, target) in staging_paths.iter().zip(move_new_paths.iter()) {
|
for (staged, target) in staging_paths.iter().zip(move_new_paths.iter()) {
|
||||||
@@ -8700,10 +8726,10 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
move_restore_event(),
|
||||||
);
|
);
|
||||||
return Err("Torrent move destination could not be published".to_string());
|
return Err("Torrent move destination could not be published".to_string());
|
||||||
}
|
}
|
||||||
@@ -8712,10 +8738,10 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
move_restore_event(),
|
||||||
);
|
);
|
||||||
return Err("Torrent move staging could not be finalized".to_string());
|
return Err("Torrent move staging could not be finalized".to_string());
|
||||||
}
|
}
|
||||||
@@ -8740,10 +8766,10 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
move_restore_event(),
|
||||||
);
|
);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
@@ -8761,8 +8787,8 @@ async fn move_torrent_data(
|
|||||||
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await;
|
||||||
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
let _ = restore_download_ownership(&app_handle, &id, ownership.clone());
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit("download-state", move_restore_event());
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if let Err(error) = write_torrent_move_journal(
|
if let Err(error) = write_torrent_move_journal(
|
||||||
@@ -8787,10 +8813,11 @@ async fn move_torrent_data(
|
|||||||
// let startup recovery finish old-source cleanup from the committed
|
// let startup recovery finish old-source cleanup from the committed
|
||||||
// destination rather than rolling the row back after commit.
|
// destination rather than rolling the row back after commit.
|
||||||
let _ = persist_torrent_relocation_check(database.inner(), &id, true);
|
let _ = persist_torrent_relocation_check(database.inner(), &id, true);
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
crate::ipc::DownloadStateEvent::new(&id, item.status)
|
||||||
|
.with_destination(new_destination.to_string_lossy()),
|
||||||
);
|
);
|
||||||
return Err(format!("Torrent data moved; cleanup recovery remains pending: {error}"));
|
return Err(format!("Torrent data moved; cleanup recovery remains pending: {error}"));
|
||||||
}
|
}
|
||||||
@@ -8828,23 +8855,32 @@ async fn move_torrent_data(
|
|||||||
&move_new_paths,
|
&move_new_paths,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
).await {
|
).await {
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(&id, item.status),
|
crate::ipc::DownloadStateEvent::new(&id, item.status)
|
||||||
|
.with_destination(new_destination.to_string_lossy()),
|
||||||
);
|
);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Torrent data moved, but cleanup recovery could not be recorded: {journal_error}"
|
"Torrent data moved, but cleanup recovery could not be recorded: {journal_error}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit(
|
||||||
|
"download-state",
|
||||||
|
crate::ipc::DownloadStateEvent::new(&id, item.status)
|
||||||
|
.with_destination(new_destination.to_string_lossy()),
|
||||||
|
);
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
return Err(format!("Torrent data moved, but old files need cleanup: {error}"));
|
return Err(format!("Torrent data moved, but old files need cleanup: {error}"));
|
||||||
}
|
}
|
||||||
let _ = tokio::fs::remove_file(&journal).await;
|
let _ = tokio::fs::remove_file(&journal).await;
|
||||||
state.queue_manager.finish_torrent_move(&id).await;
|
state.queue_manager.finish_torrent_move(&id);
|
||||||
let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status));
|
let _ = app_handle.emit(
|
||||||
|
"download-state",
|
||||||
|
crate::ipc::DownloadStateEvent::new(&id, item.status)
|
||||||
|
.with_destination(new_destination.to_string_lossy()),
|
||||||
|
);
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -8852,14 +8888,26 @@ async fn move_torrent_data(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cancel_torrent_move_data(
|
async fn cancel_torrent_move_data(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
|
properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
|
session_id: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
properties_window::ensure_main_window(&caller)?;
|
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("invalid Torrent download id".to_string());
|
return Err("invalid Torrent download id".to_string());
|
||||||
}
|
}
|
||||||
state.queue_manager.cancel_torrent_move(&id).await;
|
if caller.label() == "main" {
|
||||||
|
properties_window::ensure_main_window(&caller)?;
|
||||||
|
} else {
|
||||||
|
let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?;
|
||||||
|
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
||||||
|
properties.with_current_session(caller.label(), &session_id, || {
|
||||||
|
state.queue_manager.cancel_torrent_move(&id);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
state.queue_manager.cancel_torrent_move(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,26 @@ impl PropertiesWindowRegistry {
|
|||||||
Ok(self.session_for_window(label)?.as_deref() == Some(session_id))
|
Ok(self.session_for_window(label)?.as_deref() == Some(session_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate a session and perform a short synchronous mutation while the
|
||||||
|
/// registry lock is held. Callers use this for cancellation flags so a
|
||||||
|
/// stale session cannot pass validation and then race a replacement
|
||||||
|
/// session before its mutation is recorded.
|
||||||
|
pub fn with_current_session<T>(
|
||||||
|
&self,
|
||||||
|
label: &str,
|
||||||
|
session_id: &str,
|
||||||
|
mutation: impl FnOnce() -> Result<T, String>,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
let state = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||||
|
if state.sessions_by_window.get(label).map(String::as_str) != Some(session_id) {
|
||||||
|
return Err("Properties window session is no longer current".to_string());
|
||||||
|
}
|
||||||
|
mutation()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn is_ready(&self, label: &str) -> Result<bool, String> {
|
pub fn is_ready(&self, label: &str) -> Result<bool, String> {
|
||||||
Ok(self
|
Ok(self
|
||||||
@@ -478,8 +498,16 @@ pub fn properties_window_send_ready(
|
|||||||
pub fn properties_window_reveal(
|
pub fn properties_window_reveal(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||||
|
session_id: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
registered_download_for_caller(&caller, ®istry)?;
|
registered_download_for_caller(&caller, ®istry)?;
|
||||||
|
if caller.label() != MAIN_WINDOW_LABEL {
|
||||||
|
let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?;
|
||||||
|
validate_properties_session_id(&session_id)?;
|
||||||
|
if !registry.session_matches(caller.label(), &session_id)? {
|
||||||
|
return Err("Properties window session is no longer current".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
registry.mark_ready(caller.label())?;
|
registry.mark_ready(caller.label())?;
|
||||||
caller.show().map_err(|error| error.to_string())?;
|
caller.show().map_err(|error| error.to_string())?;
|
||||||
caller.set_focus().map_err(|error| error.to_string())
|
caller.set_focus().map_err(|error| error.to_string())
|
||||||
@@ -722,6 +750,28 @@ mod tests {
|
|||||||
assert!(!registry.session_matches(&label, "session-new").unwrap());
|
assert!(!registry.session_matches(&label, "session-new").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_session_mutation_is_fenced_from_retired_sessions() {
|
||||||
|
let registry = PropertiesWindowRegistry::default();
|
||||||
|
let label = registry.allocate("download-a").unwrap();
|
||||||
|
registry.register_session(&label, "session-old").unwrap();
|
||||||
|
|
||||||
|
let mut mutations = 0;
|
||||||
|
let stale = registry.with_current_session(&label, "session-old", || {
|
||||||
|
mutations += 1;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(stale.is_ok());
|
||||||
|
|
||||||
|
registry.register_session(&label, "session-new").unwrap();
|
||||||
|
let rejected = registry.with_current_session(&label, "session-old", || {
|
||||||
|
mutations += 1;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(rejected.is_err());
|
||||||
|
assert_eq!(mutations, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn child_actions_are_allowlisted() {
|
fn child_actions_are_allowlisted() {
|
||||||
assert!(is_properties_action("apply-properties"));
|
assert!(is_properties_action("apply-properties"));
|
||||||
|
|||||||
+21
-12
@@ -942,7 +942,7 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
|||||||
/// are scoped to the current GID and control epoch and never leave this
|
/// are scoped to the current GID and control epoch and never leave this
|
||||||
/// process as durable state.
|
/// process as durable state.
|
||||||
torrent_telemetry: Mutex<HashMap<String, TorrentTelemetryState>>,
|
torrent_telemetry: Mutex<HashMap<String, TorrentTelemetryState>>,
|
||||||
torrent_move_cancellations: Mutex<HashSet<String>>,
|
torrent_move_cancellations: StdMutex<HashSet<String>>,
|
||||||
|
|
||||||
/// aria2 gid -> download id map (shared with the WS poller).
|
/// aria2 gid -> download id map (shared with the WS poller).
|
||||||
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
|
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
|
||||||
@@ -1045,7 +1045,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}),
|
}),
|
||||||
seed_budgets: StdMutex::new(HashMap::new()),
|
seed_budgets: StdMutex::new(HashMap::new()),
|
||||||
torrent_telemetry: Mutex::new(HashMap::new()),
|
torrent_telemetry: Mutex::new(HashMap::new()),
|
||||||
torrent_move_cancellations: Mutex::new(HashSet::new()),
|
torrent_move_cancellations: StdMutex::new(HashSet::new()),
|
||||||
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||||
aria2_payloads: Mutex::new(HashMap::new()),
|
aria2_payloads: Mutex::new(HashMap::new()),
|
||||||
@@ -1135,23 +1135,32 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn begin_torrent_move(&self, id: &str) {
|
pub fn begin_torrent_move(&self, id: &str) {
|
||||||
self.torrent_move_cancellations.lock().await.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cancel_torrent_move(&self, id: &str) {
|
|
||||||
self.torrent_move_cancellations
|
self.torrent_move_cancellations
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.expect("Torrent move cancellation lock poisoned")
|
||||||
|
.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_torrent_move(&self, id: &str) {
|
||||||
|
self.torrent_move_cancellations
|
||||||
|
.lock()
|
||||||
|
.expect("Torrent move cancellation lock poisoned")
|
||||||
.insert(id.to_string());
|
.insert(id.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn torrent_move_cancelled(&self, id: &str) -> bool {
|
pub fn torrent_move_cancelled(&self, id: &str) -> bool {
|
||||||
self.torrent_move_cancellations.lock().await.contains(id)
|
self.torrent_move_cancellations
|
||||||
|
.lock()
|
||||||
|
.expect("Torrent move cancellation lock poisoned")
|
||||||
|
.contains(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn finish_torrent_move(&self, id: &str) {
|
pub fn finish_torrent_move(&self, id: &str) {
|
||||||
self.torrent_move_cancellations.lock().await.remove(id);
|
self.torrent_move_cancellations
|
||||||
|
.lock()
|
||||||
|
.expect("Torrent move cancellation lock poisoned")
|
||||||
|
.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop counters after terminal cleanup/removal. Persisted lifetime
|
/// Drop counters after terminal cleanup/removal. Persisted lifetime
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||||
|
|
||||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, torrentSeedRemaining?: number, };
|
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
propertiesDiagnosticPhase,
|
propertiesDiagnosticPhase,
|
||||||
propertiesActionRequestKey,
|
propertiesActionRequestKey,
|
||||||
propertiesWindowEventTarget,
|
propertiesWindowEventTarget,
|
||||||
|
redactPropertiesError,
|
||||||
resetPropertiesActionState,
|
resetPropertiesActionState,
|
||||||
type PropertiesAction,
|
type PropertiesAction,
|
||||||
type PropertiesActionRequest,
|
type PropertiesActionRequest,
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
type PropertiesSnapshot,
|
type PropertiesSnapshot,
|
||||||
type PropertiesSnapshotEvent,
|
type PropertiesSnapshotEvent,
|
||||||
} from '../propertiesBridge';
|
} from '../propertiesBridge';
|
||||||
import { formatDownloadBytes, formatTorrentRatio, resolveDownloadFraction } from '../utils/downloadProgress';
|
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||||
import { changeAppLocale } from '../i18n';
|
import { changeAppLocale } from '../i18n';
|
||||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||||
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
|
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
} from '../utils/propertiesDiagnostics';
|
} from '../utils/propertiesDiagnostics';
|
||||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||||
import { getPropertiesConnectionPresentation } from '../utils/propertiesPresentation';
|
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||||
import { WindowControls } from './WindowControls';
|
import { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
@@ -78,7 +79,7 @@ const safeTitle = (name: string) => {
|
|||||||
return `${bounded || 'Download'} - Properties - Firelink`;
|
return `${bounded || 'Download'} - Properties - Firelink`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
const errorText = redactPropertiesError;
|
||||||
|
|
||||||
const PropertiesHelp = ({ text }: { text: string }) => (
|
const PropertiesHelp = ({ text }: { text: string }) => (
|
||||||
<button type="button" className="properties-help" aria-label={text}>
|
<button type="button" className="properties-help" aria-label={text}>
|
||||||
@@ -103,19 +104,30 @@ const PropertiesField = ({
|
|||||||
format?: ReactNode;
|
format?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (
|
}) => {
|
||||||
|
const hintId = hint ? `${controlId}-hint` : undefined;
|
||||||
|
const describedChildren = hintId && isValidElement(children)
|
||||||
|
? cloneElement(children, {
|
||||||
|
'aria-describedby': [
|
||||||
|
(children.props as { 'aria-describedby'?: string })['aria-describedby'],
|
||||||
|
hintId,
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: children;
|
||||||
|
return (
|
||||||
<div className={`properties-field ${className}`}>
|
<div className={`properties-field ${className}`}>
|
||||||
<div className="properties-field-label">
|
<div className="properties-field-label">
|
||||||
<label className="properties-field-label-text" htmlFor={controlId}>
|
<label className="properties-field-label-text" htmlFor={controlId}>
|
||||||
<span className="min-w-0">{label}</span>
|
<span className="min-w-0">{label}</span>
|
||||||
</label>
|
</label>
|
||||||
{hint && <PropertiesHelp text={hint} />}
|
{hint && <><PropertiesHelp text={hint} /><span id={hintId} className="sr-only">{hint}</span></>}
|
||||||
{meta && <span className="properties-field-meta">{meta}</span>}
|
{meta && <span className="properties-field-meta">{meta}</span>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{describedChildren}
|
||||||
{format && <span className="properties-field-format">{format}</span>}
|
{format && <span className="properties-field-format">{format}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PropertiesOptionToggle = ({
|
const PropertiesOptionToggle = ({
|
||||||
label,
|
label,
|
||||||
@@ -133,11 +145,12 @@ const PropertiesOptionToggle = ({
|
|||||||
const controlId = useId();
|
const controlId = useId();
|
||||||
return (
|
return (
|
||||||
<div className="properties-option-toggle">
|
<div className="properties-option-toggle">
|
||||||
<input id={controlId} type="checkbox" checked={checked} onChange={event => onChange(event.target.checked)} disabled={disabled} />
|
<input id={controlId} type="checkbox" aria-describedby={`${controlId}-hint`} checked={checked} onChange={event => onChange(event.target.checked)} disabled={disabled} />
|
||||||
<span className="properties-option-toggle-copy">
|
<span className="properties-option-toggle-copy">
|
||||||
<label className="properties-option-toggle-label" htmlFor={controlId}>{label}</label>
|
<label className="properties-option-toggle-label" htmlFor={controlId}>{label}</label>
|
||||||
</span>
|
</span>
|
||||||
<PropertiesHelp text={hint} />
|
<PropertiesHelp text={hint} />
|
||||||
|
<span id={`${controlId}-hint`} className="sr-only">{hint}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -198,7 +211,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
const [pendingAction, setPendingAction] = useState<PropertiesAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<PropertiesAction | null>(null);
|
||||||
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | null>(null);
|
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null);
|
||||||
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||||
@@ -359,6 +372,11 @@ export const PropertiesWindowApp = () => {
|
|||||||
}, [currentWindow]);
|
}, [currentWindow]);
|
||||||
|
|
||||||
const revealWindow = useCallback(async () => {
|
const revealWindow = useCallback(async () => {
|
||||||
|
// The native reveal command is session-bound. Before the first snapshot,
|
||||||
|
// the child has not completed its ready handshake yet, so revealing here
|
||||||
|
// would produce a false startup error and leave stale error copy in the
|
||||||
|
// footer. The snapshot path calls reveal again after registration.
|
||||||
|
if (latestSnapshotRevisionRef.current === 0) return;
|
||||||
if (hasRevealedWindowRef.current) {
|
if (hasRevealedWindowRef.current) {
|
||||||
if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) {
|
if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) {
|
||||||
window.clearInterval(readyRetryTimerRef.current);
|
window.clearInterval(readyRetryTimerRef.current);
|
||||||
@@ -369,7 +387,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (revealInFlightRef.current) return;
|
if (revealInFlightRef.current) return;
|
||||||
revealInFlightRef.current = true;
|
revealInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
await invoke('properties_window_reveal');
|
await invoke('properties_window_reveal', { sessionId });
|
||||||
hasRevealedWindowRef.current = true;
|
hasRevealedWindowRef.current = true;
|
||||||
if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) {
|
if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) {
|
||||||
window.clearInterval(readyRetryTimerRef.current);
|
window.clearInterval(readyRetryTimerRef.current);
|
||||||
@@ -1024,11 +1042,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
||||||
if (!downloadId) return;
|
if (!downloadId) return;
|
||||||
|
if (action === 'move' && !['paused', 'completed', 'failed'].includes(snapshot?.status ?? '')) return;
|
||||||
if (action === 'verify') {
|
if (action === 'verify') {
|
||||||
|
if (pendingTorrentCommand !== null || snapshot?.status === 'moving') return;
|
||||||
await requestAction('verify-torrent');
|
await requestAction('verify-torrent');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingTorrentCommand !== null) return;
|
if (pendingTorrentCommand !== null || snapshot?.status === 'moving') return;
|
||||||
setPendingTorrentCommand(action);
|
setPendingTorrentCommand(action);
|
||||||
try {
|
try {
|
||||||
if (action === 'magnet') {
|
if (action === 'magnet') {
|
||||||
@@ -1042,8 +1062,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
} else if (action === 'move') {
|
} else if (action === 'move') {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (selected && typeof selected === 'string') {
|
if (selected && typeof selected === 'string' && window.confirm(t($ => $.properties.torrentMoveConfirm))) {
|
||||||
await invoke('move_torrent_data', { id: downloadId, destination: selected });
|
await invoke('move_torrent_data', { id: downloadId, destination: selected, sessionId });
|
||||||
setNotice(t($ => $.properties.torrentMoveCompleted));
|
setNotice(t($ => $.properties.torrentMoveCompleted));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1054,6 +1074,19 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelTorrentMove = async () => {
|
||||||
|
if (!downloadId || snapshot?.status !== 'moving' || pendingTorrentCommand === 'cancel') return;
|
||||||
|
setPendingTorrentCommand('cancel');
|
||||||
|
try {
|
||||||
|
await invoke('cancel_torrent_move_data', { id: downloadId, sessionId });
|
||||||
|
setNotice(t($ => $.properties.torrentMoveCancelRequested));
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(errorText(error));
|
||||||
|
} finally {
|
||||||
|
setPendingTorrentCommand(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
|
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
|
||||||
const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle);
|
const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle);
|
||||||
const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`;
|
const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`;
|
||||||
@@ -1076,17 +1109,11 @@ export const PropertiesWindowApp = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = resolveDownloadFraction({
|
|
||||||
fraction: snapshot.fraction,
|
|
||||||
downloadedBytes: snapshot.downloadedBytes,
|
|
||||||
totalBytes: snapshot.totalBytes,
|
|
||||||
totalIsEstimate: snapshot.totalIsEstimate,
|
|
||||||
isMedia: snapshot.isMedia,
|
|
||||||
size: snapshot.size,
|
|
||||||
status: snapshot.status,
|
|
||||||
});
|
|
||||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
|
||||||
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
||||||
|
const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status);
|
||||||
|
const torrentMoveAvailable = ['paused', 'completed', 'failed'].includes(snapshot.status);
|
||||||
|
const progress = getPropertiesProgress(snapshot);
|
||||||
|
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||||
const footerActions = getPropertiesFooterActions({
|
const footerActions = getPropertiesFooterActions({
|
||||||
isDirty,
|
isDirty,
|
||||||
hasUnsavedNavigation: pendingTab !== null || closePrompt,
|
hasUnsavedNavigation: pendingTab !== null || closePrompt,
|
||||||
@@ -1152,7 +1179,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
{lifecycleAction && <button
|
{lifecycleAction && <button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button app-button-primary properties-primary-action px-3 text-xs"
|
className="app-button app-button-primary properties-primary-action px-3 text-xs"
|
||||||
disabled={pendingAction !== null}
|
disabled={pendingAction !== null || pendingTorrentCommand !== null}
|
||||||
title={lifecycleLabel}
|
title={lifecycleLabel}
|
||||||
aria-label={lifecycleLabel}
|
aria-label={lifecycleLabel}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1169,16 +1196,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
</button>}
|
</button>}
|
||||||
{isTorrent && <>
|
{isTorrent && <>
|
||||||
<div className="properties-secondary-actions">
|
<div className="properties-secondary-actions">
|
||||||
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')} title={t($ => $.properties.torrentCopyMagnet)}><Copy size={14} /><span className="properties-command-label">{t($ => $.properties.torrentCopyMagnet)}</span></button>
|
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand !== null || snapshot.status === 'moving'} onClick={() => void performTorrentAction('magnet')} title={t($ => $.properties.torrentCopyMagnet)}><Copy size={14} /><span className="properties-command-label">{t($ => $.properties.torrentCopyMagnet)}</span></button>
|
||||||
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')} title={t($ => $.properties.torrentExportMetadata)}><FileDown size={14} /><span className="properties-command-label">{t($ => $.properties.torrentExportMetadata)}</span></button>
|
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand !== null || snapshot.status === 'moving'} onClick={() => void performTorrentAction('export')} title={t($ => $.properties.torrentExportMetadata)}><FileDown size={14} /><span className="properties-command-label">{t($ => $.properties.torrentExportMetadata)}</span></button>
|
||||||
<button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')} title={t($ => $.properties.torrentMove)}><FolderOpen size={14} /><span className="properties-command-label">{t($ => $.properties.torrentMove)}</span></button>
|
{snapshot.status === 'moving' ? <button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand === 'cancel'} onClick={() => void cancelTorrentMove()} title={t($ => $.properties.torrentMoveCancel)}><X size={14} /><span className="properties-command-label">{pendingTorrentCommand === 'cancel' ? t($ => $.properties.torrentMoveCancelRequested) : t($ => $.properties.torrentMoveCancel)}</span></button> : <button type="button" className="app-button properties-command-button px-3 text-xs" disabled={pendingTorrentCommand !== null || !torrentMoveAvailable} onClick={() => void performTorrentAction('move')} title={t($ => $.properties.torrentMove)}><FolderOpen size={14} /><span className="properties-command-label">{t($ => $.properties.torrentMove)}</span></button>}
|
||||||
</div>
|
</div>
|
||||||
<details className="properties-command-overflow">
|
<details className="properties-command-overflow">
|
||||||
<summary className="app-icon-button" title={t($ => $.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}><MoreHorizontal size={16} /></summary>
|
<summary className="app-icon-button" title={t($ => $.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}><MoreHorizontal size={16} /></summary>
|
||||||
<div className="properties-command-menu">
|
<div className="properties-command-menu">
|
||||||
<button type="button" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
<button type="button" disabled={pendingTorrentCommand !== null || snapshot.status === 'moving'} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
||||||
<button type="button" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
<button type="button" disabled={pendingTorrentCommand !== null || snapshot.status === 'moving'} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
||||||
<button type="button" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
{snapshot.status === 'moving' ? <button type="button" disabled={pendingTorrentCommand === 'cancel'} onClick={() => void cancelTorrentMove()}><X size={14} />{t($ => $.properties.torrentMoveCancel)}</button> : <button type="button" disabled={pendingTorrentCommand !== null || !torrentMoveAvailable} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</>}
|
</>}
|
||||||
@@ -1255,9 +1282,10 @@ export const PropertiesWindowApp = () => {
|
|||||||
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={useTabOverflow ? 'properties-active-section-label' : `properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
|
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={useTabOverflow ? 'properties-active-section-label' : `properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
|
||||||
{activeTab === 'overview' && <div className="space-y-4">
|
{activeTab === 'overview' && <div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!identityEditingEnabled} /></label>
|
||||||
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!identityEditingEnabled} /></label>
|
||||||
</div>
|
</div>
|
||||||
|
{!identityEditingEnabled && <p className="text-xs text-text-muted">{t($ => $.properties.identityReadOnly)}</p>}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div ref={urlCardRef} className="properties-url-card rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
|
<div ref={urlCardRef} className="properties-url-card rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
|
||||||
<div className="properties-url-card-header">
|
<div className="properties-url-card-header">
|
||||||
@@ -1314,7 +1342,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsCreator)}</span><span>{details.creator || '—'}</span>
|
<span className="text-text-muted">{t($ => $.properties.torrentDetailsCreator)}</span><span>{details.creator || '—'}</span>
|
||||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsComment)}</span><span className="break-words">{details.comment || '—'}</span>
|
<span className="text-text-muted">{t($ => $.properties.torrentDetailsComment)}</span><span className="break-words">{details.comment || '—'}</span>
|
||||||
</div>}
|
</div>}
|
||||||
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null || !['paused', 'completed', 'failed'].includes(snapshot.status)} 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={pendingAction !== null || pendingTorrentCommand !== null || snapshot.status === 'moving' || !['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">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
propertiesActionRequestKey,
|
propertiesActionRequestKey,
|
||||||
PROPERTIES_PATCH_CLEARABLE_KEYS,
|
PROPERTIES_PATCH_CLEARABLE_KEYS,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
|
redactPropertiesError,
|
||||||
sendPropertiesActionResult,
|
sendPropertiesActionResult,
|
||||||
sendPropertiesRemoved,
|
sendPropertiesRemoved,
|
||||||
sendPropertiesSnapshot,
|
sendPropertiesSnapshot,
|
||||||
@@ -46,7 +47,7 @@ import { getPlatformInfo } from '../utils/platform';
|
|||||||
import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle';
|
import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle';
|
||||||
import i18n, { localeDirection, resolveAppLocale } from '../i18n';
|
import i18n, { localeDirection, resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
const errorText = redactPropertiesError;
|
||||||
let lastPropertiesBridgeGeneration = 0;
|
let lastPropertiesBridgeGeneration = 0;
|
||||||
|
|
||||||
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
||||||
@@ -58,7 +59,10 @@ const normalizeOptionalSpeed = (value: unknown, label: string): string | undefin
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> => {
|
export const copyEditablePropertiesPatch = (
|
||||||
|
rawPatch: PropertiesPatch,
|
||||||
|
item?: Pick<DownloadItem, 'isTorrent' | 'status'>,
|
||||||
|
): Partial<DownloadItem> => {
|
||||||
const safePatch: Partial<DownloadItem> = {};
|
const safePatch: Partial<DownloadItem> = {};
|
||||||
const copy = (key: keyof PropertiesPatch) => {
|
const copy = (key: keyof PropertiesPatch) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
||||||
@@ -68,6 +72,7 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
|||||||
for (const key of [
|
for (const key of [
|
||||||
'fileName',
|
'fileName',
|
||||||
'destination',
|
'destination',
|
||||||
|
'sftpHostKeyMd',
|
||||||
'connections',
|
'connections',
|
||||||
'speedLimit',
|
'speedLimit',
|
||||||
'torrentTrackers',
|
'torrentTrackers',
|
||||||
@@ -88,6 +93,13 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
|||||||
'torrentFileAllocation',
|
'torrentFileAllocation',
|
||||||
] as const) copy(key);
|
] as const) copy(key);
|
||||||
|
|
||||||
|
if (item && (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawPatch, 'fileName')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(rawPatch, 'destination')) {
|
||||||
|
throw new Error('File identity and destination are read-only for this download state');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
|
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
|
||||||
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
||||||
const value = (rawPatch as Record<string, unknown>)[key];
|
const value = (rawPatch as Record<string, unknown>)[key];
|
||||||
@@ -383,7 +395,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) {
|
if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) {
|
||||||
throw new Error('Torrent file selection requires the dedicated selection action');
|
throw new Error('Torrent file selection requires the dedicated selection action');
|
||||||
}
|
}
|
||||||
const safePatch = copyEditablePatch(rawPatch);
|
const safePatch = copyEditablePropertiesPatch(rawPatch, item);
|
||||||
if ('password' in rawPatch) {
|
if ('password' in rawPatch) {
|
||||||
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
||||||
}
|
}
|
||||||
@@ -421,6 +433,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) {
|
if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) {
|
||||||
throw new Error('Generic connection settings are not available for Torrent downloads');
|
throw new Error('Generic connection settings are not available for Torrent downloads');
|
||||||
}
|
}
|
||||||
|
await assertCurrentAction(request);
|
||||||
await store.applyProperties(request.downloadId, safePatch);
|
await store.applyProperties(request.downloadId, safePatch);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -437,12 +450,14 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
|| selectedIndices.some(index => !Number.isInteger(index) || index < 1))) {
|
|| selectedIndices.some(index => !Number.isInteger(index) || index < 1))) {
|
||||||
throw new Error('Torrent file selection must contain at least one valid file');
|
throw new Error('Torrent file selection must contain at least one valid file');
|
||||||
}
|
}
|
||||||
|
await assertCurrentAction(request);
|
||||||
const selection = await invoke('set_torrent_file_selection', {
|
const selection = await invoke('set_torrent_file_selection', {
|
||||||
id: request.downloadId,
|
id: request.downloadId,
|
||||||
selected_indices: selectedIndices,
|
selected_indices: selectedIndices,
|
||||||
});
|
});
|
||||||
const selected = selection.files.filter(file => file.selected).map(file => file.index);
|
const selected = selection.files.filter(file => file.selected).map(file => file.index);
|
||||||
const allSelected = selection.files.length > 0 && selected.length === selection.files.length;
|
const allSelected = selection.files.length > 0 && selected.length === selection.files.length;
|
||||||
|
await assertCurrentAction(request);
|
||||||
store.updateDownload(request.downloadId, {
|
store.updateDownload(request.downloadId, {
|
||||||
torrentFileIndices: allSelected ? undefined : selected,
|
torrentFileIndices: allSelected ? undefined : selected,
|
||||||
});
|
});
|
||||||
@@ -484,29 +499,40 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
}
|
}
|
||||||
const previousVerifyOnly = item.torrentVerifyOnly;
|
const previousVerifyOnly = item.torrentVerifyOnly;
|
||||||
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
|
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
|
||||||
|
await assertCurrentAction(request);
|
||||||
store.updateDownload(request.downloadId, {
|
store.updateDownload(request.downloadId, {
|
||||||
torrentVerifyOnly: true,
|
torrentVerifyOnly: true,
|
||||||
torrentVerifyRestoreStatus: item.status,
|
torrentVerifyRestoreStatus: item.status,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
await assertCurrentAction(request);
|
||||||
await invoke('verify_torrent_data', { id: request.downloadId });
|
await invoke('verify_torrent_data', { id: request.downloadId });
|
||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
useDownloadStore.getState().updateDownload(request.downloadId, {
|
try {
|
||||||
torrentVerifyOnly: previousVerifyOnly,
|
await assertCurrentAction(request);
|
||||||
torrentVerifyRestoreStatus: previousRestoreStatus,
|
useDownloadStore.getState().updateDownload(request.downloadId, {
|
||||||
});
|
torrentVerifyOnly: previousVerifyOnly,
|
||||||
|
torrentVerifyRestoreStatus: previousRestoreStatus,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A newer Properties session owns the row now. Do not let a
|
||||||
|
// late verification failure roll back its marker.
|
||||||
|
}
|
||||||
throw verifyError;
|
throw verifyError;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'set-download-limit':
|
case 'set-download-limit':
|
||||||
|
await assertCurrentAction(request);
|
||||||
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;
|
||||||
case 'set-torrent-upload-limit':
|
case 'set-torrent-upload-limit':
|
||||||
|
await assertCurrentAction(request);
|
||||||
await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
||||||
break;
|
break;
|
||||||
case 'set-torrent-peer-options': {
|
case 'set-torrent-peer-options': {
|
||||||
if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options');
|
if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options');
|
||||||
|
await assertCurrentAction(request);
|
||||||
await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit);
|
await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -23,6 +23,7 @@
|
|||||||
--bg-input: 0 0% 100%;
|
--bg-input: 0 0% 100%;
|
||||||
--border-modal: 0 0% 85%;
|
--border-modal: 0 0% 85%;
|
||||||
--surface-raised: 0 0% 100%;
|
--surface-raised: 0 0% 100%;
|
||||||
|
--properties-header-surface: hsl(var(--bg-modal));
|
||||||
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
||||||
--surface-overlay: 0 0% 100%;
|
--surface-overlay: 0 0% 100%;
|
||||||
--shadow-color: 220 10% 20% / 0.1;
|
--shadow-color: 220 10% 20% / 0.1;
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
--bg-input: 0 0% 100%;
|
--bg-input: 0 0% 100%;
|
||||||
--border-modal: 0 0% 85%;
|
--border-modal: 0 0% 85%;
|
||||||
--surface-raised: 0 0% 100%;
|
--surface-raised: 0 0% 100%;
|
||||||
|
--properties-header-surface: hsl(var(--bg-modal));
|
||||||
--surface-overlay: 0 0% 100%;
|
--surface-overlay: 0 0% 100%;
|
||||||
--shadow-color: 220 10% 20% / 0.1;
|
--shadow-color: 220 10% 20% / 0.1;
|
||||||
--sidebar-shell-bg: 0 0% 92%;
|
--sidebar-shell-bg: 0 0% 92%;
|
||||||
@@ -99,6 +101,7 @@
|
|||||||
--text-muted: 0 0% 60%;
|
--text-muted: 0 0% 60%;
|
||||||
--bg-modal: 0 0% 13%;
|
--bg-modal: 0 0% 13%;
|
||||||
--bg-input: 0 0% 16%;
|
--bg-input: 0 0% 16%;
|
||||||
|
--properties-header-surface: hsl(0 0% 10%);
|
||||||
--shadow-color: 0 0% 0% / 0.30;
|
--shadow-color: 0 0% 0% / 0.30;
|
||||||
--status-completed: 136 62% 48%;
|
--status-completed: 136 62% 48%;
|
||||||
--status-paused: 0 0% 56%;
|
--status-paused: 0 0% 56%;
|
||||||
@@ -145,6 +148,7 @@
|
|||||||
--text-muted: 229 12% 66%;
|
--text-muted: 229 12% 66%;
|
||||||
--bg-modal: 232 14% 23%;
|
--bg-modal: 232 14% 23%;
|
||||||
--bg-input: 231 15% 20%;
|
--bg-input: 231 15% 20%;
|
||||||
|
--properties-header-surface: hsl(231 15% 17%);
|
||||||
--shadow-color: 231 20% 8% / 0.35;
|
--shadow-color: 231 20% 8% / 0.35;
|
||||||
--status-completed: 135 94% 65%;
|
--status-completed: 135 94% 65%;
|
||||||
--status-paused: 65 92% 76%;
|
--status-paused: 65 92% 76%;
|
||||||
@@ -191,6 +195,7 @@
|
|||||||
--text-muted: 218 17% 70%;
|
--text-muted: 218 17% 70%;
|
||||||
--bg-modal: 220 17% 27%;
|
--bg-modal: 220 17% 27%;
|
||||||
--bg-input: 220 16% 24%;
|
--bg-input: 220 16% 24%;
|
||||||
|
--properties-header-surface: hsl(220 16% 19%);
|
||||||
--shadow-color: 220 25% 10% / 0.34;
|
--shadow-color: 220 25% 10% / 0.34;
|
||||||
--status-completed: 92 28% 65%;
|
--status-completed: 92 28% 65%;
|
||||||
--status-paused: 40 71% 73%;
|
--status-paused: 40 71% 73%;
|
||||||
@@ -578,7 +583,6 @@ html[data-list-density="relaxed"] {
|
|||||||
|
|
||||||
.properties-window-shell {
|
.properties-window-shell {
|
||||||
--properties-body-surface: hsl(var(--main-bg));
|
--properties-body-surface: hsl(var(--main-bg));
|
||||||
--properties-header-surface: hsl(var(--bg-modal));
|
|
||||||
--properties-card-surface: hsl(var(--bg-input));
|
--properties-card-surface: hsl(var(--bg-input));
|
||||||
--properties-card-border: hsl(var(--border-modal));
|
--properties-card-border: hsl(var(--border-modal));
|
||||||
--properties-live-value: hsl(var(--properties-live-value-color));
|
--properties-live-value: hsl(var(--properties-live-value-color));
|
||||||
@@ -622,8 +626,7 @@ html[data-list-density="relaxed"] {
|
|||||||
.properties-window-header {
|
.properties-window-header {
|
||||||
background: var(--properties-header-surface);
|
background: var(--properties-header-surface);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 -1px 0 var(--properties-card-border),
|
inset 0 -1px 0 hsl(var(--text-primary) / 0.04);
|
||||||
0 8px 24px hsl(var(--shadow-color));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-hero-top {
|
.properties-window-hero-top {
|
||||||
|
|||||||
+3
-3
@@ -103,8 +103,8 @@ type CommandMap = {
|
|||||||
verify_torrent_data: { args: { id: string }; result: void };
|
verify_torrent_data: { args: { id: string }; result: void };
|
||||||
get_torrent_magnet_link: { args: { id: string }; result: string };
|
get_torrent_magnet_link: { args: { id: string }; result: string };
|
||||||
export_torrent_metadata: { args: { id: string; destination: string }; result: void };
|
export_torrent_metadata: { args: { id: string; destination: string }; result: void };
|
||||||
move_torrent_data: { args: { id: string; destination: string }; result: void };
|
move_torrent_data: { args: { id: string; destination: string; sessionId?: string }; result: void };
|
||||||
cancel_torrent_move_data: { args: { id: string }; result: void };
|
cancel_torrent_move_data: { args: { id: string; sessionId?: string }; result: void };
|
||||||
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
|
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
|
||||||
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
|
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
|
||||||
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
||||||
@@ -172,7 +172,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: { sessionId: string }; result: void };
|
properties_window_send_ready: { args: { sessionId: string }; result: void };
|
||||||
properties_window_reveal: { args: undefined; result: void };
|
properties_window_reveal: { args: { sessionId?: string }; result: void };
|
||||||
properties_window_send_action: { args: { sessionId: string; requestId: number; action: string; payload?: unknown }; result: void };
|
properties_window_send_action: { args: { sessionId: string; requestId: number; action: string; payload?: unknown }; result: void };
|
||||||
validate_properties_window_request: { args: { windowLabel: string; downloadId: string; sessionId: string; requestId?: number }; result: void };
|
validate_properties_window_request: { args: { windowLabel: string; downloadId: string; sessionId: string; requestId?: number }; result: void };
|
||||||
close_download_properties_window: { args: { id: string }; result: void };
|
close_download_properties_window: { args: { id: string }; result: void };
|
||||||
|
|||||||
+6
-3
@@ -101,9 +101,12 @@ const renderPropertiesApp = async () => {
|
|||||||
// reveal command as the normal child path.
|
// reveal command as the normal child path.
|
||||||
console.error('Failed to initialize the Properties window:', error);
|
console.error('Failed to initialize the Properties window:', error);
|
||||||
renderRoot(PropertiesStartupFailure);
|
renderRoot(PropertiesStartupFailure);
|
||||||
void invoke('properties_window_reveal').catch(revealError => {
|
const fallbackSessionId = crypto.randomUUID();
|
||||||
console.error('Failed to reveal the Properties startup error:', revealError);
|
void invoke('properties_window_send_ready', { sessionId: fallbackSessionId })
|
||||||
});
|
.then(() => invoke('properties_window_reveal', { sessionId: fallbackSessionId }))
|
||||||
|
.catch(revealError => {
|
||||||
|
console.error('Failed to reveal the Properties startup error:', revealError);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ import {
|
|||||||
propertiesTorrentPeerLimit,
|
propertiesTorrentPeerLimit,
|
||||||
propertiesWindowEventTarget,
|
propertiesWindowEventTarget,
|
||||||
resetPropertiesActionState,
|
resetPropertiesActionState,
|
||||||
|
redactPropertiesError,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
sendPropertiesSnapshot,
|
sendPropertiesSnapshot,
|
||||||
shouldAcceptPropertiesActionRequest,
|
shouldAcceptPropertiesActionRequest,
|
||||||
} from './propertiesBridge';
|
} from './propertiesBridge';
|
||||||
|
import { copyEditablePropertiesPatch } from './components/PropertiesWindowBridgeHost';
|
||||||
|
|
||||||
describe('Properties window bridge', () => {
|
describe('Properties window bridge', () => {
|
||||||
it('keeps optional override resets explicit across the JSON IPC boundary', () => {
|
it('keeps optional override resets explicit across the JSON IPC boundary', () => {
|
||||||
@@ -129,6 +131,16 @@ describe('Properties window bridge', () => {
|
|||||||
expect(snapshot).not.toHaveProperty('aria2ResolverMode');
|
expect(snapshot).not.toHaveProperty('aria2ResolverMode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redacts credentials from Properties errors at the renderer boundary', () => {
|
||||||
|
const error = redactPropertiesError(new Error(
|
||||||
|
'GET https://user:pa@ss@example.test/file?token=secret&x=1 Authorization: Bearer bearer-secret',
|
||||||
|
));
|
||||||
|
expect(error).not.toContain('pa@ss@example');
|
||||||
|
expect(error).not.toContain('token=secret');
|
||||||
|
expect(error).not.toContain('bearer-secret');
|
||||||
|
expect(error).toContain('[redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
it('projects the latest live telemetry without exposing secrets', () => {
|
it('projects the latest live telemetry without exposing secrets', () => {
|
||||||
const snapshot = sanitizePropertiesSnapshot({
|
const snapshot = sanitizePropertiesSnapshot({
|
||||||
id: 'torrent-1',
|
id: 'torrent-1',
|
||||||
@@ -353,11 +365,31 @@ describe('Properties window bridge', () => {
|
|||||||
expect(getPropertiesLifecycleAction('retrying')).toBe('pause');
|
expect(getPropertiesLifecycleAction('retrying')).toBe('pause');
|
||||||
expect(getPropertiesLifecycleAction('paused')).toBe('resume');
|
expect(getPropertiesLifecycleAction('paused')).toBe('resume');
|
||||||
expect(getPropertiesLifecycleAction('ready')).toBe('start');
|
expect(getPropertiesLifecycleAction('ready')).toBe('start');
|
||||||
expect(getPropertiesLifecycleAction('staged')).toBe('start');
|
expect(getPropertiesLifecycleAction('staged')).toBe('pause');
|
||||||
expect(getPropertiesLifecycleAction('failed')).toBe('retry');
|
expect(getPropertiesLifecycleAction('failed')).toBe('retry');
|
||||||
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves validated SFTP fingerprints and rejects identity edits after dispatch', () => {
|
||||||
|
expect(copyEditablePropertiesPatch({ sftpHostKeyMd: 'MD5=0123456789abcdef0123456789abcdef' }, {
|
||||||
|
isTorrent: false,
|
||||||
|
status: 'ready',
|
||||||
|
})).toMatchObject({ sftpHostKeyMd: 'md5=0123456789abcdef0123456789abcdef' });
|
||||||
|
|
||||||
|
expect(() => copyEditablePropertiesPatch({ fileName: 'renamed.bin' }, {
|
||||||
|
isTorrent: false,
|
||||||
|
status: 'completed',
|
||||||
|
})).toThrow('read-only');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ destination: '/new/path' }, {
|
||||||
|
isTorrent: true,
|
||||||
|
status: 'paused',
|
||||||
|
})).toThrow('read-only');
|
||||||
|
expect(() => copyEditablePropertiesPatch({ fileName: 'queued.bin' }, {
|
||||||
|
isTorrent: false,
|
||||||
|
status: 'queued',
|
||||||
|
})).toThrow('read-only');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
||||||
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
||||||
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
||||||
|
|||||||
+14
-1
@@ -190,6 +190,16 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
hasMirrors: boolean;
|
hasMirrors: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const redactPropertiesError = (error: unknown): string => {
|
||||||
|
const text = error instanceof Error ? error.message : String(error);
|
||||||
|
return text
|
||||||
|
.replace(/(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+/gi, '$1[redacted]')
|
||||||
|
.replace(/((?:cookie|set-cookie|proxy-authorization)\s*:\s*)[^\r\n]+/gi, '$1[redacted]')
|
||||||
|
.replace(/((?:https?|sftp|ftp):\/\/)[^\s]*@/gi, '$1[redacted]@')
|
||||||
|
.replace(/([?&](?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential|code)=)[^&#\s]*/gi, '$1[redacted]')
|
||||||
|
.replace(/\b(?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential)=\S+/gi, match => `${match.slice(0, match.indexOf('=') + 1)}[redacted]`);
|
||||||
|
};
|
||||||
|
|
||||||
export type SecretPatch =
|
export type SecretPatch =
|
||||||
| { kind: 'unchanged' }
|
| { kind: 'unchanged' }
|
||||||
| { kind: 'replace'; value: string }
|
| { kind: 'replace'; value: string }
|
||||||
@@ -252,7 +262,7 @@ export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
|||||||
export const getPropertiesLifecycleAction = (
|
export const getPropertiesLifecycleAction = (
|
||||||
status: DownloadStatus,
|
status: DownloadStatus,
|
||||||
): PropertiesLifecycleAction | null => {
|
): PropertiesLifecycleAction | null => {
|
||||||
if (status === 'ready' || status === 'staged') return 'start';
|
if (status === 'ready') return 'start';
|
||||||
if (canPauseDownload(status)) return 'pause';
|
if (canPauseDownload(status)) return 'pause';
|
||||||
if (status === 'paused') return 'resume';
|
if (status === 'paused') return 'resume';
|
||||||
if (status === 'failed') return 'retry';
|
if (status === 'failed') return 'retry';
|
||||||
@@ -388,6 +398,9 @@ const copyWithoutSecrets = (
|
|||||||
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
||||||
)),
|
)),
|
||||||
) as SafePropertiesFields;
|
) as SafePropertiesFields;
|
||||||
|
if (typeof safeItem.lastError === 'string') {
|
||||||
|
safeItem.lastError = redactPropertiesError(safeItem.lastError);
|
||||||
|
}
|
||||||
if (item.isTorrent === true) delete safeItem.connections;
|
if (item.isTorrent === true) delete safeItem.connections;
|
||||||
const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError);
|
const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -92,6 +92,49 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the authoritative destination carried by Torrent move completion', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'moving-torrent',
|
||||||
|
url: 'magnet:?xt=urn:btih:test',
|
||||||
|
fileName: 'data',
|
||||||
|
destination: '/old-root',
|
||||||
|
status: 'moving',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
isTorrent: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
useDownloadProgressStore.getState().setMoveProgress('moving-torrent', 0.8);
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-state']({ payload: {
|
||||||
|
id: 'moving-torrent',
|
||||||
|
status: 'paused',
|
||||||
|
error: 'stale pause from the previous lifecycle',
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads[0].status).toBe('moving');
|
||||||
|
expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBe(0.8);
|
||||||
|
|
||||||
|
handlers['download-state']({ payload: {
|
||||||
|
id: 'moving-torrent',
|
||||||
|
status: 'completed',
|
||||||
|
error: null,
|
||||||
|
destination: '/new-root',
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().downloads[0].destination).toBe('/new-root');
|
||||||
|
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||||
|
expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBeUndefined();
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Aria2 connection telemetry from the live progress event', async () => {
|
it('keeps Aria2 connection telemetry from the live progress event', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ const startDownloadListeners = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = payload.status as DownloadStatus;
|
const status = payload.status as DownloadStatus;
|
||||||
|
// A move terminal event carries its authoritative destination. Older
|
||||||
|
// lifecycle events do not, so they must not overwrite an active move or
|
||||||
|
// clear its progress while the native relocation still owns the row.
|
||||||
|
if (current.status === 'moving' && status !== 'moving' && payload.destination == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status !== 'moving') {
|
if (status !== 'moving') {
|
||||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||||
}
|
}
|
||||||
@@ -229,6 +235,9 @@ const startDownloadListeners = async () => {
|
|||||||
current.category
|
current.category
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (payload.destination && payload.destination !== current.destination) {
|
||||||
|
updates.destination = payload.destination;
|
||||||
|
}
|
||||||
if (status !== 'downloading' && status !== 'verifying') {
|
if (status !== 'downloading' && status !== 'verifying') {
|
||||||
updates.speed = '-';
|
updates.speed = '-';
|
||||||
updates.eta = '-';
|
updates.eta = '-';
|
||||||
|
|||||||
@@ -141,6 +141,28 @@ describe('useDownloadStore', () => {
|
|||||||
expect(fileName.endsWith('.mp4')).toBe(true);
|
expect(fileName.endsWith('.mp4')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects queued identity edits before invalidating their dispatch', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'queued-identity',
|
||||||
|
url: 'https://example.com/file',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
destination: '/tmp',
|
||||||
|
status: 'queued',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
}] as any[],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().applyProperties('queued-identity', {
|
||||||
|
fileName: 'renamed.bin',
|
||||||
|
})).rejects.toThrow('read-only');
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||||
|
'cancel_enqueue_generation',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the credential-required marker when the last secret is cleared', async () => {
|
it('keeps the credential-required marker when the last secret is cleared', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
@@ -1228,10 +1250,10 @@ describe('useDownloadStore', () => {
|
|||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-enqueues the edited values only after an obsolete queued dispatch is removed', async () => {
|
it('re-enqueues queued transfer edits only after an obsolete dispatch is removed', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
{ id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
{ id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false, speedLimit: '128K' },
|
||||||
] as any[],
|
] as any[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1245,7 +1267,7 @@ describe('useDownloadStore', () => {
|
|||||||
enqueueCount += 1;
|
enqueueCount += 1;
|
||||||
return (enqueueCount === 1
|
return (enqueueCount === 1
|
||||||
? firstEnqueue
|
? firstEnqueue
|
||||||
: Promise.resolve({ id: 'edited', filename: 'new.bin' })) as never;
|
: Promise.resolve({ id: 'edited', filename: 'old.bin' })) as never;
|
||||||
}
|
}
|
||||||
if (command === 'get_pending_order') return Promise.resolve(['edited']) as never;
|
if (command === 'get_pending_order') return Promise.resolve(['edited']) as never;
|
||||||
return Promise.resolve(undefined) as never;
|
return Promise.resolve(undefined) as never;
|
||||||
@@ -1253,7 +1275,7 @@ describe('useDownloadStore', () => {
|
|||||||
|
|
||||||
const start = useDownloadStore.getState().startQueue('MAIN');
|
const start = useDownloadStore.getState().startQueue('MAIN');
|
||||||
await vi.waitFor(() => expect(enqueueCount).toBe(1));
|
await vi.waitFor(() => expect(enqueueCount).toBe(1));
|
||||||
const update = useDownloadStore.getState().applyProperties('edited', { fileName: 'new.bin' });
|
const update = useDownloadStore.getState().applyProperties('edited', { speedLimit: '512K' });
|
||||||
resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' });
|
resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' });
|
||||||
|
|
||||||
await expect(update).resolves.toBeUndefined();
|
await expect(update).resolves.toBeUndefined();
|
||||||
@@ -1264,7 +1286,8 @@ describe('useDownloadStore', () => {
|
|||||||
{ queueId: 'MAIN' }
|
{ queueId: 'MAIN' }
|
||||||
);
|
);
|
||||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
fileName: 'new.bin',
|
fileName: 'old.bin',
|
||||||
|
speedLimit: '512K',
|
||||||
hasBeenDispatched: true,
|
hasBeenDispatched: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1060,23 +1060,53 @@ interface DownloadState {
|
|||||||
export const useDownloadStore = create<DownloadState>((set, get) => {
|
export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||||
const applyPropertiesInternal = async (id: string, updates: Partial<DownloadItem>): Promise<void> => {
|
const applyPropertiesInternal = async (id: string, updates: Partial<DownloadItem>): Promise<void> => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
|
const initialItem = get().downloads.find(download => download.id === id);
|
||||||
|
if (!initialItem) return;
|
||||||
|
// Reject immutable identity edits before invalidating a live queued
|
||||||
|
// dispatch. Validation after invalidation would cancel a legitimate
|
||||||
|
// enqueue even though no mutation was accepted.
|
||||||
|
if ((updates.fileName !== undefined || updates.destination !== undefined)
|
||||||
|
&& (initialItem.isTorrent === true || !['ready', 'staged'].includes(initialItem.status))) {
|
||||||
|
throw new Error(i18n.t($ => $.properties.identityReadOnly));
|
||||||
|
}
|
||||||
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
||||||
const state = get();
|
const state = get();
|
||||||
const item = state.downloads.find(d => d.id === id);
|
const item = state.downloads.find(d => d.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const previousItem = item;
|
const previousItem = item;
|
||||||
|
const previousPendingOrder = [...state.pendingOrder];
|
||||||
|
const wasBackendRegistered = state.backendRegisteredIds.has(id);
|
||||||
const commitProperties = async (): Promise<void> => {
|
const commitProperties = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await commitDownloadState();
|
await commitDownloadState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Do not leave a renderer-only Properties edit that will disappear on
|
// A queued item may already have been detached before persistence
|
||||||
// restart. Restore the prior row while retaining the native lifecycle
|
// failed. Restore both projections, then rebuild the backend lifecycle
|
||||||
// fencing already performed for this operation.
|
// when the previous row owned one; restoring only the React object
|
||||||
|
// would leave a visible queued row that can never dispatch.
|
||||||
set(current => ({
|
set(current => ({
|
||||||
downloads: current.downloads.map(download =>
|
downloads: current.downloads.map(download => download.id === id ? previousItem : download),
|
||||||
download.id === id ? previousItem : download
|
pendingOrder: previousPendingOrder,
|
||||||
)
|
backendRegisteredIds: new Set(
|
||||||
|
[...current.backendRegisteredIds].filter(registeredId => registeredId !== id)
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
if ((wasBackendRegistered || wasDispatching) && previousItem.status === 'queued') {
|
||||||
|
const restored = await dispatchItemInternal(id);
|
||||||
|
if (!restored) {
|
||||||
|
set(current => ({
|
||||||
|
downloads: current.downloads.map(download =>
|
||||||
|
download.id === id
|
||||||
|
? { ...previousItem, status: 'failed' as const, hasBeenDispatched: false, lastError: errorMessage(error) }
|
||||||
|
: download
|
||||||
|
),
|
||||||
|
pendingOrder: current.pendingOrder.filter(value => value !== id),
|
||||||
|
backendRegisteredIds: new Set(
|
||||||
|
[...current.backendRegisteredIds].filter(registeredId => registeredId !== id)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1100,6 +1130,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
&& normalizedUpdates.torrentRemoveUnselectedFile === false
|
&& normalizedUpdates.torrentRemoveUnselectedFile === false
|
||||||
&& item.torrentRemoveUnselectedFile !== false;
|
&& item.torrentRemoveUnselectedFile !== false;
|
||||||
|
|
||||||
|
if ((normalizedUpdates.fileName !== undefined || normalizedUpdates.destination !== undefined)
|
||||||
|
&& (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) {
|
||||||
|
throw new Error(i18n.t($ => $.properties.identityReadOnly));
|
||||||
|
}
|
||||||
|
|
||||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') {
|
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') {
|
||||||
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('download action policy', () => {
|
|||||||
{ status: 'completed' },
|
{ status: 'completed' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(counts).toEqual({ pause: 3, resume: 4 });
|
expect(counts).toEqual({ pause: 3, resume: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps large action badges compact without changing the accessible count', () => {
|
it('keeps large action badges compact without changing the accessible count', () => {
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export const countDownloadActions = (
|
|||||||
downloads: ReadonlyArray<{ status: DownloadStatus }>
|
downloads: ReadonlyArray<{ status: DownloadStatus }>
|
||||||
): DownloadActionCounts => downloads.reduce<DownloadActionCounts>((counts, download) => {
|
): DownloadActionCounts => downloads.reduce<DownloadActionCounts>((counts, download) => {
|
||||||
if (canPauseDownload(download.status)) counts.pause += 1;
|
if (canPauseDownload(download.status)) counts.pause += 1;
|
||||||
if (canStartDownload(download.status)) counts.resume += 1;
|
if (download.status === 'paused'
|
||||||
|
|| (canStartDownload(download.status) && !canPauseDownload(download.status))) {
|
||||||
|
counts.resume += 1;
|
||||||
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, { pause: 0, resume: 0 });
|
}, { pause: 0, resume: 0 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getPropertiesConnectionPresentation } from './propertiesPresentation';
|
import { getPropertiesConnectionPresentation, getPropertiesProgress } from './propertiesPresentation';
|
||||||
|
|
||||||
describe('Properties connection presentation', () => {
|
describe('Properties connection presentation', () => {
|
||||||
|
it('uses move progress instead of the completed download fraction during relocation', () => {
|
||||||
|
expect(getPropertiesProgress({
|
||||||
|
status: 'moving',
|
||||||
|
moveProgress: 0.42,
|
||||||
|
fraction: 1,
|
||||||
|
downloadedBytes: 100,
|
||||||
|
totalBytes: 100,
|
||||||
|
totalIsEstimate: false,
|
||||||
|
isMedia: false,
|
||||||
|
size: '100 B',
|
||||||
|
})).toBe(0.42);
|
||||||
|
expect(getPropertiesProgress({
|
||||||
|
status: 'moving',
|
||||||
|
moveProgress: 4,
|
||||||
|
fraction: 0,
|
||||||
|
isMedia: false,
|
||||||
|
})).toBe(1);
|
||||||
|
expect(getPropertiesProgress({
|
||||||
|
status: 'moving',
|
||||||
|
fraction: 1,
|
||||||
|
downloadedBytes: 100,
|
||||||
|
totalBytes: 100,
|
||||||
|
isMedia: false,
|
||||||
|
})).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps media concurrency out of the live header metrics', () => {
|
it('keeps media concurrency out of the live header metrics', () => {
|
||||||
expect(getPropertiesConnectionPresentation({
|
expect(getPropertiesConnectionPresentation({
|
||||||
isMedia: true,
|
isMedia: true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PropertiesSnapshot } from '../propertiesBridge';
|
import type { PropertiesSnapshot } from '../propertiesBridge';
|
||||||
|
import { resolveDownloadFraction } from './downloadProgress';
|
||||||
|
|
||||||
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
||||||
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
||||||
@@ -12,6 +13,12 @@ export type PropertiesConnectionPresentation = {
|
|||||||
|
|
||||||
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
||||||
|
|
||||||
|
export const getPropertiesProgress = (
|
||||||
|
snapshot: Pick<PropertiesSnapshot, 'status' | 'moveProgress' | 'fraction' | 'downloadedBytes' | 'totalBytes' | 'totalIsEstimate' | 'isMedia' | 'size'>,
|
||||||
|
): number => snapshot.status === 'moving'
|
||||||
|
? Math.max(0, Math.min(1, snapshot.moveProgress ?? 0))
|
||||||
|
: resolveDownloadFraction(snapshot);
|
||||||
|
|
||||||
export const getPropertiesConnectionPresentation = (
|
export const getPropertiesConnectionPresentation = (
|
||||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
||||||
): PropertiesConnectionPresentation => {
|
): PropertiesConnectionPresentation => {
|
||||||
|
|||||||
Reference in New Issue
Block a user