mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
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:
+173
-21
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+142
-13
@@ -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
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 : []
|
||||
});
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user