mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 22:17:56 +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}"))?;
|
.map_err(|error| format!("failed to prepare ownership query: {error}"))?;
|
||||||
let rows = statement
|
let rows = statement
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let primary_path: String = row.get(1)?;
|
Ok((
|
||||||
let owned_paths = row
|
row.get::<_, String>(0)?,
|
||||||
.get::<_, Option<String>>(2)?
|
row.get::<_, String>(1)?,
|
||||||
.and_then(|paths| serde_json::from_str::<Vec<String>>(&paths).ok())
|
row.get::<_, Option<String>>(2)?,
|
||||||
.filter(|paths| !paths.is_empty())
|
))
|
||||||
.unwrap_or_else(|| vec![primary_path.clone()]);
|
|
||||||
Ok((row.get(0)?, primary_path, owned_paths))
|
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("failed to query ownership data: {error}"))?;
|
.map_err(|error| format!("failed to query ownership data: {error}"))?;
|
||||||
rows.collect::<Result<Vec<_>, _>>()
|
let mut ownership = Vec::new();
|
||||||
.map_err(|error| format!("failed to read ownership data: {error}"))
|
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(
|
pub fn set_ownership_paths(
|
||||||
@@ -1697,7 +1713,17 @@ pub fn set_ownership_paths(
|
|||||||
primary_path: &str,
|
primary_path: &str,
|
||||||
paths: &[String],
|
paths: &[String],
|
||||||
) -> Result<(), 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(
|
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}"))?;
|
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
|
||||||
let existing = statement
|
let existing = statement
|
||||||
.query_map(params![id], |row| {
|
.query_map(params![id], |row| {
|
||||||
let primary: String = row.get(1)?;
|
Ok((
|
||||||
let owned = row
|
row.get::<_, String>(0)?,
|
||||||
.get::<_, Option<String>>(2)?
|
row.get::<_, String>(1)?,
|
||||||
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
|
row.get::<_, Option<String>>(2)?,
|
||||||
.unwrap_or_else(|| vec![primary.clone()]);
|
row.get::<_, Option<String>>(3)?,
|
||||||
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))
|
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
|
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
|
||||||
for row in existing {
|
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}"))?;
|
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)
|
let new_paths = std::iter::once(primary_path)
|
||||||
.chain(paths.iter().map(String::as_str))
|
.chain(paths.iter().map(String::as_str))
|
||||||
.chain(removal_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())
|
.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> {
|
pub fn has_user_data(connection: &Connection) -> Result<bool, String> {
|
||||||
connection
|
connection
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -3471,6 +3539,90 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn torrent_ownership_and_removal_reservation_commit_atomically() {
|
fn torrent_ownership_and_removal_reservation_commit_atomically() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -346,10 +346,27 @@ pub fn known_primary_paths<R: tauri::Runtime>(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// One-time compatibility for downloads created before the backend-owned
|
let database = app_handle.state::<crate::db::DbState>();
|
||||||
// registry existed. This imports the exact persisted queue path only.
|
let connection = database.lock()?;
|
||||||
for path in legacy_download_queue_paths(app_handle)? {
|
for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
|
||||||
if !paths.iter().any(|existing| existing == &path) {
|
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);
|
paths.push(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +374,65 @@ pub fn known_primary_paths<R: tauri::Runtime>(
|
|||||||
Ok(paths)
|
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> {
|
fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<Vec<DownloadOwnershipRecord>, String> {
|
||||||
let database = app_handle.state::<crate::db::DbState>();
|
let database = app_handle.state::<crate::db::DbState>();
|
||||||
let connection = database.lock()?;
|
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>,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
) -> Result<Vec<PathBuf>, String> {
|
) -> Result<Vec<(String, PathBuf)>, String> {
|
||||||
let settings = crate::settings::load_settings(app_handle).ok();
|
let settings = crate::settings::load_settings(app_handle).ok();
|
||||||
|
|
||||||
let downloads = {
|
let downloads = {
|
||||||
@@ -383,7 +459,7 @@ fn legacy_download_queue_paths<R: tauri::Runtime>(
|
|||||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
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 {
|
for download in downloads {
|
||||||
let category = format!("{:?}", download.category);
|
let category = format!("{:?}", download.category);
|
||||||
let mut destinations = Vec::new();
|
let mut destinations = Vec::new();
|
||||||
@@ -442,8 +518,10 @@ fn legacy_download_queue_paths<R: tauri::Runtime>(
|
|||||||
|
|
||||||
for destination in destinations {
|
for destination in destinations {
|
||||||
if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) {
|
if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) {
|
||||||
if !paths.iter().any(|existing| existing == &path) {
|
if !paths.iter().any(|(id, existing)| {
|
||||||
paths.push(path);
|
id == &download.id && crate::platform::paths_equal(existing, &path)
|
||||||
|
}) {
|
||||||
|
paths.push((download.id.clone(), path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,38 @@ pub enum DownloadErrorKind {
|
|||||||
DestinationAccess,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -222,6 +254,9 @@ pub struct DownloadItem {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub last_resolver_fallback: Option<bool>,
|
pub last_resolver_fallback: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub replace_existing_fingerprint: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub last_try: Option<String>,
|
pub last_try: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+1798
-34
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 {
|
pub fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let path = path.to_string_lossy().to_lowercase();
|
let path = path_identity(path);
|
||||||
let root = root.to_string_lossy().to_lowercase();
|
let root = path_identity(root);
|
||||||
path == root
|
path == root
|
||||||
|
|| (root.len() == 3
|
||||||
|
&& root.ends_with('/')
|
||||||
|
&& root.as_bytes()[1] == b':'
|
||||||
|
&& path.starts_with(&root))
|
||||||
|| path
|
|| path
|
||||||
.strip_prefix(&root)
|
.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)
|
path.starts_with(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
left.to_string_lossy()
|
let mut normalized = path.to_string_lossy().replace('\\', "/");
|
||||||
.to_lowercase()
|
if normalized
|
||||||
== right.to_string_lossy().to_lowercase()
|
.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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
let normalize = |path: &Path| {
|
path.to_string_lossy()
|
||||||
path.to_string_lossy().to_lowercase().nfc().collect::<String>()
|
.to_lowercase()
|
||||||
};
|
.nfc()
|
||||||
normalize(left) == normalize(right)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple};
|
||||||
use std::path::Path;
|
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]
|
#[test]
|
||||||
fn path_identity_handles_non_ascii_case_differences() {
|
fn path_identity_handles_non_ascii_case_differences() {
|
||||||
let left = Path::new("/downloads/Ärt/File.bin");
|
let left = Path::new("/downloads/Ärt/File.bin");
|
||||||
@@ -427,4 +526,34 @@ mod tests {
|
|||||||
assert!(!paths_equal(composed, decomposed));
|
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")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-4
@@ -2121,24 +2121,93 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn commit_reserved_enqueue(
|
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,
|
&self,
|
||||||
mut task: QueuedTask,
|
mut task: QueuedTask,
|
||||||
generation: u64,
|
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;
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
if self.system_action_pending.load(Ordering::Acquire) {
|
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());
|
return Err("System action is already being performed".to_string());
|
||||||
}
|
}
|
||||||
let id = task.id.clone();
|
if self
|
||||||
|
.registered_lifecycle_generation(&id)
|
||||||
|
.await
|
||||||
|
.is_none_or(|registered| registered != generation)
|
||||||
|
{
|
||||||
|
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;
|
let cancellations = self.enqueue_cancellations.lock().await;
|
||||||
if cancellations
|
if cancellations
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.is_some_and(|cancelled| *cancelled >= generation)
|
.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());
|
return Err("Download enqueue was superseded by a newer user action".to_string());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
task.lifecycle_generation = generation;
|
task.lifecycle_generation = generation;
|
||||||
self.pending.lock().await.push_back(task);
|
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.emit_state(id, DownloadStatus::Queued);
|
||||||
self.notify.notify_one();
|
self.notify.notify_one();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2152,7 +2221,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let id = task.id.clone();
|
let id = task.id.clone();
|
||||||
let previous_generation = self.reserve_enqueue_generation(&id, generation).await?;
|
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)
|
self.rollback_enqueue_reservation(&id, generation, previous_generation)
|
||||||
.await;
|
.await;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
@@ -3395,6 +3467,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
|
|
||||||
/// Pop the next task, or None if empty.
|
/// Pop the next task, or None if empty.
|
||||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
self.pending.lock().await.pop_front()
|
self.pending.lock().await.pop_front()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5789,6 +5862,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
queue_id: &str,
|
queue_id: &str,
|
||||||
direction: QueueDirection,
|
direction: QueueDirection,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
let mut pending = self.pending.lock().await;
|
let mut pending = self.pending.lock().await;
|
||||||
let queue_positions = pending
|
let queue_positions = pending
|
||||||
.iter()
|
.iter()
|
||||||
@@ -5852,6 +5926,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
queue_id: &str,
|
queue_id: &str,
|
||||||
target_index: usize,
|
target_index: usize,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
let mut pending = self.pending.lock().await;
|
let mut pending = self.pending.lock().await;
|
||||||
let queue_positions = pending
|
let queue_positions = pending
|
||||||
.iter()
|
.iter()
|
||||||
@@ -5880,6 +5955,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
/// Does NOT release a permit (the caller handles active permits via
|
/// Does NOT release a permit (the caller handles active permits via
|
||||||
/// release_permit if the task was already dispatched).
|
/// release_permit if the task was already dispatched).
|
||||||
pub async fn remove_from_pending(&self, id: &str) -> bool {
|
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 mut pending = self.pending.lock().await;
|
||||||
let before = pending.len();
|
let before = pending.len();
|
||||||
pending.retain(|t| t.id != id);
|
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 {
|
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 mut pending = self.pending.lock().await;
|
||||||
let before = pending.len();
|
let before = pending.len();
|
||||||
pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation));
|
pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation));
|
||||||
@@ -8556,6 +8633,9 @@ pub struct EnqueueItem {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub lifecycle_generation: Option<String>,
|
pub lifecycle_generation: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub replace_existing_fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnqueueItem {
|
impl EnqueueItem {
|
||||||
@@ -8756,7 +8836,7 @@ mod tests {
|
|||||||
release: Arc::clone(&release),
|
release: Arc::clone(&release),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
manager
|
let previous_generation = manager
|
||||||
.reserve_enqueue_generation("allocation", 7)
|
.reserve_enqueue_generation("allocation", 7)
|
||||||
.await
|
.await
|
||||||
.expect("lifecycle reservation");
|
.expect("lifecycle reservation");
|
||||||
@@ -8770,6 +8850,7 @@ mod tests {
|
|||||||
lifecycle_generation: 7,
|
lifecycle_generation: 7,
|
||||||
},
|
},
|
||||||
7,
|
7,
|
||||||
|
previous_generation,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("queued task");
|
.expect("queued task");
|
||||||
@@ -8796,6 +8877,106 @@ mod tests {
|
|||||||
dispatcher.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn download_start_before_gid_registration_is_buffered_and_consumed() {
|
async fn download_start_before_gid_registration_is_buffered_and_consumed() {
|
||||||
let app = tauri::test::mock_builder()
|
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");
|
.expect("reservation should succeed");
|
||||||
mgr.cancel_enqueue_generation("a", 7).await;
|
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");
|
assert!(committed.is_err(), "cancelled reservation must not commit");
|
||||||
mgr.rollback_enqueue_reservation("a", 7, previous).await;
|
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
|
assert!(manager
|
||||||
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
|
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
|
||||||
.await);
|
.await);
|
||||||
manager
|
let previous = manager
|
||||||
.reserve_enqueue_generation("duplicate", 1)
|
.reserve_enqueue_generation("duplicate", 1)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
manager
|
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
|
.await
|
||||||
.unwrap();
|
.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 { DownloadStatus } from "./DownloadStatus";
|
||||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
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.
|
// 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";
|
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 {
|
try {
|
||||||
fileExistsOnDisk = await invoke('check_file_exists', {
|
const targetInfo = await invoke('inspect_download_target', {
|
||||||
path: await resolveDownloadFilePath(itemLocation, finalFile)
|
path: await resolveDownloadFilePath(itemLocation, finalFile)
|
||||||
});
|
});
|
||||||
|
diskTargetKind = targetInfo.kind;
|
||||||
|
diskTargetFingerprint = targetInfo.fingerprint;
|
||||||
|
diskTargetOwner = targetInfo.ownedBy;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to check if file exists on disk:", 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({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: i.toString(),
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: {
|
reason: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
msg: existingDownload
|
msg: existingDownload || hasFirelinkOwnedTarget
|
||||||
? t($ => $.addDownloads.existingDownloadDestination)
|
? t($ => $.addDownloads.existingDownloadDestination)
|
||||||
: t($ => $.addDownloads.fileExistsOnDisk)
|
: t($ => $.addDownloads.fileExistsOnDisk)
|
||||||
},
|
},
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
replaceAllowed: Boolean(existingDownload),
|
replaceAllowed: existingDownload ? canReplaceExistingDownload : diskReplaceAllowed,
|
||||||
|
...(existingDownload ? {} : diskReplaceAllowed
|
||||||
|
? { replaceFingerprint: diskTargetFingerprint }
|
||||||
|
: {}),
|
||||||
existingDownloadId: existingDownload?.id
|
existingDownloadId: existingDownload?.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1332,7 +1349,11 @@ export const AddDownloadsModal = () => {
|
|||||||
action: AddDownloadAction,
|
action: AddDownloadAction,
|
||||||
finalLocation: string,
|
finalLocation: string,
|
||||||
useSharedDestination: boolean,
|
useSharedDestination: boolean,
|
||||||
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
|
resolutions?: {
|
||||||
|
id: string;
|
||||||
|
resolution: 'rename' | 'replace' | 'skip';
|
||||||
|
replaceFingerprint?: string;
|
||||||
|
}[],
|
||||||
destinationOverrides: Record<number, string> = {}
|
destinationOverrides: Record<number, string> = {}
|
||||||
) => {
|
) => {
|
||||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
||||||
@@ -1351,6 +1372,7 @@ export const AddDownloadsModal = () => {
|
|||||||
if (res.resolution === 'skip') {
|
if (res.resolution === 'skip') {
|
||||||
itemsToAdd[idx] = null;
|
itemsToAdd[idx] = null;
|
||||||
} else if (res.resolution === 'rename') {
|
} else if (res.resolution === 'rename') {
|
||||||
|
itemsToAdd[idx] = { ...item, replaceExistingFingerprint: undefined };
|
||||||
let finalFile = item.isMedia
|
let finalFile = item.isMedia
|
||||||
? mediaFileNameForSelectedFormat(item.file, item)
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
: canonicalizeDownloadFileName(item.file);
|
: canonicalizeDownloadFileName(item.file);
|
||||||
@@ -1404,9 +1426,10 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
let diskHas = false;
|
let diskHas = false;
|
||||||
try {
|
try {
|
||||||
diskHas = await invoke('check_file_exists', {
|
const targetInfo = await invoke('inspect_download_target', {
|
||||||
path: await resolveDownloadFilePath(itemLocation, newName)
|
path: await resolveDownloadFilePath(itemLocation, newName)
|
||||||
});
|
});
|
||||||
|
diskHas = targetInfo.kind !== 'missing';
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
const batchHas = batchTargets.some(target => downloadLocationEquals(
|
const batchHas = batchTargets.some(target => downloadLocationEquals(
|
||||||
target.location,
|
target.location,
|
||||||
@@ -1422,7 +1445,7 @@ export const AddDownloadsModal = () => {
|
|||||||
throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile }));
|
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') {
|
} else if (res.resolution === 'replace') {
|
||||||
if (!conflict?.replaceAllowed) {
|
if (!conflict?.replaceAllowed) {
|
||||||
const finalFile = item.isMedia
|
const finalFile = item.isMedia
|
||||||
@@ -1469,8 +1492,15 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!existingItem) {
|
if (!existingItem) {
|
||||||
|
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
|
||||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||||
}
|
}
|
||||||
|
itemsToAdd[idx] = {
|
||||||
|
...item,
|
||||||
|
replaceExistingFingerprint: res.replaceFingerprint
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||||
const mediaFormatChanged = item.isMedia
|
const mediaFormatChanged = item.isMedia
|
||||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||||
@@ -1623,7 +1653,8 @@ export const AddDownloadsModal = () => {
|
|||||||
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
|
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes,
|
||||||
|
replaceExistingFingerprint: item.replaceExistingFingerprint
|
||||||
}, action);
|
}, action);
|
||||||
if (!added) {
|
if (!added) {
|
||||||
const rejected = useDownloadStore.getState().downloads.find(download => download.id === id);
|
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 = () => {
|
export const DeleteConfirmationModal: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore();
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
const modalRef = useModalFocus(deleteModalState.isOpen);
|
const modalRef = useModalFocus(deleteModalState.isOpen);
|
||||||
@@ -49,7 +49,12 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
await removeDownload(id, deleteFile);
|
await removeDownload(
|
||||||
|
id,
|
||||||
|
deleteFile,
|
||||||
|
false,
|
||||||
|
deleteFile ? 'permanentIfUnfinished' : undefined
|
||||||
|
);
|
||||||
succeeded += 1;
|
succeeded += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(String(error));
|
failures.push(String(error));
|
||||||
@@ -72,6 +77,11 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
const handleRemoveFromList = () => removeMany(false);
|
const handleRemoveFromList = () => removeMany(false);
|
||||||
const handleDeleteFile = () => removeMany(true);
|
const handleDeleteFile = () => removeMany(true);
|
||||||
const itemCount = deleteModalState.downloadIds?.length ?? 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -101,6 +111,11 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
{itemCount > 1
|
{itemCount > 1
|
||||||
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
|
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
|
||||||
: t($ => $.dialogs.removeDownload.confirmationSingle)}
|
: 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>}
|
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const downloadStatusLabel = allocationVisible
|
const downloadStatusLabel = allocationVisible
|
||||||
? t($ => $.downloads.status.allocatingFiles)
|
? t($ => $.downloads.status.allocatingFiles)
|
||||||
: t($ => $.downloads.status[download.status]);
|
: 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
|
? download.status === 'retrying' && download.lastResolverFallback === true
|
||||||
? t($ => $.downloads.errors.nameResolutionRetrying)
|
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||||
: download.status === 'failed'
|
: download.status === 'failed'
|
||||||
@@ -357,6 +359,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'failed'
|
download.status === 'failed'
|
||||||
|| download.status === 'retrying'
|
|| download.status === 'retrying'
|
||||||
|| download.lastErrorKind === 'destinationAccess'
|
|| download.lastErrorKind === 'destinationAccess'
|
||||||
|
|| download.credentialsRequired === true
|
||||||
)
|
)
|
||||||
? download.lastError
|
? download.lastError
|
||||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||||
@@ -468,10 +471,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
||||||
className="app-icon-button main-control-button"
|
className="app-icon-button main-control-button"
|
||||||
title={resumeSelectionCount === null
|
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)})`}
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
aria-label={resumeSelectionCount === null
|
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)})`}
|
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||||
>
|
>
|
||||||
<Play size={14} fill="currentColor" />
|
<Play size={14} fill="currentColor" />
|
||||||
|
|||||||
@@ -1931,7 +1931,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
|
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
|
||||||
const credentialMarkedIds = selected
|
const credentialMarkedIds = selected
|
||||||
.filter(download => download.credentialsRequired === true)
|
.filter(download => download.credentialsRequired === true && canStartDownload(download.status))
|
||||||
.map(download => download.id);
|
.map(download => download.id);
|
||||||
if (credentialMarkedIds.length > 0
|
if (credentialMarkedIds.length > 0
|
||||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
||||||
@@ -1954,7 +1954,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
}, [showInteractionError, startSelected, t]);
|
}, [showInteractionError, startSelected, t]);
|
||||||
|
|
||||||
const handleStartAll = useCallback(() => {
|
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(t($ => $.downloadTable.resumeFailed), error);
|
||||||
});
|
});
|
||||||
}, [showInteractionError, startAll, t]);
|
}, [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;
|
reason: DuplicateReason;
|
||||||
resolution: DuplicateResolution;
|
resolution: DuplicateResolution;
|
||||||
replaceAllowed?: boolean;
|
replaceAllowed?: boolean;
|
||||||
|
replaceFingerprint?: string;
|
||||||
existingDownloadId?: string;
|
existingDownloadId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conflicts: DuplicateConflict[];
|
conflicts: DuplicateConflict[];
|
||||||
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
|
onConfirm: (resolutions: {
|
||||||
|
id: string;
|
||||||
|
resolution: DuplicateResolution;
|
||||||
|
replaceFingerprint?: string;
|
||||||
|
}[]) => void;
|
||||||
onCancel: () => 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) => {
|
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
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));
|
setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c));
|
||||||
};
|
};
|
||||||
|
|
||||||
const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict =>
|
const canReplaceAll = canReplaceAllDuplicateConflicts(conflicts);
|
||||||
conflict.replaceAllowed === true
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyResolutionToAll = (resolution: DuplicateResolution) => {
|
const applyResolutionToAll = (resolution: DuplicateResolution) => {
|
||||||
if (resolution === 'replace' && !canReplaceAll) return;
|
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"
|
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
|
<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>
|
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +136,11 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
{t($ => $.actions.cancel)}
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="app-button app-button-primary px-5 text-xs"
|
||||||
>
|
>
|
||||||
{t($ => $.actions.continue)}
|
{t($ => $.actions.continue)}
|
||||||
|
|||||||
@@ -1213,7 +1213,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
);
|
);
|
||||||
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
||||||
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||||
const lifecycleLabel = lifecycleAction === 'pause'
|
const lifecycleLabel = snapshot.credentialsRequired === true
|
||||||
|
? t($ => $.properties.retryWithoutCredentials)
|
||||||
|
: lifecycleAction === 'pause'
|
||||||
? t($ => $.downloads.actions.pause)
|
? t($ => $.downloads.actions.pause)
|
||||||
: lifecycleAction === 'resume'
|
: lifecycleAction === 'resume'
|
||||||
? t($ => $.downloads.actions.resume)
|
? t($ => $.downloads.actions.resume)
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
type LucideIcon
|
type LucideIcon
|
||||||
} from 'lucide-react';
|
} 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 { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { isTransferActiveStatus } from '../utils/downloads';
|
import { isTransferActiveStatus } from '../utils/downloads';
|
||||||
|
import { canStartDownload } from '../utils/downloadActions';
|
||||||
import { clampFloatingPosition } from '../utils/floatingPosition';
|
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||||
import { useTranslation } from 'react-i18next';
|
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"
|
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
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);
|
setContextMenu(null);
|
||||||
void startQueue(queueId).catch(error => {
|
void startQueue(queueId, {
|
||||||
|
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
||||||
|
}).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const common = {
|
|||||||
title: 'Remove Download',
|
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.',
|
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.',
|
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}}',
|
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
deleteFile: 'Delete file',
|
deleteFile: 'Delete file',
|
||||||
@@ -275,6 +276,7 @@ const common = {
|
|||||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
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.',
|
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.',
|
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',
|
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const fa = {
|
|||||||
title: 'حذف دانلود',
|
title: 'حذف دانلود',
|
||||||
confirmationSingle: 'آیا مطمئن هستید که میخواهید این مورد را از لیست حذف کنید؟ همچنین میتوانید فایل اصلی را از هارد دیسک خود حذف کنید.',
|
confirmationSingle: 'آیا مطمئن هستید که میخواهید این مورد را از لیست حذف کنید؟ همچنین میتوانید فایل اصلی را از هارد دیسک خود حذف کنید.',
|
||||||
confirmationMultiple: 'آیا مطمئن هستید که میخواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین میتوانید فایلهای اصلی را از هارد دیسک خود حذف کنید.',
|
confirmationMultiple: 'آیا مطمئن هستید که میخواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین میتوانید فایلهای اصلی را از هارد دیسک خود حذف کنید.',
|
||||||
|
mixedRemovalPolicy: 'اگر «حذف فایل» را انتخاب کنید، فایلهای ناتمام برای همیشه حذف میشوند؛ فایلهای کاملشده همچنان به سطل زباله میروند.',
|
||||||
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
|
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
|
||||||
remove: 'حذف',
|
remove: 'حذف',
|
||||||
deleteFile: 'حذف فایل',
|
deleteFile: 'حذف فایل',
|
||||||
@@ -275,6 +276,7 @@ const fa = {
|
|||||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||||
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
||||||
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
||||||
|
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیرهشده',
|
||||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const he = {
|
|||||||
title: 'הסרת הורדה',
|
title: 'הסרת הורדה',
|
||||||
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
|
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
|
||||||
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
|
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
|
||||||
|
mixedRemovalPolicy: 'אם בוחרים ב״מחיקת קובץ״, קבצים שלא הסתיימו יימחקו לצמיתות; קבצים שהושלמו ימשיכו לעבור לאשפה.',
|
||||||
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
|
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
|
||||||
remove: 'הסרה',
|
remove: 'הסרה',
|
||||||
deleteFile: 'מחיקת קובץ',
|
deleteFile: 'מחיקת קובץ',
|
||||||
@@ -275,6 +276,7 @@ const he = {
|
|||||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||||
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
||||||
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
||||||
|
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
|
||||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const ru = {
|
|||||||
title: 'Удалить загрузку',
|
title: 'Удалить загрузку',
|
||||||
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
|
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
|
||||||
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
|
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
|
||||||
|
mixedRemovalPolicy: 'Если выбрать «Удалить файл», незавершённые файлы будут удалены навсегда, а завершённые по-прежнему отправятся в корзину.',
|
||||||
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
|
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
|
||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
deleteFile: 'Удалить файл',
|
deleteFile: 'Удалить файл',
|
||||||
@@ -275,6 +276,7 @@ const ru = {
|
|||||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||||
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
||||||
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
||||||
|
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
|
||||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const uk = {
|
|||||||
title: 'Видалити завантаження',
|
title: 'Видалити завантаження',
|
||||||
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
|
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
|
||||||
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
|
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
|
||||||
|
mixedRemovalPolicy: 'Якщо вибрати «Видалити файл», незавершені файли буде видалено назавжди, а завершені й надалі переміщуватимуться до кошика.',
|
||||||
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
|
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
|
||||||
remove: 'Видалити',
|
remove: 'Видалити',
|
||||||
deleteFile: 'Видалити файл',
|
deleteFile: 'Видалити файл',
|
||||||
@@ -275,6 +276,7 @@ const uk = {
|
|||||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||||
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
||||||
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
||||||
|
retryWithoutCredentials: 'Повторити без збережених даних для входу',
|
||||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const zhCN = {
|
|||||||
title: '移除下载',
|
title: '移除下载',
|
||||||
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
||||||
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
||||||
|
mixedRemovalPolicy: '如果选择“删除文件”,未完成的文件将永久删除;已完成的文件仍会移入废纸篓。',
|
||||||
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
|
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
|
||||||
remove: '移除',
|
remove: '移除',
|
||||||
deleteFile: '删除文件',
|
deleteFile: '删除文件',
|
||||||
@@ -275,6 +276,7 @@ const zhCN = {
|
|||||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||||
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
||||||
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
||||||
|
retryWithoutCredentials: '不使用已保存凭据重试',
|
||||||
liveTorrentUploadLimit: '实时种子上传限速',
|
liveTorrentUploadLimit: '实时种子上传限速',
|
||||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,8 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
|||||||
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
|
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
|
||||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
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 { PlatformInfo } from './bindings/PlatformInfo';
|
||||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||||
@@ -76,6 +78,7 @@ type CommandMap = {
|
|||||||
deleteAssets: boolean;
|
deleteAssets: boolean;
|
||||||
preserveResumable?: boolean;
|
preserveResumable?: boolean;
|
||||||
expectedLifecycleGeneration?: string;
|
expectedLifecycleGeneration?: string;
|
||||||
|
assetRemovalPolicy?: DownloadAssetRemovalPolicy;
|
||||||
};
|
};
|
||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
@@ -131,7 +134,7 @@ type CommandMap = {
|
|||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
delete_site_login: { args: { id: string }; 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 };
|
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||||
get_extension_server_port: { args: undefined; result: number | null };
|
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 { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
|
import { canStartDownload } from '../utils/downloadActions';
|
||||||
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearDownloadControlIntent,
|
clearDownloadControlIntent,
|
||||||
@@ -531,7 +533,17 @@ const startDownloadListeners = async () => {
|
|||||||
if (event.payload === 'pause-all') {
|
if (event.payload === 'pause-all') {
|
||||||
void mainStore.pauseAll();
|
void mainStore.pauseAll();
|
||||||
} else if (event.payload === 'resume-all') {
|
} 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 () => {
|
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({
|
useDownloadStore.setState({
|
||||||
downloads: [{
|
downloads: [{
|
||||||
id: 'credentialless-resume',
|
id: 'credentialless-resume',
|
||||||
@@ -2566,6 +2575,7 @@ describe('useDownloadStore', () => {
|
|||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
credentialsRequired: true,
|
credentialsRequired: true,
|
||||||
hasBeenDispatched: true,
|
hasBeenDispatched: true,
|
||||||
|
headers: 'Referer: https://example.com/page?session=secret#part\nAuthorization: Bearer secret\nUser-Agent: Browser',
|
||||||
}] as any[],
|
}] as any[],
|
||||||
backendRegisteredIds: new Set(['credentialless-resume'])
|
backendRegisteredIds: new Set(['credentialless-resume'])
|
||||||
});
|
});
|
||||||
@@ -2588,7 +2598,7 @@ describe('useDownloadStore', () => {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
cookies: 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 () => {
|
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') return [];
|
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 () => {
|
it('starts staged queue items in their persisted queue order', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
|
|||||||
+264
-63
@@ -5,12 +5,13 @@ import { invokeCommand as invoke } from '../ipc';
|
|||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
|
import type { DownloadAssetRemovalPolicy } from '../bindings/DownloadAssetRemovalPolicy';
|
||||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||||
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||||
import type { Queue } from '../bindings/Queue';
|
import type { Queue } from '../bindings/Queue';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
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 {
|
import {
|
||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
@@ -52,6 +53,10 @@ export interface StartSelectedOptions {
|
|||||||
resumeWithoutCredentialsIds?: readonly string[];
|
resumeWithoutCredentialsIds?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartQueueOptions {
|
||||||
|
resumeWithoutCredentialsIds?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
// State events do not carry a lifecycle generation. Keep the intent that
|
// State events do not carry a lifecycle generation. Keep the intent that
|
||||||
// initiated a control transition long enough for the listener to discard an
|
// initiated a control transition long enough for the listener to discard an
|
||||||
// already-emitted event from the previous transition.
|
// already-emitted event from the previous transition.
|
||||||
@@ -126,6 +131,37 @@ const credentialsRequiredMessage = (): string =>
|
|||||||
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
||||||
typeof value === 'string' && value.trim().length > 0;
|
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 => {
|
const markCredentialsRequired = (id: string): void => {
|
||||||
useDownloadStore.getState().updateDownload(id, {
|
useDownloadStore.getState().updateDownload(id, {
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
@@ -152,6 +188,10 @@ const advanceQueueControlGeneration = (queueId: string): number => {
|
|||||||
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
|
const isCurrentQueueControlGeneration = (queueId: string, generation: number): boolean =>
|
||||||
currentQueueControlGeneration(queueId) === generation;
|
currentQueueControlGeneration(queueId) === generation;
|
||||||
|
|
||||||
|
type DispatchOptions = {
|
||||||
|
withoutSavedCredentials?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const comparableQueuePosition = (download: DownloadItem): number => {
|
const comparableQueuePosition = (download: DownloadItem): number => {
|
||||||
const position = download.queuePosition;
|
const position = download.queuePosition;
|
||||||
return typeof position === 'number' && Number.isFinite(position) && position >= 0
|
return typeof position === 'number' && Number.isFinite(position) && position >= 0
|
||||||
@@ -330,13 +370,18 @@ const speedLimitForDispatch = (
|
|||||||
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
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();
|
await waitForPendingStartupResume();
|
||||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
let lifecycleGeneration: bigint | null = null;
|
let lifecycleGeneration: bigint | null = null;
|
||||||
let backendAccepted = false;
|
let backendAccepted = false;
|
||||||
|
const withoutSavedCredentials = options.withoutSavedCredentials === true;
|
||||||
try {
|
try {
|
||||||
const state = useDownloadStore.getState();
|
const state = useDownloadStore.getState();
|
||||||
const item = state.downloads.find(d => d.id === id);
|
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);
|
await resolveCategoryDestination(settings, item.category);
|
||||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
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) {
|
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||||
settings.setShowKeychainModal(true);
|
settings.setShowKeychainModal(true);
|
||||||
return false;
|
return false;
|
||||||
@@ -365,12 +412,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
}
|
}
|
||||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||||
|
|
||||||
if (item.isTorrent !== true && item.credentialsRequired === true
|
if (!withoutSavedCredentials && credentialsNeedRecovery(item, settings, keychainPassword)) {
|
||||||
&& !hasCredentialMaterial(item.password)
|
|
||||||
&& !hasCredentialMaterial(item.cookies)
|
|
||||||
&& !hasCredentialBearingHeaders(item.headers)
|
|
||||||
&& !hasCredentialMaterial(keychainPassword)) {
|
|
||||||
markCredentialsRequired(id);
|
markCredentialsRequired(id);
|
||||||
|
await commitDownloadState();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (item.credentialsRequired === true) clearCredentialsRequired(id);
|
if (item.credentialsRequired === true) clearCredentialsRequired(id);
|
||||||
@@ -390,12 +434,18 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
? null
|
? null
|
||||||
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
|
username: withoutSavedCredentials || item.isTorrent === true
|
||||||
password: item.isTorrent === true ? null : item.password || keychainPassword,
|
? 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,
|
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
|
||||||
headers: item.isTorrent === true ? null : item.headers || null,
|
headers: item.isTorrent === true ? null : item.headers || null,
|
||||||
checksum: item.checksum || 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,
|
mirrors: item.mirrors || null,
|
||||||
user_agent: settings.customUserAgent.trim() || null,
|
user_agent: settings.customUserAgent.trim() || null,
|
||||||
max_tries: settings.maxAutomaticRetries,
|
max_tries: settings.maxAutomaticRetries,
|
||||||
@@ -404,7 +454,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
||||||
proxy,
|
proxy,
|
||||||
format_selector: item.mediaFormatSelector || null,
|
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_media: item.isMedia || false,
|
||||||
is_torrent: item.isTorrent || false,
|
is_torrent: item.isTorrent || false,
|
||||||
torrent_path: item.torrentPath || undefined,
|
torrent_path: item.torrentPath || undefined,
|
||||||
@@ -430,6 +482,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
torrent_file_allocation: item.torrentFileAllocation || undefined,
|
torrent_file_allocation: item.torrentFileAllocation || undefined,
|
||||||
torrent_verify_only: item.torrentVerifyOnly,
|
torrent_verify_only: item.torrentVerifyOnly,
|
||||||
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
|
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
|
||||||
|
replace_existing_fingerprint: item.replaceExistingFingerprint || undefined,
|
||||||
lifecycle_generation: lifecycleGeneration.toString(),
|
lifecycle_generation: lifecycleGeneration.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -473,7 +526,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
useDownloadStore.getState().registerBackendIds([id]);
|
useDownloadStore.getState().registerBackendIds([id]);
|
||||||
useDownloadStore.getState().updateDownload(id, {
|
useDownloadStore.getState().updateDownload(id, {
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
lastErrorKind: undefined
|
lastErrorKind: undefined,
|
||||||
|
replaceExistingFingerprint: undefined
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1097,14 +1151,19 @@ interface DownloadState {
|
|||||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
||||||
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
|
replaceDownload: (id: string, updates: Partial<DownloadItem>, action: AddDownloadAction) => Promise<boolean>;
|
||||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
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>;
|
pauseDownload: (id: string) => Promise<void>;
|
||||||
redownload: (id: string) => Promise<void>;
|
redownload: (id: string) => Promise<void>;
|
||||||
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
|
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
|
||||||
startSelected: (ids: string[], options?: StartSelectedOptions) => Promise<number>;
|
startSelected: (ids: string[], options?: StartSelectedOptions) => Promise<number>;
|
||||||
startQueue: (queueId: string) => Promise<string[]>;
|
startQueue: (queueId: string, options?: StartQueueOptions) => Promise<string[]>;
|
||||||
pauseQueue: (queueId: string) => Promise<number>;
|
pauseQueue: (queueId: string) => Promise<number>;
|
||||||
startAll: () => Promise<number>;
|
startAll: (options?: StartQueueOptions) => Promise<number>;
|
||||||
pauseAll: () => Promise<number>;
|
pauseAll: () => Promise<number>;
|
||||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||||
@@ -1292,18 +1351,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
username: undefined,
|
username: undefined,
|
||||||
password: undefined,
|
password: undefined,
|
||||||
cookies: undefined,
|
cookies: undefined,
|
||||||
headers: undefined,
|
headers: headersWithoutCredentialMaterial(targetItem.headers),
|
||||||
});
|
});
|
||||||
targetItem = get().downloads.find(download => download.id === id);
|
targetItem = get().downloads.find(download => download.id === id);
|
||||||
if (!targetItem) return false;
|
if (!targetItem) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true
|
|
||||||
&& !hasCredentialMaterial(targetItem.password)
|
|
||||||
&& !hasCredentialMaterial(targetItem.cookies)
|
|
||||||
&& !hasCredentialBearingHeaders(targetItem.headers)) {
|
|
||||||
if (!resumeWithoutCredentials) {
|
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
|
if (credentialsNeedRecovery(targetItem, settings)) {
|
||||||
|
if (!resumeWithoutCredentials) {
|
||||||
const login = getSiteLogin(targetItem.url, settings);
|
const login = getSiteLogin(targetItem.url, settings);
|
||||||
let keychainPassword: string | null = null;
|
let keychainPassword: string | null = null;
|
||||||
if (login && settings.keychainAccessReady) {
|
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);
|
console.warn('Could not fetch keychain password for resume:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasCredentialMaterial(keychainPassword)) {
|
if (credentialsNeedRecovery(targetItem, settings, keychainPassword)) {
|
||||||
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||||
settings.setShowKeychainModal(true);
|
settings.setShowKeychainModal(true);
|
||||||
}
|
}
|
||||||
markCredentialsRequired(id);
|
markCredentialsRequired(id);
|
||||||
|
await commitDownloadState();
|
||||||
return false;
|
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) {
|
} else if (targetItem.isTorrent === true && targetItem.credentialsRequired === true) {
|
||||||
clearCredentialsRequired(id);
|
clearCredentialsRequired(id);
|
||||||
}
|
}
|
||||||
@@ -1336,7 +1398,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
const { pendingDispatch } = await invalidateDispatch(id, true);
|
const { pendingDispatch } = await invalidateDispatch(id, true);
|
||||||
if (pendingDispatch) await pendingDispatch;
|
if (pendingDispatch) await pendingDispatch;
|
||||||
targetItem = get().downloads.find(download => download.id === id);
|
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');
|
clearDownloadControlIntent(id, 'resume');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1347,8 +1413,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
forceRequeue &&
|
forceRequeue &&
|
||||||
currentTargetItem.status === 'paused' &&
|
get().backendRegisteredIds.has(id) &&
|
||||||
get().backendRegisteredIds.has(id)
|
(currentTargetItem.status === 'paused' || resumeWithoutCredentials)
|
||||||
) {
|
) {
|
||||||
await invoke('detach_download_for_reconfigure', { id });
|
await invoke('detach_download_for_reconfigure', { id });
|
||||||
get().unregisterBackendIds([id]);
|
get().unregisterBackendIds([id]);
|
||||||
@@ -1377,7 +1443,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
clearDownloadControlIntent(id, 'resume');
|
clearDownloadControlIntent(id, 'resume');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (await dispatchItemInternal(id)) {
|
if (await dispatchItemInternal(
|
||||||
|
id,
|
||||||
|
undefined,
|
||||||
|
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
|
||||||
|
)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
get().updateDownload(id, { status: currentTargetItem.status });
|
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
|
// lifecycle. Advance and cancel the old generation before dispatching
|
||||||
// so QueueManager does not reject the legitimate user retry as stale.
|
// so QueueManager does not reject the legitimate user retry as stale.
|
||||||
await invalidateAndWaitForDispatch(id, true);
|
await invalidateAndWaitForDispatch(id, true);
|
||||||
dispatchSucceeded = await dispatchItemInternal(id);
|
dispatchSucceeded = await dispatchItemInternal(
|
||||||
|
id,
|
||||||
|
undefined,
|
||||||
|
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dispatchSucceeded) {
|
if (dispatchSucceeded) {
|
||||||
@@ -1972,9 +2046,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeDownload: (id, deleteFile = false, preserveResumable = false) => runDownloadLifecycleOperation(
|
removeDownload: (id, deleteFile = false, preserveResumable = false, assetRemovalPolicy) => runDownloadLifecycleOperation(
|
||||||
id,
|
id,
|
||||||
`remove:${deleteFile}:${preserveResumable}`,
|
`remove:${deleteFile}:${preserveResumable}:${assetRemovalPolicy ?? 'default'}`,
|
||||||
async () => {
|
async () => {
|
||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
clearDownloadControlIntent(id);
|
clearDownloadControlIntent(id);
|
||||||
@@ -1988,7 +2062,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
await invoke('remove_download', {
|
await invoke('remove_download', {
|
||||||
id,
|
id,
|
||||||
deleteAssets: deleteFile,
|
deleteAssets: deleteFile,
|
||||||
preserveResumable
|
preserveResumable,
|
||||||
|
...(assetRemovalPolicy ? { assetRemovalPolicy } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2202,7 +2277,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
return startedCount;
|
return startedCount;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
startQueue: (queueId) => {
|
startQueue: (queueId, options = {}) => {
|
||||||
|
const resumeWithoutCredentialsIds = new Set(options.resumeWithoutCredentialsIds ?? []);
|
||||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||||
const operation = previousOperation.catch(() => []).then(async () => {
|
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 [];
|
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);
|
const currentItem = get().downloads.find(download => download.id === item.id);
|
||||||
if (!currentItem) return false;
|
if (!currentItem) return false;
|
||||||
// Paused rows must go through resumeDownload. This includes rows that
|
// Paused rows must go through resumeDownload. This includes rows that
|
||||||
@@ -2241,7 +2362,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = errorMessage(error);
|
const message = errorMessage(error);
|
||||||
console.error(`Could not safely resolve the proxy for queue ${queueId}:`, 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 => ({
|
set(state => ({
|
||||||
downloads: state.downloads.map(item =>
|
downloads: state.downloads.map(item =>
|
||||||
runnableIds.has(item.id) && item.status !== 'completed'
|
runnableIds.has(item.id) && item.status !== 'completed'
|
||||||
@@ -2254,7 +2375,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const acceptedIds: string[] = [];
|
const acceptedIds: string[] = [];
|
||||||
for (const item of runnable) {
|
for (const item of runnableForStart) {
|
||||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
|
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
|
||||||
|
|
||||||
const currentItem = get().downloads.find(download => download.id === item.id);
|
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 backendRegistered = get().backendRegisteredIds.has(item.id);
|
||||||
const backendPending = get().pendingOrder.includes(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') {
|
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 (!resumed) continue;
|
||||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
||||||
const afterResume = get().downloads.find(download => download.id === item.id);
|
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 (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);
|
acceptedIds.push(item.id);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -2294,7 +2448,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
!currentItem.hasBeenDispatched ||
|
!currentItem.hasBeenDispatched ||
|
||||||
!backendRegistered
|
!backendRegistered
|
||||||
) {
|
) {
|
||||||
if (await dispatchItem(item.id, queueProxy)) {
|
const started = await dispatchItem(item.id, queueProxy);
|
||||||
|
if (started) {
|
||||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
||||||
const afterDispatch = get().downloads.find(download => download.id === item.id);
|
const afterDispatch = get().downloads.find(download => download.id === item.id);
|
||||||
if (
|
if (
|
||||||
@@ -2355,7 +2510,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
return pausedCount;
|
return pausedCount;
|
||||||
},
|
},
|
||||||
startAll: async () => {
|
startAll: async (options = {}) => {
|
||||||
set(state => ({
|
set(state => ({
|
||||||
downloads: state.downloads.map(item =>
|
downloads: state.downloads.map(item =>
|
||||||
item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID }
|
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))
|
.filter(item => item.status === 'queued' || canStartDownload(item.status))
|
||||||
.map(item => item.queueId || MAIN_QUEUE_ID)
|
.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);
|
return results.reduce((total, ids) => total + ids.length, 0);
|
||||||
},
|
},
|
||||||
pauseAll: async () => {
|
pauseAll: async () => {
|
||||||
@@ -2673,6 +2828,44 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = useSettingsStore.getState();
|
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;
|
let proxy: string | null;
|
||||||
try {
|
try {
|
||||||
proxy = await getProxyArgs(settings);
|
proxy = await getProxyArgs(settings);
|
||||||
@@ -2687,33 +2880,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
await commitDownloadState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const preparedById = new Map(preparedCandidates.map(candidate => [candidate.id, candidate]));
|
||||||
const itemsToEnqueue = [];
|
const itemsToEnqueue = [];
|
||||||
for (const pendingItem of active) {
|
for (const pendingItem of active) {
|
||||||
const item = get().downloads.find(download => download.id === pendingItem.id);
|
const item = get().downloads.find(download => download.id === pendingItem.id);
|
||||||
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
|
const prepared = preparedById.get(pendingItem.id);
|
||||||
|
if (
|
||||||
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
|
!item
|
||||||
let keychainPassword = null;
|
|| !prepared
|
||||||
if (login && !item.password && settings.keychainAccessReady) {
|
|| item.status !== 'queued'
|
||||||
try {
|
|| get().backendRegisteredIds.has(item.id)
|
||||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
|| currentDownloadLifecycle(item.id) !== prepared.lifecycleGeneration
|
||||||
} catch (e) {
|
) continue;
|
||||||
console.warn("Could not fetch keychain password for login:", e);
|
if (credentialsNeedRecovery(item, settings, prepared.keychainPassword)) {
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.isTorrent !== true && item.credentialsRequired === true
|
|
||||||
&& !hasCredentialMaterial(item.password)
|
|
||||||
&& !hasCredentialMaterial(item.cookies)
|
|
||||||
&& !hasCredentialBearingHeaders(item.headers)
|
|
||||||
&& !hasCredentialMaterial(keychainPassword)) {
|
|
||||||
markCredentialsRequired(item.id);
|
markCredentialsRequired(item.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
|
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
|
||||||
const destPath = item.destination ||
|
const destPath = item.destination ||
|
||||||
await resolveCategoryDestination(settings, item.category);
|
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({
|
itemsToEnqueue.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
queue_id: item.queueId || MAIN_QUEUE_ID,
|
queue_id: item.queueId || MAIN_QUEUE_ID,
|
||||||
@@ -2724,8 +2916,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
? null
|
? null
|
||||||
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.isTorrent === true ? null : item.username || (login ? login.username : null),
|
username: item.isTorrent === true ? null : item.username || (prepared.login ? prepared.login.username : null),
|
||||||
password: item.isTorrent === true ? null : item.password || keychainPassword,
|
password: item.isTorrent === true ? null : item.password || prepared.keychainPassword,
|
||||||
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
|
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
|
||||||
headers: item.isTorrent === true ? null : item.headers || null,
|
headers: item.isTorrent === true ? null : item.headers || null,
|
||||||
checksum: item.checksum || null,
|
checksum: item.checksum || null,
|
||||||
@@ -2738,7 +2930,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
adaptive_mirror_selection: settings.adaptiveMirrorSelection,
|
||||||
proxy,
|
proxy,
|
||||||
format_selector: item.mediaFormatSelector || null,
|
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_media: item.isMedia || false,
|
||||||
is_torrent: item.isTorrent || false,
|
is_torrent: item.isTorrent || false,
|
||||||
torrent_path: item.torrentPath || undefined,
|
torrent_path: item.torrentPath || undefined,
|
||||||
@@ -2764,10 +2958,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
torrent_file_allocation: item.torrentFileAllocation || undefined,
|
torrent_file_allocation: item.torrentFileAllocation || undefined,
|
||||||
torrent_verify_only: item.torrentVerifyOnly,
|
torrent_verify_only: item.torrentVerifyOnly,
|
||||||
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
|
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]));
|
const currentItems = new Map(get().downloads.map(item => [item.id, item]));
|
||||||
let dispatchableItems = itemsToEnqueue.filter(item => {
|
let dispatchableItems = itemsToEnqueue.filter(item => {
|
||||||
const current = currentItems.get(item.id);
|
const current = currentItems.get(item.id);
|
||||||
@@ -2777,7 +2974,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
!backendDispatchPromises.has(item.id) &&
|
!backendDispatchPromises.has(item.id) &&
|
||||||
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
|
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
|
||||||
});
|
});
|
||||||
if (dispatchableItems.length === 0) return;
|
if (dispatchableItems.length === 0) {
|
||||||
|
await commitDownloadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await commitDownloadState();
|
await commitDownloadState();
|
||||||
const latestItems = new Map(get().downloads.map(item => [item.id, item]));
|
const latestItems = new Map(get().downloads.map(item => [item.id, item]));
|
||||||
@@ -2864,7 +3064,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
hasBeenDispatched: true,
|
hasBeenDispatched: true,
|
||||||
lastError: undefined
|
lastError: undefined,
|
||||||
|
replaceExistingFingerprint: undefined
|
||||||
}
|
}
|
||||||
: download
|
: download
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface AddDownloadDraftRow {
|
|||||||
playlistError?: string;
|
playlistError?: string;
|
||||||
metadataBlockedReason?: 'unsafe-url';
|
metadataBlockedReason?: 'unsafe-url';
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
/** Opaque native fingerprint captured for an exact unmanaged-file replace. */
|
||||||
|
replaceExistingFingerprint?: string;
|
||||||
isTorrent?: boolean;
|
isTorrent?: boolean;
|
||||||
torrentPath?: string;
|
torrentPath?: string;
|
||||||
torrentCacheId?: string;
|
torrentCacheId?: string;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
normalizeTorrentTrackerInterval,
|
normalizeTorrentTrackerInterval,
|
||||||
normalizeTorrentTrackerTimeout,
|
normalizeTorrentTrackerTimeout,
|
||||||
headerNameHasCredentialMaterial,
|
headerNameHasCredentialMaterial,
|
||||||
|
headersWithoutCredentialMaterial,
|
||||||
redactDownloadForPersistence,
|
redactDownloadForPersistence,
|
||||||
resolveDownloadConnections
|
resolveDownloadConnections
|
||||||
} from './downloads';
|
} from './downloads';
|
||||||
@@ -111,6 +112,12 @@ describe('download persistence progress snapshots', () => {
|
|||||||
expect(JSON.stringify(persisted)).not.toContain('secret');
|
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', () => {
|
it('marks username-only authentication as requiring credentials after restart', () => {
|
||||||
const persisted = redactDownloadForPersistence({
|
const persisted = redactDownloadForPersistence({
|
||||||
...item('paused'),
|
...item('paused'),
|
||||||
|
|||||||
@@ -788,6 +788,16 @@ const persistableRequestHeaders = (headers: string | null | undefined): string |
|
|||||||
return lines.length > 0 ? lines.join('\n') : undefined;
|
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
|
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||||
|
|||||||
Reference in New Issue
Block a user