fix(downloads): harden recovery, replacement, and cleanup

- make media credential recovery durable and explicit across lifecycle entry points
- protect exact output replacement with platform-aware ownership, fingerprints, locks, and crash-safe quarantine
- permanently remove unfinished assets while preserving safe completed and Torrent cleanup
- add frontend, native, and localization regressions
This commit is contained in:
NimBold
2026-08-23 03:24:55 +03:30
parent 686bba97a9
commit d49c7bd292
33 changed files with 3242 additions and 192 deletions
+173 -21
View File
@@ -1678,17 +1678,33 @@ pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String, Ve
.map_err(|error| format!("failed to prepare ownership query: {error}"))?;
let rows = statement
.query_map([], |row| {
let primary_path: String = row.get(1)?;
let owned_paths = row
.get::<_, Option<String>>(2)?
.and_then(|paths| serde_json::from_str::<Vec<String>>(&paths).ok())
.filter(|paths| !paths.is_empty())
.unwrap_or_else(|| vec![primary_path.clone()]);
Ok((row.get(0)?, primary_path, owned_paths))
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.map_err(|error| format!("failed to query ownership data: {error}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read ownership data: {error}"))
let mut ownership = Vec::new();
for row in rows {
let (id, primary_path, encoded_paths) =
row.map_err(|error| format!("failed to read ownership data: {error}"))?;
let owned_paths = match encoded_paths {
Some(encoded) => {
let paths = serde_json::from_str::<Vec<String>>(&encoded).map_err(|error| {
format!("failed to decode owned paths for download '{id}': {error}")
})?;
if paths.is_empty() {
vec![primary_path.clone()]
} else {
paths
}
}
None => vec![primary_path.clone()],
};
ownership.push((id, primary_path, owned_paths));
}
Ok(ownership)
}
pub fn set_ownership_paths(
@@ -1697,7 +1713,17 @@ pub fn set_ownership_paths(
primary_path: &str,
paths: &[String],
) -> Result<(), String> {
set_ownership_paths_checked(connection, id, primary_path, paths, &[])
// The path collision check and both ownership writes must be one SQLite
// transaction. Otherwise two concurrent admissions can both observe an
// empty registry and claim the same output before either insert becomes
// visible to the other.
let transaction = connection
.unchecked_transaction()
.map_err(|error| format!("failed to begin download ownership transaction: {error}"))?;
set_ownership_paths_checked(&transaction, id, primary_path, paths, &[])?;
transaction
.commit()
.map_err(|error| format!("failed to commit download ownership transaction: {error}"))
}
fn set_ownership_paths_checked(
@@ -1718,21 +1744,38 @@ fn set_ownership_paths_checked(
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
let existing = statement
.query_map(params![id], |row| {
let primary: String = row.get(1)?;
let owned = row
.get::<_, Option<String>>(2)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| vec![primary.clone()]);
let removal = row
.get::<_, Option<String>>(3)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_default();
Ok((primary, owned, removal))
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
for row in existing {
let (existing_primary, owned, removal) =
let (existing_id, existing_primary, encoded_owned, encoded_removal) =
row.map_err(|error| format!("failed to read download ownership paths: {error}"))?;
let owned = match encoded_owned {
Some(encoded) => {
let paths = serde_json::from_str::<Vec<String>>(&encoded).map_err(|error| {
format!("failed to decode owned paths for download '{existing_id}': {error}")
})?;
if paths.is_empty() {
vec![existing_primary.clone()]
} else {
paths
}
}
None => vec![existing_primary.clone()],
};
let removal = match encoded_removal {
Some(encoded) => serde_json::from_str::<Vec<String>>(&encoded).map_err(|error| {
format!(
"failed to decode removal paths for download '{existing_id}': {error}"
)
})?,
None => Vec::new(),
};
let new_paths = std::iter::once(primary_path)
.chain(paths.iter().map(String::as_str))
.chain(removal_paths.iter().map(String::as_str));
@@ -1924,6 +1967,31 @@ pub fn load_torrent_removal_paths(
.map(|paths| paths.unwrap_or_default())
}
pub fn load_all_torrent_removal_paths(
connection: &Connection,
) -> Result<Vec<(String, Vec<String>)>, String> {
let mut statement = connection
.prepare("SELECT id, paths FROM download_removal_paths")
.map_err(|error| format!("failed to prepare torrent removal ownership query: {error}"))?;
let rows = statement
.query_map([], |row| {
let id: String = row.get(0)?;
let encoded: String = row.get(1)?;
Ok((id, encoded))
})
.map_err(|error| format!("failed to query torrent removal ownership: {error}"))?;
rows.map(|row| {
let (id, encoded) = row
.map_err(|error| format!("failed to read torrent removal ownership: {error}"))?;
let paths = serde_json::from_str::<Vec<String>>(&encoded).map_err(|error| {
format!("failed to decode torrent removal paths for download '{id}': {error}")
})?;
Ok((id, paths))
})
.collect()
}
pub fn has_user_data(connection: &Connection) -> Result<bool, String> {
connection
.query_row(
@@ -3471,6 +3539,90 @@ mod tests {
.unwrap();
}
#[test]
fn malformed_owned_path_json_fails_closed_for_loading_and_new_claims() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
connection
.execute(
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)",
params!["broken-owned", "/downloads/broken.bin"],
)
.unwrap();
connection
.execute(
"INSERT INTO download_owned_paths (id, paths) VALUES (?1, ?2)",
params!["broken-owned", "{not-json"],
)
.unwrap();
let error =
load_ownership(&connection).expect_err("malformed owned paths must not be ignored");
assert!(error.contains("broken-owned"));
let error = set_ownership_paths(
&connection,
"later",
"/downloads/later.bin",
&["/downloads/later.bin".to_string()],
)
.expect_err("new ownership claims must fail closed");
assert!(error.contains("broken-owned"));
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM download_ownership WHERE id = 'later'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
}
#[test]
fn malformed_removal_path_json_fails_closed_for_loading_and_new_claims() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
connection
.execute(
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)",
params!["broken-removal", "/downloads/broken.bin"],
)
.unwrap();
connection
.execute(
"INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)",
params!["broken-removal", "{not-json"],
)
.unwrap();
let error = load_all_torrent_removal_paths(&connection)
.expect_err("malformed removal paths must not be ignored");
assert!(error.contains("broken-removal"));
let error = set_ownership_paths(
&connection,
"later",
"/downloads/later.bin",
&["/downloads/later.bin".to_string()],
)
.expect_err("new ownership claims must fail closed");
assert!(error.contains("broken-removal"));
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM download_ownership WHERE id = 'later'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
}
#[test]
fn torrent_ownership_and_removal_reservation_commit_atomically() {
let temp = TempDir::new().unwrap();
+87 -9
View File
@@ -346,10 +346,27 @@ pub fn known_primary_paths<R: tauri::Runtime>(
})
.collect();
// One-time compatibility for downloads created before the backend-owned
// registry existed. This imports the exact persisted queue path only.
for path in legacy_download_queue_paths(app_handle)? {
if !paths.iter().any(|existing| existing == &path) {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
for path in removal_paths.into_iter().map(PathBuf::from) {
if !paths
.iter()
.any(|existing| crate::platform::paths_equal(existing, &path))
{
paths.push(path);
}
}
}
drop(connection);
// Compatibility for downloads created before the backend-owned registry
// existed. Import only the exact persisted queue paths.
for (_, path) in legacy_download_queue_path_records(app_handle)? {
if !paths
.iter()
.any(|existing| crate::platform::paths_equal(existing, &path))
{
paths.push(path);
}
}
@@ -357,6 +374,65 @@ pub fn known_primary_paths<R: tauri::Runtime>(
Ok(paths)
}
/// Return the Firelink download that owns an exact output path, if any.
///
/// This is intentionally based on the persisted ownership registry rather
/// than on the visible download list. The renderer can be stale while a
/// queued/native lifecycle is being admitted, so duplicate replacement must
/// make this decision at the native boundary.
pub fn owner_for_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
path: &Path,
) -> Result<Option<String>, String> {
let canonical = crate::canonicalize_with_missing_components(path)
.ok_or_else(|| "Download target could not be canonicalized".to_string())?;
let mut owners = Vec::new();
for record in load_records(app_handle)? {
let primary = PathBuf::from(&record.primary_path);
if crate::platform::paths_equal(&primary, &canonical)
|| record
.owned_paths
.iter()
.map(PathBuf::from)
.any(|owned| crate::platform::paths_equal(&owned, &canonical))
{
owners.push(record.id);
}
}
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
for (id, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
if removal_paths
.into_iter()
.map(PathBuf::from)
.any(|removal| crate::platform::paths_equal(&removal, &canonical))
&& !owners.contains(&id)
{
owners.push(id);
}
}
drop(connection);
// Older rows may predate the ownership registry. They still represent
// Firelink-owned targets and must not be downgraded to unmanaged disk
// files merely because their migration record is absent.
for (id, legacy_path) in legacy_download_queue_path_records(app_handle)? {
if crate::platform::paths_equal(&legacy_path, &canonical) && !owners.contains(&id) {
owners.push(id);
}
}
match owners.len() {
0 => Ok(None),
1 => Ok(owners.pop()),
_ => Err(format!(
"Download target is claimed by multiple Firelink downloads: {}",
owners.join(", ")
)),
}
}
fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<Vec<DownloadOwnershipRecord>, String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
@@ -372,9 +448,9 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
})
}
fn legacy_download_queue_paths<R: tauri::Runtime>(
fn legacy_download_queue_path_records<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
) -> Result<Vec<(String, PathBuf)>, String> {
let settings = crate::settings::load_settings(app_handle).ok();
let downloads = {
@@ -383,7 +459,7 @@ fn legacy_download_queue_paths<R: tauri::Runtime>(
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
};
let mut paths = Vec::new();
let mut paths: Vec<(String, PathBuf)> = Vec::new();
for download in downloads {
let category = format!("{:?}", download.category);
let mut destinations = Vec::new();
@@ -442,8 +518,10 @@ fn legacy_download_queue_paths<R: tauri::Runtime>(
for destination in destinations {
if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
if !paths.iter().any(|(id, existing)| {
id == &download.id && crate::platform::paths_equal(existing, &path)
}) {
paths.push((download.id.clone(), path));
}
}
}
+35
View File
@@ -153,6 +153,38 @@ pub enum DownloadErrorKind {
DestinationAccess,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadTargetKind {
Missing,
RegularFile,
Directory,
Symlink,
Special,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct DownloadTargetInfo {
pub kind: DownloadTargetKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub owned_by: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadAssetRemovalPolicy {
Trash,
PermanentIfUnfinished,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -222,6 +254,9 @@ pub struct DownloadItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub last_resolver_fallback: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub replace_existing_fingerprint: Option<String>,
#[ts(optional)]
pub last_try: Option<String>,
#[serde(default)]
+1801 -37
View File
File diff suppressed because it is too large Load Diff
+142 -13
View File
@@ -305,39 +305,115 @@ fn trusted_system_path_entries() -> Vec<PathBuf> {
pub fn path_is_within(path: &Path, root: &Path) -> bool {
#[cfg(target_os = "windows")]
{
let path = path.to_string_lossy().to_lowercase();
let root = root.to_string_lossy().to_lowercase();
let path = path_identity(path);
let root = path_identity(root);
path == root
|| (root.len() == 3
&& root.ends_with('/')
&& root.as_bytes()[1] == b':'
&& path.starts_with(&root))
|| path
.strip_prefix(&root)
.is_some_and(|suffix| suffix.starts_with(['\\', '/']))
.is_some_and(|suffix| suffix.starts_with('/'))
}
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "macos")]
{
// Containment is a scope check, not an equality check. Do not fold
// case here: case-sensitive APFS/HFS+ volumes are valid macOS
// configurations, and lowercasing could admit `/Users/nima2` or a
// differently-cased sibling outside the approved root. Callers pass
// canonical paths (with only missing leaf components preserved), so
// NFC normalization is enough to compare macOS path spellings.
use unicode_normalization::UnicodeNormalization;
let path = path.to_string_lossy().nfc().collect::<String>();
let root = root.to_string_lossy().nfc().collect::<String>();
let root = root.trim_end_matches('/');
if path == root || (root.is_empty() && path == "/") {
return true;
}
if root.is_empty() {
return path.starts_with('/');
}
path.strip_prefix(root)
.is_some_and(|suffix| suffix.starts_with('/'))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
path.starts_with(root)
}
#[cfg(not(any(unix, target_os = "windows", target_os = "macos")))]
{
path.starts_with(root)
}
}
pub fn paths_equal(left: &Path, right: &Path) -> bool {
path_identity(left) == path_identity(right)
}
/// Return the in-process lock identity for a path using the same platform
/// equivalence rules as `paths_equal`. Callers use this for serialization,
/// not for display or persistence.
pub fn path_identity(path: &Path) -> String {
#[cfg(target_os = "windows")]
{
left.to_string_lossy()
.to_lowercase()
== right.to_string_lossy().to_lowercase()
let mut normalized = path.to_string_lossy().replace('\\', "/");
if normalized
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/"))
{
normalized.replace_range(..8, "//");
} else if normalized
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/"))
{
normalized.replace_range(..4, "");
}
let is_unc = normalized.starts_with("//");
let mut collapsed = String::with_capacity(normalized.len());
for character in normalized.chars() {
if character == '/' && collapsed.ends_with('/') && !(is_unc && collapsed.len() == 1) {
continue;
}
collapsed.push(character);
}
while collapsed.len() > 1
&& collapsed.ends_with('/')
&& !(collapsed.len() == 3 && collapsed.as_bytes()[1] == b':')
{
collapsed.pop();
}
collapsed.to_lowercase()
}
#[cfg(target_os = "macos")]
{
use unicode_normalization::UnicodeNormalization;
let normalize = |path: &Path| {
path.to_string_lossy().to_lowercase().nfc().collect::<String>()
};
normalize(left) == normalize(right)
path.to_string_lossy()
.to_lowercase()
.nfc()
.collect::<String>()
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[cfg(all(unix, not(target_os = "macos")))]
{
left == right
use std::os::unix::ffi::OsStrExt;
path.as_os_str()
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(not(any(unix, target_os = "windows", target_os = "macos")))]
{
path.to_string_lossy().to_string()
}
}
@@ -362,6 +438,8 @@ fn numbered_windows_device(stem: &str, prefix: &str) -> bool {
#[cfg(test)]
mod tests {
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::path_is_within;
use super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple};
use std::path::Path;
@@ -406,6 +484,27 @@ mod tests {
}
}
#[cfg(target_os = "windows")]
#[test]
fn windows_path_identity_normalizes_separators_and_verbatim_prefixes() {
assert!(paths_equal(
Path::new(r"C:\downloads\file.bin"),
Path::new("c:/DOWNLOADS/file.bin")
));
assert!(paths_equal(
Path::new(r"C:\downloads\file.bin"),
Path::new(r"\\?\C:\downloads\file.bin")
));
assert!(paths_equal(
Path::new(r"\\server\share\file.bin"),
Path::new(r"\\?\UNC\server\share\file.bin")
));
assert!(path_is_within(
Path::new("c:/downloads/file.bin"),
Path::new(r"C:\downloads")
));
}
#[test]
fn path_identity_handles_non_ascii_case_differences() {
let left = Path::new("/downloads/Ärt/File.bin");
@@ -427,4 +526,34 @@ mod tests {
assert!(!paths_equal(composed, decomposed));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_path_is_within_preserves_scope_and_unicode_identity() {
assert!(path_is_within(
Path::new("/Downloads/cafe\u{301}/movie.bin"),
Path::new("/Downloads/café")
));
assert!(path_is_within(
Path::new("/Downloads/movie.bin"),
Path::new("/Downloads")
));
assert!(path_is_within(
Path::new("/Downloads"),
Path::new("/Downloads/")
));
assert!(path_is_within(Path::new("/"), Path::new("////")));
assert!(path_is_within(
Path::new("/Downloads/movie.bin"),
Path::new("/")
));
assert!(!path_is_within(
Path::new("/downloads/cafeteria/movie.bin"),
Path::new("/Downloads/café")
));
assert!(!path_is_within(
Path::new("/downloads/movie.bin"),
Path::new("/Downloads")
));
}
}
+190 -9
View File
@@ -2121,24 +2121,93 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
pub async fn commit_reserved_enqueue(
&self,
task: QueuedTask,
generation: u64,
previous_generation: Option<u64>,
) -> Result<(), String> {
self.commit_reserved_enqueue_with_finalizer(task, generation, previous_generation, || async {
Ok(())
})
.await
}
/// Commit an enqueue and its final durable admission marker as one
/// dispatcher-visible boundary. The task is placed in the pending list
/// before the finalizer runs, but the admission gate stays held so the
/// dispatcher cannot pop it until the finalizer succeeds. If the
/// finalizer fails, the task is removed before any worker can observe it.
pub async fn commit_reserved_enqueue_with_finalizer<F, Fut>(
&self,
mut task: QueuedTask,
generation: u64,
) -> Result<(), String> {
previous_generation: Option<u64>,
finalizer: F,
) -> Result<(), String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<(), String>>,
{
let id = task.id.clone();
let _admission_gate = self.admission_gate.lock().await;
if self.system_action_pending.load(Ordering::Acquire) {
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err("System action is already being performed".to_string());
}
let id = task.id.clone();
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
.get(&id)
.is_some_and(|cancelled| *cancelled >= generation)
if self
.registered_lifecycle_generation(&id)
.await
.is_none_or(|registered| registered != generation)
{
return Err("Download enqueue was superseded by a newer user action".to_string());
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err("Download enqueue reservation is no longer current".to_string());
}
{
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
.get(&id)
.is_some_and(|cancelled| *cancelled >= generation)
{
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err("Download enqueue was superseded by a newer user action".to_string());
}
}
task.lifecycle_generation = generation;
self.pending.lock().await.push_back(task);
if let Err(error) = finalizer().await {
let mut pending = self.pending.lock().await;
pending.retain(|candidate| {
!(candidate.id == id && candidate.lifecycle_generation == generation)
});
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err(error);
}
// Cancellation can arrive while the durable admission marker is being
// written. Recheck it before making the task visible to the rest of
// the lifecycle; the admission gate prevents a dispatcher or queue
// mutation from observing a half-committed replacement.
{
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
.get(&id)
.is_some_and(|cancelled| *cancelled >= generation)
{
let mut pending = self.pending.lock().await;
pending.retain(|candidate| {
!(candidate.id == id && candidate.lifecycle_generation == generation)
});
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err("Download enqueue was superseded by a newer user action".to_string());
}
}
self.emit_state(id, DownloadStatus::Queued);
self.notify.notify_one();
Ok(())
@@ -2152,7 +2221,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
) -> Result<(), String> {
let id = task.id.clone();
let previous_generation = self.reserve_enqueue_generation(&id, generation).await?;
if let Err(error) = self.commit_reserved_enqueue(task, generation).await {
if let Err(error) = self
.commit_reserved_enqueue(task, generation, previous_generation)
.await
{
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err(error);
@@ -3395,6 +3467,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Pop the next task, or None if empty.
pub async fn pop_front(&self) -> Option<QueuedTask> {
let _admission_gate = self.admission_gate.lock().await;
self.pending.lock().await.pop_front()
}
@@ -5789,6 +5862,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let queue_positions = pending
.iter()
@@ -5852,6 +5926,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
queue_id: &str,
target_index: usize,
) -> Vec<String> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let queue_positions = pending
.iter()
@@ -5880,6 +5955,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Does NOT release a permit (the caller handles active permits via
/// release_permit if the task was already dispatched).
pub async fn remove_from_pending(&self, id: &str) -> bool {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let before = pending.len();
pending.retain(|t| t.id != id);
@@ -5891,6 +5967,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
pub async fn remove_from_pending_for_generation(&self, id: &str, generation: u64) -> bool {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let before = pending.len();
pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation));
@@ -8556,6 +8633,9 @@ pub struct EnqueueItem {
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub replace_existing_fingerprint: Option<String>,
}
impl EnqueueItem {
@@ -8756,7 +8836,7 @@ mod tests {
release: Arc::clone(&release),
}),
));
manager
let previous_generation = manager
.reserve_enqueue_generation("allocation", 7)
.await
.expect("lifecycle reservation");
@@ -8770,6 +8850,7 @@ mod tests {
lifecycle_generation: 7,
},
7,
previous_generation,
)
.await
.expect("queued task");
@@ -8796,6 +8877,106 @@ mod tests {
dispatcher.abort();
}
#[tokio::test]
async fn enqueue_finalizer_failure_removes_pending_task_before_dispatch() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
let id = "finalizer-failure";
let generation = 3;
let previous_generation = manager
.reserve_enqueue_generation(id, generation)
.await
.expect("lifecycle reservation");
let error = manager
.commit_reserved_enqueue_with_finalizer(
QueuedTask {
id: id.to_string(),
queue_id: "main".to_string(),
kind: TaskKind::Aria2,
payload: SpawnPayload::default(),
lifecycle_generation: generation,
},
generation,
previous_generation,
|| async { Err("journal commit failed".to_string()) },
)
.await
.expect_err("a failed finalizer must reject admission");
assert_eq!(error, "journal commit failed");
assert!(manager.pending_order(None).await.is_empty());
assert_eq!(manager.registered_lifecycle_generation(id).await, None);
assert!(!manager.is_registered(id).await);
}
#[tokio::test]
async fn enqueue_cancellation_during_finalizer_rejects_admission() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::new(TestSpawner),
));
let started = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let finalizer_started = Arc::clone(&started);
let finalizer_release = Arc::clone(&release);
let id = "finalizer-cancelled".to_string();
let generation = 4;
let previous_generation = manager
.reserve_enqueue_generation(&id, generation)
.await
.expect("lifecycle reservation");
let commit_manager = Arc::clone(&manager);
let commit_id = id.clone();
let commit = tokio::spawn(async move {
commit_manager
.commit_reserved_enqueue_with_finalizer(
QueuedTask {
id: commit_id,
queue_id: "main".to_string(),
kind: TaskKind::Aria2,
payload: SpawnPayload::default(),
lifecycle_generation: generation,
},
generation,
previous_generation,
{
move || async move {
finalizer_started.notify_one();
finalizer_release.notified().await;
Ok(())
}
},
)
.await
});
tokio::time::timeout(Duration::from_secs(1), started.notified())
.await
.expect("finalizer should begin");
manager.cancel_enqueue_generation(&id, generation).await;
release.notify_one();
let error = tokio::time::timeout(Duration::from_secs(1), commit)
.await
.expect("enqueue should finish")
.expect("enqueue task should not panic")
.expect_err("cancellation must reject the in-flight admission");
assert_eq!(
error,
"Download enqueue was superseded by a newer user action"
);
assert!(manager.pending_order(None).await.is_empty());
assert!(!manager.is_registered(&id).await);
}
#[tokio::test]
async fn download_start_before_gid_registration_is_buffered_and_consumed() {
let app = tauri::test::mock_builder()
+9 -3
View File
@@ -433,7 +433,9 @@ async fn cancellation_between_reservation_and_commit_cannot_start_the_task() {
.expect("reservation should succeed");
mgr.cancel_enqueue_generation("a", 7).await;
let committed = mgr.commit_reserved_enqueue(sample_task("a"), 7).await;
let committed = mgr
.commit_reserved_enqueue(sample_task("a"), 7, previous)
.await;
assert!(committed.is_err(), "cancelled reservation must not commit");
mgr.rollback_enqueue_reservation("a", 7, previous).await;
@@ -1799,12 +1801,16 @@ async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() {
assert!(manager
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
.await);
manager
let previous = manager
.reserve_enqueue_generation("duplicate", 1)
.await
.unwrap();
manager
.commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1)
.commit_reserved_enqueue(
aria2_task_in_queue("duplicate", "queue-a"),
1,
previous,
)
.await
.unwrap();
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadAssetRemovalPolicy = "trash" | "permanentIfUnfinished";
+1 -1
View File
@@ -4,4 +4,4 @@ import type { DownloadErrorKind } from "./DownloadErrorKind";
import type { DownloadStatus } from "./DownloadStatus";
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, replaceExistingFingerprint?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DownloadTargetKind } from "./DownloadTargetKind";
export type DownloadTargetInfo = { kind: DownloadTargetKind, fingerprint?: string, ownedBy?: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadTargetKind = "missing" | "regularFile" | "directory" | "symlink" | "special";
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: string, };
+41 -10
View File
@@ -1281,27 +1281,44 @@ export const AddDownloadsModal = () => {
}
}
let fileExistsOnDisk = false;
let diskTargetKind: string | null = null;
let diskTargetFingerprint: string | undefined;
let diskTargetOwner: string | undefined;
try {
fileExistsOnDisk = await invoke('check_file_exists', {
const targetInfo = await invoke('inspect_download_target', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
diskTargetKind = targetInfo.kind;
diskTargetFingerprint = targetInfo.fingerprint;
diskTargetOwner = targetInfo.ownedBy;
} catch (e) {
console.error("Failed to check if file exists on disk:", e);
}
if (existingDownload || fileExistsOnDisk) {
const fileExistsOnDisk = diskTargetKind !== null && diskTargetKind !== 'missing';
const hasFirelinkOwnedTarget = Boolean(diskTargetOwner);
const diskReplaceAllowed = diskTargetKind === 'regularFile'
&& !diskTargetOwner
&& Boolean(diskTargetFingerprint);
const canReplaceExistingDownload = existingDownload
? !isTransferLocked(existingDownload.status)
&& (!diskTargetOwner || diskTargetOwner === existingDownload.id)
: false;
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: {
type: 'file',
msg: existingDownload
msg: existingDownload || hasFirelinkOwnedTarget
? t($ => $.addDownloads.existingDownloadDestination)
: t($ => $.addDownloads.fileExistsOnDisk)
},
resolution: 'rename',
replaceAllowed: Boolean(existingDownload),
replaceAllowed: existingDownload ? canReplaceExistingDownload : diskReplaceAllowed,
...(existingDownload ? {} : diskReplaceAllowed
? { replaceFingerprint: diskTargetFingerprint }
: {}),
existingDownloadId: existingDownload?.id
});
}
@@ -1332,7 +1349,11 @@ export const AddDownloadsModal = () => {
action: AddDownloadAction,
finalLocation: string,
useSharedDestination: boolean,
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
resolutions?: {
id: string;
resolution: 'rename' | 'replace' | 'skip';
replaceFingerprint?: string;
}[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
@@ -1351,6 +1372,7 @@ export const AddDownloadsModal = () => {
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
itemsToAdd[idx] = { ...item, replaceExistingFingerprint: undefined };
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
@@ -1404,9 +1426,10 @@ export const AddDownloadsModal = () => {
}
let diskHas = false;
try {
diskHas = await invoke('check_file_exists', {
const targetInfo = await invoke('inspect_download_target', {
path: await resolveDownloadFilePath(itemLocation, newName)
});
diskHas = targetInfo.kind !== 'missing';
} catch(e) {}
const batchHas = batchTargets.some(target => downloadLocationEquals(
target.location,
@@ -1422,7 +1445,7 @@ export const AddDownloadsModal = () => {
throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile }));
}
itemsToAdd[idx] = { ...item, file: newName };
itemsToAdd[idx] = { ...item, file: newName, replaceExistingFingerprint: undefined };
} else if (res.resolution === 'replace') {
if (!conflict?.replaceAllowed) {
const finalFile = item.isMedia
@@ -1469,7 +1492,14 @@ export const AddDownloadsModal = () => {
}
if (!existingItem) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: res.replaceFingerprint
};
continue;
}
const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia
@@ -1623,7 +1653,8 @@ export const AddDownloadsModal = () => {
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
: undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
sizeBytes: item.sizeBytes,
replaceExistingFingerprint: item.replaceExistingFingerprint
}, action);
if (!added) {
const rejected = useDownloadStore.getState().downloads.find(download => download.id === id);
+17 -2
View File
@@ -6,7 +6,7 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
export const DeleteConfirmationModal: React.FC = () => {
const { t } = useTranslation();
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
const modalRef = useModalFocus(deleteModalState.isOpen);
@@ -49,7 +49,12 @@ export const DeleteConfirmationModal: React.FC = () => {
const failures: string[] = [];
for (const id of ids) {
try {
await removeDownload(id, deleteFile);
await removeDownload(
id,
deleteFile,
false,
deleteFile ? 'permanentIfUnfinished' : undefined
);
succeeded += 1;
} catch (error) {
failures.push(String(error));
@@ -72,6 +77,11 @@ export const DeleteConfirmationModal: React.FC = () => {
const handleRemoveFromList = () => removeMany(false);
const handleDeleteFile = () => removeMany(true);
const itemCount = deleteModalState.downloadIds?.length ?? 0;
const selectedItems = (deleteModalState.downloadIds ?? [])
.map(id => downloads.find(download => download.id === id))
.filter(Boolean);
const hasCompletedSelection = selectedItems.some(item => item?.status === 'completed');
const hasUnfinishedSelection = selectedItems.some(item => item?.status !== 'completed');
return (
<div
@@ -101,6 +111,11 @@ export const DeleteConfirmationModal: React.FC = () => {
{itemCount > 1
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
: t($ => $.dialogs.removeDownload.confirmationSingle)}
{hasCompletedSelection && hasUnfinishedSelection && (
<div className="mt-3 text-xs text-amber-300" role="note">
{t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
</div>
)}
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
</div>
+10 -3
View File
@@ -239,7 +239,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const downloadStatusLabel = allocationVisible
? t($ => $.downloads.status.allocatingFiles)
: t($ => $.downloads.status[download.status]);
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
const visibleErrorStatusLabel = download.credentialsRequired === true
? t($ => $.properties.credentialsRequired)
: download.lastErrorKind === 'nameResolution'
? download.status === 'retrying' && download.lastResolverFallback === true
? t($ => $.downloads.errors.nameResolutionRetrying)
: download.status === 'failed'
@@ -357,6 +359,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed'
|| download.status === 'retrying'
|| download.lastErrorKind === 'destinationAccess'
|| download.credentialsRequired === true
)
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
@@ -468,10 +471,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
className="app-icon-button main-control-button"
title={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
? download.credentialsRequired === true
? t($ => $.properties.retryWithoutCredentials)
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
aria-label={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
? download.credentialsRequired === true
? t($ => $.properties.retryWithoutCredentials)
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
>
<Play size={14} fill="currentColor" />
+12 -2
View File
@@ -1931,7 +1931,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
if (ids.length === 0) return;
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
const credentialMarkedIds = selected
.filter(download => download.credentialsRequired === true)
.filter(download => download.credentialsRequired === true && canStartDownload(download.status))
.map(download => download.id);
if (credentialMarkedIds.length > 0
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
@@ -1954,7 +1954,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [showInteractionError, startSelected, t]);
const handleStartAll = useCallback(() => {
void startAll().catch(error => {
const credentialMarkedIds = useDownloadStore.getState().downloads
.filter(download =>
download.credentialsRequired === true
&& (download.status === 'queued' || canStartDownload(download.status))
)
.map(download => download.id);
const resumeWithoutCredentials = credentialMarkedIds.length > 0
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
void startAll({
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
}).catch(error => {
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
});
}, [showInteractionError, startAll, t]);
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
canReplaceAllDuplicateConflicts,
duplicateConflictCanReplace,
} from './DuplicateResolutionModal';
describe('duplicate replacement eligibility', () => {
it('exposes Replace for an eligible unmanaged regular-file conflict', () => {
expect(duplicateConflictCanReplace({ replaceAllowed: true })).toBe(true);
expect(duplicateConflictCanReplace({ replaceAllowed: false })).toBe(false);
expect(duplicateConflictCanReplace({})).toBe(false);
});
it('enables Replace all only when every conflict is eligible', () => {
expect(canReplaceAllDuplicateConflicts([{ replaceAllowed: true }])).toBe(true);
expect(canReplaceAllDuplicateConflicts([
{ replaceAllowed: true },
{ replaceAllowed: true },
])).toBe(true);
expect(canReplaceAllDuplicateConflicts([
{ replaceAllowed: true },
{ replaceAllowed: false },
])).toBe(false);
expect(canReplaceAllDuplicateConflicts([])).toBe(false);
});
});
+21 -6
View File
@@ -11,15 +11,28 @@ export interface DuplicateConflict {
reason: DuplicateReason;
resolution: DuplicateResolution;
replaceAllowed?: boolean;
replaceFingerprint?: string;
existingDownloadId?: string;
}
interface Props {
conflicts: DuplicateConflict[];
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
onConfirm: (resolutions: {
id: string;
resolution: DuplicateResolution;
replaceFingerprint?: string;
}[]) => void;
onCancel: () => void;
}
export const duplicateConflictCanReplace = (
conflict: Pick<DuplicateConflict, 'replaceAllowed'>
): boolean => conflict.replaceAllowed === true;
export const canReplaceAllDuplicateConflicts = (
conflicts: readonly Pick<DuplicateConflict, 'replaceAllowed'>[]
): boolean => conflicts.length > 0 && conflicts.every(duplicateConflictCanReplace);
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const { t } = useTranslation();
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
@@ -40,9 +53,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c));
};
const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict =>
conflict.replaceAllowed === true
);
const canReplaceAll = canReplaceAllDuplicateConflicts(conflicts);
const applyResolutionToAll = (resolution: DuplicateResolution) => {
if (resolution === 'replace' && !canReplaceAll) return;
@@ -113,7 +124,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
{duplicateConflictCanReplace(conflict) && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>
@@ -125,7 +136,11 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
{t($ => $.actions.cancel)}
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
onClick={() => onConfirm(conflicts.map(c => ({
id: c.id,
resolution: c.resolution,
...(c.replaceFingerprint ? { replaceFingerprint: c.replaceFingerprint } : {})
})))}
className="app-button app-button-primary px-5 text-xs"
>
{t($ => $.actions.continue)}
+9 -7
View File
@@ -1213,13 +1213,15 @@ export const PropertiesWindowApp = () => {
);
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
const lifecycleLabel = lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume'
? t($ => $.downloads.actions.resume)
: lifecycleAction === 'retry'
? t($ => $.downloads.actions.retry)
: t($ => $.downloads.actions.start);
const lifecycleLabel = snapshot.credentialsRequired === true
? t($ => $.properties.retryWithoutCredentials)
: lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume'
? t($ => $.downloads.actions.resume)
: lifecycleAction === 'retry'
? t($ => $.downloads.actions.retry)
: t($ => $.downloads.actions.start);
const tabLabel = (tab: PropertiesTab) => {
switch (tab) {
case 'overview': return t($ => $.properties.tabs.overview);
+14 -2
View File
@@ -7,11 +7,12 @@ import {
ChevronDown,
type LucideIcon
} from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { useDownloadStore, DownloadCategory, Queue, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
import { canStartDownload } from '../utils/downloadActions';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { useTranslation } from 'react-i18next';
@@ -524,8 +525,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const queueId = contextMenu.id;
const credentialMarkedIds = downloads
.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId
&& download.credentialsRequired === true
&& (download.status === 'queued' || canStartDownload(download.status))
)
.map(download => download.id);
const resumeWithoutCredentials = credentialMarkedIds.length > 0
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
setContextMenu(null);
void startQueue(queueId).catch(error => {
void startQueue(queueId, {
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
}).catch(error => {
addToast({
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
variant: 'error',
+2
View File
@@ -60,6 +60,7 @@ const common = {
title: 'Remove Download',
confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.',
confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.',
mixedRemovalPolicy: 'If you choose Delete File, unfinished files are permanently removed; completed files continue to use Trash.',
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
remove: 'Remove',
deleteFile: 'Delete file',
@@ -275,6 +276,7 @@ const common = {
editingUnavailable: 'These properties cannot be edited while the download is active.',
credentialsRequired: 'Credentials, cookies, or request headers from the previous session were not saved. Add them in Advanced, or confirm a retry without them.',
resumeWithoutCredentialsConfirm: 'This download used credentials, cookies, or request headers that are no longer available. Retry without them? If access is required, the server may reject the request.',
retryWithoutCredentials: 'Retry without saved credentials',
liveTorrentUploadLimit: 'Live Torrent upload limit',
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
+2
View File
@@ -60,6 +60,7 @@ const fa = {
title: 'حذف دانلود',
confirmationSingle: 'آیا مطمئن هستید که می‌خواهید این مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل اصلی را از هارد دیسک خود حذف کنید.',
confirmationMultiple: 'آیا مطمئن هستید که می‌خواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین می‌توانید فایل‌های اصلی را از هارد دیسک خود حذف کنید.',
mixedRemovalPolicy: 'اگر «حذف فایل» را انتخاب کنید، فایل‌های ناتمام برای همیشه حذف می‌شوند؛ فایل‌های کامل‌شده همچنان به سطل زباله می‌روند.',
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
remove: 'حذف',
deleteFile: 'حذف فایل',
@@ -275,6 +276,7 @@ const fa = {
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگی‌ها ممکن نیست.',
credentialsRequired: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های درخواستِ نشست قبلی ذخیره نشده‌اند. آن‌ها را در بخش پیشرفته وارد کنید یا ادامه‌دادن بدون آن‌ها را تأیید کنید.',
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های این دانلود دیگر در دسترس نیستند. دانلود بدون آن‌ها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیره‌شده',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
+2
View File
@@ -60,6 +60,7 @@ const he = {
title: 'הסרת הורדה',
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
mixedRemovalPolicy: 'אם בוחרים ב״מחיקת קובץ״, קבצים שלא הסתיימו יימחקו לצמיתות; קבצים שהושלמו ימשיכו לעבור לאשפה.',
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
remove: 'הסרה',
deleteFile: 'מחיקת קובץ',
@@ -275,6 +276,7 @@ const he = {
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
+2
View File
@@ -60,6 +60,7 @@ const ru = {
title: 'Удалить загрузку',
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
mixedRemovalPolicy: 'Если выбрать «Удалить файл», незавершённые файлы будут удалены навсегда, а завершённые по-прежнему отправятся в корзину.',
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
remove: 'Удалить',
deleteFile: 'Удалить файл',
@@ -275,6 +276,7 @@ const ru = {
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
+2
View File
@@ -60,6 +60,7 @@ const uk = {
title: 'Видалити завантаження',
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
mixedRemovalPolicy: 'Якщо вибрати «Видалити файл», незавершені файли буде видалено назавжди, а завершені й надалі переміщуватимуться до кошика.',
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
remove: 'Видалити',
deleteFile: 'Видалити файл',
@@ -275,6 +276,7 @@ const uk = {
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
retryWithoutCredentials: 'Повторити без збережених даних для входу',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
+2
View File
@@ -60,6 +60,7 @@ const zhCN = {
title: '移除下载',
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
mixedRemovalPolicy: '如果选择“删除文件”,未完成的文件将永久删除;已完成的文件仍会移入废纸篓。',
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
remove: '移除',
deleteFile: '删除文件',
@@ -275,6 +276,7 @@ const zhCN = {
editingUnavailable: '下载进行时无法编辑这些属性。',
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
retryWithoutCredentials: '不使用已保存凭据重试',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
+4 -1
View File
@@ -17,6 +17,8 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
import type { DownloadAssetRemovalPolicy } from './bindings/DownloadAssetRemovalPolicy';
import type { DownloadTargetInfo } from './bindings/DownloadTargetInfo';
import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
import type { TorrentMetadata } from './bindings/TorrentMetadata';
@@ -76,6 +78,7 @@ type CommandMap = {
deleteAssets: boolean;
preserveResumable?: boolean;
expectedLifecycleGeneration?: string;
assetRemovalPolicy?: DownloadAssetRemovalPolicy;
};
result: void;
};
@@ -131,7 +134,7 @@ type CommandMap = {
result: void;
};
delete_site_login: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
inspect_download_target: { args: { path: string }; result: DownloadTargetInfo };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
get_extension_server_port: { args: undefined; result: number | null };
+13 -1
View File
@@ -5,8 +5,10 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
import { listenEvent as listen } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { canStartDownload } from '../utils/downloadActions';
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
import { useDownloadProgressStore } from './downloadProgressStore';
import i18n from '../i18n';
import {
clearDownloadControlIntent,
@@ -531,7 +533,17 @@ const startDownloadListeners = async () => {
if (event.payload === 'pause-all') {
void mainStore.pauseAll();
} else if (event.payload === 'resume-all') {
void mainStore.startAll();
const credentialMarkedIds = mainStore.downloads
.filter(download =>
download.credentialsRequired === true
&& (download.status === 'queued' || canStartDownload(download.status))
)
.map(download => download.id);
const resumeWithoutCredentials = credentialMarkedIds.length > 0
&& window.confirm(i18n.t($ => $.properties.resumeWithoutCredentialsConfirm));
void mainStore.startAll({
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
});
}
}),
]);
+331 -1
View File
@@ -2555,6 +2555,15 @@ describe('useDownloadStore', () => {
});
it('explicitly requeues a credential-marked download without saved credentials', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
siteLogins: [{
id: 'example-login',
urlPattern: 'example.com',
username: 'alice',
}],
keychainAccessReady: true,
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
id: 'credentialless-resume',
@@ -2566,6 +2575,7 @@ describe('useDownloadStore', () => {
dateAdded: '',
credentialsRequired: true,
hasBeenDispatched: true,
headers: 'Referer: https://example.com/page?session=secret#part\nAuthorization: Bearer secret\nUser-Agent: Browser',
}] as any[],
backendRegisteredIds: new Set(['credentialless-resume'])
});
@@ -2588,7 +2598,7 @@ describe('useDownloadStore', () => {
username: null,
password: null,
cookies: null,
headers: null,
headers: 'Referer: https://example.com/page\nUser-Agent: Browser',
})
})
);
@@ -2598,6 +2608,303 @@ describe('useDownloadStore', () => {
});
});
it('replaces a stale registered queued lifecycle during credentialless retry', async () => {
useDownloadStore.setState({
downloads: [{
id: 'credentialless-queued-lifecycle',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'queued',
category: 'Other',
dateAdded: '',
credentialsRequired: true,
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
}] as any[],
backendRegisteredIds: new Set(['credentialless-queued-lifecycle'])
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'enqueue_download') {
return { id: 'credentialless-queued-lifecycle', filename: 'file.bin' };
}
if (command === 'get_pending_order') return [];
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('credentialless-queued-lifecycle', {
resumeWithoutCredentials: true
})).resolves.toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'credentialless-queued-lifecycle' }
);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
password: null,
cookies: null,
headers: 'User-Agent: Browser',
})
})
);
});
it('keeps credential recovery available when credentialless detach fails', async () => {
useDownloadStore.setState({
downloads: [{
id: 'credentialless-detach-failure',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
credentialsRequired: true,
username: 'alice',
password: 'secret',
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
}] as any[],
backendRegisteredIds: new Set(['credentialless-detach-failure'])
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'detach_download_for_reconfigure') {
throw new Error('detach unavailable');
}
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('credentialless-detach-failure', {
resumeWithoutCredentials: true
})).resolves.toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
credentialsRequired: true,
username: undefined,
password: undefined,
headers: 'User-Agent: Browser',
});
});
it('uses the configured media browser-cookie source during startup recovery', async () => {
const disposePersistence = initializeDownloadPersistence('main');
const id = 'startup-media-browser-cookies';
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: 'chrome'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
id,
url: 'https://www.youtube.com/watch?v=browser-cookie-source',
fileName: 'video.mp4',
destination: '/tmp',
status: 'queued',
category: 'Movies',
dateAdded: '',
isMedia: true,
credentialsRequired: true,
hasBeenDispatched: true,
queueId: MAIN_QUEUE_ID,
}] as any[],
pendingOrder: [id],
});
let enqueuedItems: Array<Record<string, unknown>> = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_many') {
enqueuedItems = (args as { items: Array<Record<string, unknown>> }).items;
return [{ id, success: true, filename: 'video.mp4' }];
}
if (command === 'get_pending_order') return [id];
return undefined;
});
try {
await useDownloadStore.getState().resumePendingDownloads();
expect(enqueuedItems).toHaveLength(1);
expect(enqueuedItems[0]).toMatchObject({
id,
is_media: true,
cookie_source: 'chrome',
});
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
} finally {
disposePersistence();
}
});
it('durably pauses startup media rows when no recoverable credential source exists', async () => {
const disposePersistence = initializeDownloadPersistence('main');
const id = 'startup-media-credential-block';
const persistedSnapshots: Array<Array<{ id: string; status: string }>> = [];
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: 'none',
proxyMode: 'system'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [{
id,
url: 'https://www.youtube.com/watch?v=missing-cookie-source',
fileName: 'video.mp4',
destination: '/tmp',
status: 'queued',
category: 'Movies',
dateAdded: '',
isMedia: true,
credentialsRequired: true,
hasBeenDispatched: true,
queueId: MAIN_QUEUE_ID,
}] as any[],
pendingOrder: [id],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'get_system_proxy') throw new Error('proxy unavailable');
if (command === 'db_commit_download_state') {
const downloads = JSON.parse((args as { downloadsData: string }).downloadsData) as Array<{
id: string;
status: string;
}>;
persistedSnapshots.push(downloads);
return undefined;
}
return undefined;
});
try {
await useDownloadStore.getState().resumePendingDownloads();
await flushDownloadPersistence();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_system_proxy', expect.anything());
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
id,
status: 'paused',
credentialsRequired: true,
});
expect(useDownloadStore.getState().pendingOrder).not.toContain(id);
expect(persistedSnapshots.some(snapshot => snapshot.some(item =>
item.id === id && item.status === 'paused'
))).toBe(true);
} finally {
disposePersistence();
}
});
it('treats an invalid media-cookie source as unavailable during recovery', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: undefined
} as unknown as ReturnType<typeof useSettingsStore.getState>);
const id = 'invalid-media-cookie-source';
useDownloadStore.setState({
downloads: [{
id,
url: 'https://www.youtube.com/watch?v=invalid-cookie-source',
fileName: 'video.mp4',
status: 'paused',
category: 'Movies',
dateAdded: '',
isMedia: true,
credentialsRequired: true,
hasBeenDispatched: true,
}] as any[],
backendRegisteredIds: new Set([id])
});
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
credentialsRequired: true
});
});
it('applies one explicit credentialless approval to queue and global starts', async () => {
const ids = ['queue-recovery-approved', 'global-recovery-approved'];
useDownloadStore.setState({
downloads: ids.map((id, index) => ({
id,
url: `https://www.youtube.com/watch?v=${id}`,
fileName: `${id}.mp4`,
destination: '/tmp',
status: 'paused',
category: 'Movies',
dateAdded: '',
isMedia: true,
credentialsRequired: true,
hasBeenDispatched: true,
queueId: `recovery-queue-${index}`,
})) as any[],
queues: [
{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true },
{ id: 'recovery-queue-0', name: 'Queue recovery', isMain: false },
{ id: 'recovery-queue-1', name: 'Global recovery', isMain: false },
],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_download') {
const item = (args as { item: { id: string; password: string | null; cookies: string | null; headers: string | null } }).item;
return { id: item.id, filename: item.id };
}
if (command === 'get_pending_order') return [];
return undefined;
});
await expect(useDownloadStore.getState().startQueue('recovery-queue-0', {
resumeWithoutCredentialsIds: [ids[0]]
})).resolves.toEqual([ids[0]]);
useDownloadStore.getState().updateDownload(ids[0], { status: 'completed' });
await expect(useDownloadStore.getState().startAll({
resumeWithoutCredentialsIds: [ids[1]]
})).resolves.toBe(1);
const enqueuedItems = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'enqueue_download')
.map(([, args]) => (args as { item: Record<string, unknown> }).item);
expect(enqueuedItems).toHaveLength(2);
expect(enqueuedItems).toEqual(expect.arrayContaining([
expect.objectContaining({ id: ids[0], password: null, cookies: null, headers: null }),
expect.objectContaining({ id: ids[1], password: null, cookies: null, headers: null }),
]));
expect(useDownloadStore.getState().downloads.every(item => item.credentialsRequired === false)).toBe(true);
});
it('does not let a media browser-cookie setting bypass normal-download credential recovery', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
mediaCookieSource: 'chrome'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
const id = 'normal-download-credential-isolation';
useDownloadStore.setState({
downloads: [{
id,
url: 'https://example.com/private.bin',
fileName: 'private.bin',
status: 'paused',
category: 'Other',
dateAdded: '',
credentialsRequired: true,
hasBeenDispatched: true,
queueId: MAIN_QUEUE_ID,
}] as any[],
backendRegisteredIds: new Set([id]),
});
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
credentialsRequired: true,
});
});
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
@@ -3521,6 +3828,29 @@ describe('useDownloadStore', () => {
});
});
it('passes permanent-if-unfinished only for the user Delete File action', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'unfinished-delete', url: 'https://example.com/file', fileName: 'file', status: 'paused', category: 'Other', dateAdded: '' }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().removeDownload(
'unfinished-delete',
true,
false,
'permanentIfUnfinished'
);
expect(ipc.invokeCommand).toHaveBeenCalledWith('remove_download', {
id: 'unfinished-delete',
deleteAssets: true,
preserveResumable: false,
assetRemovalPolicy: 'permanentIfUnfinished'
});
});
it('starts staged queue items in their persisted queue order', async () => {
useDownloadStore.setState({
downloads: [
+264 -63
View File
@@ -5,12 +5,13 @@ import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { DownloadAssetRemovalPolicy } from '../bindings/DownloadAssetRemovalPolicy';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -52,6 +53,10 @@ export interface StartSelectedOptions {
resumeWithoutCredentialsIds?: readonly string[];
}
export interface StartQueueOptions {
resumeWithoutCredentialsIds?: readonly string[];
}
// State events do not carry a lifecycle generation. Keep the intent that
// initiated a control transition long enough for the listener to discard an
// already-emitted event from the previous transition.
@@ -126,6 +131,37 @@ const credentialsRequiredMessage = (): string =>
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
const RECOVERABLE_MEDIA_COOKIE_SOURCES = new Set([
'safari',
'chrome',
'chromium',
'firefox',
'edge',
'brave',
'opera',
'vivaldi',
'whale'
]);
const hasConfiguredMediaCookieSource = (
item: Pick<DownloadItem, 'isMedia'>,
settings: Pick<ReturnType<typeof useSettingsStore.getState>, 'mediaCookieSource'>
): boolean => item.isMedia === true
&& typeof settings.mediaCookieSource === 'string'
&& RECOVERABLE_MEDIA_COOKIE_SOURCES.has(settings.mediaCookieSource);
const credentialsNeedRecovery = (
item: Pick<DownloadItem, 'isTorrent' | 'isMedia' | 'credentialsRequired' | 'password' | 'cookies' | 'headers'>,
settings: Pick<ReturnType<typeof useSettingsStore.getState>, 'mediaCookieSource'>,
keychainPassword?: string | null
): boolean => item.isTorrent !== true
&& item.credentialsRequired === true
&& !hasCredentialMaterial(item.password)
&& !hasCredentialMaterial(item.cookies)
&& !hasCredentialBearingHeaders(item.headers)
&& !hasCredentialMaterial(keychainPassword)
&& !hasConfiguredMediaCookieSource(item, settings);
const markCredentialsRequired = (id: string): void => {
useDownloadStore.getState().updateDownload(id, {
status: 'paused',
@@ -152,6 +188,10 @@ const advanceQueueControlGeneration = (queueId: string): number => {
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
currentQueueControlGeneration(queueId) === generation;
type DispatchOptions = {
withoutSavedCredentials?: boolean;
};
const comparableQueuePosition = (download: DownloadItem): number => {
const position = download.queuePosition;
return typeof position === 'number' && Number.isFinite(position) && position >= 0
@@ -330,13 +370,18 @@ const speedLimitForDispatch = (
return normalizeSpeedLimitForBackend(globalSpeedLimit);
};
async function dispatchItemInternal(id: string, proxyOverride?: string | null): Promise<boolean> {
async function dispatchItemInternal(
id: string,
proxyOverride?: string | null,
options: DispatchOptions = {}
): Promise<boolean> {
await waitForPendingStartupResume();
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
const promise = (async () => {
let lifecycleGeneration: bigint | null = null;
let backendAccepted = false;
const withoutSavedCredentials = options.withoutSavedCredentials === true;
try {
const state = useDownloadStore.getState();
const item = state.downloads.find(d => d.id === id);
@@ -350,7 +395,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
await resolveCategoryDestination(settings, item.category);
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
const login = withoutSavedCredentials || item.isTorrent === true
? null
: getSiteLogin(item.url, settings);
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
return false;
@@ -365,12 +412,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
}
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
if (item.isTorrent !== true && item.credentialsRequired === true
&& !hasCredentialMaterial(item.password)
&& !hasCredentialMaterial(item.cookies)
&& !hasCredentialBearingHeaders(item.headers)
&& !hasCredentialMaterial(keychainPassword)) {
if (!withoutSavedCredentials && credentialsNeedRecovery(item, settings, keychainPassword)) {
markCredentialsRequired(id);
await commitDownloadState();
return false;
}
if (item.credentialsRequired === true) clearCredentialsRequired(id);
@@ -390,12 +434,18 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
? null
: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
password: item.isTorrent === true ? null : item.password || keychainPassword,
username: withoutSavedCredentials || item.isTorrent === true
? null
: item.username || (login ? login.username : null),
password: withoutSavedCredentials || item.isTorrent === true
? null
: item.password || keychainPassword,
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
headers: item.isTorrent === true ? null : item.headers || null,
checksum: item.checksum || null,
cookies: item.isTorrent === true ? null : item.cookies || null,
cookies: withoutSavedCredentials || item.isTorrent === true
? null
: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
@@ -404,7 +454,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
proxy,
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
cookie_source: item.isMedia === true && hasConfiguredMediaCookieSource(item, settings)
? settings.mediaCookieSource
: null,
is_media: item.isMedia || false,
is_torrent: item.isTorrent || false,
torrent_path: item.torrentPath || undefined,
@@ -430,6 +482,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
replace_existing_fingerprint: item.replaceExistingFingerprint || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -473,7 +526,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
useDownloadStore.getState().registerBackendIds([id]);
useDownloadStore.getState().updateDownload(id, {
lastError: undefined,
lastErrorKind: undefined
lastErrorKind: undefined,
replaceExistingFingerprint: undefined
});
return true;
} catch (e) {
@@ -1097,14 +1151,19 @@ interface DownloadState {
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
removeDownload: (
id: string,
deleteFile?: boolean,
preserveResumable?: boolean,
assetRemovalPolicy?: DownloadAssetRemovalPolicy
) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
startSelected: (ids: string[], options?: StartSelectedOptions) => Promise<number>;
startQueue: (queueId: string) => Promise<string[]>;
startQueue: (queueId: string, options?: StartQueueOptions) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>;
startAll: (options?: StartQueueOptions) => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
@@ -1292,18 +1351,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
username: undefined,
password: undefined,
cookies: undefined,
headers: undefined,
headers: headersWithoutCredentialMaterial(targetItem.headers),
});
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem) return false;
}
if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true
&& !hasCredentialMaterial(targetItem.password)
&& !hasCredentialMaterial(targetItem.cookies)
&& !hasCredentialBearingHeaders(targetItem.headers)) {
const settings = useSettingsStore.getState();
if (credentialsNeedRecovery(targetItem, settings)) {
if (!resumeWithoutCredentials) {
const settings = useSettingsStore.getState();
const login = getSiteLogin(targetItem.url, settings);
let keychainPassword: string | null = null;
if (login && settings.keychainAccessReady) {
@@ -1313,15 +1369,21 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
console.warn('Could not fetch keychain password for resume:', error);
}
}
if (!hasCredentialMaterial(keychainPassword)) {
if (credentialsNeedRecovery(targetItem, settings, keychainPassword)) {
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
}
markCredentialsRequired(id);
await commitDownloadState();
return false;
}
// A normal resume has now proved that a configured credential source
// is available. Clear the durable marker before accepting the
// existing lifecycle. Explicit credentialless retries defer this
// until fresh admission succeeds so a detach/enqueue failure leaves
// the recovery action available.
clearCredentialsRequired(id);
}
clearCredentialsRequired(id);
} else if (targetItem.isTorrent === true && targetItem.credentialsRequired === true) {
clearCredentialsRequired(id);
}
@@ -1336,7 +1398,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const { pendingDispatch } = await invalidateDispatch(id, true);
if (pendingDispatch) await pendingDispatch;
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem || !canStartDownload(targetItem.status)) {
if (
!targetItem
|| (!canStartDownload(targetItem.status)
&& !(resumeWithoutCredentials && targetItem.status === 'queued'))
) {
clearDownloadControlIntent(id, 'resume');
return false;
}
@@ -1347,8 +1413,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (
forceRequeue &&
currentTargetItem.status === 'paused' &&
get().backendRegisteredIds.has(id)
get().backendRegisteredIds.has(id) &&
(currentTargetItem.status === 'paused' || resumeWithoutCredentials)
) {
await invoke('detach_download_for_reconfigure', { id });
get().unregisterBackendIds([id]);
@@ -1377,7 +1443,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
clearDownloadControlIntent(id, 'resume');
return false;
}
if (await dispatchItemInternal(id)) {
if (await dispatchItemInternal(
id,
undefined,
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
)) {
return true;
}
get().updateDownload(id, { status: currentTargetItem.status });
@@ -1433,7 +1503,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// lifecycle. Advance and cancel the old generation before dispatching
// so QueueManager does not reject the legitimate user retry as stale.
await invalidateAndWaitForDispatch(id, true);
dispatchSucceeded = await dispatchItemInternal(id);
dispatchSucceeded = await dispatchItemInternal(
id,
undefined,
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
);
}
if (dispatchSucceeded) {
@@ -1972,9 +2046,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
syncSystemIntegrations();
}
},
removeDownload: (id, deleteFile = false, preserveResumable = false) => runDownloadLifecycleOperation(
removeDownload: (id, deleteFile = false, preserveResumable = false, assetRemovalPolicy) => runDownloadLifecycleOperation(
id,
`remove:${deleteFile}:${preserveResumable}`,
`remove:${deleteFile}:${preserveResumable}:${assetRemovalPolicy ?? 'default'}`,
async () => {
await waitForPendingStartupResume();
clearDownloadControlIntent(id);
@@ -1988,7 +2062,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
await invoke('remove_download', {
id,
deleteAssets: deleteFile,
preserveResumable
preserveResumable,
...(assetRemovalPolicy ? { assetRemovalPolicy } : {})
});
}
@@ -2202,7 +2277,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
return startedCount;
});
},
startQueue: (queueId) => {
startQueue: (queueId, options = {}) => {
const resumeWithoutCredentialsIds = new Set(options.resumeWithoutCredentialsIds ?? []);
const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
const operation = previousOperation.catch(() => []).then(async () => {
@@ -2216,7 +2292,52 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const needsNewDispatch = runnable.some(item => {
const settings = useSettingsStore.getState();
let credentialStateChanged = false;
const credentialBlockedIds = new Set<string>();
for (const item of runnable) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const currentItem = get().downloads.find(download => download.id === item.id);
if (
!currentItem
|| (currentItem.queueId || MAIN_QUEUE_ID) !== queueId
|| (currentItem.status !== 'queued' && !canStartDownload(currentItem.status))
|| resumeWithoutCredentialsIds.has(item.id)
) continue;
const login = currentItem.isTorrent === true ? null : getSiteLogin(currentItem.url, settings);
let keychainPassword: string | null = null;
if (login && !currentItem.password && settings.keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (error) {
console.warn('Could not fetch keychain password for queue start:', error);
}
}
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const latestItem = get().downloads.find(download => download.id === item.id);
if (
!latestItem
|| (latestItem.queueId || MAIN_QUEUE_ID) !== queueId
|| (latestItem.status !== 'queued' && !canStartDownload(latestItem.status))
) continue;
if (credentialsNeedRecovery(latestItem, settings, keychainPassword)) {
markCredentialsRequired(latestItem.id);
credentialBlockedIds.add(latestItem.id);
credentialStateChanged = true;
} else if (latestItem.credentialsRequired === true) {
// A row can retain the durable marker after a configured browser
// source or keychain credential becomes available again. Clear the
// marker before accepting an already-queued backend lifecycle so the
// UI does not keep advertising a credentialless retry indefinitely.
clearCredentialsRequired(latestItem.id);
credentialStateChanged = true;
}
}
if (credentialStateChanged) await commitDownloadState();
const runnableForStart = runnable.filter(item => !credentialBlockedIds.has(item.id));
if (runnableForStart.length === 0) return [];
const needsNewDispatch = runnableForStart.some(item => {
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem) return false;
// Paused rows must go through resumeDownload. This includes rows that
@@ -2241,7 +2362,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} catch (error) {
const message = errorMessage(error);
console.error(`Could not safely resolve the proxy for queue ${queueId}:`, error);
const runnableIds = new Set(runnable.map(item => item.id));
const runnableIds = new Set(runnableForStart.map(item => item.id));
set(state => ({
downloads: state.downloads.map(item =>
runnableIds.has(item.id) && item.status !== 'completed'
@@ -2254,7 +2375,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
const acceptedIds: string[] = [];
for (const item of runnable) {
for (const item of runnableForStart) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
const currentItem = get().downloads.find(download => download.id === item.id);
@@ -2262,8 +2383,38 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
// An explicit recovery approval always replaces the old lifecycle,
// even if a stale renderer projection still says that the row is
// queued and pending. This is the admission point that makes the
// user confirmation meaningful across restart and replayed events.
if (currentItem.credentialsRequired === true
&& resumeWithoutCredentialsIds.has(item.id)) {
const resumed = await resumeDownloadInternal(item.id, {
preserveQueuePosition: true,
forceRequeue: true,
resumeWithoutCredentials: true
});
if (!resumed) continue;
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterResume = get().downloads.find(download => download.id === item.id);
if (
backendDispatchPromises.has(item.id) ||
get().backendRegisteredIds.has(item.id) ||
(afterResume && canPauseDownload(afterResume.status))
) {
await get().pauseDownload(item.id);
}
continue;
}
acceptedIds.push(item.id);
continue;
}
if (currentItem.status === 'paused') {
const resumed = await get().resumeDownload(item.id, { preserveQueuePosition: true });
const resumed = await get().resumeDownload(item.id, {
preserveQueuePosition: true,
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(item.id)
});
if (!resumed) continue;
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterResume = get().downloads.find(download => download.id === item.id);
@@ -2281,7 +2432,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id, { preserveQueuePosition: true })) {
if (await get().resumeDownload(item.id, {
preserveQueuePosition: true,
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(item.id)
})) {
acceptedIds.push(item.id);
}
continue;
@@ -2294,7 +2448,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
!currentItem.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id, queueProxy)) {
const started = await dispatchItem(item.id, queueProxy);
if (started) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterDispatch = get().downloads.find(download => download.id === item.id);
if (
@@ -2355,7 +2510,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
syncSystemIntegrations();
return pausedCount;
},
startAll: async () => {
startAll: async (options = {}) => {
set(state => ({
downloads: state.downloads.map(item =>
item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID }
@@ -2366,7 +2521,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
.filter(item => item.status === 'queued' || canStartDownload(item.status))
.map(item => item.queueId || MAIN_QUEUE_ID)
);
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId, options)));
return results.reduce((total, ids) => total + ids.length, 0);
},
pauseAll: async () => {
@@ -2673,6 +2828,44 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
try {
const settings = useSettingsStore.getState();
const preparedCandidates: Array<{
id: string;
lifecycleGeneration: bigint;
login: ReturnType<typeof getSiteLogin>;
keychainPassword: string | null;
}> = [];
// Credential admission must happen before any global prerequisite
// such as proxy resolution. Otherwise a proxy failure can leave a
// credential-marked row queued and make every subsequent startup
// silently retry the same unavailable lifecycle.
for (const pendingItem of active) {
const item = get().downloads.find(download => download.id === pendingItem.id);
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
const lifecycleGeneration = currentDownloadLifecycle(item.id);
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
let keychainPassword: string | null = null;
if (login && !item.password && settings.keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
if (currentDownloadLifecycle(item.id) !== lifecycleGeneration) continue;
const latestItem = get().downloads.find(download => download.id === item.id);
if (!latestItem || latestItem.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
if (credentialsNeedRecovery(latestItem, settings, keychainPassword)) {
markCredentialsRequired(latestItem.id);
continue;
}
if (latestItem.credentialsRequired === true) clearCredentialsRequired(latestItem.id);
preparedCandidates.push({ id: latestItem.id, lifecycleGeneration, login, keychainPassword });
}
await commitDownloadState();
if (preparedCandidates.length === 0) return;
let proxy: string | null;
try {
proxy = await getProxyArgs(settings);
@@ -2687,33 +2880,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
: item
)
}));
await commitDownloadState();
return;
}
const preparedById = new Map(preparedCandidates.map(candidate => [candidate.id, candidate]));
const itemsToEnqueue = [];
for (const pendingItem of active) {
const item = get().downloads.find(download => download.id === pendingItem.id);
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login && !item.password && settings.keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
if (item.isTorrent !== true && item.credentialsRequired === true
&& !hasCredentialMaterial(item.password)
&& !hasCredentialMaterial(item.cookies)
&& !hasCredentialBearingHeaders(item.headers)
&& !hasCredentialMaterial(keychainPassword)) {
const prepared = preparedById.get(pendingItem.id);
if (
!item
|| !prepared
|| item.status !== 'queued'
|| get().backendRegisteredIds.has(item.id)
|| currentDownloadLifecycle(item.id) !== prepared.lifecycleGeneration
) continue;
if (credentialsNeedRecovery(item, settings, prepared.keychainPassword)) {
markCredentialsRequired(item.id);
continue;
}
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
const destPath = item.destination ||
await resolveCategoryDestination(settings, item.category);
if (
currentDownloadLifecycle(item.id) !== prepared.lifecycleGeneration
|| !get().downloads.some(download => download.id === item.id && download.status === 'queued')
) continue;
itemsToEnqueue.push({
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
@@ -2724,8 +2916,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? null
: resolveDownloadConnections(item.connections, settings.perServerConnections),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
password: item.isTorrent === true ? null : item.password || keychainPassword,
username: item.isTorrent === true ? null : item.username || (prepared.login ? prepared.login.username : null),
password: item.isTorrent === true ? null : item.password || prepared.keychainPassword,
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
headers: item.isTorrent === true ? null : item.headers || null,
checksum: item.checksum || null,
@@ -2738,7 +2930,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
proxy,
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
cookie_source: item.isMedia === true && hasConfiguredMediaCookieSource(item, settings)
? settings.mediaCookieSource
: null,
is_media: item.isMedia || false,
is_torrent: item.isTorrent || false,
torrent_path: item.torrentPath || undefined,
@@ -2764,10 +2958,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
replace_existing_fingerprint: item.replaceExistingFingerprint || undefined,
lifecycle_generation: prepared.lifecycleGeneration.toString(),
});
}
await commitDownloadState();
const currentItems = new Map(get().downloads.map(item => [item.id, item]));
let dispatchableItems = itemsToEnqueue.filter(item => {
const current = currentItems.get(item.id);
@@ -2777,7 +2974,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
!backendDispatchPromises.has(item.id) &&
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
});
if (dispatchableItems.length === 0) return;
if (dispatchableItems.length === 0) {
await commitDownloadState();
return;
}
await commitDownloadState();
const latestItems = new Map(get().downloads.map(item => [item.id, item]));
@@ -2864,7 +3064,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
: {}),
hasBeenDispatched: true,
lastError: undefined
lastError: undefined,
replaceExistingFingerprint: undefined
}
: download
)
+2
View File
@@ -54,6 +54,8 @@ export interface AddDownloadDraftRow {
playlistError?: string;
metadataBlockedReason?: 'unsafe-url';
selected?: boolean;
/** Opaque native fingerprint captured for an exact unmanaged-file replace. */
replaceExistingFingerprint?: string;
isTorrent?: boolean;
torrentPath?: string;
torrentCacheId?: string;
+7
View File
@@ -22,6 +22,7 @@ import {
normalizeTorrentTrackerInterval,
normalizeTorrentTrackerTimeout,
headerNameHasCredentialMaterial,
headersWithoutCredentialMaterial,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -111,6 +112,12 @@ describe('download persistence progress snapshots', () => {
expect(JSON.stringify(persisted)).not.toContain('secret');
});
it('sanitizes saved request context for an explicit credentialless retry', () => {
expect(headersWithoutCredentialMaterial(
'Referer: https://example.com/page?session=secret#part\nAuthorization: Bearer secret\nUser-Agent: Browser'
)).toBe('Referer: https://example.com/page\nUser-Agent: Browser');
});
it('marks username-only authentication as requiring credentials after restart', () => {
const persisted = redactDownloadForPersistence({
...item('paused'),
+10
View File
@@ -788,6 +788,16 @@ const persistableRequestHeaders = (headers: string | null | undefined): string |
return lines.length > 0 ? lines.join('\n') : undefined;
};
/**
* Preserve only stable, non-credential request context for an explicit
* credentialless retry. This uses the same allow-list and URL sanitization as
* persistence so a retry cannot accidentally reuse a signed Referer or an
* unknown token-bearing header.
*/
export const headersWithoutCredentialMaterial = (
headers: string | null | undefined
): string | undefined => persistableRequestHeaders(headers);
/**
* Returns a shallow copy of `item` with secret fields removed. Volatile
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the