mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 17:25:42 +00:00
test(startup): compare v1.3.1 source (#37)
- Issue #37: compare the pre-1.4.0 application source with the current packaged-app stability workflow.\n- Keep the current workflow and 5-second delayed-crash check so the Windows result is comparable.\n\nRefs #37.
This commit is contained in:
Generated
+237
-372
File diff suppressed because it is too large
Load Diff
@@ -40,8 +40,6 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
||||
sysinfo = "0.39.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] }
|
||||
tempfile = "3"
|
||||
@@ -65,11 +63,9 @@ keyring-core = "1.0.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] }
|
||||
objc = "0.2.7"
|
||||
unicode-normalization = "0.1.25"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"log:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text"
|
||||
"clipboard-manager:allow-read-text"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "properties-window",
|
||||
"description": "Minimal capability for Firelink Properties windows",
|
||||
"windows": ["properties-*"],
|
||||
"permissions": [
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"log:default"
|
||||
]
|
||||
}
|
||||
@@ -4,12 +4,10 @@ use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_in_file_manager(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let primary = authorize_reveal_path(&app_handle, &path)?;
|
||||
let primary = authorize_download_path(&app_handle, &path)?;
|
||||
let path = existing_download_asset(&primary).ok_or_else(|| {
|
||||
format!(
|
||||
"Downloaded file or partial file is missing: {}",
|
||||
@@ -31,11 +29,9 @@ pub async fn reveal_in_file_manager(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_downloaded_file(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let path = authorize_download_path(&app_handle, &path)?;
|
||||
if !path.exists() {
|
||||
return Err(format!("Downloaded file is missing: {}", path.display()));
|
||||
@@ -60,37 +56,18 @@ fn authorize_download_path(
|
||||
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
|
||||
}
|
||||
|
||||
fn authorize_reveal_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
authorize_exact_path_with_directory(
|
||||
Path::new(requested),
|
||||
&known_download_paths(app_handle)?,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
crate::download_ownership::known_primary_paths(app_handle)
|
||||
}
|
||||
|
||||
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
authorize_exact_path_with_directory(requested, allowed_paths, false)
|
||||
}
|
||||
|
||||
fn authorize_exact_path_with_directory(
|
||||
requested: &Path,
|
||||
allowed_paths: &[PathBuf],
|
||||
allow_directory: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symlink_component(requested) {
|
||||
return Err("Download path may not contain symlink components".to_string());
|
||||
}
|
||||
|
||||
let requested = canonicalize_with_missing_leaf(requested)?;
|
||||
if let Ok(metadata) = std::fs::metadata(&requested) {
|
||||
if !metadata.is_file() && !(allow_directory && metadata.is_dir()) {
|
||||
if !metadata.is_file() {
|
||||
return Err("Download path is not a file".to_string());
|
||||
}
|
||||
}
|
||||
@@ -163,9 +140,8 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
|
||||
(metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink()
|
||||
})
|
||||
std::fs::symlink_metadata(candidate)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+70
-1771
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ use tauri::Manager;
|
||||
struct DownloadOwnershipRecord {
|
||||
id: String,
|
||||
primary_path: String,
|
||||
owned_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn canonical_download_filename(filename: &str) -> String {
|
||||
@@ -91,8 +90,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
pub fn expected_primary_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
pub fn expected_primary_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
destination: &str,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
@@ -110,138 +109,21 @@ pub fn expected_primary_path<R: tauri::Runtime>(
|
||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||
}
|
||||
|
||||
pub fn set_primary_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
pub fn register_expected(
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
destination: &str,
|
||||
filename: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = expected_primary_path(app_handle, destination, filename)?;
|
||||
set_primary_path(app_handle, id, &path)
|
||||
}
|
||||
|
||||
pub fn set_primary_path(
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
set_owned_paths(app_handle, id, &[path.to_path_buf()])
|
||||
}
|
||||
|
||||
pub fn set_owned_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
let primary = paths
|
||||
.first()
|
||||
.ok_or_else(|| "Download ownership requires at least one path".to_string())?;
|
||||
set_owned_paths_with_primary(app_handle, id, primary, paths)
|
||||
}
|
||||
|
||||
pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
primary: &Path,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
if paths.is_empty() {
|
||||
return Err("Download ownership requires at least one path".to_string());
|
||||
}
|
||||
|
||||
let canonical_primary = canonical_owned_path(app_handle, primary)?;
|
||||
let mut canonical_paths = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
return Err("Download ownership file path is a directory".to_string());
|
||||
}
|
||||
let canonical_path = canonical_owned_path(app_handle, path)?;
|
||||
if !canonical_paths
|
||||
.iter()
|
||||
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
|
||||
{
|
||||
canonical_paths.push(canonical_path);
|
||||
}
|
||||
}
|
||||
|
||||
let path_strings = canonical_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership_paths(
|
||||
&connection,
|
||||
id,
|
||||
&canonical_primary.to_string_lossy(),
|
||||
&path_strings,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_owned_paths_with_primary_and_removal<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
primary: &Path,
|
||||
paths: &[PathBuf],
|
||||
removal_paths: &[PathBuf],
|
||||
) -> Result<(), String> {
|
||||
if paths.is_empty() {
|
||||
return Err("Download ownership requires at least one path".to_string());
|
||||
}
|
||||
|
||||
let canonical_primary = canonical_owned_path(app_handle, primary)?;
|
||||
let canonical_paths = canonical_file_paths(app_handle, paths)?;
|
||||
let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?;
|
||||
let mut current_paths = owned_paths_for_id(app_handle, id)?;
|
||||
if let Some(primary) = primary_path_for_id(app_handle, id)? {
|
||||
current_paths.push(primary);
|
||||
}
|
||||
let known_paths = known_primary_paths(app_handle)?;
|
||||
if canonical_removal_paths.iter().any(|candidate| {
|
||||
known_paths.iter().any(|known| {
|
||||
crate::platform::paths_equal(candidate, known)
|
||||
&& !current_paths
|
||||
.iter()
|
||||
.any(|current| crate::platform::paths_equal(candidate, current))
|
||||
})
|
||||
}) {
|
||||
return Err(
|
||||
"Torrent removal would delete a file owned by another Firelink download".to_string(),
|
||||
);
|
||||
}
|
||||
let path_strings = canonical_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let removal_strings = canonical_removal_paths
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership_and_removal_paths(
|
||||
&connection,
|
||||
id,
|
||||
&canonical_primary.to_string_lossy(),
|
||||
&path_strings,
|
||||
&removal_strings,
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_file_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut canonical_paths = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
return Err("Download ownership file path is a directory".to_string());
|
||||
}
|
||||
let canonical_path = canonical_owned_path(app_handle, path)?;
|
||||
if !canonical_paths
|
||||
.iter()
|
||||
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
|
||||
{
|
||||
canonical_paths.push(canonical_path);
|
||||
}
|
||||
}
|
||||
Ok(canonical_paths)
|
||||
}
|
||||
|
||||
fn canonical_owned_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
path: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
if !path.is_absolute() {
|
||||
return Err("Download ownership path must be absolute".to_string());
|
||||
}
|
||||
@@ -258,61 +140,18 @@ fn canonical_owned_path<R: tauri::Runtime>(
|
||||
}
|
||||
let canonical_path = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
|
||||
if !crate::is_safe_path(&canonical_path, app_handle) {
|
||||
return Err("Download ownership path is outside an allowed download location".to_string());
|
||||
}
|
||||
Ok(canonical_path)
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
|
||||
}
|
||||
|
||||
pub fn remove<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<(), String> {
|
||||
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::remove_ownership(&connection, id)
|
||||
}
|
||||
|
||||
pub fn clear_torrent_removal_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<(), String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::remove_torrent_removal_paths(&connection, id)
|
||||
}
|
||||
|
||||
/// Clear a Torrent removal reservation only after every reserved path is
|
||||
/// absent. The reservation protects paths that Aria2 may still remove after
|
||||
/// a terminal event has been observed; callers must not release it merely
|
||||
/// because the daemon reported completion or failure.
|
||||
pub fn clear_torrent_removal_paths_if_absent<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<bool, String> {
|
||||
let paths = torrent_removal_paths_for_id(app_handle, id)?;
|
||||
if paths.iter().any(|path| {
|
||||
!matches!(
|
||||
std::fs::symlink_metadata(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||||
)
|
||||
}) {
|
||||
return Ok(false);
|
||||
}
|
||||
clear_torrent_removal_paths(app_handle, id)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn torrent_removal_paths_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_torrent_removal_paths(&connection, id)
|
||||
.map(|paths| paths.into_iter().map(PathBuf::from).collect())
|
||||
}
|
||||
|
||||
pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
@@ -323,50 +162,16 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||
.map(|record| PathBuf::from(record.primary_path)))
|
||||
}
|
||||
|
||||
pub fn owned_paths_for_id<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
id: &str,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
Ok(load_records(app_handle)?
|
||||
.into_iter()
|
||||
.find(|record| record.id == id)
|
||||
.map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn known_primary_paths<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||
.into_iter()
|
||||
.flat_map(|record| {
|
||||
std::iter::once(PathBuf::from(record.primary_path)).chain(
|
||||
record.owned_paths.into_iter().map(PathBuf::from),
|
||||
)
|
||||
})
|
||||
.map(|record| PathBuf::from(record.primary_path))
|
||||
.collect();
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
for (_, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
|
||||
for path in removal_paths.into_iter().map(PathBuf::from) {
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| crate::platform::paths_equal(existing, &path))
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(connection);
|
||||
|
||||
// Compatibility for downloads created before the backend-owned registry
|
||||
// existed. Import only the exact persisted queue paths.
|
||||
for (_, path) in legacy_download_queue_path_records(app_handle)? {
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| crate::platform::paths_equal(existing, &path))
|
||||
{
|
||||
// One-time compatibility for downloads created before the backend-owned
|
||||
// registry existed. This imports the exact persisted queue path only.
|
||||
for path in legacy_download_queue_paths(app_handle)? {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
@@ -374,83 +179,18 @@ pub fn known_primary_paths<R: tauri::Runtime>(
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Return the Firelink download that owns an exact output path, if any.
|
||||
///
|
||||
/// This is intentionally based on the persisted ownership registry rather
|
||||
/// than on the visible download list. The renderer can be stale while a
|
||||
/// queued/native lifecycle is being admitted, so duplicate replacement must
|
||||
/// make this decision at the native boundary.
|
||||
pub fn owner_for_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
path: &Path,
|
||||
) -> Result<Option<String>, String> {
|
||||
let canonical = crate::canonicalize_with_missing_components(path)
|
||||
.ok_or_else(|| "Download target could not be canonicalized".to_string())?;
|
||||
let mut owners = Vec::new();
|
||||
for record in load_records(app_handle)? {
|
||||
let primary = PathBuf::from(&record.primary_path);
|
||||
if crate::platform::paths_equal(&primary, &canonical)
|
||||
|| record
|
||||
.owned_paths
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.any(|owned| crate::platform::paths_equal(&owned, &canonical))
|
||||
{
|
||||
owners.push(record.id);
|
||||
}
|
||||
}
|
||||
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
for (id, removal_paths) in crate::db::load_all_torrent_removal_paths(&connection)? {
|
||||
if removal_paths
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.any(|removal| crate::platform::paths_equal(&removal, &canonical))
|
||||
&& !owners.contains(&id)
|
||||
{
|
||||
owners.push(id);
|
||||
}
|
||||
}
|
||||
drop(connection);
|
||||
|
||||
// Older rows may predate the ownership registry. They still represent
|
||||
// Firelink-owned targets and must not be downgraded to unmanaged disk
|
||||
// files merely because their migration record is absent.
|
||||
for (id, legacy_path) in legacy_download_queue_path_records(app_handle)? {
|
||||
if crate::platform::paths_equal(&legacy_path, &canonical) && !owners.contains(&id) {
|
||||
owners.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
match owners.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(owners.pop()),
|
||||
_ => Err(format!(
|
||||
"Download target is claimed by multiple Firelink downloads: {}",
|
||||
owners.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<Vec<DownloadOwnershipRecord>, String> {
|
||||
let database = app_handle.state::<crate::db::DbState>();
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_ownership(&connection).map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
|
||||
id,
|
||||
primary_path,
|
||||
owned_paths,
|
||||
})
|
||||
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path })
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_download_queue_path_records<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let settings = crate::settings::load_settings(app_handle).ok();
|
||||
|
||||
let downloads = {
|
||||
@@ -459,7 +199,7 @@ fn legacy_download_queue_path_records<R: tauri::Runtime>(
|
||||
parse_legacy_download_items(crate::db::load_downloads(&connection)?)
|
||||
};
|
||||
|
||||
let mut paths: Vec<(String, PathBuf)> = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
for download in downloads {
|
||||
let category = format!("{:?}", download.category);
|
||||
let mut destinations = Vec::new();
|
||||
@@ -518,10 +258,8 @@ fn legacy_download_queue_path_records<R: tauri::Runtime>(
|
||||
|
||||
for destination in destinations {
|
||||
if let Ok(path) = expected_primary_path(app_handle, &destination, &download.file_name) {
|
||||
if !paths.iter().any(|(id, existing)| {
|
||||
id == &download.id && crate::platform::paths_equal(existing, &path)
|
||||
}) {
|
||||
paths.push((download.id.clone(), path));
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub fn resolve_bundled_binary_path(
|
||||
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
||||
for candidate in packaged_candidates(&resource_dir, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
@@ -20,24 +20,19 @@ pub fn resolve_bundled_binary_path(
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
for candidate in executable_relative_candidates(&exe_path, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, candidate);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Development payloads are intentionally discoverable from the checkout,
|
||||
// but a packaged/release app must never execute an engine selected by its
|
||||
// working directory. If the packaged resource or executable-relative
|
||||
// payload is missing, fail closed instead of allowing a same-named binary
|
||||
// from an untrusted CWD to take over the media/download process.
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
for candidate in development_candidates_for_runtime(&cwd, &target, &binary_name) {
|
||||
for candidate in development_candidates(&cwd, &target, &binary_name) {
|
||||
if candidate.is_file() {
|
||||
let absolute = candidate.canonicalize().map_err(|error| {
|
||||
format!("Failed to canonicalize '{}': {error}", candidate.display())
|
||||
})?;
|
||||
log::info!("Resolved bundled '{}' for target '{}'", engine, target);
|
||||
log::info!("Resolved bundled '{}' at: {:?}", engine, absolute);
|
||||
return Ok(absolute);
|
||||
}
|
||||
}
|
||||
@@ -49,22 +44,6 @@ pub fn resolve_bundled_binary_path(
|
||||
))
|
||||
}
|
||||
|
||||
fn development_candidates_for_runtime(
|
||||
cwd: &Path,
|
||||
target: &str,
|
||||
binary_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
development_candidates(cwd, target, binary_name)
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = (cwd, target, binary_name);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn packaged_candidates(resource_dir: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
resource_dir
|
||||
@@ -119,7 +98,6 @@ fn executable_relative_candidates(
|
||||
candidates
|
||||
}
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<PathBuf> {
|
||||
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
|
||||
let mut candidates = Vec::new();
|
||||
@@ -163,7 +141,7 @@ fn aria2_openssl_modules_dir(binary_path: &Path) -> Option<PathBuf> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{development_candidates, development_candidates_for_runtime, packaged_candidates};
|
||||
use super::{development_candidates, packaged_candidates};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
@@ -193,19 +171,4 @@ mod tests {
|
||||
Path::new("/repo/engine-dist/x86_64-pc-windows-msvc/aria2c-x86_64-pc-windows-msvc.exe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_resolution_is_disabled_in_release_builds() {
|
||||
let candidates = development_candidates_for_runtime(
|
||||
Path::new("/repo"),
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"yt-dlp-x86_64-unknown-linux-gnu",
|
||||
);
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
assert!(!candidates.is_empty());
|
||||
} else {
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
|
||||
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
|
||||
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
|
||||
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
|
||||
const PROTOCOL_VERSION: &str = "5";
|
||||
const PROTOCOL_VERSION: &str = "4";
|
||||
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
|
||||
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
@@ -75,8 +75,6 @@ struct ExtensionRequest {
|
||||
#[serde(default)]
|
||||
media: bool,
|
||||
#[serde(default)]
|
||||
torrent: bool,
|
||||
#[serde(default)]
|
||||
batch: bool,
|
||||
#[serde(default)]
|
||||
batch_name: Option<String>,
|
||||
@@ -102,7 +100,6 @@ pub struct ExtensionDownload {
|
||||
cookies: Option<String>,
|
||||
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
|
||||
media: bool,
|
||||
torrent: bool,
|
||||
batch: bool,
|
||||
batch_name: Option<String>,
|
||||
}
|
||||
@@ -308,16 +305,18 @@ async fn download_handler(
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let is_hidden = state
|
||||
.app_handle
|
||||
.get_webview_window("main")
|
||||
.and_then(|window| window.is_visible().ok())
|
||||
.is_some_and(|is_visible| !is_visible);
|
||||
crate::restore_main_window(&state.app_handle);
|
||||
if is_hidden {
|
||||
// Sleep briefly to let the webview wake up from macOS App Nap
|
||||
// otherwise the IPC event emitted immediately after is dropped.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
if let Some(window) = state.app_handle.get_webview_window("main") {
|
||||
let is_visible = window.is_visible().unwrap_or(true);
|
||||
if !is_visible {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
// Sleep briefly to let the webview wake up from macOS App Nap
|
||||
// otherwise the IPC event emitted immediately after is dropped.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
if !wait_for_frontend(&state.frontend_ready).await {
|
||||
@@ -429,20 +428,6 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let torrent = !payload.media
|
||||
&& urls.len() == 1
|
||||
&& Url::parse(&urls[0]).ok().is_some_and(|url| {
|
||||
if url.scheme() == "magnet" {
|
||||
return true;
|
||||
}
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& (payload.torrent
|
||||
|| filename_is_torrent(payload.filename.as_deref())
|
||||
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
|
||||
});
|
||||
if payload.torrent && !torrent {
|
||||
return None;
|
||||
}
|
||||
|
||||
let referer = payload.referer.and_then(|value| {
|
||||
let url = Url::parse(value.trim()).ok()?;
|
||||
@@ -497,7 +482,6 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
|
||||
cookies,
|
||||
cookie_scopes,
|
||||
media: payload.media,
|
||||
torrent,
|
||||
batch,
|
||||
batch_name,
|
||||
})
|
||||
@@ -564,8 +548,18 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.split_once(':')
|
||||
.map(|(name, _)| !crate::queue::header_name_has_credential_material(name))
|
||||
.unwrap_or(false)
|
||||
.map(|(name, _)| {
|
||||
!matches!(
|
||||
name.trim().to_ascii_lowercase().as_str(),
|
||||
"authorization"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "proxy-authorization"
|
||||
| "set-cookie"
|
||||
| "set-cookie2"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -574,15 +568,7 @@ fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
|
||||
|
||||
fn normalize_url(raw_url: &str) -> Option<String> {
|
||||
let url = Url::parse(raw_url.trim()).ok()?;
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet")
|
||||
.then(|| url.to_string())
|
||||
}
|
||||
|
||||
fn filename_is_torrent(filename: Option<&str>) -> bool {
|
||||
filename
|
||||
.and_then(|value| Path::new(value.trim()).file_name())
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.to_ascii_lowercase().ends_with(".torrent"))
|
||||
matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_filename(filename: &str) -> Option<String> {
|
||||
@@ -766,7 +752,7 @@ mod tests {
|
||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||
assert_eq!(
|
||||
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||
"5"
|
||||
"4"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
@@ -830,7 +816,6 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
});
|
||||
@@ -851,7 +836,6 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
});
|
||||
@@ -907,13 +891,12 @@ mod tests {
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(format!(
|
||||
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nX-Api-Key: stale\nX-Auth-Token: stale\nX-Access-Token: stale\nX-Request-Signature: stale\nX-Session: stale\n: malformed\nUser-Agent: Firefox\nX-Trace: safe",
|
||||
"Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox",
|
||||
"x".repeat(64 * 1024)
|
||||
)),
|
||||
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
|
||||
cookie_scopes: None,
|
||||
media: true,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -921,10 +904,7 @@ mod tests {
|
||||
|
||||
assert!(download.media);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(
|
||||
download.headers.as_deref(),
|
||||
Some("User-Agent: Firefox\nX-Trace: safe")
|
||||
);
|
||||
assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -938,7 +918,6 @@ mod tests {
|
||||
cookies: Some("session=browser-cookie-header".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -951,94 +930,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_url_capture_drops_shared_credentials_but_keeps_safe_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"https://one.example/file.zip".to_string(),
|
||||
"https://two.example/file.zip".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: Some(
|
||||
"X-Api-Key: shared-secret\nX-Request-Signature: signature-secret\n: malformed\nUser-Agent: Firefox\nX-Trace: safe"
|
||||
.to_string(),
|
||||
),
|
||||
cookies: Some("session=must-not-cross-hosts".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("batch".to_string()),
|
||||
})
|
||||
.expect("valid multi-url handoff");
|
||||
|
||||
assert!(download.batch);
|
||||
assert!(download.cookies.is_none());
|
||||
assert_eq!(
|
||||
download.headers.as_deref(),
|
||||
Some("User-Agent: Firefox\nX-Trace: safe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_handoff_accepts_magnets_and_preserves_the_intent() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
.expect("valid magnet torrent handoff");
|
||||
|
||||
assert!(download.torrent);
|
||||
assert_eq!(download.urls[0], "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
let opaque = normalize_download(ExtensionRequest {
|
||||
urls: vec!["https://example.com/download?id=opaque".to_string()],
|
||||
referer: None,
|
||||
silent: true,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: true,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
.expect("explicit opaque torrent handoff");
|
||||
assert!(opaque.torrent);
|
||||
|
||||
let legacy_magnet = normalize_download(ExtensionRequest {
|
||||
urls: vec![
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string(),
|
||||
],
|
||||
referer: None,
|
||||
silent: false,
|
||||
filename: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
.expect("legacy magnet handoff");
|
||||
assert!(legacy_magnet.torrent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_capture_normalizes_host_scoped_cookie_headers() {
|
||||
let download = normalize_download(ExtensionRequest {
|
||||
@@ -1063,7 +954,6 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -1094,7 +984,6 @@ mod tests {
|
||||
cookies: Some("session=secret".to_string()),
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: false,
|
||||
batch_name: None,
|
||||
})
|
||||
@@ -1118,7 +1007,6 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
|
||||
})
|
||||
@@ -1142,7 +1030,6 @@ mod tests {
|
||||
cookies: None,
|
||||
cookie_scopes: None,
|
||||
media: false,
|
||||
torrent: false,
|
||||
batch: true,
|
||||
batch_name: Some("Example Gallery".to_string()),
|
||||
})
|
||||
|
||||
+3
-565
@@ -14,50 +14,6 @@ fn default_sidebar_position() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
fn default_torrent_enable_dht() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_torrent_enable_dht6() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_enable_pex() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_torrent_enable_lpd() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_open_files() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
}
|
||||
|
||||
fn default_torrent_dht_message_timeout() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
}
|
||||
|
||||
fn default_torrent_separate_seed_slots() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_concurrent_seeds() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}
|
||||
|
||||
fn default_torrent_ipv6_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_aria2_disk_cache() -> String {
|
||||
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
||||
}
|
||||
|
||||
fn default_adaptive_mirror_selection() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -70,13 +26,6 @@ pub enum DownloadStatus {
|
||||
/// Post-download media processing such as yt-dlp/ffmpeg merging or
|
||||
/// extraction. The queue permit is still held.
|
||||
Processing,
|
||||
/// A BitTorrent download has all selected data and is still seeding.
|
||||
/// The Aria2 GID and queue permit remain live until seeding ends.
|
||||
Seeding,
|
||||
/// A BitTorrent download is complete but paused while waiting for a
|
||||
/// Firelink-owned seeding slot.
|
||||
#[serde(rename = "waitingToSeed")]
|
||||
WaitingToSeed,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
@@ -84,11 +33,6 @@ pub enum DownloadStatus {
|
||||
/// Transient state: a connection-aware retry is in progress with
|
||||
/// exponential backoff. The download slot/permit is still held.
|
||||
Retrying,
|
||||
/// Aria2 is verifying already-present Torrent data before transfer or
|
||||
/// after an explicit integrity check.
|
||||
Verifying,
|
||||
/// Firelink is moving owned Torrent data between managed destinations.
|
||||
Moving,
|
||||
}
|
||||
|
||||
impl DownloadStatus {
|
||||
@@ -98,15 +42,11 @@ impl DownloadStatus {
|
||||
Self::Staged => "staged",
|
||||
Self::Downloading => "downloading",
|
||||
Self::Processing => "processing",
|
||||
Self::Seeding => "seeding",
|
||||
Self::WaitingToSeed => "waitingToSeed",
|
||||
Self::Paused => "paused",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Queued => "queued",
|
||||
Self::Retrying => "retrying",
|
||||
Self::Verifying => "verifying",
|
||||
Self::Moving => "moving",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +60,6 @@ pub enum DownloadCategory {
|
||||
Documents,
|
||||
Pictures,
|
||||
Applications,
|
||||
Torrents,
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -145,46 +84,6 @@ pub struct QueueConcurrencyConfig {
|
||||
pub max_concurrent: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadErrorKind {
|
||||
NameResolution,
|
||||
DestinationAccess,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadTargetKind {
|
||||
Missing,
|
||||
RegularFile,
|
||||
Directory,
|
||||
Symlink,
|
||||
Special,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadTargetInfo {
|
||||
pub kind: DownloadTargetKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub owned_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadAssetRemovalPolicy {
|
||||
Trash,
|
||||
PermanentIfUnfinished,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -220,8 +119,6 @@ pub struct DownloadItem {
|
||||
#[ts(optional)]
|
||||
pub password: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub sftp_host_key_md: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub headers: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub checksum: Option<String>,
|
||||
@@ -245,282 +142,8 @@ pub struct DownloadItem {
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub credentials_required: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_resolver_fallback: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub replace_existing_fingerprint: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub is_torrent: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_path: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_file_indices: Option<Vec<u32>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_info_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_time: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional, type = "number")]
|
||||
pub torrent_uploaded_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional, type = "number")]
|
||||
pub torrent_seeded_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_relocation_check_pending: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_move_destination: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_move_restore_status: Option<DownloadStatus>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds_native: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_max_peers: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_check_integrity: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_connect_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_tracker_interval: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_prioritize_piece: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_remove_unselected_file: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_encryption_policy: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_file_allocation: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_verify_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_verify_restore_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeer {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub ip: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub port: Option<u16>,
|
||||
#[ts(type = "number")]
|
||||
pub download_speed: u64,
|
||||
#[ts(type = "number")]
|
||||
pub upload_speed: u64,
|
||||
pub seeder: bool,
|
||||
pub am_choking: bool,
|
||||
pub peer_choking: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeerDiagnostics {
|
||||
#[ts(type = "number")]
|
||||
pub listed_peers: u32,
|
||||
#[ts(type = "number")]
|
||||
pub listed_seeders: u32,
|
||||
pub peers: Vec<TorrentPeer>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgress {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_length: u64,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgressSnapshot {
|
||||
pub files: Vec<TorrentFileProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPieceProgressSnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub num_pieces: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_pieces: u64,
|
||||
pub buckets: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileSelectionEntry {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
pub selected: bool,
|
||||
#[ts(type = "number")]
|
||||
#[ts(optional)]
|
||||
pub completed_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileSelectionSnapshot {
|
||||
pub files: Vec<TorrentFileSelectionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentDetails {
|
||||
pub info_hash: String,
|
||||
pub display_name: String,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
#[ts(type = "number")]
|
||||
pub file_count: u32,
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub piece_count: u64,
|
||||
pub private: bool,
|
||||
pub creation_date: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub trackers: Vec<String>,
|
||||
pub web_seeds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentAvailabilityBucket {
|
||||
#[ts(type = "number")]
|
||||
pub minimum_copies: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentAvailabilitySnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_count: u64,
|
||||
pub availability: f64,
|
||||
#[ts(type = "number")]
|
||||
pub connected_peers: u32,
|
||||
pub buckets: Vec<TorrentAvailabilityBucket>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentMoveProgressEvent {
|
||||
pub id: String,
|
||||
pub fraction: f64,
|
||||
#[ts(type = "number")]
|
||||
pub copied_bytes: u64,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentWebSeed {
|
||||
#[ts(type = "number")]
|
||||
pub file_index: u32,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFile {
|
||||
pub index: u32,
|
||||
pub path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentMetadata {
|
||||
pub name: String,
|
||||
#[ts(type = "number")]
|
||||
pub total_bytes: u64,
|
||||
pub files: Vec<TorrentFile>,
|
||||
pub info_hash: String,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
@@ -689,14 +312,6 @@ pub struct SchedulerSettings {
|
||||
pub post_queue_action: PostQueueAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MainWindowSize {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -717,16 +332,9 @@ pub struct PersistedSettings {
|
||||
pub approved_download_roots: Vec<String>,
|
||||
pub max_concurrent_downloads: usize,
|
||||
pub global_speed_limit: String,
|
||||
#[serde(default)]
|
||||
pub torrent_overall_upload_limit: String,
|
||||
pub speed_limit_preset_values: Vec<f64>,
|
||||
pub logs_enabled: bool,
|
||||
pub is_sidebar_visible: bool,
|
||||
#[serde(default)]
|
||||
pub is_folders_collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub main_window_size: Option<MainWindowSize>,
|
||||
#[serde(default = "default_sidebar_position")]
|
||||
pub sidebar_position: String,
|
||||
pub active_settings_tab: SettingsTab,
|
||||
@@ -734,21 +342,12 @@ pub struct PersistedSettings {
|
||||
pub scheduler_running: bool,
|
||||
pub scheduler_active_download_ids: Vec<String>,
|
||||
pub scheduler_last_start_key: String,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub scheduler_triggered_start_key: Option<String>,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
#[serde(default = "default_speed_limit_unit")]
|
||||
pub last_custom_speed_limit_unit: String,
|
||||
pub per_server_connections: i32,
|
||||
pub max_automatic_retries: i32,
|
||||
#[serde(default)]
|
||||
pub minimum_normal_download_speed_ki_b: u32,
|
||||
#[serde(default)]
|
||||
pub retry_not_found_errors: bool,
|
||||
#[serde(default = "default_adaptive_mirror_selection")]
|
||||
pub adaptive_mirror_selection: bool,
|
||||
pub show_notifications: bool,
|
||||
pub play_completion_sound: bool,
|
||||
#[serde(default)]
|
||||
@@ -760,46 +359,6 @@ pub struct PersistedSettings {
|
||||
pub proxy_mode: ProxyMode,
|
||||
pub proxy_host: String,
|
||||
pub proxy_port: u16,
|
||||
#[serde(default = "default_torrent_enable_dht")]
|
||||
pub torrent_enable_dht: bool,
|
||||
#[serde(default = "default_torrent_enable_dht6")]
|
||||
pub torrent_enable_dht6: bool,
|
||||
#[serde(default = "default_torrent_enable_pex")]
|
||||
pub torrent_enable_pex: bool,
|
||||
#[serde(default = "default_torrent_enable_lpd")]
|
||||
pub torrent_enable_lpd: bool,
|
||||
#[serde(default = "default_torrent_max_open_files")]
|
||||
pub torrent_max_open_files: u32,
|
||||
#[serde(default = "default_torrent_dht_message_timeout")]
|
||||
pub torrent_dht_message_timeout: u32,
|
||||
#[serde(default = "default_torrent_separate_seed_slots")]
|
||||
pub torrent_separate_seed_slots: bool,
|
||||
#[serde(default = "default_torrent_max_concurrent_seeds")]
|
||||
pub torrent_max_concurrent_seeds: u32,
|
||||
#[serde(default = "default_torrent_ipv6_enabled")]
|
||||
pub torrent_ipv6_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub torrent_listen_port: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_listen_port: String,
|
||||
#[serde(default)]
|
||||
pub torrent_external_ip: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_entry_point: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_entry_point6: String,
|
||||
#[serde(default)]
|
||||
pub torrent_dht_listen_addr6: String,
|
||||
#[serde(default)]
|
||||
pub torrent_lpd_interface: String,
|
||||
#[serde(default)]
|
||||
pub torrent_peer_id_prefix: String,
|
||||
#[serde(default)]
|
||||
pub torrent_peer_agent: String,
|
||||
#[serde(default)]
|
||||
pub torrent_bind_address: String,
|
||||
#[serde(default = "default_aria2_disk_cache")]
|
||||
pub aria2_disk_cache: String,
|
||||
pub custom_user_agent: String,
|
||||
pub ask_where_to_save_each_file: bool,
|
||||
pub remember_last_used_download_directory: bool,
|
||||
@@ -831,31 +390,6 @@ pub enum QueueDirection {
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadStateProgress {
|
||||
pub fraction: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub downloaded_bytes: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub total_bytes: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub total_is_estimate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadAllocationEvent {
|
||||
pub id: String,
|
||||
pub pending: bool,
|
||||
pub lifecycle_generation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -863,22 +397,8 @@ pub struct DownloadStateEvent {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub resolver_fallback: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub file_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub destination: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub progress: Option<DownloadStateProgress>,
|
||||
}
|
||||
|
||||
impl DownloadStateEvent {
|
||||
@@ -887,56 +407,25 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: status.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Failed.as_str().to_string(),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_seed_remaining(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,69 +434,18 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Completed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: Some(file_name.into()),
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient retry state. Carries the human-readable reason so the UI can
|
||||
/// surface "network dropped, retrying in 5s…". The slot is still held.
|
||||
pub fn retrying(id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
let (reason, error_kind) = Self::safe_error(reason);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||
error: Some(reason),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
error: Some(reason.into()),
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waiting_to_seed(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
destination: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retrying_with_resolver_fallback(
|
||||
id: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) -> Self {
|
||||
let mut event = Self::retrying(id, reason);
|
||||
event.resolver_fallback = Some(true);
|
||||
event
|
||||
}
|
||||
|
||||
pub fn with_destination(mut self, destination: impl Into<String>) -> Self {
|
||||
self.destination = Some(destination.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_progress(mut self, progress: DownloadStateProgress) -> Self {
|
||||
self.progress = Some(progress);
|
||||
self
|
||||
}
|
||||
|
||||
fn safe_error(error: impl Into<String>) -> (String, Option<DownloadErrorKind>) {
|
||||
let error = crate::redact_sensitive_text(&error.into());
|
||||
let error_kind = crate::retry::is_aria2_name_resolution_error(&error)
|
||||
.then_some(DownloadErrorKind::NameResolution);
|
||||
(error, error_kind)
|
||||
}
|
||||
}
|
||||
|
||||
+303
-10244
File diff suppressed because it is too large
Load Diff
+2
-27
@@ -6,8 +6,7 @@ use ts_rs::TS;
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
match native_system_proxy() {
|
||||
Ok(Some(proxy)) => Ok(Some(proxy)),
|
||||
Ok(None) => Ok(proxy_from_environment()),
|
||||
@@ -486,9 +485,7 @@ pub fn get_file_category(filename: String) -> DownloadCategory {
|
||||
"run", "sh", "bin", "jar",
|
||||
];
|
||||
|
||||
if ext == "torrent" {
|
||||
DownloadCategory::Torrents
|
||||
} else if music_exts.contains(&ext.as_str()) {
|
||||
if music_exts.contains(&ext.as_str()) {
|
||||
DownloadCategory::Musics
|
||||
} else if movie_exts.contains(&ext.as_str()) {
|
||||
DownloadCategory::Movies
|
||||
@@ -542,10 +539,8 @@ struct GitHubRelease {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<ReleaseCheckOutcome, String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let current_version = app_handle.package_info().version.to_string();
|
||||
|
||||
crate::ensure_reqwest_crypto_provider();
|
||||
@@ -611,12 +606,10 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_category_directories(
|
||||
caller: tauri::WebviewWindow,
|
||||
app_handle: tauri::AppHandle,
|
||||
base_folder: String,
|
||||
subfolders: std::collections::HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
crate::properties_window::ensure_main_window(&caller)?;
|
||||
let base = crate::resolve_path(&base_folder, &app_handle);
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -691,21 +684,3 @@ pub fn is_supported_media(url: String) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::get_file_category;
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[test]
|
||||
fn classifies_torrent_files_as_torrents() {
|
||||
assert!(matches!(
|
||||
get_file_category("Example.TORRENT".to_string()),
|
||||
DownloadCategory::Torrents
|
||||
));
|
||||
assert!(matches!(
|
||||
get_file_category("Example.mkv".to_string()),
|
||||
DownloadCategory::Movies
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-431
@@ -1,255 +1,6 @@
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Return a stable filesystem identity for an existing Windows file without
|
||||
/// relying on unstable `std::fs::MetadataExt` APIs. The handle is opened with
|
||||
/// delete sharing so inspection does not unnecessarily block normal cleanup
|
||||
/// or replacement; callers still validate the path with `symlink_metadata`
|
||||
/// before using this identity.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn file_identity(path: &Path) -> Option<String> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_NORMAL,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
OPEN_EXISTING,
|
||||
};
|
||||
|
||||
let wide_path = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
// A zero desired-access mask requests metadata access only. Opening with
|
||||
// all sharing flags avoids introducing a lock that changes the outcome of
|
||||
// a subsequent exact replacement or cleanup operation.
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide_path.as_ptr(),
|
||||
0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut metadata = BY_HANDLE_FILE_INFORMATION::default();
|
||||
let result = unsafe {
|
||||
let succeeded = GetFileInformationByHandle(handle, &mut metadata) != 0;
|
||||
let _ = CloseHandle(handle);
|
||||
succeeded
|
||||
};
|
||||
result.then(|| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
metadata.dwVolumeSerialNumber, metadata.nFileIndexHigh, metadata.nFileIndexLow
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const ATOMIC_TEMP_PREFIX: &str = ".firelink-atomic-";
|
||||
|
||||
/// Write bytes to a same-directory temporary file, synchronize them, and
|
||||
/// replace the destination without ever opening the destination for writing.
|
||||
///
|
||||
/// The destination is checked with `symlink_metadata` so managed callers fail
|
||||
/// closed when an attacker or another process has substituted a link or a
|
||||
/// non-file. The final rename is atomic on Unix and uses Windows replace
|
||||
/// semantics rather than the non-replacing `std::fs::rename` behavior.
|
||||
pub async fn atomic_write_replace(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "atomic path has no parent"))?;
|
||||
validate_atomic_parent(parent).await?;
|
||||
|
||||
match tokio::fs::symlink_metadata(path).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"atomic destination cannot be a symbolic link",
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.file_type().is_file() => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"atomic destination is not a regular file",
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let temporary = parent.join(format!(
|
||||
"{ATOMIC_TEMP_PREFIX}{}.tmp",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let write_result = async {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.await?;
|
||||
file.write_all(bytes).await?;
|
||||
file.sync_all().await
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = write_result {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if let Err(error) = replace_staged_file(&temporary, path) {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// A directory sync makes the rename durable across a power loss on
|
||||
// platforms that support opening directories as file descriptors.
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_atomic_parent(parent: &Path) -> io::Result<()> {
|
||||
use std::path::Component;
|
||||
|
||||
let mut current = PathBuf::new();
|
||||
for component in parent.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => current.push(prefix.as_os_str()),
|
||||
Component::RootDir => current.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"atomic parent contains a parent-directory component",
|
||||
));
|
||||
}
|
||||
Component::Normal(name) => {
|
||||
current.push(name);
|
||||
let metadata = tokio::fs::symlink_metadata(¤t).await?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
if let Some(canonical_alias) = resolve_atomic_system_alias(¤t)? {
|
||||
current = canonical_alias;
|
||||
continue;
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"atomic parent cannot contain a symbolic link",
|
||||
));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotADirectory,
|
||||
"atomic parent is not a directory",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_atomic_system_alias(path: &Path) -> io::Result<Option<PathBuf>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let expected = match path {
|
||||
path if path == Path::new("/tmp") => Some(Path::new("/private/tmp")),
|
||||
path if path == Path::new("/var") => Some(Path::new("/private/var")),
|
||||
path if path == Path::new("/etc") => Some(Path::new("/private/etc")),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
let canonical = std::fs::canonicalize(path)?;
|
||||
if canonical == expected {
|
||||
return Ok(Some(canonical));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = path;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn is_atomic_temp_file_name(name: &str) -> bool {
|
||||
let Some(suffix) = name.strip_prefix(ATOMIC_TEMP_PREFIX) else {
|
||||
return false;
|
||||
};
|
||||
let Some(identifier) = suffix.strip_suffix(".tmp") else {
|
||||
return false;
|
||||
};
|
||||
identifier.len() == 32 && identifier.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn replace_staged_file(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::fs::rename(temporary, destination)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
GetLastError, ERROR_LOCK_VIOLATION, ERROR_SHARING_VIOLATION,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
let temporary = temporary
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let destination = destination
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for attempt in 0..5 {
|
||||
// SAFETY: both paths are NUL-terminated UTF-16 buffers owned for
|
||||
// the duration of the call, and the flags request same-volume
|
||||
// replacement with write-through semantics.
|
||||
let replaced = unsafe {
|
||||
MoveFileExW(
|
||||
temporary.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
if replaced != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let error = unsafe { GetLastError() };
|
||||
if !matches!(error, ERROR_LOCK_VIOLATION | ERROR_SHARING_VIOLATION) || attempt == 4 {
|
||||
return Err(io::Error::from_raw_os_error(error as i32));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25 * (attempt + 1) as u64));
|
||||
}
|
||||
|
||||
unreachable!("atomic Windows replacement loop always returns");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_arch() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
@@ -357,115 +108,29 @@ fn trusted_system_path_entries() -> Vec<PathBuf> {
|
||||
pub fn path_is_within(path: &Path, root: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path = path_identity(path);
|
||||
let root = path_identity(root);
|
||||
let path = path.to_string_lossy().to_lowercase();
|
||||
let root = root.to_string_lossy().to_lowercase();
|
||||
path == root
|
||||
|| (root.len() == 3
|
||||
&& root.ends_with('/')
|
||||
&& root.as_bytes()[1] == b':'
|
||||
&& path.starts_with(&root))
|
||||
|| path
|
||||
.strip_prefix(&root)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
.is_some_and(|suffix| suffix.starts_with(['\\', '/']))
|
||||
}
|
||||
|
||||
#[cfg(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")))]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
path.starts_with(root)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paths_equal(left: &Path, right: &Path) -> bool {
|
||||
path_identity(left) == path_identity(right)
|
||||
}
|
||||
|
||||
/// Return the in-process lock identity for a path using the same platform
|
||||
/// equivalence rules as `paths_equal`. Callers use this for serialization,
|
||||
/// not for display or persistence.
|
||||
pub fn path_identity(path: &Path) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut normalized = path.to_string_lossy().replace('\\', "/");
|
||||
if normalized
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/"))
|
||||
{
|
||||
normalized.replace_range(..8, "//");
|
||||
} else if normalized
|
||||
.get(..4)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/"))
|
||||
{
|
||||
normalized.replace_range(..4, "");
|
||||
}
|
||||
|
||||
let is_unc = normalized.starts_with("//");
|
||||
let mut collapsed = String::with_capacity(normalized.len());
|
||||
for character in normalized.chars() {
|
||||
if character == '/' && collapsed.ends_with('/') && !(is_unc && collapsed.len() == 1) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(character);
|
||||
}
|
||||
while collapsed.len() > 1
|
||||
&& collapsed.ends_with('/')
|
||||
&& !(collapsed.len() == 3 && collapsed.as_bytes()[1] == b':')
|
||||
{
|
||||
collapsed.pop();
|
||||
}
|
||||
collapsed.to_lowercase()
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
path.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.nfc()
|
||||
.collect::<String>()
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
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()
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,10 +155,7 @@ fn numbered_windows_device(stem: &str, prefix: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use super::path_is_within;
|
||||
use super::{engine_binary_name, is_windows_reserved_filename, paths_equal, target_triple};
|
||||
use std::path::Path;
|
||||
use super::{engine_binary_name, is_windows_reserved_filename, target_triple};
|
||||
|
||||
#[test]
|
||||
fn target_engine_name_uses_current_rust_target() {
|
||||
@@ -524,88 +186,4 @@ mod tests {
|
||||
assert!(!is_windows_reserved_filename(filename), "{filename}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_matches_the_host_filesystem_case_contract() {
|
||||
let left = Path::new("/downloads/Selected/File.bin");
|
||||
let right = Path::new("/Downloads/selected/file.BIN");
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) {
|
||||
assert!(paths_equal(left, right));
|
||||
} else {
|
||||
assert!(!paths_equal(left, right));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_path_identity_normalizes_separators_and_verbatim_prefixes() {
|
||||
assert!(paths_equal(
|
||||
Path::new(r"C:\downloads\file.bin"),
|
||||
Path::new("c:/DOWNLOADS/file.bin")
|
||||
));
|
||||
assert!(paths_equal(
|
||||
Path::new(r"C:\downloads\file.bin"),
|
||||
Path::new(r"\\?\C:\downloads\file.bin")
|
||||
));
|
||||
assert!(paths_equal(
|
||||
Path::new(r"\\server\share\file.bin"),
|
||||
Path::new(r"\\?\UNC\server\share\file.bin")
|
||||
));
|
||||
assert!(path_is_within(
|
||||
Path::new("c:/downloads/file.bin"),
|
||||
Path::new(r"C:\downloads")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_handles_non_ascii_case_differences() {
|
||||
let left = Path::new("/downloads/Ärt/File.bin");
|
||||
let right = Path::new("/DOWNLOADS/ärt/file.BIN");
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) {
|
||||
assert!(paths_equal(left, right));
|
||||
} else {
|
||||
assert!(!paths_equal(left, right));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_identity_handles_macos_unicode_normalization() {
|
||||
let composed = Path::new("/downloads/café/File.bin");
|
||||
let decomposed = Path::new("/DOWNLOADS/cafe\u{301}/file.BIN");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(paths_equal(composed, decomposed));
|
||||
} else {
|
||||
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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,786 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const PROPERTIES_LABEL_PREFIX: &str = "properties-";
|
||||
const PROPERTIES_WINDOW_TITLE: &str = "Properties - Firelink";
|
||||
const PROPERTIES_DEFAULT_WIDTH: f64 = 960.0;
|
||||
const PROPERTIES_DEFAULT_HEIGHT: f64 = 640.0;
|
||||
const PROPERTIES_MIN_WIDTH: f64 = 680.0;
|
||||
const PROPERTIES_MIN_HEIGHT: f64 = 500.0;
|
||||
const PROPERTIES_WINDOW_READY_EVENT: &str = "properties-window-ready";
|
||||
const PROPERTIES_WINDOW_ACTION_REQUEST_EVENT: &str = "properties-window-action-request";
|
||||
const MAX_PROPERTIES_ACTION_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
const MAX_PROPERTIES_SESSION_ID_BYTES: usize = 128;
|
||||
const MAX_PROPERTIES_REQUEST_ID: u64 = 9_007_199_254_740_991;
|
||||
const MAX_RETIRED_PROPERTIES_SESSIONS: usize = 256;
|
||||
const PROPERTIES_SESSION_HISTORY_EXHAUSTED: &str =
|
||||
"Properties window session history is exhausted; close and reopen the window";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PropertiesWindowRegistry {
|
||||
state: Mutex<RegistryState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RegistryState {
|
||||
by_download: HashMap<String, String>,
|
||||
by_window: HashMap<String, String>,
|
||||
ready_windows: HashSet<String>,
|
||||
sessions_by_window: HashMap<String, String>,
|
||||
retired_sessions_by_window: HashMap<String, HashSet<String>>,
|
||||
remembered_size: Option<PropertiesWindowSize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PropertiesWindowSize {
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PropertiesWindowReadyEvent {
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PropertiesWindowActionEvent {
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
request_id: u64,
|
||||
action: String,
|
||||
payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl PropertiesWindowRegistry {
|
||||
pub(crate) fn remember_size(
|
||||
&self,
|
||||
window_label: &str,
|
||||
physical_width: u32,
|
||||
physical_height: u32,
|
||||
scale_factor: f64,
|
||||
) -> Result<(), String> {
|
||||
if !scale_factor.is_finite() || scale_factor <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let width = (f64::from(physical_width) / scale_factor).round();
|
||||
let height = (f64::from(physical_height) / scale_factor).round();
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(window_label) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.remembered_size = Some(PropertiesWindowSize {
|
||||
width: width.max(PROPERTIES_MIN_WIDTH),
|
||||
height: height.max(PROPERTIES_MIN_HEIGHT),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remembered_size(&self) -> Result<Option<(f64, f64)>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.remembered_size
|
||||
.map(|size| (size.width, size.height)))
|
||||
}
|
||||
|
||||
pub fn allocate(&self, download_id: &str) -> Result<String, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if let Some(label) = state.by_download.get(download_id) {
|
||||
return Ok(label.clone());
|
||||
}
|
||||
|
||||
let label = format!("{PROPERTIES_LABEL_PREFIX}{}", Uuid::new_v4().simple());
|
||||
state.by_download.insert(download_id.to_string(), label.clone());
|
||||
state.by_window.insert(label.clone(), download_id.to_string());
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
pub fn download_for_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.by_window
|
||||
.get(label)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn remove_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
let download_id = state.by_window.remove(label);
|
||||
state.ready_windows.remove(label);
|
||||
state.sessions_by_window.remove(label);
|
||||
state.retired_sessions_by_window.remove(label);
|
||||
if let Some(download_id) = &download_id {
|
||||
state.by_download.remove(download_id);
|
||||
}
|
||||
Ok(download_id)
|
||||
}
|
||||
|
||||
pub fn remove_download(&self, download_id: &str) -> Result<Option<String>, String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
let label = state.by_download.remove(download_id);
|
||||
if let Some(label) = &label {
|
||||
state.by_window.remove(label);
|
||||
state.ready_windows.remove(label);
|
||||
state.sessions_by_window.remove(label);
|
||||
state.retired_sessions_by_window.remove(label);
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
pub fn window_for_download(&self, download_id: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.by_download
|
||||
.get(download_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn mark_ready(&self, label: &str) -> Result<(), String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(label) {
|
||||
return Err("Properties window is no longer registered".to_string());
|
||||
}
|
||||
state.ready_windows.insert(label.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_session(&self, label: &str, session_id: &str) -> Result<(), String> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if !state.by_window.contains_key(label) {
|
||||
return Err("Properties window is no longer registered".to_string());
|
||||
}
|
||||
if state
|
||||
.sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|current| current == session_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if state
|
||||
.retired_sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|retired| retired.contains(session_id))
|
||||
{
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
if state.sessions_by_window.contains_key(label)
|
||||
&& state
|
||||
.retired_sessions_by_window
|
||||
.get(label)
|
||||
.is_some_and(|retired| retired.len() >= MAX_RETIRED_PROPERTIES_SESSIONS)
|
||||
{
|
||||
return Err(PROPERTIES_SESSION_HISTORY_EXHAUSTED.to_string());
|
||||
}
|
||||
if let Some(previous) = state
|
||||
.sessions_by_window
|
||||
.insert(label.to_string(), session_id.to_string())
|
||||
{
|
||||
state
|
||||
.retired_sessions_by_window
|
||||
.entry(label.to_string())
|
||||
.or_default()
|
||||
.insert(previous);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_for_window(&self, label: &str) -> Result<Option<String>, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.sessions_by_window
|
||||
.get(label)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn session_matches(&self, label: &str, session_id: &str) -> Result<bool, String> {
|
||||
Ok(self.session_for_window(label)?.as_deref() == Some(session_id))
|
||||
}
|
||||
|
||||
/// Validate a session and perform a short synchronous mutation while the
|
||||
/// registry lock is held. Callers use this for cancellation flags so a
|
||||
/// stale session cannot pass validation and then race a replacement
|
||||
/// session before its mutation is recorded.
|
||||
pub fn with_current_session<T>(
|
||||
&self,
|
||||
label: &str,
|
||||
session_id: &str,
|
||||
mutation: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?;
|
||||
if state.sessions_by_window.get(label).map(String::as_str) != Some(session_id) {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
mutation()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_ready(&self, label: &str) -> Result<bool, String> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.ready_windows
|
||||
.contains(label))
|
||||
}
|
||||
|
||||
pub fn clear_ready(&self, label: &str) -> Result<(), String> {
|
||||
self.state
|
||||
.lock()
|
||||
.map_err(|_| "Properties window registry is unavailable".to_string())?
|
||||
.ready_windows
|
||||
.remove(label);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_properties_window_label(label: &str) -> bool {
|
||||
label.starts_with(PROPERTIES_LABEL_PREFIX)
|
||||
&& label.len() > PROPERTIES_LABEL_PREFIX.len()
|
||||
&& label[PROPERTIES_LABEL_PREFIX.len()..]
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Custom Tauri commands are not automatically narrowed by a capability's
|
||||
/// window list. Commands that a Properties child may call must therefore
|
||||
/// validate the invoking webview and its registered download explicitly.
|
||||
pub fn ensure_properties_or_main(
|
||||
caller: &tauri::WebviewWindow,
|
||||
registry: &PropertiesWindowRegistry,
|
||||
download_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() == MAIN_WINDOW_LABEL {
|
||||
return Ok(());
|
||||
}
|
||||
if !is_properties_window_label(caller.label())
|
||||
|| registry.download_for_window(caller.label())?.as_deref() != Some(download_id)
|
||||
{
|
||||
return Err("This window is not authorized for the requested download".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_main_window(caller: &tauri::WebviewWindow) -> Result<(), String> {
|
||||
(caller.label() == MAIN_WINDOW_LABEL)
|
||||
.then_some(())
|
||||
.ok_or_else(|| "This command is available only to the main window".to_string())
|
||||
}
|
||||
|
||||
fn emit_to_main<T: Serialize + Clone>(
|
||||
app: &tauri::AppHandle,
|
||||
event: &str,
|
||||
payload: T,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
if app.get_webview_window(MAIN_WINDOW_LABEL).is_none() {
|
||||
return Err("Firelink main window is unavailable".to_string());
|
||||
}
|
||||
|
||||
app.emit_to(
|
||||
tauri::EventTarget::webview_window(MAIN_WINDOW_LABEL),
|
||||
event,
|
||||
payload,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn registered_download_for_caller(
|
||||
caller: &tauri::WebviewWindow,
|
||||
registry: &PropertiesWindowRegistry,
|
||||
) -> Result<String, String> {
|
||||
let label = caller.label();
|
||||
if !is_properties_window_label(label) {
|
||||
return Err("This window is not a Properties window".to_string());
|
||||
}
|
||||
registry
|
||||
.download_for_window(label)?
|
||||
.ok_or_else(|| "Properties window is no longer registered".to_string())
|
||||
}
|
||||
|
||||
fn is_properties_action(action: &str) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
"apply-properties"
|
||||
| "set-torrent-file-selection"
|
||||
| "pause-resume"
|
||||
| "verify-torrent"
|
||||
| "set-download-limit"
|
||||
| "set-torrent-upload-limit"
|
||||
| "set-torrent-peer-options"
|
||||
)
|
||||
}
|
||||
|
||||
fn download_exists(db: &crate::db::DbState, download_id: &str) -> Result<bool, String> {
|
||||
let connection = db.lock()?;
|
||||
Ok(crate::db::load_downloads(&connection)?.into_iter().any(|record| {
|
||||
serde_json::from_str::<serde_json::Value>(&record)
|
||||
.ok()
|
||||
.and_then(|value| value.get("id").and_then(serde_json::Value::as_str).map(str::to_owned))
|
||||
.is_some_and(|id| id == download_id)
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_download_id(download_id: &str) -> Result<(), String> {
|
||||
let trimmed = download_id.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) {
|
||||
return Err("Invalid download ID".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_properties_session_id(session_id: &str) -> Result<(), String> {
|
||||
if session_id.is_empty()
|
||||
|| session_id.len() > MAX_PROPERTIES_SESSION_ID_BYTES
|
||||
|| !session_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err("Invalid Properties window session".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_properties_request_id(request_id: u64) -> Result<(), String> {
|
||||
if request_id == 0 || request_id > MAX_PROPERTIES_REQUEST_ID {
|
||||
return Err("Invalid Properties action request ID".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_download_properties_window(
|
||||
app: tauri::AppHandle,
|
||||
caller: tauri::WebviewWindow,
|
||||
db: tauri::State<'_, crate::db::DbState>,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<String, String> {
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
return Err("Only the main window can open Properties windows".to_string());
|
||||
}
|
||||
validate_download_id(&id)?;
|
||||
if !download_exists(&db, &id)? {
|
||||
return Err("Download no longer exists".to_string());
|
||||
}
|
||||
|
||||
let label = registry.allocate(&id)?;
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// Visibility belongs to the native window owner, not to the renderer
|
||||
// handshake. A delayed or lost snapshot must leave a usable loading
|
||||
// window on screen instead of making the open request appear to do
|
||||
// nothing.
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(label);
|
||||
}
|
||||
|
||||
// If the native window disappeared without delivering Destroyed, discard
|
||||
// the old readiness bit before constructing a fresh hidden webview.
|
||||
registry.clear_ready(&label)?;
|
||||
let (initial_width, initial_height) = registry
|
||||
.remembered_size()?
|
||||
.unwrap_or((PROPERTIES_DEFAULT_WIDTH, PROPERTIES_DEFAULT_HEIGHT));
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
|
||||
.title(PROPERTIES_WINDOW_TITLE)
|
||||
.inner_size(initial_width, initial_height)
|
||||
.min_inner_size(PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT)
|
||||
.resizable(true)
|
||||
.always_on_top(false)
|
||||
// Let the child renderer paint its rounded loading shell before the
|
||||
// native window becomes visible. Showing an opaque native surface
|
||||
// here exposes the webview's unpainted white background.
|
||||
.visible(false)
|
||||
// A hidden WebView2 must not request focus during construction. The
|
||||
// native reveal path focuses it after the window is visible.
|
||||
.focused(false)
|
||||
.transparent(true);
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||
let builder = builder.decorations(false);
|
||||
let build_result = builder.build();
|
||||
if let Err(error) = build_result {
|
||||
// Two rapid main-window requests can race between the native lookup
|
||||
// above and builder creation. If the first request won, retain the
|
||||
// registry entry and focus its window instead of treating the second
|
||||
// request as a failed open.
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(label);
|
||||
}
|
||||
let _ = registry.remove_window(&label);
|
||||
return Err(format!("Could not open Properties window: {error}"));
|
||||
}
|
||||
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_properties_window_download_id(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
) -> Result<String, String> {
|
||||
registered_download_for_caller(&caller, ®istry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_send_ready(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
validate_properties_session_id(&session_id)?;
|
||||
let download_id = registered_download_for_caller(&caller, ®istry)?;
|
||||
if let Err(error) = registry.register_session(caller.label(), &session_id) {
|
||||
if error == PROPERTIES_SESSION_HISTORY_EXHAUSTED {
|
||||
let _ = registry.remove_window(caller.label());
|
||||
let _ = caller.close();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
emit_to_main(
|
||||
&app,
|
||||
PROPERTIES_WINDOW_READY_EVENT,
|
||||
PropertiesWindowReadyEvent {
|
||||
window_label: caller.label().to_string(),
|
||||
download_id,
|
||||
session_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_reveal(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
registered_download_for_caller(&caller, ®istry)?;
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
let session_id = session_id.ok_or_else(|| "Properties window session is required".to_string())?;
|
||||
validate_properties_session_id(&session_id)?;
|
||||
if !registry.session_matches(caller.label(), &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
}
|
||||
registry.mark_ready(caller.label())?;
|
||||
caller.show().map_err(|error| error.to_string())?;
|
||||
caller.set_focus().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_send_action(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
session_id: String,
|
||||
request_id: u64,
|
||||
action: String,
|
||||
payload: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
validate_properties_session_id(&session_id)?;
|
||||
validate_properties_request_id(request_id)?;
|
||||
if !is_properties_action(&action)
|
||||
|| action.len() > 64
|
||||
|| action.chars().any(char::is_control)
|
||||
{
|
||||
return Err("Invalid Properties action".to_string());
|
||||
}
|
||||
if let Some(payload) = payload.as_ref() {
|
||||
let payload_size = serde_json::to_vec(payload)
|
||||
.map_err(|_| "Invalid Properties action payload".to_string())?
|
||||
.len();
|
||||
if payload_size > MAX_PROPERTIES_ACTION_PAYLOAD_BYTES {
|
||||
return Err("Properties action payload is too large".to_string());
|
||||
}
|
||||
}
|
||||
let download_id = registered_download_for_caller(&caller, ®istry)?;
|
||||
if !registry.session_matches(caller.label(), &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
emit_to_main(
|
||||
&app,
|
||||
PROPERTIES_WINDOW_ACTION_REQUEST_EVENT,
|
||||
PropertiesWindowActionEvent {
|
||||
window_label: caller.label().to_string(),
|
||||
download_id,
|
||||
session_id,
|
||||
request_id,
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn validate_properties_window_request(
|
||||
caller: tauri::WebviewWindow,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
window_label: String,
|
||||
download_id: String,
|
||||
session_id: String,
|
||||
request_id: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
return Err("Only the main window can validate Properties requests".to_string());
|
||||
}
|
||||
validate_download_id(&download_id)?;
|
||||
validate_properties_session_id(&session_id)?;
|
||||
if let Some(request_id) = request_id {
|
||||
validate_properties_request_id(request_id)?;
|
||||
}
|
||||
if !is_properties_window_label(&window_label) {
|
||||
return Err("Invalid Properties window label".to_string());
|
||||
}
|
||||
if registry.download_for_window(&window_label)?.as_deref() != Some(download_id.as_str()) {
|
||||
return Err("Properties window request does not match its registered download".to_string());
|
||||
}
|
||||
if !registry.session_matches(&window_label, &session_id)? {
|
||||
return Err("Properties window session is no longer current".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn close_download_properties_window(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let label = caller.label();
|
||||
let registered_id = if label == MAIN_WINDOW_LABEL {
|
||||
registry.window_for_download(&id)?.map(|_| id.clone())
|
||||
} else {
|
||||
registry.download_for_window(label)?
|
||||
};
|
||||
if registered_id.as_deref() != Some(id.as_str()) {
|
||||
return Err("Properties window close request is not registered".to_string());
|
||||
}
|
||||
if let Some(window_label) = registry.window_for_download(&id)? {
|
||||
if let Some(window) = app.get_webview_window(&window_label) {
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
// A native window can disappear without delivering its Destroyed
|
||||
// event. Only clear this stale registry entry when there is no
|
||||
// window left to receive a close-request veto from the child.
|
||||
let _ = registry.remove_download(&id);
|
||||
}
|
||||
} else {
|
||||
let _ = registry.remove_download(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn properties_window_registry_remove_for_download(
|
||||
caller: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PropertiesWindowRegistry>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
if caller.label() != MAIN_WINDOW_LABEL {
|
||||
return Err("Only the main window can remove a Properties window".to_string());
|
||||
}
|
||||
if let Some(label) = registry.remove_download(&id)? {
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// This command is used after the download has already been
|
||||
// removed. It is a forced lifecycle teardown, so a dirty-draft
|
||||
// close-request handler must not be able to leave an orphaned
|
||||
// Properties window behind.
|
||||
let _ = window.destroy();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_are_opaque_and_strictly_scoped() {
|
||||
assert!(is_properties_window_label("properties-0123456789abcdef"));
|
||||
assert!(!is_properties_window_label("properties-download-id"));
|
||||
assert!(!is_properties_window_label("main"));
|
||||
assert!(!is_properties_window_label("properties-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_reuses_one_label_per_download_and_cleans_both_indexes() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let first = registry.allocate("download-a").unwrap();
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
registry.mark_ready(&first).unwrap();
|
||||
assert!(registry.is_ready(&first).unwrap());
|
||||
registry.clear_ready(&first).unwrap();
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
registry.mark_ready(&first).unwrap();
|
||||
assert_eq!(registry.allocate("download-a").unwrap(), first);
|
||||
assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string()));
|
||||
assert_eq!(registry.remove_window(&first).unwrap(), Some("download-a".to_string()));
|
||||
assert_eq!(registry.download_for_window(&first).unwrap(), None);
|
||||
assert!(!registry.is_ready(&first).unwrap());
|
||||
assert_ne!(registry.allocate("download-a").unwrap(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_size_uses_logical_units_and_survives_window_cleanup() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.remember_size(&label, 1920, 1280, 2.0).unwrap();
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
|
||||
registry.remove_window(&label).unwrap();
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_size_clamps_below_minimum_and_ignores_invalid_scale() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
registry.remember_size(&label, 1, 1, 1.0).unwrap();
|
||||
assert_eq!(
|
||||
registry.remembered_size().unwrap(),
|
||||
Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT))
|
||||
);
|
||||
|
||||
registry.remember_size(&label, 2000, 1600, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
registry.remembered_size().unwrap(),
|
||||
Some((PROPERTIES_MIN_WIDTH, PROPERTIES_MIN_HEIGHT))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_resize_from_unregistered_window_cannot_overwrite_session_size() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.remember_size(&label, 1920, 1280, 2.0).unwrap();
|
||||
registry.remove_window(&label).unwrap();
|
||||
registry
|
||||
.remember_size(&label, 2560, 1600, 2.0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(registry.remembered_size().unwrap(), Some((960.0, 640.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ids_are_rejected() {
|
||||
assert!(validate_download_id("").is_err());
|
||||
assert!(validate_download_id("\n").is_err());
|
||||
assert!(validate_download_id("valid-id").is_ok());
|
||||
assert!(validate_properties_session_id("session-1").is_ok());
|
||||
assert!(validate_properties_session_id("").is_err());
|
||||
assert!(validate_properties_session_id("bad session").is_err());
|
||||
assert!(validate_properties_request_id(1).is_ok());
|
||||
assert!(validate_properties_request_id(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_ready_from_a_retired_session_cannot_reclaim_the_window() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
|
||||
registry.register_session(&label, "session-old").unwrap();
|
||||
assert!(registry.session_matches(&label, "session-old").unwrap());
|
||||
|
||||
registry.register_session(&label, "session-new").unwrap();
|
||||
assert!(!registry.session_matches(&label, "session-old").unwrap());
|
||||
assert!(registry.session_matches(&label, "session-new").unwrap());
|
||||
assert!(registry.register_session(&label, "session-old").is_err());
|
||||
assert!(registry.session_matches(&label, "session-new").unwrap());
|
||||
|
||||
for index in 0..(MAX_RETIRED_PROPERTIES_SESSIONS - 1) {
|
||||
registry
|
||||
.register_session(&label, &format!("session-{index}"))
|
||||
.unwrap();
|
||||
}
|
||||
assert!(registry.register_session(&label, "session-after-limit").is_err());
|
||||
|
||||
registry.remove_window(&label).unwrap();
|
||||
assert!(!registry.session_matches(&label, "session-new").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_session_mutation_is_fenced_from_retired_sessions() {
|
||||
let registry = PropertiesWindowRegistry::default();
|
||||
let label = registry.allocate("download-a").unwrap();
|
||||
registry.register_session(&label, "session-old").unwrap();
|
||||
|
||||
let mut mutations = 0;
|
||||
let stale = registry.with_current_session(&label, "session-old", || {
|
||||
mutations += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert!(stale.is_ok());
|
||||
|
||||
registry.register_session(&label, "session-new").unwrap();
|
||||
let rejected = registry.with_current_session(&label, "session-old", || {
|
||||
mutations += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert!(rejected.is_err());
|
||||
assert_eq!(mutations, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_actions_are_allowlisted() {
|
||||
assert!(is_properties_action("apply-properties"));
|
||||
assert!(is_properties_action("verify-torrent"));
|
||||
assert!(is_properties_action("set-torrent-peer-options"));
|
||||
assert!(!is_properties_action("get_keychain_password"));
|
||||
assert!(!is_properties_action(""));
|
||||
}
|
||||
}
|
||||
+249
-9460
File diff suppressed because it is too large
Load Diff
+5
-193
@@ -50,121 +50,6 @@ pub const BACKOFF_SCHEDULE_429: [Duration; 3] = [
|
||||
/// fall through to a hard `Failed`. Three strikes matches the schedule length.
|
||||
pub const MAX_RETRIES: usize = BACKOFF_SCHEDULE.len();
|
||||
|
||||
/// Detect Aria2's name-resolution failure without treating arbitrary DNS-like
|
||||
/// text as a resolver failure. The numeric code is the authoritative signal;
|
||||
/// the message forms cover older/alternate Aria2 wrappers that omit it.
|
||||
pub fn is_aria2_name_resolution_error(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
aria2_error_code(message).as_deref() == Some("19")
|
||||
|| (lower.contains("name resolution")
|
||||
&& lower.contains("failed")
|
||||
&& lower.contains("could not contact dns"))
|
||||
|| lower.contains("could not contact dns server")
|
||||
}
|
||||
|
||||
/// Extract Aria2's numeric error code without retaining the rest of its
|
||||
/// message. Aria2 error messages can include the request URI, so diagnostics
|
||||
/// should record this code rather than the raw text.
|
||||
pub fn aria2_error_code(message: &str) -> Option<String> {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
let marker = "aria2 error code";
|
||||
let start = lower.find(marker)? + marker.len();
|
||||
let remainder = lower[start..].trim_start_matches(|character: char| {
|
||||
character.is_ascii_whitespace()
|
||||
|| matches!(character, ':' | '=' | '(' | ')' | '[' | ']')
|
||||
});
|
||||
let digits: String = remainder
|
||||
.chars()
|
||||
.take_while(|character| character.is_ascii_digit())
|
||||
.collect();
|
||||
(!digits.is_empty()).then_some(digits)
|
||||
}
|
||||
|
||||
/// Coarse, secret-free classification for retry diagnostics. The returned
|
||||
/// value is intentionally stable and contains no provider or request text.
|
||||
pub fn network_error_class(message: &str) -> &'static str {
|
||||
if is_aria2_name_resolution_error(message) {
|
||||
return "name_resolution";
|
||||
}
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("private/local ip") || lower.contains("ssrf") {
|
||||
return "ssrf_policy";
|
||||
}
|
||||
if lower.contains("permission denied") || lower.contains("operation not permitted") {
|
||||
return "permission";
|
||||
}
|
||||
if lower.contains("timed out") || lower.contains("timeout") {
|
||||
return "timeout";
|
||||
}
|
||||
if lower.contains("connection refused") {
|
||||
return "connection_refused";
|
||||
}
|
||||
if lower.contains("connection reset") || lower.contains("connection aborted") {
|
||||
return "connection_reset";
|
||||
}
|
||||
if [
|
||||
"invalid range",
|
||||
"range not satisfiable",
|
||||
"range request",
|
||||
"range support",
|
||||
"accept-ranges",
|
||||
"bounded range",
|
||||
"byte range",
|
||||
"does not support range",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
{
|
||||
return "range";
|
||||
}
|
||||
if lower.contains("dns") || lower.contains("name resolution") {
|
||||
return "dns";
|
||||
}
|
||||
let has_http_version_token = lower.split_whitespace().any(|token| {
|
||||
let token = token.trim_start_matches(|character: char| {
|
||||
matches!(character, '(' | '[' | '{')
|
||||
});
|
||||
token.starts_with("http/")
|
||||
&& token
|
||||
.chars()
|
||||
.nth(5)
|
||||
.is_some_and(|character| character.is_ascii_digit())
|
||||
});
|
||||
if lower.contains("http error")
|
||||
|| has_http_version_token
|
||||
|| lower.contains("http status")
|
||||
|| lower.contains("response status")
|
||||
|| lower.contains("status code")
|
||||
|| [
|
||||
"status=400",
|
||||
"status=401",
|
||||
"status=403",
|
||||
"status=404",
|
||||
"status=408",
|
||||
"status=410",
|
||||
"status=429",
|
||||
"status=451",
|
||||
"status=500",
|
||||
"status=502",
|
||||
"status=503",
|
||||
"status=504",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| {
|
||||
lower.split_whitespace().any(|token| {
|
||||
token
|
||||
.trim_matches(|character: char| {
|
||||
!character.is_ascii_alphanumeric() && character != '='
|
||||
})
|
||||
== *marker
|
||||
})
|
||||
})
|
||||
{
|
||||
return "http";
|
||||
}
|
||||
"transport"
|
||||
}
|
||||
|
||||
/// Resolve the backoff delay for a 0-based strike. Strikes at or beyond the
|
||||
/// schedule length clamp to the longest slot (10s) rather than panicking, so a
|
||||
/// mis-sized loop degrades gracefully instead of aborting the worker.
|
||||
@@ -179,7 +64,7 @@ pub fn backoff_for(strike: usize) -> Duration {
|
||||
/// Classify an error string as a transient network condition worth retrying.
|
||||
///
|
||||
/// Returns `true` for socket drops, connect/read timeouts, connection resets,
|
||||
/// and transient HTTP status conditions across both download paths:
|
||||
/// and HTTP 408 / request-timeout conditions across both download paths:
|
||||
///
|
||||
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
|
||||
/// `HTTP Error 408`.
|
||||
@@ -237,13 +122,9 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_aria2_name_resolution_error(message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 36] = [
|
||||
const TRANSIENT: [&str; 34] = [
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
@@ -259,8 +140,6 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"connection aborted",
|
||||
"error sending request", // reqwest wrapper for connect/send failures
|
||||
"dns error", // transient resolver failures
|
||||
"name resolution", // aria2 name-resolution failures
|
||||
"could not contact dns", // aria2 c-ares resolver failures
|
||||
"protocol error", // aria2 read/protocol failures after a link drop
|
||||
"tls handshake failure",
|
||||
"ssl/tls handshake failure",
|
||||
@@ -285,12 +164,9 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"timeout.",
|
||||
"invalid range header",
|
||||
];
|
||||
const TRANSIENT_HTTP_STATUS: [&str; 11] = [
|
||||
"408", "429", "500", "502", "503", "504", "520", "521", "522", "523", "524",
|
||||
];
|
||||
TRANSIENT_HTTP_STATUS
|
||||
.iter()
|
||||
.any(|status| contains_http_status(&m, status))
|
||||
contains_http_status(&m, "408")
|
||||
|| contains_http_status(&m, "429")
|
||||
|| contains_http_status(&m, "503")
|
||||
|| TRANSIENT.iter().any(|t| m.contains(t))
|
||||
}
|
||||
|
||||
@@ -366,44 +242,6 @@ mod tests {
|
||||
assert_eq!(MAX_RETRIES, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_aria2_error_code_without_message_material() {
|
||||
for error in [
|
||||
"aria2 error code 19: Could not contact DNS servers",
|
||||
"aria2 error code: 19: Could not contact DNS servers",
|
||||
"aria2 error code (19): Could not contact DNS servers",
|
||||
"aria2 error code=19: Could not contact DNS servers",
|
||||
] {
|
||||
assert_eq!(aria2_error_code(error).as_deref(), Some("19"));
|
||||
}
|
||||
let error =
|
||||
"aria2 error code 19: Could not contact DNS servers for https://example.test/file?token=secret";
|
||||
assert_eq!(network_error_class(error), "name_resolution");
|
||||
assert_eq!(aria2_error_code("aria2 error code: unknown 19"), None);
|
||||
assert!(is_aria2_name_resolution_error("aria2 error code: 19"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_diagnostic_errors_without_echoing_private_details() {
|
||||
assert_eq!(network_error_class("operation not permitted"), "permission");
|
||||
assert_eq!(network_error_class("connect timed out"), "timeout");
|
||||
assert_eq!(network_error_class("invalid range header"), "range");
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for https://example.test/file"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for http://example.test/file"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(
|
||||
network_error_class("error sending request for https://example.test/file?status=503"),
|
||||
"transport"
|
||||
);
|
||||
assert_eq!(network_error_class("ranged GET fallback failed"), "transport");
|
||||
assert_eq!(network_error_class("HTTP Error 503"), "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_for_indexes_then_clamps() {
|
||||
assert_eq!(backoff_for(0), Duration::from_secs(2));
|
||||
@@ -454,16 +292,6 @@ mod tests {
|
||||
assert!(is_transient_network_error("The response status is not successful. status=429"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_rpc_http_gateway_errors_as_transient() {
|
||||
for status in [500, 502, 503, 504, 520, 521, 522, 523, 524] {
|
||||
assert!(
|
||||
is_transient_network_error(&format!("HTTP {status} gateway failure")),
|
||||
"HTTP {status} should be retryable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ytdlp_and_aria2_phrasing_as_transient() {
|
||||
assert!(is_transient_network_error(
|
||||
@@ -485,22 +313,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_aria2_name_resolution_failures_precisely() {
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers."
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"Name resolution for example.test failed: Could not contact DNS server"
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: connection refused"
|
||||
));
|
||||
assert!(!is_aria2_name_resolution_error(
|
||||
"aria2 error code 8: No URI available"
|
||||
));
|
||||
}
|
||||
|
||||
// --- transient classification: negative cases -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-109
@@ -16,14 +16,13 @@ fn stop_is_due(
|
||||
stop_minute: Option<u32>,
|
||||
current_minute: u32,
|
||||
last_start_key: &str,
|
||||
triggered_start_key: &str,
|
||||
start_key: &str,
|
||||
last_stop_key: &str,
|
||||
stop_key: &str,
|
||||
) -> bool {
|
||||
stop_time_enabled
|
||||
&& stop_minute.is_some_and(|stop| current_minute >= stop)
|
||||
&& (last_start_key == start_key || triggered_start_key == start_key)
|
||||
&& last_start_key == start_key
|
||||
&& last_stop_key != stop_key
|
||||
}
|
||||
|
||||
@@ -34,7 +33,6 @@ struct OvernightStopCheck<'a> {
|
||||
current_minute: u32,
|
||||
previous_day_allowed: bool,
|
||||
last_start_key: &'a str,
|
||||
triggered_start_key: &'a str,
|
||||
previous_start_key: &'a str,
|
||||
last_stop_key: &'a str,
|
||||
stop_key: &'a str,
|
||||
@@ -48,7 +46,6 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
|
||||
current_minute,
|
||||
previous_day_allowed,
|
||||
last_start_key,
|
||||
triggered_start_key,
|
||||
previous_start_key,
|
||||
last_stop_key,
|
||||
stop_key,
|
||||
@@ -58,31 +55,10 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool {
|
||||
&& start_minute.zip(stop_minute).is_some_and(|(start, stop)| {
|
||||
stop < start && current_minute >= stop && current_minute < start
|
||||
})
|
||||
&& (last_start_key == previous_start_key || triggered_start_key == previous_start_key)
|
||||
&& last_start_key == previous_start_key
|
||||
&& last_stop_key != stop_key
|
||||
}
|
||||
|
||||
fn persist_scheduler_start_trigger(
|
||||
app_handle: &tauri::AppHandle,
|
||||
settings_cache: &Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
key: &str,
|
||||
) {
|
||||
if let Err(error) = crate::settings::update_settings_state(app_handle, |state| {
|
||||
state.insert(
|
||||
"schedulerTriggeredStartKey".to_string(),
|
||||
serde_json::json!(key),
|
||||
);
|
||||
}) {
|
||||
log::warn!("Failed to persist scheduler start trigger: {error}");
|
||||
}
|
||||
|
||||
if let Ok(mut settings) = settings_cache.write() {
|
||||
if let Some(settings) = settings.as_mut() {
|
||||
settings.scheduler_triggered_start_key = Some(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(
|
||||
app_handle: tauri::AppHandle,
|
||||
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
@@ -90,11 +66,6 @@ pub fn spawn_scheduler(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
|
||||
// Renderer acknowledgement remains the durable completion record, but
|
||||
// a native dispatch marker also survives a closed/unmounted webview so
|
||||
// an overnight stop does not become permanently ineligible. The
|
||||
// process-local start key also covers the same-loop event/stop check.
|
||||
let mut triggered_start_key = String::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
@@ -103,21 +74,11 @@ pub fn spawn_scheduler(
|
||||
(
|
||||
settings.scheduler.clone(),
|
||||
settings.scheduler_last_start_key.clone(),
|
||||
settings
|
||||
.scheduler_triggered_start_key
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
settings.scheduler_last_stop_key.clone(),
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some((
|
||||
scheduler,
|
||||
scheduler_last_start_key,
|
||||
persisted_triggered_start_key,
|
||||
scheduler_last_stop_key,
|
||||
)) = settings
|
||||
{
|
||||
if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings {
|
||||
if !scheduler.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -147,29 +108,13 @@ pub fn spawn_scheduler(
|
||||
.get("start")
|
||||
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
||||
{
|
||||
if persisted_triggered_start_key != start_key
|
||||
&& triggered_start_key != start_key
|
||||
{
|
||||
// Record the dispatch intent before emitting so a
|
||||
// crash between the native event and renderer ack
|
||||
// still makes an overnight stop eligible. Start
|
||||
// events remain retryable until the renderer acks
|
||||
// them, which covers startup/listener races.
|
||||
persist_scheduler_start_trigger(
|
||||
&app_handle,
|
||||
&settings_cache,
|
||||
&start_key,
|
||||
);
|
||||
}
|
||||
if app_handle.emit(
|
||||
let _ = app_handle.emit(
|
||||
"schedule-trigger",
|
||||
serde_json::json!({
|
||||
"action": "start",
|
||||
"key": start_key
|
||||
}),
|
||||
).is_ok() {
|
||||
triggered_start_key = start_key.clone();
|
||||
}
|
||||
);
|
||||
last_emit.insert("start", std::time::Instant::now());
|
||||
}
|
||||
|
||||
@@ -180,13 +125,6 @@ pub fn spawn_scheduler(
|
||||
stop_minute,
|
||||
current_minute,
|
||||
&scheduler_last_start_key,
|
||||
if triggered_start_key == start_key {
|
||||
start_key.as_str()
|
||||
} else if persisted_triggered_start_key == start_key {
|
||||
start_key.as_str()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
&start_key,
|
||||
&scheduler_last_stop_key,
|
||||
&stop_key,
|
||||
@@ -208,13 +146,6 @@ pub fn spawn_scheduler(
|
||||
current_minute,
|
||||
previous_day_allowed,
|
||||
last_start_key: &scheduler_last_start_key,
|
||||
triggered_start_key: if triggered_start_key == previous_start_key {
|
||||
previous_start_key.as_str()
|
||||
} else if persisted_triggered_start_key == previous_start_key {
|
||||
previous_start_key.as_str()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
previous_start_key: &previous_start_key,
|
||||
last_stop_key: &scheduler_last_stop_key,
|
||||
stop_key: &stop_key,
|
||||
@@ -264,7 +195,6 @@ mod tests {
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
@@ -274,43 +204,12 @@ mod tests {
|
||||
Some(480),
|
||||
600,
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_accepts_process_local_start_when_renderer_ack_is_missing() {
|
||||
assert!(stop_is_due(
|
||||
true,
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_stop_accepts_persisted_start_trigger_when_app_restarts() {
|
||||
assert!(overnight_stop_is_due(OvernightStopCheck {
|
||||
stop_time_enabled: true,
|
||||
start_minute: Some(1320),
|
||||
stop_minute: Some(360),
|
||||
current_minute: 420,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "",
|
||||
triggered_start_key: "2026-06-22-start",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_stop_uses_the_previous_day_start() {
|
||||
assert!(overnight_stop_is_due(OvernightStopCheck {
|
||||
@@ -320,7 +219,6 @@ mod tests {
|
||||
current_minute: 420,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
@@ -332,7 +230,6 @@ mod tests {
|
||||
current_minute: 1380,
|
||||
previous_day_allowed: true,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-22-stop",
|
||||
@@ -344,7 +241,6 @@ mod tests {
|
||||
current_minute: 420,
|
||||
previous_day_allowed: false,
|
||||
last_start_key: "2026-06-22-start",
|
||||
triggered_start_key: "",
|
||||
previous_start_key: "2026-06-22-start",
|
||||
last_stop_key: "",
|
||||
stop_key: "2026-06-23-stop",
|
||||
|
||||
+4
-782
@@ -7,118 +7,6 @@ use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TorrentStartupSettings {
|
||||
pub listen_port: String,
|
||||
pub dht_listen_port: String,
|
||||
pub external_ip: String,
|
||||
pub dht_entry_point: String,
|
||||
pub dht_entry_point6: String,
|
||||
pub dht_listen_addr6: String,
|
||||
pub lpd_interface: String,
|
||||
pub peer_id_prefix: String,
|
||||
pub peer_agent: String,
|
||||
pub dht_message_timeout: u32,
|
||||
pub ipv6_enabled: bool,
|
||||
pub bind_address: String,
|
||||
pub disk_cache: String,
|
||||
}
|
||||
|
||||
fn normalize_torrent_startup_value(
|
||||
field: &str,
|
||||
value: &str,
|
||||
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
|
||||
) -> String {
|
||||
match normalize(Some(value)) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => String::new(),
|
||||
Err(error) => {
|
||||
log::error!("invalid persisted {field}; using Aria2 default: {error}");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> TorrentStartupSettings {
|
||||
let Some(settings) = settings else {
|
||||
return TorrentStartupSettings::default();
|
||||
};
|
||||
let bind_address = normalize_torrent_startup_value(
|
||||
"Torrent bind address",
|
||||
&settings.torrent_bind_address,
|
||||
crate::queue::normalize_torrent_bind_address,
|
||||
);
|
||||
let bind_address = if !settings.torrent_ipv6_enabled
|
||||
&& bind_address
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_ipv6())
|
||||
{
|
||||
log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled");
|
||||
String::new()
|
||||
} else {
|
||||
bind_address
|
||||
};
|
||||
|
||||
TorrentStartupSettings {
|
||||
listen_port: normalize_torrent_startup_value(
|
||||
"TCP listen ports",
|
||||
&settings.torrent_listen_port,
|
||||
|value| crate::queue::normalize_torrent_port_spec(value, "TCP listen ports"),
|
||||
),
|
||||
dht_listen_port: normalize_torrent_startup_value(
|
||||
"UDP listen ports",
|
||||
&settings.torrent_dht_listen_port,
|
||||
|value| crate::queue::normalize_torrent_port_spec(value, "UDP listen ports"),
|
||||
),
|
||||
external_ip: normalize_torrent_startup_value(
|
||||
"Torrent external IP",
|
||||
&settings.torrent_external_ip,
|
||||
crate::queue::normalize_torrent_external_ip,
|
||||
),
|
||||
dht_entry_point: normalize_torrent_startup_value(
|
||||
"IPv4 DHT entry point",
|
||||
&settings.torrent_dht_entry_point,
|
||||
|value| crate::queue::normalize_torrent_dht_entry_point(value, false),
|
||||
),
|
||||
dht_entry_point6: normalize_torrent_startup_value(
|
||||
"IPv6 DHT entry point",
|
||||
&settings.torrent_dht_entry_point6,
|
||||
|value| crate::queue::normalize_torrent_dht_entry_point(value, true),
|
||||
),
|
||||
dht_listen_addr6: normalize_torrent_startup_value(
|
||||
"IPv6 DHT listen address",
|
||||
&settings.torrent_dht_listen_addr6,
|
||||
crate::queue::normalize_torrent_dht_listen_addr6,
|
||||
),
|
||||
lpd_interface: normalize_torrent_startup_value(
|
||||
"Torrent LPD interface",
|
||||
&settings.torrent_lpd_interface,
|
||||
crate::queue::normalize_torrent_lpd_interface,
|
||||
),
|
||||
peer_id_prefix: normalize_torrent_startup_value(
|
||||
"Torrent peer ID prefix",
|
||||
&settings.torrent_peer_id_prefix,
|
||||
crate::queue::normalize_torrent_peer_id_prefix,
|
||||
),
|
||||
peer_agent: normalize_torrent_startup_value(
|
||||
"Torrent peer agent",
|
||||
&settings.torrent_peer_agent,
|
||||
crate::queue::normalize_torrent_peer_agent,
|
||||
),
|
||||
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
|
||||
ipv6_enabled: settings.torrent_ipv6_enabled,
|
||||
bind_address,
|
||||
disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
||||
.unwrap_or_else(|error| {
|
||||
log::error!("invalid persisted Aria2 disk cache; using default: {error}");
|
||||
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_settings<R: tauri::Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<PersistedSettings, String> {
|
||||
@@ -144,139 +32,6 @@ pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, Strin
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn canonicalize_torrent_network_value(
|
||||
state: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
normalize: impl Fn(Option<&str>) -> Result<Option<String>, String>,
|
||||
) {
|
||||
let Some(value) = state.get(key).and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let normalized = normalize(Some(value)).ok().flatten().unwrap_or_default();
|
||||
state.insert(key.to_string(), Value::String(normalized));
|
||||
}
|
||||
|
||||
pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, String> {
|
||||
let mut document = decode_document(&Value::String(stored.to_string()))?;
|
||||
let state = settings_state_mut(&mut document)?;
|
||||
canonicalize_torrent_network_value(state, "torrentListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(value, "TCP listen ports")
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(value, "UDP listen ports")
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentExternalIp", crate::queue::normalize_torrent_external_ip);
|
||||
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(value, false)
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtEntryPoint6", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(value, true)
|
||||
});
|
||||
canonicalize_torrent_network_value(state, "torrentDhtListenAddr6", crate::queue::normalize_torrent_dht_listen_addr6);
|
||||
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
|
||||
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
|
||||
canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address);
|
||||
let disk_cache = state
|
||||
.get("aria2DiskCache")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok())
|
||||
.unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
||||
state.insert("aria2DiskCache".to_string(), Value::String(disk_cache));
|
||||
let dht_message_timeout = state
|
||||
.get("torrentDhtMessageTimeout")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
state.insert(
|
||||
"torrentDhtMessageTimeout".to_string(),
|
||||
Value::Number(serde_json::Number::from(dht_message_timeout)),
|
||||
);
|
||||
let max_concurrent_seeds = state
|
||||
.get("torrentMaxConcurrentSeeds")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
state.insert(
|
||||
"torrentMaxConcurrentSeeds".to_string(),
|
||||
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
|
||||
);
|
||||
if !state
|
||||
.get("torrentSeparateSeedSlots")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
|
||||
}
|
||||
if !state
|
||||
.get("torrentIpv6Enabled")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true));
|
||||
}
|
||||
if state
|
||||
.get("torrentIpv6Enabled")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false)
|
||||
&& state
|
||||
.get("torrentBindAddress")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.is_some_and(|address| address.is_ipv6())
|
||||
{
|
||||
return Err(
|
||||
"IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(),
|
||||
);
|
||||
}
|
||||
// Renderer snapshots are also a persistence boundary. Remove malformed
|
||||
// scalar values before the document is written so a recoverable default
|
||||
// is not hidden behind a hostile value that will fail on the next save or
|
||||
// restart. Keep the network canonicalization above first so invalid text
|
||||
// fields retain their established empty-string representation.
|
||||
let state_value = if document.get("state").is_some() {
|
||||
document
|
||||
.get_mut("state")
|
||||
.ok_or_else(|| "persisted settings state is missing".to_string())?
|
||||
} else {
|
||||
&mut document
|
||||
};
|
||||
sanitize_persisted_setting_values(state_value);
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode canonical settings: {error}"))
|
||||
}
|
||||
|
||||
/// Normalize one text setting before the frontend commits it to durable state.
|
||||
/// Keep this on the native boundary so interactive validation and persisted
|
||||
/// settings use exactly the same Aria2-compatible rules.
|
||||
pub fn canonicalize_torrent_network_setting(field: &str, value: &str) -> Result<String, String> {
|
||||
let normalized = match field {
|
||||
"torrentListenPort" => {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports")?
|
||||
}
|
||||
"torrentDhtListenPort" => {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports")?
|
||||
}
|
||||
"torrentExternalIp" => crate::queue::normalize_torrent_external_ip(Some(value))?,
|
||||
"torrentDhtEntryPoint" => {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), false)?
|
||||
}
|
||||
"torrentDhtEntryPoint6" => {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), true)?
|
||||
}
|
||||
"torrentDhtListenAddr6" => {
|
||||
crate::queue::normalize_torrent_dht_listen_addr6(Some(value))?
|
||||
}
|
||||
"torrentLpdInterface" => crate::queue::normalize_torrent_lpd_interface(Some(value))?,
|
||||
"torrentPeerIdPrefix" => crate::queue::normalize_torrent_peer_id_prefix(Some(value))?,
|
||||
"torrentPeerAgent" => crate::queue::normalize_torrent_peer_agent(Some(value))?,
|
||||
"torrentBindAddress" => crate::queue::normalize_torrent_bind_address(Some(value))?,
|
||||
"aria2DiskCache" => return crate::queue::normalize_aria2_disk_cache(Some(value)),
|
||||
_ => return Err("unknown Torrent network setting".to_string()),
|
||||
};
|
||||
Ok(normalized.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn update_settings_state(
|
||||
app_handle: &AppHandle,
|
||||
update: impl FnOnce(&mut Map<String, Value>),
|
||||
@@ -315,11 +70,7 @@ pub fn preserve_scheduler_runtime_keys(
|
||||
};
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
for key in [
|
||||
"schedulerLastStartKey",
|
||||
"schedulerTriggeredStartKey",
|
||||
"schedulerLastStopKey",
|
||||
] {
|
||||
for key in ["schedulerLastStartKey", "schedulerLastStopKey"] {
|
||||
if let Some(value) = existing_state.get(key) {
|
||||
incoming_state.insert(key.to_string(), value.clone());
|
||||
}
|
||||
@@ -434,101 +185,9 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
return;
|
||||
};
|
||||
|
||||
let main_window_size = state
|
||||
.get("mainWindowSize")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<crate::ipc::MainWindowSize>(value).ok())
|
||||
.and_then(|size| crate::window_geometry::normalize_main_window_size(Some(&size)));
|
||||
match main_window_size {
|
||||
Some(size) => {
|
||||
state.insert(
|
||||
"mainWindowSize".to_string(),
|
||||
serde_json::to_value(size).expect("main window size is serializable"),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
state.remove("mainWindowSize");
|
||||
}
|
||||
}
|
||||
|
||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "proxyPort", |value| {
|
||||
value
|
||||
.as_u64()
|
||||
.is_some_and(|value| (1..=u16::MAX as u64).contains(&value))
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxOpenFiles", |value| {
|
||||
value
|
||||
.as_u64()
|
||||
.is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_OPEN_FILES as u64..=
|
||||
crate::queue::MAX_TORRENT_MAX_OPEN_FILES as u64)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
for key in [
|
||||
"isSidebarVisible",
|
||||
"torrentEnableDht",
|
||||
"torrentEnableDht6",
|
||||
"torrentEnablePex",
|
||||
"torrentEnableLpd",
|
||||
"torrentSeparateSeedSlots",
|
||||
"torrentIpv6Enabled",
|
||||
] {
|
||||
sanitize_boolean_setting(state, key);
|
||||
}
|
||||
for key in ["proxyHost", "customUserAgent"] {
|
||||
sanitize_string_setting(state, key);
|
||||
}
|
||||
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "UDP listen ports").is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentExternalIp", |value| {
|
||||
crate::queue::normalize_torrent_external_ip(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtEntryPoint", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), false).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtEntryPoint6", |value| {
|
||||
crate::queue::normalize_torrent_dht_entry_point(Some(value), true).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentDhtListenAddr6", |value| {
|
||||
crate::queue::normalize_torrent_dht_listen_addr6(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentLpdInterface", |value| {
|
||||
crate::queue::normalize_torrent_lpd_interface(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentPeerIdPrefix", |value| {
|
||||
crate::queue::normalize_torrent_peer_id_prefix(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentPeerAgent", |value| {
|
||||
crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "torrentBindAddress", |value| {
|
||||
crate::queue::normalize_torrent_bind_address(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_torrent_network_string(state, "aria2DiskCache", |value| {
|
||||
crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok()
|
||||
});
|
||||
sanitize_allowed_string(
|
||||
state,
|
||||
"theme",
|
||||
@@ -614,32 +273,6 @@ fn sanitize_integer_setting(
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_boolean_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
||||
if state.get(key).is_some_and(|value| !value.is_boolean()) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_string_setting(state: &mut serde_json::Map<String, Value>, key: &str) {
|
||||
if state.get(key).is_some_and(|value| !value.is_string()) {
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_torrent_network_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
is_valid: impl Fn(&str) -> bool,
|
||||
) {
|
||||
if state
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !is_valid(value))
|
||||
{
|
||||
state.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_allowed_string(
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
@@ -655,113 +288,12 @@ fn sanitize_allowed_string(
|
||||
}
|
||||
|
||||
fn validate_settings(settings: &mut PersistedSettings) {
|
||||
settings.main_window_size = crate::window_geometry::normalize_main_window_size(
|
||||
settings.main_window_size.as_ref(),
|
||||
);
|
||||
if settings.max_concurrent_downloads == 0 {
|
||||
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
|
||||
}
|
||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||
settings.minimum_normal_download_speed_ki_b =
|
||||
crate::queue::normalize_minimum_normal_download_speed_kib(
|
||||
settings.minimum_normal_download_speed_ki_b,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit)
|
||||
.unwrap_or_default();
|
||||
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
|
||||
&settings.torrent_overall_upload_limit,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
settings.torrent_max_open_files = crate::queue::normalize_torrent_max_open_files(
|
||||
settings.torrent_max_open_files,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
|
||||
settings.torrent_max_concurrent_seeds,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address(
|
||||
Some(&settings.torrent_bind_address),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
if !settings.torrent_ipv6_enabled
|
||||
&& settings
|
||||
.torrent_bind_address
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_ipv6())
|
||||
{
|
||||
log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled");
|
||||
settings.torrent_bind_address.clear();
|
||||
}
|
||||
settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
|
||||
.unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
|
||||
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_listen_port),
|
||||
"TCP listen ports",
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_dht_listen_port),
|
||||
"UDP listen ports",
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_external_ip = crate::queue::normalize_torrent_external_ip(
|
||||
Some(&settings.torrent_external_ip),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_entry_point = crate::queue::normalize_torrent_dht_entry_point(
|
||||
Some(&settings.torrent_dht_entry_point),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_entry_point6 = crate::queue::normalize_torrent_dht_entry_point(
|
||||
Some(&settings.torrent_dht_entry_point6),
|
||||
true,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_dht_listen_addr6 = crate::queue::normalize_torrent_dht_listen_addr6(
|
||||
Some(&settings.torrent_dht_listen_addr6),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_lpd_interface = crate::queue::normalize_torrent_lpd_interface(
|
||||
Some(&settings.torrent_lpd_interface),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_peer_id_prefix = crate::queue::normalize_torrent_peer_id_prefix(
|
||||
Some(&settings.torrent_peer_id_prefix),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.torrent_peer_agent = crate::queue::normalize_torrent_peer_agent(
|
||||
Some(&settings.torrent_peer_agent),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
if !matches!(
|
||||
settings.last_custom_speed_limit_unit.as_str(),
|
||||
"KB/s" | "MB/s"
|
||||
@@ -778,7 +310,6 @@ fn default_category_subfolders() -> HashMap<String, String> {
|
||||
("Documents", "Documents"),
|
||||
("Pictures", "Pictures"),
|
||||
("Applications", "Applications"),
|
||||
("Torrents", "Torrents"),
|
||||
("Other", "Other"),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -848,7 +379,6 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
|
||||
("Documents", "Documents"),
|
||||
("Pictures", "Images"),
|
||||
("Applications", "Apps"),
|
||||
("Torrents", "Torrents"),
|
||||
("Other", "Other"),
|
||||
];
|
||||
for (category, alias) in aliases {
|
||||
@@ -919,12 +449,9 @@ fn default_settings() -> PersistedSettings {
|
||||
approved_download_roots: Vec::new(),
|
||||
max_concurrent_downloads: 3,
|
||||
global_speed_limit: String::new(),
|
||||
torrent_overall_upload_limit: String::new(),
|
||||
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
|
||||
logs_enabled: false,
|
||||
is_sidebar_visible: true,
|
||||
is_folders_collapsed: false,
|
||||
main_window_size: None,
|
||||
sidebar_position: "auto".to_string(),
|
||||
active_settings_tab: SettingsTab::Downloads,
|
||||
scheduler: SchedulerSettings {
|
||||
@@ -940,15 +467,11 @@ fn default_settings() -> PersistedSettings {
|
||||
scheduler_running: false,
|
||||
scheduler_active_download_ids: Vec::new(),
|
||||
scheduler_last_start_key: String::new(),
|
||||
scheduler_triggered_start_key: None,
|
||||
scheduler_last_stop_key: String::new(),
|
||||
last_custom_speed_limit_ki_b: 1024,
|
||||
last_custom_speed_limit_unit: "MB/s".to_string(),
|
||||
per_server_connections: 16,
|
||||
max_automatic_retries: 3,
|
||||
minimum_normal_download_speed_ki_b: 0,
|
||||
retry_not_found_errors: false,
|
||||
adaptive_mirror_selection: true,
|
||||
show_notifications: true,
|
||||
play_completion_sound: false,
|
||||
auto_add_clipboard_links: false,
|
||||
@@ -959,26 +482,6 @@ fn default_settings() -> PersistedSettings {
|
||||
proxy_mode: ProxyMode::None,
|
||||
proxy_host: String::new(),
|
||||
proxy_port: 8080,
|
||||
torrent_enable_dht: true,
|
||||
torrent_enable_dht6: false,
|
||||
torrent_enable_pex: true,
|
||||
torrent_enable_lpd: false,
|
||||
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
torrent_separate_seed_slots: false,
|
||||
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
torrent_ipv6_enabled: true,
|
||||
torrent_listen_port: String::new(),
|
||||
torrent_dht_listen_port: String::new(),
|
||||
torrent_external_ip: String::new(),
|
||||
torrent_dht_entry_point: String::new(),
|
||||
torrent_dht_entry_point6: String::new(),
|
||||
torrent_dht_listen_addr6: String::new(),
|
||||
torrent_lpd_interface: String::new(),
|
||||
torrent_peer_id_prefix: String::new(),
|
||||
torrent_peer_agent: String::new(),
|
||||
torrent_bind_address: String::new(),
|
||||
aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(),
|
||||
custom_user_agent: String::new(),
|
||||
ask_where_to_save_each_file: false,
|
||||
remember_last_used_download_directory: false,
|
||||
@@ -995,10 +498,8 @@ fn default_settings() -> PersistedSettings {
|
||||
mod tests {
|
||||
use crate::ipc::{FontFamily, WindowControlStyle};
|
||||
use super::{
|
||||
canonicalize_torrent_network_setting, canonicalize_torrent_network_settings,
|
||||
decode_stored_settings, default_settings,
|
||||
preserve_portable_pairing_token, preserve_scheduler_runtime_keys,
|
||||
torrent_startup_settings,
|
||||
decode_stored_settings, default_settings, preserve_portable_pairing_token,
|
||||
preserve_scheduler_runtime_keys,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -1007,7 +508,6 @@ mod tests {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "2026-06-22-start",
|
||||
"schedulerTriggeredStartKey": "2026-06-22-start",
|
||||
"schedulerLastStopKey": "2026-06-22-stop"
|
||||
},
|
||||
"version": 3
|
||||
@@ -1016,7 +516,6 @@ mod tests {
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "",
|
||||
"schedulerTriggeredStartKey": "",
|
||||
"schedulerLastStopKey": "",
|
||||
"theme": "system"
|
||||
},
|
||||
@@ -1027,10 +526,6 @@ mod tests {
|
||||
let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap();
|
||||
let merged: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start");
|
||||
assert_eq!(
|
||||
merged["state"]["schedulerTriggeredStartKey"],
|
||||
"2026-06-22-start"
|
||||
);
|
||||
assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop");
|
||||
}
|
||||
|
||||
@@ -1040,7 +535,6 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 7,
|
||||
"globalSpeedLimit": "2M",
|
||||
"torrentOverallUploadLimit": "1.5M",
|
||||
"sidebarPosition": "right",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
@@ -1059,7 +553,6 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 7);
|
||||
assert_eq!(settings.global_speed_limit, "2M");
|
||||
assert_eq!(settings.torrent_overall_upload_limit, "1.5M");
|
||||
assert_eq!(settings.sidebar_position, "right");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
@@ -1085,7 +578,6 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.max_concurrent_downloads, 5);
|
||||
assert_eq!(settings.global_speed_limit, "512K");
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
|
||||
assert!(!settings.logs_enabled);
|
||||
@@ -1108,33 +600,6 @@ mod tests {
|
||||
assert!(settings.logs_enabled);
|
||||
assert!(!settings.scheduler.enabled);
|
||||
assert!(settings.global_speed_limit.is_empty());
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_invalid_torrent_overall_upload_limit_to_unlimited() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentOverallUploadLimit": "not-a-rate"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(settings.torrent_overall_upload_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_invalid_global_speed_limit_to_unlimited() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"globalSpeedLimit": "not-a-rate"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(settings.global_speed_limit.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1154,7 +619,6 @@ mod tests {
|
||||
|
||||
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
|
||||
assert_eq!(settings.category_subfolders["Movies"], "Movies");
|
||||
assert_eq!(settings.category_subfolders["Torrents"], "Torrents");
|
||||
assert!(!settings.category_directory_overrides.contains_key("Movies"));
|
||||
assert_eq!(
|
||||
settings.category_directory_overrides["Documents"],
|
||||
@@ -1286,8 +750,7 @@ mod tests {
|
||||
"state": {
|
||||
"maxConcurrentDownloads": 99,
|
||||
"perServerConnections": -4,
|
||||
"maxAutomaticRetries": 99,
|
||||
"minimumNormalDownloadSpeedKiB": 2000000
|
||||
"maxAutomaticRetries": 99
|
||||
},
|
||||
"version": 3
|
||||
});
|
||||
@@ -1297,17 +760,6 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 12);
|
||||
assert_eq!(settings.per_server_connections, 1);
|
||||
assert_eq!(settings.max_automatic_retries, 10);
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_reliability_defaults_are_migration_safe() {
|
||||
let stored = json!({ "state": {}, "version": 5 });
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
assert!(!settings.retry_not_found_errors);
|
||||
assert!(settings.adaptive_mirror_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1318,20 +770,6 @@ mod tests {
|
||||
"maxConcurrentDownloads": "not-a-number",
|
||||
"perServerConnections": 5,
|
||||
"showNotifications": "yes",
|
||||
"torrentEnableDht": "yes",
|
||||
"torrentEnableDht6": 1,
|
||||
"torrentEnablePex": null,
|
||||
"torrentEnableLpd": [],
|
||||
"torrentMaxOpenFiles": 0,
|
||||
"torrentListenPort": "7000-6999",
|
||||
"torrentDhtListenPort": "6881,\n",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentDhtEntryPoint": "bootstrap.example",
|
||||
"torrentDhtEntryPoint6": "2001:db8::1:6881",
|
||||
"torrentDhtListenAddr6": "127.0.0.1",
|
||||
"torrentLpdInterface": "en0\n--bad",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": "agent\nname",
|
||||
"theme": "not-a-theme",
|
||||
"calendarPreference": "lunar",
|
||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||
@@ -1345,27 +783,6 @@ mod tests {
|
||||
assert_eq!(settings.max_concurrent_downloads, 3);
|
||||
assert_eq!(settings.per_server_connections, 5);
|
||||
assert!(settings.show_notifications);
|
||||
assert!(settings.torrent_enable_dht);
|
||||
assert!(!settings.torrent_enable_dht6);
|
||||
assert!(settings.torrent_enable_pex);
|
||||
assert!(!settings.torrent_enable_lpd);
|
||||
assert_eq!(
|
||||
settings.torrent_max_open_files,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
);
|
||||
assert_eq!(
|
||||
settings.torrent_dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert!(settings.torrent_listen_port.is_empty());
|
||||
assert!(settings.torrent_dht_listen_port.is_empty());
|
||||
assert!(settings.torrent_external_ip.is_empty());
|
||||
assert!(settings.torrent_dht_entry_point.is_empty());
|
||||
assert!(settings.torrent_dht_entry_point6.is_empty());
|
||||
assert!(settings.torrent_dht_listen_addr6.is_empty());
|
||||
assert!(settings.torrent_lpd_interface.is_empty());
|
||||
assert!(settings.torrent_peer_id_prefix.is_empty());
|
||||
assert!(settings.torrent_peer_agent.is_empty());
|
||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||
assert!(matches!(
|
||||
settings.calendar_preference,
|
||||
@@ -1375,160 +792,10 @@ mod tests {
|
||||
assert_eq!(settings.site_logins[0].id, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_proxy_and_user_agent_values_fall_back_to_safe_defaults() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"proxyMode": "custom",
|
||||
"proxyHost": 123,
|
||||
"proxyPort": 70000,
|
||||
"customUserAgent": ["not-a-string"],
|
||||
"isSidebarVisible": "yes"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert!(matches!(settings.proxy_mode, crate::ipc::ProxyMode::Custom));
|
||||
assert!(settings.proxy_host.is_empty());
|
||||
assert_eq!(settings.proxy_port, 8080);
|
||||
assert!(settings.custom_user_agent.is_empty());
|
||||
assert!(settings.is_sidebar_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_valid_torrent_network_settings() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentDhtListenPort": "6881",
|
||||
"torrentExternalIp": "203.0.113.7",
|
||||
"torrentDhtEntryPoint": "Bootstrap.Example:6881",
|
||||
"torrentDhtEntryPoint6": "[2001:db8::1]:6881",
|
||||
"torrentDhtListenAddr6": "2001:db8::2",
|
||||
"torrentLpdInterface": "en0",
|
||||
"torrentPeerIdPrefix": "-FL-1-3-1-",
|
||||
"torrentPeerAgent": "Firelink/1.3.1"
|
||||
}
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.torrent_listen_port, "6881-6999");
|
||||
assert_eq!(settings.torrent_dht_listen_port, "6881");
|
||||
assert_eq!(settings.torrent_external_ip, "203.0.113.7");
|
||||
assert_eq!(settings.torrent_dht_entry_point, "bootstrap.example:6881");
|
||||
assert_eq!(settings.torrent_dht_entry_point6, "[2001:db8::1]:6881");
|
||||
assert_eq!(settings.torrent_dht_listen_addr6, "2001:db8::2");
|
||||
assert_eq!(settings.torrent_lpd_interface, "en0");
|
||||
assert_eq!(settings.torrent_peer_id_prefix, "-FL-1-3-1-");
|
||||
assert_eq!(settings.torrent_peer_agent, "Firelink/1.3.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_torrent_network_settings_for_frontend_hydration() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": " Firelink/1.3.1 ",
|
||||
"torrentDhtMessageTimeout": 601,
|
||||
"torrentMaxConcurrentSeeds": 65,
|
||||
"torrentSeparateSeedSlots": "yes",
|
||||
"proxyPort": 70000,
|
||||
"proxyHost": 123,
|
||||
"customUserAgent": ["not-a-string"]
|
||||
},
|
||||
"version": 6
|
||||
});
|
||||
|
||||
let canonical = canonicalize_torrent_network_settings(&stored.to_string()).unwrap();
|
||||
let canonical: Value = serde_json::from_str(&canonical).unwrap();
|
||||
assert_eq!(canonical["state"]["torrentListenPort"], "6881-6999");
|
||||
assert_eq!(canonical["state"]["torrentExternalIp"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentDhtMessageTimeout"],
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentMaxConcurrentSeeds"],
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
|
||||
assert!(canonical["state"].get("proxyPort").is_none());
|
||||
assert!(canonical["state"].get("proxyHost").is_none());
|
||||
assert!(canonical["state"].get("customUserAgent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_individual_torrent_network_inputs_with_shared_rules() {
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentListenPort", " 6881-6999 ").unwrap(),
|
||||
"6881-6999"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentDhtEntryPoint6", "[2001:db8::1]:6881")
|
||||
.unwrap(),
|
||||
"[2001:db8::1]:6881"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("aria2DiskCache", " 256m ").unwrap(),
|
||||
"256M"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_torrent_network_setting("torrentBindAddress", " ").unwrap(),
|
||||
""
|
||||
);
|
||||
assert!(canonicalize_torrent_network_setting("torrentListenPort", "61").is_err());
|
||||
assert!(canonicalize_torrent_network_setting("unknown", "value").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ipv6_bind_address_when_transport_is_disabled() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentIpv6Enabled": false,
|
||||
"torrentBindAddress": "2001:db8::10"
|
||||
}
|
||||
});
|
||||
|
||||
let error = canonicalize_torrent_network_settings(&stored.to_string())
|
||||
.expect_err("IPv6 bind must not be accepted with IPv6 transport disabled");
|
||||
assert!(error.contains("IPv6 Torrent bind address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"torrentListenPort": "not-a-port",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": "Firelink/1.3.1"
|
||||
}
|
||||
});
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
let startup = torrent_startup_settings(Some(&settings));
|
||||
assert!(startup.listen_port.is_empty());
|
||||
assert!(startup.peer_id_prefix.is_empty());
|
||||
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
startup.dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
assert!(!default_settings().torrent_separate_seed_slots);
|
||||
assert_eq!(
|
||||
default_settings().torrent_max_concurrent_seeds,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1536,51 +803,6 @@ mod tests {
|
||||
assert!(!default_settings().remember_last_used_download_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_geometry_use_no_persisted_size() {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({ "state": { "theme": "system" }, "version": 0 }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(settings.main_window_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_main_window_geometry_round_trips() {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({
|
||||
"state": { "mainWindowSize": { "width": 1440, "height": 900 } },
|
||||
"version": 6
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
settings
|
||||
.main_window_size
|
||||
.as_ref()
|
||||
.map(|size| (size.width, size.height)),
|
||||
Some((1440, 900))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_out_of_range_geometry_is_dropped() {
|
||||
for geometry in [
|
||||
json!({ "width": "1440", "height": 900 }),
|
||||
json!({ "width": 959, "height": 900 }),
|
||||
json!({ "width": 1440, "height": 16_385 }),
|
||||
] {
|
||||
let settings = decode_stored_settings(&Value::String(
|
||||
json!({ "state": { "mainWindowSize": geometry }, "version": 6 }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(settings.main_window_size.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_disabled_last_used_download_directory_setting() {
|
||||
let stored = json!({
|
||||
|
||||
+2
-277
@@ -5,11 +5,6 @@ pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
const ARIA2_DATA_DIR: &str = "aria2";
|
||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||
const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt";
|
||||
const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
@@ -109,149 +104,10 @@ impl StorageLayout {
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
|
||||
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
(
|
||||
directory.join(ARIA2_DHT_FILE),
|
||||
directory.join(ARIA2_DHT6_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn aria2_server_stat_path(&self) -> PathBuf {
|
||||
self.data_dir
|
||||
.join(ARIA2_DATA_DIR)
|
||||
.join(ARIA2_SERVER_STAT_FILE)
|
||||
}
|
||||
|
||||
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||
/// the table contents; Firelink owns this exact location and must never
|
||||
/// fall back to a user-global default when it cannot establish it.
|
||||
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err(format!(
|
||||
"Aria2 state directory contains a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
|
||||
match std::fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"Aria2 state directory is a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
return Err(format!(
|
||||
"Aria2 state path is not a directory: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(&directory).map_err(|error| {
|
||||
format!(
|
||||
"failed to create Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.aria2_dht_paths())
|
||||
}
|
||||
|
||||
/// Prepare the exact cache file used by Aria2's adaptive URI selector.
|
||||
/// The cache is non-authoritative: malformed or oversized contents are
|
||||
/// reset to empty, while symlinks and non-files disable the cache instead
|
||||
/// of allowing Aria2 to write outside Firelink's storage boundary.
|
||||
pub fn prepare_aria2_server_stat_path(&self) -> Result<PathBuf, String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err("Aria2 server-stat directory contains a symlink".to_string());
|
||||
}
|
||||
std::fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?;
|
||||
|
||||
let path = self.aria2_server_stat_path();
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err("Aria2 server-stat cache is a symlink".to_string());
|
||||
}
|
||||
Ok(metadata) if !metadata.is_file() => {
|
||||
return Err("Aria2 server-stat cache is not a regular file".to_string());
|
||||
}
|
||||
Ok(metadata) => {
|
||||
let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.is_some_and(|contents| aria2_server_stat_is_valid(&contents));
|
||||
if !valid {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to reset Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("failed to create Aria2 server-stat cache: {error}")
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 server-stat cache: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn aria2_server_stat_is_valid(contents: &str) -> bool {
|
||||
contents.lines().all(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if line.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
let fields = line
|
||||
.split(',')
|
||||
.filter_map(|field| field.trim().split_once('='))
|
||||
.map(|(name, value)| (name.trim(), value.trim()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
["host", "protocol", "dl_speed", "last_updated", "status"]
|
||||
.iter()
|
||||
.all(|name| fields.get(name).is_some_and(|value| !value.is_empty()))
|
||||
})
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
if crate::path_has_symbolic_link_component(path) {
|
||||
if crate::path_has_symlink_component(path) {
|
||||
return Err(format!(
|
||||
"storage path contains a symlinked component: '{}'",
|
||||
path.display()
|
||||
@@ -298,7 +154,7 @@ fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -326,109 +182,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn test_layout(data_dir: &Path) -> StorageLayout {
|
||||
let data_dir = fs::canonicalize(data_dir).unwrap();
|
||||
StorageLayout {
|
||||
mode: StorageMode::Standard,
|
||||
data_dir: data_dir.clone(),
|
||||
log_dir: data_dir.join("logs"),
|
||||
webview_dir: data_dir.join("webview"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
layout.aria2_dht_paths(),
|
||||
(
|
||||
root_path.join("aria2/dht.dat"),
|
||||
root_path.join("aria2/dht6.dat")
|
||||
)
|
||||
);
|
||||
let prepared = layout.prepare_aria2_dht_paths().unwrap();
|
||||
assert_eq!(prepared, layout.aria2_dht_paths());
|
||||
assert!(root_path.join("aria2").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not a directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
let path = layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(path, layout.aria2_server_stat_path());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
fs::write(&path, "not an aria2 server profile\n").unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
|
||||
let valid =
|
||||
"host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n";
|
||||
fs::write(&path, valid).unwrap();
|
||||
layout.prepare_aria2_server_stat_path().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_server_stat_cache_rejects_symlink_output() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
layout.prepare_aria2_dht_paths().unwrap();
|
||||
symlink(
|
||||
target.path().join("outside"),
|
||||
layout.aria2_server_stat_path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(layout.prepare_aria2_server_stat_path().is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
symlink(target.path(), root_path.join("aria2")).unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
@@ -455,32 +208,4 @@ mod tests {
|
||||
|
||||
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accepts_windows_junctions_for_redirected_storage_paths() {
|
||||
use std::process::Command;
|
||||
|
||||
let parent = TempDir::new().unwrap();
|
||||
let spaced_parent = parent.path().join("firelink test data");
|
||||
fs::create_dir(&spaced_parent).unwrap();
|
||||
let root = TempDir::new_in(&spaced_parent).unwrap();
|
||||
let target = TempDir::new_in(&spaced_parent).unwrap();
|
||||
let redirected = root.path().join("redirected");
|
||||
let target_storage = target.path().join("firelink");
|
||||
fs::create_dir(&target_storage).unwrap();
|
||||
|
||||
let status = Command::new("cmd")
|
||||
.args(["/D", "/C", "mklink", "/J"])
|
||||
.arg(&redirected)
|
||||
.arg(target.path())
|
||||
.status()
|
||||
.expect("Windows junction creation command should start");
|
||||
assert!(status.success(), "mklink /J failed with status {status}");
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_storage_path(&redirected.join("firelink")).unwrap(),
|
||||
fs::canonicalize(target_storage).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
use crate::ipc::MainWindowSize;
|
||||
|
||||
pub const MAIN_WINDOW_DEFAULT_WIDTH: u32 = 1280;
|
||||
pub const MAIN_WINDOW_DEFAULT_HEIGHT: u32 = 800;
|
||||
pub const MAIN_WINDOW_MIN_WIDTH: u32 = 960;
|
||||
pub const MAIN_WINDOW_MIN_HEIGHT: u32 = 640;
|
||||
pub const MAIN_WINDOW_MAX_WIDTH: u32 = 16_384;
|
||||
pub const MAIN_WINDOW_MAX_HEIGHT: u32 = 16_384;
|
||||
|
||||
pub fn default_main_window_size() -> MainWindowSize {
|
||||
MainWindowSize {
|
||||
width: MAIN_WINDOW_DEFAULT_WIDTH,
|
||||
height: MAIN_WINDOW_DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_main_window_size(size: Option<&MainWindowSize>) -> Option<MainWindowSize> {
|
||||
let size = size?;
|
||||
if size.width < MAIN_WINDOW_MIN_WIDTH
|
||||
|| size.height < MAIN_WINDOW_MIN_HEIGHT
|
||||
|| size.width > MAIN_WINDOW_MAX_WIDTH
|
||||
|| size.height > MAIN_WINDOW_MAX_HEIGHT
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(size.clone())
|
||||
}
|
||||
|
||||
pub fn clamp_main_window_size(
|
||||
size: MainWindowSize,
|
||||
work_area_width: u32,
|
||||
work_area_height: u32,
|
||||
) -> MainWindowSize {
|
||||
let width_limit = work_area_width.max(MAIN_WINDOW_MIN_WIDTH);
|
||||
let height_limit = work_area_height.max(MAIN_WINDOW_MIN_HEIGHT);
|
||||
MainWindowSize {
|
||||
width: size.width.min(width_limit),
|
||||
height: size.height.min(height_limit),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
clamp_main_window_size, default_main_window_size, normalize_main_window_size,
|
||||
MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH,
|
||||
};
|
||||
use crate::ipc::MainWindowSize;
|
||||
|
||||
#[test]
|
||||
fn default_size_matches_the_main_window_configuration() {
|
||||
assert_eq!(default_main_window_size().width, 1280);
|
||||
assert_eq!(default_main_window_size().height, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sizes_outside_the_persisted_bounds() {
|
||||
assert!(normalize_main_window_size(Some(&MainWindowSize {
|
||||
width: MAIN_WINDOW_MIN_WIDTH - 1,
|
||||
height: 800,
|
||||
}))
|
||||
.is_none());
|
||||
assert!(normalize_main_window_size(Some(&MainWindowSize {
|
||||
width: 1280,
|
||||
height: 16_385,
|
||||
}))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_a_valid_size_to_the_available_work_area() {
|
||||
let clamped = clamp_main_window_size(
|
||||
MainWindowSize {
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
},
|
||||
1280,
|
||||
720,
|
||||
);
|
||||
assert_eq!(clamped.width, 1280);
|
||||
assert_eq!(clamped.height, 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_minimum_when_the_work_area_is_shorter_than_the_minimum() {
|
||||
let clamped = clamp_main_window_size(
|
||||
MainWindowSize {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
},
|
||||
800,
|
||||
500,
|
||||
);
|
||||
assert_eq!(clamped.width, MAIN_WINDOW_MIN_WIDTH);
|
||||
assert_eq!(clamped.height, MAIN_WINDOW_MIN_HEIGHT);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false
|
||||
@@ -38,26 +38,12 @@
|
||||
"resources": {
|
||||
"engine-dist/": "engine-dist/",
|
||||
"../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["torrent"],
|
||||
"mimeType": "application/x-bittorrent",
|
||||
"name": "BitTorrent file",
|
||||
"description": "BitTorrent metadata file",
|
||||
"role": "Viewer",
|
||||
"rank": "Alternate",
|
||||
"exportedType": {
|
||||
"identifier": "org.bittorrent.torrent",
|
||||
"conformsTo": ["public.data", "public.item"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["firelink", "magnet"]
|
||||
"schemes": ["firelink"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
+119
-236
@@ -10,17 +10,11 @@ import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { flushDownloadPersistence, initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import {
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore,
|
||||
waitForSettingsPersistence
|
||||
} from "./store/useSettingsStore";
|
||||
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { setLogStreamActive } from './utils/logger';
|
||||
import { updateDockBadge } from './utils/dockBadge';
|
||||
@@ -39,17 +33,6 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
consumeSchedulerHandoffIds,
|
||||
handoffSupersededSchedulerIds,
|
||||
isSchedulerControlCurrent
|
||||
} from './utils/schedulerControl';
|
||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
@@ -66,6 +49,9 @@ const SettingsView = lazy(loadSettingsView);
|
||||
const SchedulerView = lazy(loadSchedulerView);
|
||||
const SpeedLimiterView = lazy(loadSpeedLimiterView);
|
||||
const LogsView = lazy(loadLogsView);
|
||||
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
|
||||
default: module.PropertiesModal,
|
||||
})));
|
||||
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
|
||||
default: module.DeleteConfirmationModal,
|
||||
})));
|
||||
@@ -114,6 +100,7 @@ const PageLoadingFallback = () => {
|
||||
};
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
let powerPreferencesSync: Promise<void> = Promise.resolve();
|
||||
|
||||
const waitForSettingsHydration = (): Promise<void> => {
|
||||
@@ -200,9 +187,6 @@ function App() {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||
});
|
||||
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreSidebarFocusRef = useRef(false);
|
||||
|
||||
const theme = useSettingsStore(state => state.theme);
|
||||
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||
@@ -242,6 +226,7 @@ function App() {
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
@@ -264,7 +249,6 @@ function App() {
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const extensionProcessing = useRef(createSerialTaskQueue());
|
||||
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
||||
const pendingStartupInputs = useRef<Array<
|
||||
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
||||
@@ -275,6 +259,7 @@ function App() {
|
||||
const preventsDisplaySleepWhileDownloading = useSettingsStore(
|
||||
state => state.preventsDisplaySleepWhileDownloading
|
||||
);
|
||||
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const { addToast, removeToast } = useToast();
|
||||
const isMacUserAgent = navigator.userAgent.includes('Mac');
|
||||
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
|
||||
@@ -318,79 +303,12 @@ function App() {
|
||||
return update;
|
||||
}, []);
|
||||
|
||||
const acknowledgeExtensionDownload = useCallback(async (requestId?: string) => {
|
||||
if (!requestId) return;
|
||||
try {
|
||||
await invoke('ack_extension_download', { requestId });
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processExtensionDownload = useCallback(async (payload: ExtensionDownloadRequest) => {
|
||||
await useDownloadStore.getState().handleExtensionDownload(payload);
|
||||
await acknowledgeExtensionDownload(payload.request_id);
|
||||
}, [acknowledgeExtensionDownload]);
|
||||
|
||||
const enqueueAddInput = useCallback((task: () => void | Promise<void>) => {
|
||||
return extensionProcessing.current(task);
|
||||
}, []);
|
||||
|
||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||
clearPendingPostActionTimer();
|
||||
|
||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const showForceActionToast = () => {
|
||||
let forceToastId: string | null = null;
|
||||
const proceed = () => {
|
||||
if (forceToastId !== null) {
|
||||
removeToast(forceToastId);
|
||||
forceToastId = null;
|
||||
}
|
||||
invoke('perform_system_action', { action, force: true }).catch(error => {
|
||||
console.error('Forced scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
forceToastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
duration: 0,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{t($ => $.app.systemActionCancelled)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={proceed}
|
||||
>
|
||||
{t($ => $.app.systemActionProceedAnyway)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
};
|
||||
const perform = (force: boolean) => {
|
||||
invoke('perform_system_action', { action, force }).catch(error => {
|
||||
const detail = String(error);
|
||||
if (!force && detail.includes('active or queued')) {
|
||||
showForceActionToast();
|
||||
return;
|
||||
}
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
const cancel = () => {
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
@@ -432,117 +350,67 @@ function App() {
|
||||
isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (activeTransfers) {
|
||||
showForceActionToast();
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionCancelled),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
perform(false);
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
pendingPostActionTimer.current = timerId;
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
|
||||
}, [addToast, clearPendingPostActionTimer, removeToast]);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
sidebarResizeCleanupRef.current?.();
|
||||
event.preventDefault();
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture is best-effort; the session still listens on window.
|
||||
}
|
||||
const cleanup = createSidebarResizeSession({
|
||||
windowTarget: window,
|
||||
body: document.body,
|
||||
captureTarget: event.currentTarget,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: sidebarWidth,
|
||||
isRight: isSidebarOnRight,
|
||||
onWidth: setSidebarWidth,
|
||||
});
|
||||
sidebarResizeCleanupRef.current = cleanup;
|
||||
};
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
useEffect(() => () => {
|
||||
sidebarResizeCleanupRef.current?.();
|
||||
}, []);
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const delta = isSidebarOnRight
|
||||
? startX - moveEvent.clientX
|
||||
: moveEvent.clientX - startX;
|
||||
const nextWidth = Math.min(260, Math.max(190, startWidth + delta));
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSidebarVisible) return;
|
||||
if (restoreSidebarFocusRef.current) {
|
||||
restoreSidebarFocusRef.current = false;
|
||||
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [isSidebarVisible]);
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
document.body.classList.remove('is-resizing');
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
if (isSidebarVisible) {
|
||||
const activeElement = document.activeElement;
|
||||
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
|
||||
&& Boolean(activeElement.closest('.app-sidebar-shell'));
|
||||
}
|
||||
toggleSidebar();
|
||||
document.body.classList.add('is-resizing');
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return clearPendingPostActionTimer;
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTransferCount > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
}
|
||||
}, [activeTransferCount, clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
initMediaDomains();
|
||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
|
||||
let active = true;
|
||||
let exitRequested = false;
|
||||
let exiting = false;
|
||||
let settingsHydrated = useSettingsStore.persist.hasHydrated();
|
||||
let latestSizeBeforeHydration: MainWindowSize | null = null;
|
||||
const unlistenSettingsHydration = settingsHydrated
|
||||
? null
|
||||
: useSettingsStore.persist.onFinishHydration(() => {
|
||||
settingsHydrated = true;
|
||||
const size = latestSizeBeforeHydration;
|
||||
latestSizeBeforeHydration = null;
|
||||
if (size && active && !exitRequested && !exiting) {
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
const mainWindowSizePersistence = createMainWindowSizePersistence({
|
||||
appWindow: getCurrentWindow(),
|
||||
onSize: size => {
|
||||
if (!active || exiting) return;
|
||||
if (!settingsHydrated) {
|
||||
latestSizeBeforeHydration = size;
|
||||
return;
|
||||
}
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
const exitListener = listen('app-exit-requested', async () => {
|
||||
exitRequested = true;
|
||||
try {
|
||||
await mainWindowSizePersistence.flush();
|
||||
await waitForSettingsPersistence();
|
||||
await flushDownloadPersistence();
|
||||
} catch (error) {
|
||||
console.error('Failed to flush download state before exit:', error);
|
||||
} finally {
|
||||
exiting = true;
|
||||
latestSizeBeforeHydration = null;
|
||||
await invoke('ack_frontend_exit').catch(error => {
|
||||
console.error('Failed to acknowledge frontend exit flush:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
void exitListener.then(unlisten => {
|
||||
if (active) unlistenExit = unlisten;
|
||||
else unlisten();
|
||||
}).catch(error => {
|
||||
console.error('Failed to listen for frontend exit flush:', error);
|
||||
});
|
||||
const initialize = async () => {
|
||||
let unlistenDownload: (() => void) | null = null;
|
||||
let unlistenTerminalState: (() => void) | null = null;
|
||||
@@ -550,9 +418,6 @@ function App() {
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
mainWindowSizePersistence.dispose();
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenTerminalState?.();
|
||||
unlistenTerminalState = null;
|
||||
unlistenExtension?.();
|
||||
@@ -693,11 +558,16 @@ function App() {
|
||||
}
|
||||
});
|
||||
unlistenExtension = await listen('extension-add-download', (event) => {
|
||||
if (event.payload.request_id) {
|
||||
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
||||
console.error('Failed to acknowledge browser extension download:', error);
|
||||
});
|
||||
}
|
||||
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
||||
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
enqueueAddInput(() => processExtensionDownload(event.payload)).catch(error => {
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
||||
console.error('Failed to handle browser extension download:', error);
|
||||
});
|
||||
});
|
||||
@@ -706,7 +576,7 @@ function App() {
|
||||
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
||||
return;
|
||||
}
|
||||
enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(event.payload));
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
|
||||
cleanupListeners = disposeListeners;
|
||||
@@ -754,13 +624,8 @@ function App() {
|
||||
pendingStartupInputs.current = [];
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenSettingsHydration?.();
|
||||
mainWindowSizePersistence.dispose();
|
||||
disposePersistence();
|
||||
};
|
||||
}, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]);
|
||||
}, [addToast, queueFrontendReadyUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
@@ -782,14 +647,14 @@ function App() {
|
||||
const pendingInputs = pendingStartupInputs.current.splice(0);
|
||||
for (const input of pendingInputs) {
|
||||
if (input.type === 'extension') {
|
||||
enqueueAddInput(() => processExtensionDownload(input.payload)).catch(error => {
|
||||
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
||||
console.error('Failed to handle queued browser extension download:', error);
|
||||
});
|
||||
} else {
|
||||
enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(input.payload));
|
||||
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
||||
}
|
||||
}
|
||||
}, [coreReady, enqueueAddInput, processExtensionDownload, showKeychainModal]);
|
||||
}, [coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
||||
@@ -804,13 +669,17 @@ function App() {
|
||||
});
|
||||
}, [addToast, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => synchronizeDocumentAppearance(window, {
|
||||
theme,
|
||||
fontFamily,
|
||||
appFontSize,
|
||||
listRowDensity,
|
||||
locale: resolveAppLocale(i18n.language),
|
||||
}), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]);
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-family', fontFamily);
|
||||
}, [fontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
}, [appFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-list-density', listRowDensity);
|
||||
}, [listRowDensity]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdate = () => {
|
||||
@@ -914,11 +783,6 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
// Scope duplicate suppression to this listener instance. A module-level
|
||||
// set can retain a key across a webview/listener restart while the old
|
||||
// async handler is still unwinding, causing the replacement listener to
|
||||
// drop the only retry for that scheduled action.
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
const payload = event.payload;
|
||||
@@ -928,7 +792,6 @@ function App() {
|
||||
if (payload.action === 'start') {
|
||||
clearPendingPostActionTimer();
|
||||
const scheduledQueueIds = getScheduledQueueIds();
|
||||
const generation = beginSchedulerControl(scheduledQueueIds);
|
||||
if (scheduledQueueIds.length === 0) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
@@ -945,24 +808,10 @@ function App() {
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = startedResults.flat();
|
||||
if (!isSchedulerControlCurrent(generation)) {
|
||||
const handoffIds = handoffSupersededSchedulerIds(
|
||||
acceptedIds,
|
||||
id => useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID
|
||||
);
|
||||
await Promise.allSettled(
|
||||
acceptedIds
|
||||
.filter(id => !handoffIds.has(id))
|
||||
.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const handoffIds = consumeSchedulerHandoffIds(generation);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
(previouslyTrackedIds.has(download.id) || handoffIds.has(download.id)) &&
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
@@ -972,18 +821,14 @@ function App() {
|
||||
state.setSchedulerRunning(activeIds.length > 0);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
} else if (payload.action === 'stop') {
|
||||
const generation = beginSchedulerControl();
|
||||
// A stop event can race with the completion effect's post-action
|
||||
// countdown after it has already cleared the tracked IDs. Always
|
||||
// cancel that pending action before applying the stop transition.
|
||||
clearPendingPostActionTimer();
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
clearPendingPostActionTimer();
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0 && isSchedulerControlCurrent(generation)) {
|
||||
if (failedPauses > 0) {
|
||||
addToast({
|
||||
message: failedPauses === 1
|
||||
? t($ => $.app.schedulerPauseOneFailed)
|
||||
@@ -993,10 +838,8 @@ function App() {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isSchedulerControlCurrent(generation)) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
}
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
|
||||
}
|
||||
} finally {
|
||||
@@ -1005,7 +848,6 @@ function App() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
beginSchedulerControl();
|
||||
unlisten.then(f => f()).catch(console.error);
|
||||
};
|
||||
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||
@@ -1029,7 +871,15 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||
addToast({
|
||||
message: t($ => $.app.scheduledActionSkippedActive),
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else {
|
||||
schedulePostQueueAction(settings.scheduler.postQueueAction);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
@@ -1175,6 +1025,39 @@ function App() {
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
// Remove all theme classes first
|
||||
root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
root.classList.add(systemDark ? 'theme-dark' : 'theme-light');
|
||||
root.dataset.resolvedTheme = systemDark ? 'dark' : 'light';
|
||||
root.style.colorScheme = systemDark ? 'dark' : 'light';
|
||||
if (systemDark) root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.add(`theme-${theme}`);
|
||||
if (['dark', 'dracula', 'nord'].includes(theme)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
|
||||
root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = () => applyTheme();
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
||||
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
|
||||
@@ -1195,8 +1078,6 @@ function App() {
|
||||
} ${
|
||||
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-hidden={!isSidebarVisible}
|
||||
inert={!isSidebarVisible}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
marginInlineStart: isSidebarVisible || isSidebarOnRight ? 0 : -sidebarWidth,
|
||||
@@ -1209,7 +1090,6 @@ function App() {
|
||||
>
|
||||
<Sidebar
|
||||
selectedFilter={filter}
|
||||
onToggleSidebar={handleSidebarToggle}
|
||||
onSelectFilter={(f) => {
|
||||
setFilter(f);
|
||||
useSettingsStore.getState().setActiveView('downloads');
|
||||
@@ -1233,7 +1113,6 @@ function App() {
|
||||
{!isSidebarVisible && (
|
||||
<button
|
||||
type="button"
|
||||
ref={sidebarRevealRef}
|
||||
onClick={toggleSidebar}
|
||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||
title={t($ => $.actions.showSidebar)}
|
||||
@@ -1287,7 +1166,11 @@ function App() {
|
||||
|
||||
{isAddModalOpen && <AddDownloadsModal />}
|
||||
|
||||
<PropertiesWindowBridgeHost />
|
||||
{selectedPropertiesDownloadId !== null && (
|
||||
<Suspense fallback={null}>
|
||||
<PropertiesModal />
|
||||
</Suspense>
|
||||
)}
|
||||
{isDeleteModalOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<DeleteConfirmationModal />
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadAllocationEvent = { id: string, pending: boolean, lifecycleGeneration: string, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadAssetRemovalPolicy = "trash" | "permanentIfUnfinished";
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Torrents" | "Other";
|
||||
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadErrorKind = "nameResolution" | "destinationAccess";
|
||||
@@ -1,7 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, 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, };
|
||||
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, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, effective_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, };
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStateProgress } from "./DownloadStateProgress";
|
||||
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, progress?: DownloadStateProgress, };
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, };
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadStateProgress = { fraction: number, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, };
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying" | "moving";
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// 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, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// 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,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, minimum_normal_download_speed_kib?: number, retry_not_found_errors?: boolean, adaptive_mirror_selection?: boolean, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, replace_existing_fingerprint?: 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, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, };
|
||||
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, batch: boolean, batch_name: string | null, };
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MainWindowSize = { width: number, height: number, };
|
||||
@@ -3,7 +3,6 @@ import type { AppFontSize } from "./AppFontSize";
|
||||
import type { CalendarPreference } from "./CalendarPreference";
|
||||
import type { FontFamily } from "./FontFamily";
|
||||
import type { ListRowDensity } from "./ListRowDensity";
|
||||
import type { MainWindowSize } from "./MainWindowSize";
|
||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||
import type { ProxyMode } from "./ProxyMode";
|
||||
import type { SchedulerSettings } from "./SchedulerSettings";
|
||||
@@ -12,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentAvailabilityBucket = { minimumCopies: number, };
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentAvailabilityBucket } from "./TorrentAvailabilityBucket";
|
||||
|
||||
export type TorrentAvailabilitySnapshot = { pieceCount: number, availability: number, connectedPeers: number, buckets: Array<TorrentAvailabilityBucket>, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentDetails = { infoHash: string, displayName: string, totalBytes: number, fileCount: number, pieceLength: number, pieceCount: number, private: boolean, creationDate: string | null, creator: string | null, comment: string | null, trackers: Array<string>, webSeeds: Array<string>, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentFile = { index: number, path: string, length: number, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentFileProgress = { index: number, relativePath: string, length: number, completedLength: number, selected: boolean, };
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentFileProgress } from "./TorrentFileProgress";
|
||||
|
||||
export type TorrentFileProgressSnapshot = { files: Array<TorrentFileProgress>, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentFileSelectionEntry = { index: number, relativePath: string, length: number, selected: boolean, completedLength?: number, };
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentFileSelectionEntry } from "./TorrentFileSelectionEntry";
|
||||
|
||||
export type TorrentFileSelectionSnapshot = { files: Array<TorrentFileSelectionEntry>, };
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentFile } from "./TorrentFile";
|
||||
|
||||
export type TorrentMetadata = { name: string, totalBytes: number, files: Array<TorrentFile>, infoHash: string, torrentPath?: string, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentMoveProgressEvent = { id: string, fraction: number, copiedBytes: number, totalBytes: number, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentPeer = { ip?: string, port?: number, downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
|
||||
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentPeer } from "./TorrentPeer";
|
||||
|
||||
export type TorrentPeerDiagnostics = { listedPeers: number, listedSeeders: number, peers: Array<TorrentPeer>, truncated: boolean, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentPieceProgressSnapshot = { pieceLength: number, numPieces: number, completedPieces: number, buckets: Array<number>, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type TorrentWebSeed = { fileIndex: number, uri: string, };
|
||||
+124
-1135
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
|
||||
export const DeleteConfirmationModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { deleteModalState, closeDeleteModal, removeDownload, downloads } = useDownloadStore();
|
||||
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const modalRef = useModalFocus(deleteModalState.isOpen);
|
||||
@@ -49,12 +49,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
const failures: string[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await removeDownload(
|
||||
id,
|
||||
deleteFile,
|
||||
false,
|
||||
deleteFile ? 'permanentIfUnfinished' : undefined
|
||||
);
|
||||
await removeDownload(id, deleteFile);
|
||||
succeeded += 1;
|
||||
} catch (error) {
|
||||
failures.push(String(error));
|
||||
@@ -77,11 +72,6 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
const handleRemoveFromList = () => removeMany(false);
|
||||
const handleDeleteFile = () => removeMany(true);
|
||||
const itemCount = deleteModalState.downloadIds?.length ?? 0;
|
||||
const selectedItems = (deleteModalState.downloadIds ?? [])
|
||||
.map(id => downloads.find(download => download.id === id))
|
||||
.filter(Boolean);
|
||||
const hasCompletedSelection = selectedItems.some(item => item?.status === 'completed');
|
||||
const hasUnfinishedSelection = selectedItems.some(item => item?.status !== 'completed');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -111,11 +101,6 @@ export const DeleteConfirmationModal: React.FC = () => {
|
||||
{itemCount > 1
|
||||
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
|
||||
: t($ => $.dialogs.removeDownload.confirmationSingle)}
|
||||
{hasCompletedSelection && hasUnfinishedSelection && (
|
||||
<div className="mt-3 text-xs text-amber-300" role="note">
|
||||
{t($ => $.dialogs.removeDownload.mixedRemovalPolicy)}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
||||
</div>
|
||||
|
||||
|
||||
+17
-113
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import {
|
||||
canPauseDownload,
|
||||
@@ -10,16 +10,12 @@ import {
|
||||
} from '../utils/downloadActions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { isAllocationPhaseVisible } from '../utils/downloads';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatTorrentDuration,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay,
|
||||
resolveDownloadFraction
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
|
||||
import {
|
||||
COLUMN_ALIGNMENT_JUSTIFY,
|
||||
getDownloadActionPosition,
|
||||
@@ -30,7 +26,6 @@ import {
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadItemType;
|
||||
allocationPending: boolean;
|
||||
queueIndex: number;
|
||||
columnOrder: DownloadTableColumnKey[];
|
||||
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
|
||||
@@ -54,7 +49,6 @@ interface DownloadItemProps {
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download,
|
||||
allocationPending,
|
||||
queueIndex,
|
||||
columnOrder,
|
||||
columnAlignments,
|
||||
@@ -78,23 +72,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const { t, i18n } = useTranslation();
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
||||
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
|
||||
const [isActionHovered, setIsActionHovered] = React.useState(false);
|
||||
const [isActionFocused, setIsActionFocused] = React.useState(false);
|
||||
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
|
||||
const waitingForPeers = isTorrentWaitingForPeers({
|
||||
isTorrent: download.isTorrent,
|
||||
status: download.status,
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
fraction: liveProgress?.fraction ?? download.fraction,
|
||||
connectedPeers: liveProgress?.active_connections,
|
||||
connectedSeeders: liveProgress?.num_seeders,
|
||||
});
|
||||
const allocationVisible = download.isTorrent !== true
|
||||
&& isAllocationPhaseVisible(allocationPending, download.status);
|
||||
const hasRowActions = download.status !== 'completed';
|
||||
const isBulkSelection = isSelected && selectedDownloadCount > 1;
|
||||
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
|
||||
@@ -195,41 +178,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
};
|
||||
}, [isActionVisible, updateActionPosition]);
|
||||
|
||||
const progressFraction = download.status === 'moving'
|
||||
? moveProgress ?? download.fraction
|
||||
: download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
|
||||
? liveProgress?.fraction ?? download.fraction
|
||||
: download.fraction;
|
||||
const displayFraction = download.status === 'moving' && moveProgress !== undefined
|
||||
? Math.max(0, Math.min(1, moveProgress))
|
||||
: download.status === 'moving'
|
||||
? resolveDownloadFraction({ fraction: progressFraction, status: download.status })
|
||||
: resolveDownloadFraction({
|
||||
fraction: progressFraction,
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
|
||||
isMedia: download.isMedia,
|
||||
size: download.size,
|
||||
status: download.status,
|
||||
});
|
||||
const displayFraction = download.status === 'downloading'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
: download.fraction ?? 0;
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
const displaySpeed = allocationVisible
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? liveProgress?.upload_speed ?? '-'
|
||||
: download.status === 'downloading' || download.status === 'verifying'
|
||||
const displaySpeed = download.status === 'downloading'
|
||||
? liveProgress?.speed ?? download.speed
|
||||
: download.status === 'processing'
|
||||
? t($ => $.downloads.values.processing)
|
||||
: '-';
|
||||
const displayEta = allocationVisible
|
||||
? '-'
|
||||
: download.status === 'seeding'
|
||||
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
||||
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
|
||||
: '-'
|
||||
: download.status === 'downloading' || download.status === 'verifying'
|
||||
const displayEta = download.status === 'downloading'
|
||||
? liveProgress?.eta ?? download.eta
|
||||
: download.status === 'processing'
|
||||
? t($ => $.downloads.values.muxing)
|
||||
@@ -246,20 +204,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const downloadStatusLabel = allocationVisible
|
||||
? t($ => $.downloads.status.allocatingFiles)
|
||||
: waitingForPeers
|
||||
? t($ => $.downloads.status.waitingForPeers)
|
||||
: t($ => $.downloads.status[download.status]);
|
||||
const visibleErrorStatusLabel = download.credentialsRequired === true
|
||||
? t($ => $.properties.credentialsRequired)
|
||||
: download.lastErrorKind === 'nameResolution'
|
||||
? download.status === 'retrying' && download.lastResolverFallback === true
|
||||
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||
: download.status === 'failed'
|
||||
? t($ => $.downloads.errors.nameResolutionFailed)
|
||||
: downloadStatusLabel
|
||||
: downloadStatusLabel;
|
||||
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
|
||||
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
|
||||
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||
downloaded: sizeDisplay.downloaded ?? '',
|
||||
@@ -296,11 +241,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
{mediaQualityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{download.isTorrent ? (
|
||||
<span className="download-quality-chip shrink-0" title={t($ => $.addDownloads.torrent)}>
|
||||
{t($ => $.addDownloads.torrent)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -342,42 +282,23 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</div>
|
||||
) : (
|
||||
<div className="download-cell-content download-status-content">
|
||||
<div
|
||||
className="download-progress-track"
|
||||
aria-label={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
aria-busy={allocationVisible ? true : undefined}
|
||||
aria-valuetext={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
role={allocationVisible || waitingForPeers ? 'progressbar' : undefined}
|
||||
>
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
className={`download-progress-fill ${
|
||||
allocationVisible ? 'allocating' :
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'seeding' ? 'seeding' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
download.status === 'verifying' ? 'processing' :
|
||||
download.status === 'moving' ? 'processing' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
style={{ width: allocationVisible ? undefined : `${displayFraction * 100}%` }}
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title={
|
||||
allocationVisible
|
||||
? downloadStatusLabel
|
||||
: download.lastError && (
|
||||
download.status === 'failed'
|
||||
|| download.status === 'retrying'
|
||||
|| download.lastErrorKind === 'destinationAccess'
|
||||
|| download.credentialsRequired === true
|
||||
)
|
||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||
? download.lastError
|
||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
? `${downloadStatusLabel} #${queueIndex + 1}`
|
||||
: waitingForPeers
|
||||
? downloadStatusLabel
|
||||
: download.status === 'downloading'
|
||||
? displayPercent
|
||||
: download.status === 'processing'
|
||||
@@ -385,40 +306,27 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: downloadStatusLabel
|
||||
}
|
||||
className={`download-status flex items-center gap-1.5 ${
|
||||
allocationVisible ? 'download-status-downloading' :
|
||||
download.status === 'paused' ? 'download-status-paused' :
|
||||
download.status === 'seeding' ? 'download-status-seeding' :
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
download.status === 'processing' ? 'download-status-processing' :
|
||||
download.status === 'verifying' ? 'download-status-processing' :
|
||||
download.status === 'moving' ? 'download-status-processing' :
|
||||
download.status === 'processing' ? 'download-status-processing' :
|
||||
download.status === 'downloading' ? 'download-status-downloading' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
|
||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||
}`}
|
||||
>
|
||||
{allocationVisible ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{downloadStatusLabel}</span>
|
||||
</>
|
||||
) : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
|
||||
<>
|
||||
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} />
|
||||
<span className="truncate">
|
||||
{downloadStatusLabel} #{queueIndex + 1}
|
||||
</span>
|
||||
</>
|
||||
) : waitingForPeers ? (
|
||||
<span className="truncate">{downloadStatusLabel}</span>
|
||||
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
|
||||
displayPercent
|
||||
) : download.status === 'seeding' ? (
|
||||
) : download.status === 'downloading' ? (
|
||||
displayPercent
|
||||
) : download.status === 'processing' ? (
|
||||
downloadStatusLabel
|
||||
) : (
|
||||
visibleErrorStatusLabel
|
||||
downloadStatusLabel
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -487,14 +395,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
||||
className="app-icon-button main-control-button"
|
||||
title={resumeSelectionCount === null
|
||||
? download.credentialsRequired === true
|
||||
? t($ => $.properties.retryWithoutCredentials)
|
||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||
aria-label={resumeSelectionCount === null
|
||||
? download.credentialsRequired === true
|
||||
? t($ => $.properties.retryWithoutCredentials)
|
||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useToast } from '../contexts/ToastContext';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
import {
|
||||
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, Magnet,
|
||||
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
|
||||
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronUp, ChevronDown, MoreHorizontal,
|
||||
AlignLeft, AlignCenter, AlignRight, GripVertical
|
||||
} from 'lucide-react';
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
sortDownloads,
|
||||
@@ -53,11 +52,9 @@ import {
|
||||
import {
|
||||
moveSelectedBlockToIndex
|
||||
} from '../utils/queueOrdering';
|
||||
import { selectContextMenuTarget, updateDownloadSelection } from '../utils/downloadSelection';
|
||||
import { createColumnResizeSession } from '../utils/columnResize';
|
||||
import { updateDownloadSelection } from '../utils/downloadSelection';
|
||||
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||
import { FloatingQueueSubmenu } from './FloatingQueueSubmenu';
|
||||
import { openPropertiesWindow } from '../propertiesBridge';
|
||||
|
||||
export interface DownloadTableStatusSummary {
|
||||
summary: DownloadSummary;
|
||||
@@ -162,8 +159,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
moveManyInQueueToPosition,
|
||||
startAll,
|
||||
pauseAll,
|
||||
startSelected,
|
||||
allocationPendingIds
|
||||
startSelected
|
||||
} = useDownloadStore();
|
||||
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||
const { addToast } = useToast();
|
||||
@@ -672,27 +668,33 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
const startX = event.clientX;
|
||||
const startWidth = columnWidthsRef.current[index];
|
||||
|
||||
const cleanup = createColumnResizeSession({
|
||||
windowTarget: window,
|
||||
documentTarget: document,
|
||||
body: document.body,
|
||||
pointerId: event.pointerId,
|
||||
startX,
|
||||
startWidth,
|
||||
minWidth: COLUMN_MINIMUMS[index],
|
||||
onWidth: nextWidth => {
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX);
|
||||
const nextWidths = columnWidthsRef.current.map((width, columnIndex) =>
|
||||
columnIndex === index ? nextWidth : width
|
||||
);
|
||||
columnWidthsRef.current = nextWidths;
|
||||
setColumnWidths(nextWidths);
|
||||
},
|
||||
onEnd: () => {
|
||||
persistColumnWidths(columnWidthsRef.current);
|
||||
resizeCleanupRef.current = null;
|
||||
},
|
||||
});
|
||||
resizeCleanupRef.current = cleanup;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
window.removeEventListener('blur', handlePointerUp);
|
||||
document.removeEventListener('visibilitychange', handlePointerUp);
|
||||
persistColumnWidths(columnWidthsRef.current);
|
||||
document.body.classList.remove('is-column-resizing');
|
||||
resizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
resizeCleanupRef.current = handlePointerUp;
|
||||
document.body.classList.add('is-column-resizing');
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
window.addEventListener('blur', handlePointerUp);
|
||||
document.addEventListener('visibilitychange', handlePointerUp);
|
||||
};
|
||||
|
||||
const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => {
|
||||
@@ -1330,9 +1332,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
finishQueueDrag(true);
|
||||
}
|
||||
};
|
||||
const lostPointerCapture = (event: Event) => {
|
||||
if ((event as PointerEvent).pointerId === pointerId) finishQueueDrag(true);
|
||||
};
|
||||
const lostPointerCapture = () => finishQueueDrag(true);
|
||||
const cancel = () => finishQueueDrag(true);
|
||||
window.addEventListener('pointermove', pointerMove);
|
||||
window.addEventListener('pointerup', pointerUp);
|
||||
@@ -1365,14 +1365,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, []);
|
||||
|
||||
const getDownloadPath = useCallback(async (item: DownloadItem) => {
|
||||
if (item.isTorrent) {
|
||||
try {
|
||||
const ownedPath = await invoke('get_download_primary_path', { id: item.id });
|
||||
if (ownedPath) return ownedPath;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve torrent output path:", error);
|
||||
}
|
||||
}
|
||||
const fileName = item.fileName?.trim();
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
@@ -1382,29 +1374,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, []);
|
||||
|
||||
const openProperties = useCallback((id: string) => {
|
||||
void openPropertiesWindow(id).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.interactionError, {
|
||||
message: t($ => $.downloadTable.properties),
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}), error);
|
||||
});
|
||||
}, [showInteractionError, t]);
|
||||
|
||||
const revealDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
|
||||
if (!pathToReveal) {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||
} catch (error) {
|
||||
console.error("Failed to show in Finder:", error);
|
||||
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||
}
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
|
||||
}, []);
|
||||
|
||||
const openDownloadFile = useCallback(async (item: DownloadItem) => {
|
||||
if (item.status !== 'completed') {
|
||||
@@ -1412,11 +1383,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isTorrent) {
|
||||
await revealDownloadFile(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = await getDownloadPath(item);
|
||||
if (!fullPath) {
|
||||
openProperties(item.id);
|
||||
@@ -1429,7 +1395,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
console.error("Failed to open file:", error);
|
||||
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
||||
}
|
||||
}, [getDownloadPath, openProperties, revealDownloadFile, showInteractionError]);
|
||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||
|
||||
const revealDownloadFile = async (item: DownloadItem) => {
|
||||
const pathToReveal = await getDownloadPath(item);
|
||||
|
||||
if (!pathToReveal) {
|
||||
openProperties(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||
} catch (error) {
|
||||
console.error("Failed to show in Finder:", error);
|
||||
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadDoubleClick = useCallback((item: DownloadItem) => {
|
||||
if (item.status === 'completed') {
|
||||
@@ -1776,13 +1758,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, [clearQueueClickSuppression, handleDownloadDoubleClick]);
|
||||
|
||||
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
|
||||
const nextSelection = selectContextMenuTarget({
|
||||
selectedIds: selectedIdsRef.current,
|
||||
lastSelectedId: lastSelectedIdRef.current,
|
||||
targetId: menu.id,
|
||||
});
|
||||
setSelectedIds(nextSelection.selectedIds);
|
||||
setLastSelectedId(nextSelection.lastSelectedId);
|
||||
if (!selectedIdsRef.current.has(menu.id)) {
|
||||
setSelectedIds(new Set([menu.id]));
|
||||
setLastSelectedId(menu.id);
|
||||
}
|
||||
setColumnMenu(null);
|
||||
const position = clampMenuPosition(menu.x, menu.y, 200, 300);
|
||||
setContextMenuPosition(position);
|
||||
@@ -1851,7 +1830,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
case 'Documents': return t($ => $.navigation.categories.documents);
|
||||
case 'Pictures': return t($ => $.navigation.categories.pictures);
|
||||
case 'Applications': return t($ => $.navigation.categories.applications);
|
||||
case 'Torrents': return t($ => $.navigation.categories.torrents);
|
||||
case 'Other': return t($ => $.navigation.categories.other);
|
||||
default: return filter;
|
||||
}
|
||||
@@ -1876,28 +1854,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
|
||||
const handleResume = useCallback(async (item: DownloadItem) => {
|
||||
try {
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
if (!current) return;
|
||||
let resumeWithoutCredentials = false;
|
||||
if (current.credentialsRequired === true) {
|
||||
resumeWithoutCredentials = window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
if (!resumeWithoutCredentials) return;
|
||||
}
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id, {
|
||||
resumeWithoutCredentials
|
||||
});
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||
if (!resumed) {
|
||||
const latest = useDownloadStore.getState().downloads.find(
|
||||
download => download.id === item.id
|
||||
);
|
||||
const reason = latest?.lastError?.trim();
|
||||
throw new Error(reason || t($ => $.downloadTable.backendRejectedStart));
|
||||
throw new Error(t($ => $.downloadTable.backendRejectedStart));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resume:", error);
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: item.fileName }), error);
|
||||
}
|
||||
}, [showInteractionError, t]);
|
||||
}, [showInteractionError]);
|
||||
|
||||
const getCurrentSelectedDownloads = useCallback(() => {
|
||||
const selected = selectedIdsRef.current;
|
||||
@@ -1929,42 +1894,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
const handleResumeSelected = useCallback(() => {
|
||||
const ids = Array.from(selectedIdsRef.current);
|
||||
if (ids.length === 0) return;
|
||||
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
|
||||
const credentialMarkedIds = selected
|
||||
.filter(download => download.credentialsRequired === true && canStartDownload(download.status))
|
||||
.map(download => download.id);
|
||||
if (credentialMarkedIds.length > 0
|
||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
||||
// Continue ordinary selected resumes. Credential-marked rows remain
|
||||
// fail-closed and can be handled individually after the user supplies
|
||||
// credentials or confirms a credentialless retry.
|
||||
const credentialMarkedIdSet = new Set(credentialMarkedIds);
|
||||
const ordinaryIds = ids.filter(id => !credentialMarkedIdSet.has(id));
|
||||
if (ordinaryIds.length === 0) return;
|
||||
void startSelected(ordinaryIds).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
void startSelected(ids, {
|
||||
resumeWithoutCredentialsIds: credentialMarkedIds
|
||||
}).catch(error => {
|
||||
void startSelected(ids).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
}, [showInteractionError, startSelected, t]);
|
||||
|
||||
const handleStartAll = useCallback(() => {
|
||||
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 => {
|
||||
void startAll().catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
}, [showInteractionError, startAll, t]);
|
||||
@@ -2052,7 +1988,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
case 'Applications': return <Box size={16} className="text-indigo-400" />;
|
||||
case 'Pictures': return <ImageIcon size={16} className="text-purple-400" />;
|
||||
case 'Compressed': return <Archive size={16} className="text-amber-600" />;
|
||||
case 'Torrents': return <Magnet size={16} className="text-violet-400" />;
|
||||
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
|
||||
default: return <FileQuestion size={16} className="text-gray-400" />;
|
||||
}
|
||||
@@ -2314,7 +2249,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
download={d}
|
||||
allocationPending={allocationPendingIds.has(d.id)}
|
||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||
columnOrder={orderedColumns}
|
||||
columnAlignments={columnAlignments}
|
||||
@@ -2605,23 +2539,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
{t($ => $.downloadTable.copyAddress)}
|
||||
</button>
|
||||
|
||||
{contextItem.isTorrent && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
try {
|
||||
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
|
||||
await writeClipboardText(magnet);
|
||||
} catch (error) {
|
||||
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
{t($ => $.downloadTable.copyMagnet)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{contextItem.status === 'completed' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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,28 +11,15 @@ export interface DuplicateConflict {
|
||||
reason: DuplicateReason;
|
||||
resolution: DuplicateResolution;
|
||||
replaceAllowed?: boolean;
|
||||
replaceFingerprint?: string;
|
||||
existingDownloadId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conflicts: DuplicateConflict[];
|
||||
onConfirm: (resolutions: {
|
||||
id: string;
|
||||
resolution: DuplicateResolution;
|
||||
replaceFingerprint?: string;
|
||||
}[]) => void;
|
||||
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const duplicateConflictCanReplace = (
|
||||
conflict: Pick<DuplicateConflict, 'replaceAllowed'>
|
||||
): boolean => conflict.replaceAllowed === true;
|
||||
|
||||
export const canReplaceAllDuplicateConflicts = (
|
||||
conflicts: readonly Pick<DuplicateConflict, 'replaceAllowed'>[]
|
||||
): boolean => conflicts.length > 0 && conflicts.every(duplicateConflictCanReplace);
|
||||
|
||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||
@@ -53,7 +40,9 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c));
|
||||
};
|
||||
|
||||
const canReplaceAll = canReplaceAllDuplicateConflicts(conflicts);
|
||||
const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict =>
|
||||
conflict.replaceAllowed === true
|
||||
);
|
||||
|
||||
const applyResolutionToAll = (resolution: DuplicateResolution) => {
|
||||
if (resolution === 'replace' && !canReplaceAll) return;
|
||||
@@ -124,7 +113,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
|
||||
{duplicateConflictCanReplace(conflict) && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
|
||||
{conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
|
||||
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -136,11 +125,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
{t($ => $.actions.cancel)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(conflicts.map(c => ({
|
||||
id: c.id,
|
||||
resolution: c.resolution,
|
||||
...(c.replaceFingerprint ? { replaceFingerprint: c.replaceFingerprint } : {})
|
||||
})))}
|
||||
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
||||
className="app-button app-button-primary px-5 text-xs"
|
||||
>
|
||||
{t($ => $.actions.continue)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import type { Queue } from '../store/useDownloadStore';
|
||||
import { isFloatingSubmenuCloseKey, positionFloatingSubmenu, type FloatingSubmenuPosition } from '../utils/floatingPosition';
|
||||
import { positionFloatingSubmenu, type FloatingSubmenuPosition } from '../utils/floatingPosition';
|
||||
|
||||
interface FloatingQueueSubmenuProps {
|
||||
label: React.ReactNode;
|
||||
@@ -167,7 +167,7 @@ export const FloatingQueueSubmenu: React.FC<FloatingQueueSubmenuProps> = ({ labe
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (!isFloatingSubmenuCloseKey(event.key, isRtl)) return;
|
||||
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
import {
|
||||
getPauseResumeAction,
|
||||
isIdentityLocked as getIdentityLocked,
|
||||
isTransferLocked as getTransferLocked
|
||||
} from '../utils/downloadActions';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
const formatLastTry = (
|
||||
value: string | undefined,
|
||||
locale: string,
|
||||
calendar: CalendarPreference
|
||||
): string => {
|
||||
if (!value) return '-';
|
||||
return formatDateTime(value, {
|
||||
locale,
|
||||
calendar,
|
||||
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||
});
|
||||
};
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabel = (category: string) => {
|
||||
switch (category) {
|
||||
case 'Musics': return t($ => $.navigation.categories.musics);
|
||||
case 'Movies': return t($ => $.navigation.categories.movies);
|
||||
case 'Compressed': return t($ => $.navigation.categories.compressed);
|
||||
case 'Documents': return t($ => $.navigation.categories.documents);
|
||||
case 'Pictures': return t($ => $.navigation.categories.pictures);
|
||||
case 'Applications': return t($ => $.navigation.categories.applications);
|
||||
default: return t($ => $.navigation.categories.other);
|
||||
}
|
||||
};
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||
const item = useDownloadStore(useShallow(state =>
|
||||
selectedPropertiesDownloadId
|
||||
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
||||
: null
|
||||
));
|
||||
const liveProgress = useDownloadProgressStore(useShallow(state =>
|
||||
selectedPropertiesDownloadId
|
||||
? state.progressMap[selectedPropertiesDownloadId]
|
||||
: undefined
|
||||
));
|
||||
|
||||
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||
|
||||
// Form states
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [saveLocation, setSaveLocation] = useState('');
|
||||
const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections));
|
||||
const [connectionsDirty, setConnectionsDirty] = useState(false);
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
||||
const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256');
|
||||
const [checksumValue, setChecksumValue] = useState('');
|
||||
const [cookies, setCookies] = useState('');
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||
const actionRequestRef = useRef(0);
|
||||
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
||||
|
||||
useEffect(() => {
|
||||
// Invalidate native pickers and transfer-control results when the modal
|
||||
// switches items, closes, or reopens for the same download.
|
||||
actionRequestRef.current += 1;
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPropertiesDownloadId) {
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem) {
|
||||
setUrl(activeItem.url);
|
||||
setFileName(activeItem.fileName);
|
||||
if (activeItem.destination) {
|
||||
setSaveLocation(activeItem.destination);
|
||||
} else {
|
||||
const propertiesDownloadId = selectedPropertiesDownloadId;
|
||||
const requestId = actionRequestRef.current;
|
||||
void resolveCategoryDestination(
|
||||
useSettingsStore.getState(),
|
||||
activeItem.category
|
||||
).then(location => {
|
||||
if (
|
||||
requestId === actionRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setSaveLocation(location);
|
||||
}
|
||||
});
|
||||
}
|
||||
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
|
||||
setConnectionsDirty(false);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
setSpeedLimitEnabled(true);
|
||||
setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, ''));
|
||||
} else {
|
||||
setSpeedLimitEnabled(false);
|
||||
}
|
||||
|
||||
if (activeItem.username || activeItem.password) {
|
||||
setLoginMode('custom');
|
||||
setUsername(activeItem.username || '');
|
||||
setPassword(activeItem.password || '');
|
||||
} else {
|
||||
setLoginMode('matching');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
}
|
||||
|
||||
setHeaders(activeItem.headers || '');
|
||||
setChecksumEnabled(!!activeItem.checksum);
|
||||
if (activeItem.checksum) {
|
||||
const [algo, val] = activeItem.checksum.split('=');
|
||||
if (val) {
|
||||
setChecksumAlgorithm(algo);
|
||||
setChecksumValue(val);
|
||||
}
|
||||
} else {
|
||||
setChecksumAlgorithm('SHA-256');
|
||||
setChecksumValue('');
|
||||
}
|
||||
setCookies(activeItem.cookies || '');
|
||||
setMirrors(activeItem.mirrors || '');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeLimit = item?.speedLimit?.trim();
|
||||
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.speedLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem && activeItem.connections === undefined) {
|
||||
setConnections(resolveDownloadConnections(undefined, perServerConnections));
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isTopmostModal(modalRef.current)) {
|
||||
event.preventDefault();
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
const handleBrowse = async () => {
|
||||
if (identityLocked) return;
|
||||
const requestId = ++actionRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
if (
|
||||
selected
|
||||
&& typeof selected === 'string'
|
||||
&& requestId === actionRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setSaveLocation(selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!url.trim()) {
|
||||
setErrorMessage(t($ => $.properties.enterValidUrl));
|
||||
return;
|
||||
}
|
||||
if (!fileName.trim()) {
|
||||
setErrorMessage(t($ => $.properties.fileNameEmpty));
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<DownloadItem> = {
|
||||
url,
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
|
||||
username: loginMode === 'custom' ? username.trim() : undefined,
|
||||
password: loginMode === 'custom' ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
...(connectionsDirty
|
||||
? { connections: resolveDownloadConnections(connections, perServerConnections) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const requestId = ++actionRequestRef.current;
|
||||
try {
|
||||
setErrorMessage('');
|
||||
await useDownloadStore.getState().applyProperties(item.id, updates);
|
||||
if (
|
||||
requestId === actionRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === item.id
|
||||
) {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setErrorMessage(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseResume = async () => {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
const action = currentItem ? getPauseResumeAction(currentItem.status) : null;
|
||||
if (!currentItem || !action || isPauseResumePending) return;
|
||||
|
||||
if (action === 'pause' && currentItem.resumable === false) {
|
||||
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
|
||||
if (!confirmPause) return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
const requestId = ++actionRequestRef.current;
|
||||
setIsPauseResumePending(true);
|
||||
try {
|
||||
if (action === 'pause') {
|
||||
await useDownloadStore.getState().pauseDownload(currentItem.id);
|
||||
} else {
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id);
|
||||
if (!resumed) {
|
||||
throw new Error(t($ => $.downloadTable.backendRejectedStart));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const message = action === 'pause'
|
||||
? t($ => $.downloadTable.pauseFailed)
|
||||
: t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName });
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail }));
|
||||
}
|
||||
} finally {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setIsPauseResumePending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveSpeedLimit = async (limit: string | null) => {
|
||||
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
|
||||
|
||||
setErrorMessage('');
|
||||
const requestId = ++actionRequestRef.current;
|
||||
setIsLiveSpeedLimitPending(true);
|
||||
try {
|
||||
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
|
||||
if (
|
||||
limit === null
|
||||
&& requestId === actionRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === item.id
|
||||
) {
|
||||
setLiveSpeedLimitValue('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setIsLiveSpeedLimitPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
const observedConnectionTotal = Math.max(
|
||||
1,
|
||||
liveProgress?.requested_connections ?? configuredConnections
|
||||
);
|
||||
const observedActiveConnections = liveProgress?.active_connections;
|
||||
const connectionTelemetryActive = item.status === 'downloading' ||
|
||||
item.status === 'processing' ||
|
||||
item.status === 'retrying';
|
||||
const connectionStatus = (() => {
|
||||
if (!connectionTelemetryActive) return String(configuredConnections);
|
||||
// yt-dlp exposes the configured fragment limit through Firelink, but its
|
||||
// progress stream does not expose a reliable active-worker count. Keep
|
||||
// the selected limit visible without presenting it as an active count.
|
||||
if (item.isMedia) {
|
||||
return t($ => $.properties.connectionCountUnknown, {
|
||||
total: configuredConnections,
|
||||
});
|
||||
}
|
||||
if (typeof observedActiveConnections === 'number') {
|
||||
return t($ => $.properties.connectionCount, {
|
||||
active: observedActiveConnections,
|
||||
total: observedConnectionTotal,
|
||||
});
|
||||
}
|
||||
if (item.status === 'downloading') {
|
||||
return t($ => $.properties.connectionCountUnknown, { total: observedConnectionTotal });
|
||||
}
|
||||
return t($ => $.properties.connectionCount, {
|
||||
active: 0,
|
||||
total: observedConnectionTotal,
|
||||
});
|
||||
})();
|
||||
const displayedFraction = item.status === 'completed'
|
||||
? 1
|
||||
: liveProgress?.fraction ?? item.fraction ?? 0;
|
||||
const displayedSpeed = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.speed ?? item.speed ?? '-';
|
||||
const displayedEta = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.eta ?? item.eta ?? '-';
|
||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
|
||||
totalBytes: liveProgress?.total_bytes ?? item.totalBytes,
|
||||
totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate,
|
||||
fallbackSize: item.size
|
||||
});
|
||||
const hasDownloadedAmount = item.status !== 'completed' &&
|
||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||
const completedSizeLabel = (() => {
|
||||
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const statusLabel = t($ => $.downloads.status[item.status]);
|
||||
const pauseResumeAction = getPauseResumeAction(item.status);
|
||||
const pauseResumeLabel = pauseResumeAction === 'pause'
|
||||
? t($ => $.downloadTable.pause)
|
||||
: t($ => $.downloadTable.resume);
|
||||
const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play;
|
||||
const sizeDescription = sizeDisplay.totalIsEstimate
|
||||
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||
downloaded: sizeDisplay.downloaded ?? '',
|
||||
total: sizeDisplay.total ?? '',
|
||||
unit: sizeDisplay.unit ?? '',
|
||||
})
|
||||
: t($ => $.downloads.size.downloadedOf, {
|
||||
downloaded: sizeDisplay.downloaded ?? '',
|
||||
total: sizeDisplay.total ?? '',
|
||||
unit: sizeDisplay.unit ?? '',
|
||||
});
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
||||
else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="properties-modal-title"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
data-modal-surface="true"
|
||||
className="app-modal properties-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm"
|
||||
>
|
||||
|
||||
{/* Header Summary */}
|
||||
<div className="p-4 px-5 bg-sidebar-bg/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 id="properties-modal-title" className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
|
||||
<StatusIcon size={14} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${displayedFraction * 100}%` }}></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.progress)}</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0">
|
||||
<span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
|
||||
<span
|
||||
className="truncate"
|
||||
title={hasDownloadedAmount
|
||||
? sizeDescription
|
||||
: completedSizeLabel}
|
||||
>
|
||||
{hasDownloadedAmount ? (
|
||||
<>
|
||||
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
|
||||
<span className="text-text-muted"> / </span>
|
||||
<span className="text-text-secondary">
|
||||
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} {sizeDisplay.unit}
|
||||
</span>
|
||||
</>
|
||||
) : completedSizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.speed)}</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">{t($ => $.properties.eta)}</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium shrink-0 whitespace-nowrap">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate whitespace-nowrap" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}><bdi>{connectionStatus}</bdi></span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry, i18n.language, calendarPreference)}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{formatDateTime(item.dateAdded, { locale: i18n.language, calendar: calendarPreference, options: { dateStyle: 'medium', timeStyle: 'short' } })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
||||
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
|
||||
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
|
||||
|
||||
{identityLocked && (
|
||||
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
|
||||
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
||||
<span>
|
||||
{item.status === 'completed'
|
||||
? t($ => $.properties.identityReadOnly)
|
||||
: t($ => $.properties.transferSettings)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.download)}</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
|
||||
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
|
||||
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
||||
<FolderPlus size={14} /> {t($ => $.properties.select)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.connections)}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">{t($ => $.properties.perFile)}</span>
|
||||
<span className="text-xs text-text-secondary font-mono" aria-live="polite"><bdi>{connectionStatus}</bdi></span>
|
||||
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.savedPerDownload)}
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speedCap)}</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex min-h-7 items-center gap-2 rounded-md border border-border-modal bg-bg-input px-2.5 py-1.5 text-xs text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={speedLimitEnabled}
|
||||
onChange={e => setSpeedLimitEnabled(e.target.checked)}
|
||||
disabled={transferLocked}
|
||||
className="accent-accent disabled:opacity-50"
|
||||
/>
|
||||
{t($ => $.properties.limit)}
|
||||
</label>
|
||||
{speedLimitEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={speedLimitValue}
|
||||
min={1}
|
||||
step={128}
|
||||
onChange={e => setSpeedLimitValue(e.target.value)}
|
||||
disabled={transferLocked}
|
||||
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.savedPerDownload)}
|
||||
</div>
|
||||
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
|
||||
{liveSpeedLimitAvailable ? (
|
||||
<>
|
||||
<label htmlFor="live-speed-limit" className="block text-xs font-semibold text-text-primary">
|
||||
{t($ => $.properties.liveSpeedLimit)}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
id="live-speed-limit"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={liveSpeedLimitValue}
|
||||
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
|
||||
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
|
||||
disabled={isLiveSpeedLimitPending}
|
||||
aria-describedby="live-speed-limit-hint"
|
||||
className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
|
||||
disabled={isLiveSpeedLimitPending}
|
||||
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitApply)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveSpeedLimit(null)}
|
||||
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitClear)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveSpeedLimitHint)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveSpeedLimitUnavailable)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Site Login Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
||||
{(['matching', 'custom', 'none'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => !transferLocked && setLoginMode(mode)}
|
||||
disabled={transferLocked}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
|
||||
>
|
||||
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||
{loginMode === 'matching' && (
|
||||
<div className="col-start-2 text-xs text-text-secondary italic">
|
||||
{t($ => $.properties.useSavedLogin)}
|
||||
</div>
|
||||
)}
|
||||
{loginMode === 'custom' && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.username)}</label>
|
||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.username)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.password)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Transfer Section */}
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
|
||||
</button>
|
||||
|
||||
{advancedExpanded && (
|
||||
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.checksum)}</label>
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||
{t($ => $.properties.verify)}
|
||||
</label>
|
||||
|
||||
{checksumEnabled && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
|
||||
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA-1">SHA-1</option>
|
||||
<option value="SHA-256">SHA-256</option>
|
||||
<option value="SHA-512">SHA-512</option>
|
||||
</select>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.digest)}</label>
|
||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
|
||||
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<div className="col-span-2 mt-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
|
||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
|
||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 px-4 bg-sidebar-bg flex items-center justify-between shrink-0">
|
||||
<div className="text-red-500 text-xs truncate max-w-[400px]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||
className="app-button px-4 text-xs"
|
||||
>
|
||||
{t($ => $.properties.cancel)}
|
||||
</button>
|
||||
{pauseResumeAction && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePauseResume()}
|
||||
disabled={isPauseResumePending}
|
||||
aria-label={pauseResumeLabel}
|
||||
title={pauseResumeLabel}
|
||||
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<PauseResumeIcon size={14} fill="currentColor" />
|
||||
{pauseResumeLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={transferLocked}
|
||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
{t($ => $.properties.save)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,814 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import type { DownloadItem } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import {
|
||||
MAX_TORRENT_STOP_TIMEOUT,
|
||||
isValidTorrentExcludeTrackerList,
|
||||
isValidTorrentTrackerList,
|
||||
normalizeSpeedLimitForBackend,
|
||||
normalizeTorrentEncryptionPolicy,
|
||||
normalizeTorrentFileAllocation,
|
||||
normalizeTorrentPrioritizePiece,
|
||||
normalizeTorrentTrackerInterval,
|
||||
normalizeTorrentTrackerTimeout,
|
||||
} from '../utils/downloads';
|
||||
import {
|
||||
PROPERTIES_WINDOW_ACTION_REQUEST,
|
||||
PROPERTIES_WINDOW_CLOSED,
|
||||
PROPERTIES_WINDOW_READY,
|
||||
applySecretPatch,
|
||||
attachAsyncPropertiesListener,
|
||||
beginExclusivePropertiesAction,
|
||||
classifyPropertiesActionRequest,
|
||||
createFrameCoalescer,
|
||||
decodePropertiesPatchValue,
|
||||
enqueuePropertiesAction,
|
||||
getPropertiesLifecycleAction,
|
||||
propertiesActionRequestKey,
|
||||
PROPERTIES_PATCH_CLEARABLE_KEYS,
|
||||
sanitizePropertiesSnapshot,
|
||||
redactPropertiesError,
|
||||
sendPropertiesActionResult,
|
||||
sendPropertiesRemoved,
|
||||
sendPropertiesSnapshot,
|
||||
propertiesWindowEventTarget,
|
||||
type PropertiesActionRequest,
|
||||
type PropertiesActionResult,
|
||||
type PropertiesPatch,
|
||||
type PropertiesWindowRegistration,
|
||||
type PropertiesWindowReady,
|
||||
} from '../propertiesBridge';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { getPlatformInfo } from '../utils/platform';
|
||||
import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle';
|
||||
import i18n, { localeDirection, resolveAppLocale } from '../i18n';
|
||||
|
||||
const errorText = redactPropertiesError;
|
||||
let lastPropertiesBridgeGeneration = 0;
|
||||
|
||||
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
||||
if (typeof value !== 'string') throw new Error(`Invalid ${label}`);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const normalized = normalizeSpeedLimitForBackend(trimmed);
|
||||
if (!normalized) throw new Error(`Invalid ${label}`);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const copyEditablePropertiesPatch = (
|
||||
rawPatch: PropertiesPatch,
|
||||
item?: Pick<DownloadItem, 'isTorrent' | 'status'>,
|
||||
): Partial<DownloadItem> => {
|
||||
const safePatch: Partial<DownloadItem> = {};
|
||||
const copy = (key: keyof PropertiesPatch) => {
|
||||
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
||||
(safePatch as Record<string, unknown>)[key] = rawPatch[key];
|
||||
}
|
||||
};
|
||||
for (const key of [
|
||||
'fileName',
|
||||
'destination',
|
||||
'sftpHostKeyMd',
|
||||
'connections',
|
||||
'speedLimit',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
'torrentSeedTime',
|
||||
'torrentSeedRatio',
|
||||
'torrentCheckIntegrity',
|
||||
'torrentRemoveUnselectedFile',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
'torrentTrackerConnectTimeout',
|
||||
'torrentTrackerTimeout',
|
||||
'torrentTrackerInterval',
|
||||
'torrentStopTimeout',
|
||||
'torrentPrioritizePiece',
|
||||
'torrentEncryptionPolicy',
|
||||
'torrentFileAllocation',
|
||||
] as const) copy(key);
|
||||
|
||||
if (item && (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) {
|
||||
if (Object.prototype.hasOwnProperty.call(rawPatch, 'fileName')
|
||||
|| Object.prototype.hasOwnProperty.call(rawPatch, 'destination')) {
|
||||
throw new Error('File identity and destination are read-only for this download state');
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
||||
const value = (rawPatch as Record<string, unknown>)[key];
|
||||
(safePatch as Record<string, unknown>)[key] = decodePropertiesPatchValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
|
||||
throw new Error('Invalid file name');
|
||||
}
|
||||
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
|
||||
throw new Error('Invalid destination');
|
||||
}
|
||||
if (safePatch.sftpHostKeyMd !== undefined) {
|
||||
if (typeof safePatch.sftpHostKeyMd !== 'string') throw new Error('Invalid SFTP host-key fingerprint');
|
||||
const fingerprint = safePatch.sftpHostKeyMd.trim().toLowerCase();
|
||||
const valid = /^(md5|sha-1)=[0-9a-f]+$/.test(fingerprint)
|
||||
&& ((fingerprint.startsWith('md5=') && fingerprint.length === 36)
|
||||
|| (fingerprint.startsWith('sha-1=') && fingerprint.length === 45));
|
||||
if (!valid) throw new Error('Invalid SFTP host-key fingerprint');
|
||||
safePatch.sftpHostKeyMd = fingerprint;
|
||||
}
|
||||
|
||||
if (safePatch.connections !== undefined
|
||||
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
|
||||
throw new Error('Connections must be a whole number from 1 to 16');
|
||||
}
|
||||
if (safePatch.speedLimit !== undefined) {
|
||||
safePatch.speedLimit = normalizeOptionalSpeed(safePatch.speedLimit, 'download speed limit');
|
||||
}
|
||||
if (safePatch.torrentUploadLimit !== undefined) {
|
||||
safePatch.torrentUploadLimit = normalizeOptionalSpeed(safePatch.torrentUploadLimit, 'Torrent upload limit');
|
||||
}
|
||||
if (safePatch.torrentPeerSpeedLimit !== undefined) {
|
||||
safePatch.torrentPeerSpeedLimit = normalizeOptionalSpeed(safePatch.torrentPeerSpeedLimit, 'Torrent peer speed limit');
|
||||
}
|
||||
if (safePatch.torrentMaxPeers !== undefined
|
||||
&& (!Number.isInteger(safePatch.torrentMaxPeers) || safePatch.torrentMaxPeers < 0 || safePatch.torrentMaxPeers > 1000)) {
|
||||
throw new Error('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||
}
|
||||
for (const [key, minimum] of [
|
||||
['torrentSeedTime', 0],
|
||||
['torrentSeedRatio', 0],
|
||||
] as const) {
|
||||
const value = safePatch[key];
|
||||
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < minimum)) {
|
||||
throw new Error(`Invalid ${key}`);
|
||||
}
|
||||
}
|
||||
for (const key of ['torrentTrackerConnectTimeout', 'torrentTrackerTimeout'] as const) {
|
||||
const value = safePatch[key];
|
||||
if (value !== undefined && normalizeTorrentTrackerTimeout(value) === undefined) {
|
||||
throw new Error(`Invalid ${key}`);
|
||||
}
|
||||
}
|
||||
if (safePatch.torrentTrackerInterval !== undefined
|
||||
&& normalizeTorrentTrackerInterval(safePatch.torrentTrackerInterval) === undefined) {
|
||||
throw new Error('Invalid torrentTrackerInterval');
|
||||
}
|
||||
if (safePatch.torrentStopTimeout !== undefined
|
||||
&& (!Number.isInteger(safePatch.torrentStopTimeout)
|
||||
|| safePatch.torrentStopTimeout < 0
|
||||
|| safePatch.torrentStopTimeout > MAX_TORRENT_STOP_TIMEOUT)) {
|
||||
throw new Error('Invalid torrentStopTimeout');
|
||||
}
|
||||
if (safePatch.torrentPrioritizePiece !== undefined
|
||||
&& normalizeTorrentPrioritizePiece(safePatch.torrentPrioritizePiece) == null) {
|
||||
throw new Error('Invalid torrentPrioritizePiece');
|
||||
}
|
||||
if (safePatch.torrentEncryptionPolicy !== undefined
|
||||
&& normalizeTorrentEncryptionPolicy(safePatch.torrentEncryptionPolicy) === undefined) {
|
||||
throw new Error('Invalid torrentEncryptionPolicy');
|
||||
}
|
||||
if (safePatch.torrentFileAllocation !== undefined
|
||||
&& normalizeTorrentFileAllocation(safePatch.torrentFileAllocation) === undefined) {
|
||||
throw new Error('Invalid torrentFileAllocation');
|
||||
}
|
||||
for (const key of ['torrentCheckIntegrity', 'torrentRemoveUnselectedFile'] as const) {
|
||||
if (safePatch[key] !== undefined && typeof safePatch[key] !== 'boolean') {
|
||||
throw new Error(`Invalid ${key}`);
|
||||
}
|
||||
}
|
||||
if (safePatch.torrentTrackers !== undefined
|
||||
&& (typeof safePatch.torrentTrackers !== 'string' || !isValidTorrentTrackerList(safePatch.torrentTrackers))) {
|
||||
throw new Error('Invalid Torrent tracker list');
|
||||
}
|
||||
if (typeof safePatch.torrentTrackers === 'string' && !safePatch.torrentTrackers.trim()) {
|
||||
safePatch.torrentTrackers = undefined;
|
||||
}
|
||||
if (safePatch.torrentExcludeTrackers !== undefined
|
||||
&& (typeof safePatch.torrentExcludeTrackers !== 'string' || !isValidTorrentExcludeTrackerList(safePatch.torrentExcludeTrackers))) {
|
||||
throw new Error('Invalid excluded Torrent tracker list');
|
||||
}
|
||||
if (typeof safePatch.torrentExcludeTrackers === 'string' && !safePatch.torrentExcludeTrackers.trim()) {
|
||||
safePatch.torrentExcludeTrackers = undefined;
|
||||
}
|
||||
if (safePatch.torrentFileIndices !== undefined
|
||||
&& (!Array.isArray(safePatch.torrentFileIndices)
|
||||
|| safePatch.torrentFileIndices.length === 0
|
||||
|| safePatch.torrentFileIndices.some(index => !Number.isInteger(index) || index < 0))) {
|
||||
throw new Error('Torrent file selection must contain at least one valid file');
|
||||
}
|
||||
return safePatch;
|
||||
};
|
||||
|
||||
const LIVE_PROPERTIES_STATUSES = new Set(['downloading', 'seeding', 'retrying']);
|
||||
const LIVE_PROPERTIES_KEYS = new Set<keyof PropertiesPatch>([
|
||||
'speedLimit',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Active transfers may change only the controls whose native consumers expose
|
||||
* an in-place Aria2 mutation. Keep this check at the main-webview boundary so
|
||||
* an active Properties save cannot enter applyPropertiesInternal and detach a
|
||||
* live lifecycle before being rejected by its status gate.
|
||||
*/
|
||||
export const isLivePropertiesPatch = (
|
||||
item: Pick<DownloadItem, 'isMedia' | 'isTorrent' | 'status'>,
|
||||
patch: Partial<DownloadItem>,
|
||||
): boolean => {
|
||||
if (!LIVE_PROPERTIES_STATUSES.has(item.status) || item.isMedia === true) return false;
|
||||
const keys = Object.keys(patch) as Array<keyof PropertiesPatch>;
|
||||
if (item.isTorrent !== true) {
|
||||
return keys.every(key => key === 'speedLimit' && ['downloading', 'retrying'].includes(item.status));
|
||||
}
|
||||
return keys.every(key => key !== 'speedLimit' || ['downloading', 'retrying'].includes(item.status))
|
||||
&& keys.every(key => LIVE_PROPERTIES_KEYS.has(key));
|
||||
};
|
||||
|
||||
export const PropertiesWindowBridgeHost = () => {
|
||||
useEffect(() => {
|
||||
const mainWindowTarget = propertiesWindowEventTarget(getCurrentWindow().label);
|
||||
const windows = new Map<string, PropertiesWindowRegistration>();
|
||||
const snapshotRevisions = new Map<string, number>();
|
||||
const actionsInFlight = new Set<string>();
|
||||
const actionChains = new Map<string, Promise<void>>();
|
||||
const actionOperations = new Map<string, Promise<void>>();
|
||||
const actionResults = new Map<string, PropertiesActionResult>();
|
||||
let platformOs = 'unknown';
|
||||
const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1);
|
||||
lastPropertiesBridgeGeneration = bridgeGeneration;
|
||||
let disposed = false;
|
||||
let unlistenReady: UnlistenFn | undefined;
|
||||
let unlistenAction: UnlistenFn | undefined;
|
||||
let unlistenClosed: UnlistenFn | undefined;
|
||||
const snapshotCoalescer = createFrameCoalescer(
|
||||
windowLabel => {
|
||||
const registration = windows.get(windowLabel);
|
||||
if (registration) void sendFor(windowLabel, registration.downloadId).catch(() => undefined);
|
||||
},
|
||||
callback => window.requestAnimationFrame(callback),
|
||||
handle => window.cancelAnimationFrame(handle),
|
||||
);
|
||||
|
||||
const clearWindowActionState = (windowLabel: string) => {
|
||||
const resultPrefix = `${windowLabel}\u0000`;
|
||||
for (const key of actionResults.keys()) {
|
||||
if (key.startsWith(resultPrefix)) actionResults.delete(key);
|
||||
}
|
||||
for (const key of actionOperations.keys()) {
|
||||
if (key.startsWith(resultPrefix)) actionOperations.delete(key);
|
||||
}
|
||||
// A renderer session can be replaced while an accepted mutation is
|
||||
// still running. Keep the download-scoped chain so the next session
|
||||
// cannot start a second mutation concurrently with that operation.
|
||||
// Completed chains remove themselves; host teardown clears the map.
|
||||
};
|
||||
|
||||
const clearSessionActionResults = (windowLabel: string, sessionId: string) => {
|
||||
const resultPrefix = `${windowLabel}\u0000${sessionId}\u0000`;
|
||||
for (const key of actionResults.keys()) {
|
||||
if (key.startsWith(resultPrefix)) actionResults.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const cacheActionResult = (key: string, result: PropertiesActionResult) => {
|
||||
// A child can have only one pending action per session. Retain the most
|
||||
// recent completed result for that session until a newer request is
|
||||
// accepted, so a lost result can always be replayed without allowing an
|
||||
// unbounded per-action cache.
|
||||
const separator = key.lastIndexOf('\u0000');
|
||||
const sessionPrefixEnd = separator >= 0 ? key.lastIndexOf('\u0000', separator - 1) : -1;
|
||||
if (sessionPrefixEnd >= 0) {
|
||||
const sessionPrefix = key.slice(0, sessionPrefixEnd + 1);
|
||||
for (const existingKey of actionResults.keys()) {
|
||||
if (existingKey.startsWith(sessionPrefix)) actionResults.delete(existingKey);
|
||||
}
|
||||
}
|
||||
actionResults.set(key, result);
|
||||
};
|
||||
|
||||
const sendFor = async (windowLabel: string, downloadId: string) => {
|
||||
const registration = windows.get(windowLabel);
|
||||
if (!registration || registration.downloadId !== downloadId || disposed) return false;
|
||||
const store = useDownloadStore.getState();
|
||||
const item = store.downloads.find(download => download.id === downloadId);
|
||||
if (!item) return false;
|
||||
const queue = store.queues.find(candidate => candidate.id === item.queueId)
|
||||
?? store.queues.find(candidate => candidate.isMain);
|
||||
const settings = useSettingsStore.getState();
|
||||
const progress = useDownloadProgressStore.getState();
|
||||
const windowChrome = {
|
||||
controlStyle: resolveWindowControlStyle(
|
||||
settings.windowControlStyle,
|
||||
platformOs,
|
||||
navigator.userAgent,
|
||||
),
|
||||
side: resolveWindowControlSide(
|
||||
settings.sidebarPosition,
|
||||
localeDirection(resolveAppLocale(i18n.language)),
|
||||
),
|
||||
};
|
||||
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
|
||||
snapshotRevisions.set(windowLabel, revision);
|
||||
await sendPropertiesSnapshot(windowLabel, {
|
||||
windowLabel,
|
||||
downloadId,
|
||||
sessionId: registration.sessionId,
|
||||
bridgeGeneration,
|
||||
revision,
|
||||
snapshot: sanitizePropertiesSnapshot(item, {
|
||||
theme: settings.theme,
|
||||
fontFamily: settings.fontFamily,
|
||||
appFontSize: settings.appFontSize,
|
||||
listRowDensity: settings.listRowDensity,
|
||||
locale: resolveAppLocale(i18n.language),
|
||||
}, {
|
||||
progress: progress.progressMap[downloadId],
|
||||
moveProgress: progress.moveProgressMap[downloadId],
|
||||
}, {
|
||||
queueName: queue?.name,
|
||||
windowChrome,
|
||||
allocationPending: store.allocationPendingIds.has(downloadId),
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const synchronizeRegistration = (
|
||||
windowLabel: string,
|
||||
downloadId: string,
|
||||
sessionId: string,
|
||||
) => {
|
||||
const previous = windows.get(windowLabel);
|
||||
const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId;
|
||||
if (sessionChanged) clearWindowActionState(windowLabel);
|
||||
windows.set(windowLabel, {
|
||||
downloadId,
|
||||
sessionId,
|
||||
latestRequestId: sessionChanged ? 0 : (previous?.latestRequestId ?? 0),
|
||||
});
|
||||
if (sessionChanged) {
|
||||
snapshotRevisions.set(windowLabel, 0);
|
||||
} else if (!snapshotRevisions.has(windowLabel)) {
|
||||
snapshotRevisions.set(windowLabel, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const assertCurrentAction = async (request: PropertiesActionRequest) => {
|
||||
await invoke('validate_properties_window_request', {
|
||||
windowLabel: request.windowLabel,
|
||||
downloadId: request.downloadId,
|
||||
sessionId: request.sessionId,
|
||||
requestId: request.requestId,
|
||||
});
|
||||
if (disposed) throw new Error('Properties bridge is no longer active');
|
||||
const registration = windows.get(request.windowLabel);
|
||||
if (!registration
|
||||
|| registration.downloadId !== request.downloadId
|
||||
|| registration.sessionId !== request.sessionId
|
||||
|| registration.latestRequestId !== request.requestId) {
|
||||
throw new Error('Properties action is stale');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReady = async (payload: PropertiesWindowReady) => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
await invoke('validate_properties_window_request', payload);
|
||||
if (disposed) return;
|
||||
const item = useDownloadStore.getState().downloads.find(download => download.id === payload.downloadId);
|
||||
if (!item) {
|
||||
// The store subscription cannot see a window that never completed
|
||||
// registration. Tear down the native registry entry here as well,
|
||||
// otherwise a late ready event can leave an empty child window and
|
||||
// a permanently reserved label for a deleted download.
|
||||
void sendPropertiesRemoved(payload.windowLabel, payload.downloadId).catch(() => undefined);
|
||||
await invoke('properties_window_registry_remove_for_download', { id: payload.downloadId });
|
||||
return;
|
||||
}
|
||||
synchronizeRegistration(payload.windowLabel, payload.downloadId, payload.sessionId);
|
||||
await sendFor(payload.windowLabel, payload.downloadId);
|
||||
} catch {
|
||||
// The child will show its own unavailable state. Do not log bridge
|
||||
// payloads because they may contain URLs or other user data.
|
||||
}
|
||||
};
|
||||
|
||||
const processAction = async (request: PropertiesActionRequest) => {
|
||||
let ok = false;
|
||||
let error: string | undefined;
|
||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||
let releaseAction: (() => void) | undefined;
|
||||
try {
|
||||
// The request may have waited behind another action. Revalidate the
|
||||
// native session and request ordering at dequeue time so a closed,
|
||||
// reopened, or reloaded Properties window cannot apply stale work.
|
||||
await assertCurrentAction(request);
|
||||
releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey);
|
||||
const store = useDownloadStore.getState();
|
||||
const item = store.downloads.find(download => download.id === request.downloadId);
|
||||
if (!item) throw new Error('Download no longer exists');
|
||||
|
||||
switch (request.action) {
|
||||
case 'apply-properties': {
|
||||
await assertCurrentAction(request);
|
||||
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
|
||||
if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) {
|
||||
throw new Error('Torrent file selection requires the dedicated selection action');
|
||||
}
|
||||
const safePatch = copyEditablePropertiesPatch(rawPatch, item);
|
||||
if ('password' in rawPatch) {
|
||||
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
||||
}
|
||||
if ('cookies' in rawPatch) {
|
||||
safePatch.cookies = applySecretPatch(rawPatch.cookies, item.cookies);
|
||||
}
|
||||
if ('headers' in rawPatch) {
|
||||
safePatch.headers = applySecretPatch(rawPatch.headers, item.headers);
|
||||
}
|
||||
if ('username' in rawPatch) {
|
||||
safePatch.username = applySecretPatch(rawPatch.username, item.username);
|
||||
}
|
||||
const torrentOptionKeys = [
|
||||
'torrentFileIndices',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
'torrentSeedTime',
|
||||
'torrentSeedRatio',
|
||||
'torrentCheckIntegrity',
|
||||
'torrentRemoveUnselectedFile',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
'torrentTrackerConnectTimeout',
|
||||
'torrentTrackerTimeout',
|
||||
'torrentTrackerInterval',
|
||||
'torrentStopTimeout',
|
||||
'torrentPrioritizePiece',
|
||||
'torrentEncryptionPolicy',
|
||||
'torrentFileAllocation',
|
||||
] as const;
|
||||
if (item.isTorrent !== true && torrentOptionKeys.some(key => Object.prototype.hasOwnProperty.call(rawPatch, key))) {
|
||||
throw new Error('Torrent properties are only available for Torrent downloads');
|
||||
}
|
||||
if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) {
|
||||
throw new Error('Generic connection settings are not available for Torrent downloads');
|
||||
}
|
||||
await assertCurrentAction(request);
|
||||
if (LIVE_PROPERTIES_STATUSES.has(item.status)) {
|
||||
if (!isLivePropertiesPatch(item, safePatch)) {
|
||||
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(safePatch, 'speedLimit')) {
|
||||
await store.setDownloadSpeedLimit(
|
||||
request.downloadId,
|
||||
safePatch.speedLimit ?? null,
|
||||
);
|
||||
}
|
||||
if (item.isTorrent === true
|
||||
&& Object.prototype.hasOwnProperty.call(safePatch, 'torrentUploadLimit')) {
|
||||
await store.setTorrentUploadLimit(
|
||||
request.downloadId,
|
||||
safePatch.torrentUploadLimit ?? null,
|
||||
);
|
||||
}
|
||||
if (item.isTorrent === true
|
||||
&& (Object.prototype.hasOwnProperty.call(safePatch, 'torrentMaxPeers')
|
||||
|| Object.prototype.hasOwnProperty.call(safePatch, 'torrentPeerSpeedLimit'))) {
|
||||
const maxPeers = Object.prototype.hasOwnProperty.call(safePatch, 'torrentMaxPeers')
|
||||
? safePatch.torrentMaxPeers == null ? null : String(safePatch.torrentMaxPeers)
|
||||
: item.torrentMaxPeers == null ? null : String(item.torrentMaxPeers);
|
||||
const peerSpeedLimit = Object.prototype.hasOwnProperty.call(safePatch, 'torrentPeerSpeedLimit')
|
||||
? safePatch.torrentPeerSpeedLimit ?? null
|
||||
: item.torrentPeerSpeedLimit ?? null;
|
||||
await store.setTorrentPeerOptions(
|
||||
request.downloadId,
|
||||
maxPeers,
|
||||
peerSpeedLimit,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await store.applyProperties(request.downloadId, safePatch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set-torrent-file-selection': {
|
||||
if (item.isTorrent !== true
|
||||
|| !request.payload
|
||||
|| !('selectedIndices' in request.payload)) {
|
||||
throw new Error('Torrent file selection is unavailable for this download');
|
||||
}
|
||||
const selectedIndices = request.payload.selectedIndices;
|
||||
if (selectedIndices !== null
|
||||
&& (!Array.isArray(selectedIndices)
|
||||
|| selectedIndices.length === 0
|
||||
|| selectedIndices.some(index => !Number.isInteger(index) || index < 1))) {
|
||||
throw new Error('Torrent file selection must contain at least one valid file');
|
||||
}
|
||||
await assertCurrentAction(request);
|
||||
const selection = await invoke('set_torrent_file_selection', {
|
||||
id: request.downloadId,
|
||||
selected_indices: selectedIndices,
|
||||
});
|
||||
const selected = selection.files.filter(file => file.selected).map(file => file.index);
|
||||
const allSelected = selection.files.length > 0 && selected.length === selection.files.length;
|
||||
await assertCurrentAction(request);
|
||||
store.updateDownload(request.downloadId, {
|
||||
torrentFileIndices: allSelected ? undefined : selected,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'pause-resume': {
|
||||
const lifecycleAction = getPropertiesLifecycleAction(item.status);
|
||||
if (!lifecycleAction) {
|
||||
throw new Error('This download has no available lifecycle action');
|
||||
}
|
||||
if (lifecycleAction === 'pause') {
|
||||
await store.pauseDownload(request.downloadId);
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId);
|
||||
if (!current) throw new Error('Download was removed while pausing');
|
||||
if (!['paused', 'completed', 'failed'].includes(current.status)) {
|
||||
throw new Error('The download did not reach a paused or terminal state');
|
||||
}
|
||||
} else {
|
||||
const resumeWithoutCredentials = typeof request.payload === 'object'
|
||||
&& request.payload !== null
|
||||
&& 'resumeWithoutCredentials' in request.payload
|
||||
&& request.payload.resumeWithoutCredentials === true;
|
||||
const resumed = await store.resumeDownload(
|
||||
request.downloadId,
|
||||
resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined,
|
||||
);
|
||||
if (!resumed) {
|
||||
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
|
||||
}
|
||||
// resumeDownload returns after the lifecycle request has been
|
||||
// accepted, while the backend may still be admitting a queue
|
||||
// slot, rebinding a retained GID, or emitting the first active
|
||||
// state. Do not inspect the store synchronously here: an event
|
||||
// from the previous lifecycle can still leave the row paused
|
||||
// for one turn even though the request was accepted.
|
||||
if (!useDownloadStore.getState().downloads.some(download => download.id === request.downloadId)) {
|
||||
throw new Error('Download was removed while starting');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'verify-torrent': {
|
||||
if (item.isTorrent !== true
|
||||
|| !['paused', 'completed', 'failed'].includes(item.status)) {
|
||||
throw new Error('Pause the Torrent before verifying its data');
|
||||
}
|
||||
const previousVerifyOnly = item.torrentVerifyOnly;
|
||||
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
|
||||
await assertCurrentAction(request);
|
||||
store.updateDownload(request.downloadId, {
|
||||
torrentVerifyOnly: true,
|
||||
torrentVerifyRestoreStatus: item.status,
|
||||
});
|
||||
try {
|
||||
await assertCurrentAction(request);
|
||||
await invoke('verify_torrent_data', { id: request.downloadId });
|
||||
} catch (verifyError) {
|
||||
try {
|
||||
await assertCurrentAction(request);
|
||||
useDownloadStore.getState().updateDownload(request.downloadId, {
|
||||
torrentVerifyOnly: previousVerifyOnly,
|
||||
torrentVerifyRestoreStatus: previousRestoreStatus,
|
||||
});
|
||||
} catch {
|
||||
// A newer Properties session owns the row now. Do not let a
|
||||
// late verification failure roll back its marker.
|
||||
}
|
||||
throw verifyError;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set-download-limit':
|
||||
await assertCurrentAction(request);
|
||||
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
||||
break;
|
||||
case 'set-torrent-upload-limit':
|
||||
await assertCurrentAction(request);
|
||||
await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
||||
break;
|
||||
case 'set-torrent-peer-options': {
|
||||
if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options');
|
||||
await assertCurrentAction(request);
|
||||
await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error('Invalid Properties action');
|
||||
}
|
||||
if (!useDownloadStore.getState().downloads.some(download => download.id === request.downloadId)) {
|
||||
throw new Error('Download was removed while applying the action');
|
||||
}
|
||||
ok = true;
|
||||
} catch (caught) {
|
||||
error = errorText(caught);
|
||||
} finally {
|
||||
releaseAction?.();
|
||||
}
|
||||
if (ok) void sendFor(request.windowLabel, request.downloadId).catch(() => undefined);
|
||||
const result: PropertiesActionResult = {
|
||||
windowLabel: request.windowLabel,
|
||||
downloadId: request.downloadId,
|
||||
sessionId: request.sessionId,
|
||||
requestId: request.requestId,
|
||||
ok,
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
cacheActionResult(propertiesActionRequestKey(request), result);
|
||||
try {
|
||||
await sendPropertiesActionResult(request.windowLabel, result);
|
||||
} catch {
|
||||
// The result remains cached so a same-request retry can replay it.
|
||||
}
|
||||
};
|
||||
|
||||
const sendRejectedActionResult = async (request: PropertiesActionRequest, reason: unknown) => {
|
||||
const result: PropertiesActionResult = {
|
||||
windowLabel: request.windowLabel,
|
||||
downloadId: request.downloadId,
|
||||
sessionId: request.sessionId,
|
||||
requestId: request.requestId,
|
||||
ok: false,
|
||||
error: errorText(reason),
|
||||
};
|
||||
// Validation failures did not enter the mutation queue, so do not cache
|
||||
// them as a completed request. A same-ID retry must be able to recover
|
||||
// from a transient registry/session race.
|
||||
try {
|
||||
await sendPropertiesActionResult(request.windowLabel, result);
|
||||
} catch {
|
||||
// The child may have closed while the validation error was delivered.
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (request: PropertiesActionRequest) => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
// The native command validates the caller, download binding, and
|
||||
// renderer session. If a ready event is delayed or lost, this valid
|
||||
// action can also establish the main-window registration.
|
||||
await invoke('validate_properties_window_request', request);
|
||||
if (disposed) return;
|
||||
synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId);
|
||||
} catch (error) {
|
||||
// Stale renderer actions are deliberately ignored. The current child
|
||||
// session cannot safely consume a result for a superseded renderer,
|
||||
// but an active child still needs a terminal result to unlock its
|
||||
// request state and decide whether to retry.
|
||||
sendRejectedActionResult(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = windows.get(request.windowLabel);
|
||||
if (!registration) {
|
||||
sendRejectedActionResult(request, new Error('Properties window is no longer registered'));
|
||||
return;
|
||||
}
|
||||
const requestKey = propertiesActionRequestKey(request);
|
||||
const disposition = classifyPropertiesActionRequest(
|
||||
registration,
|
||||
request,
|
||||
actionResults.has(requestKey),
|
||||
actionOperations.has(requestKey),
|
||||
);
|
||||
if (disposition === 'replay') {
|
||||
const result = actionResults.get(requestKey);
|
||||
if (result) void sendPropertiesActionResult(request.windowLabel, result).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (disposition === 'pending') return;
|
||||
if (disposition === 'ignore') {
|
||||
sendRejectedActionResult(request, new Error('Properties action is stale'));
|
||||
return;
|
||||
}
|
||||
clearSessionActionResults(request.windowLabel, request.sessionId);
|
||||
registration.latestRequestId = request.requestId;
|
||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||
|
||||
// Preserve user order for accepted requests. This keeps a pause from an
|
||||
// earlier request from running after a newer resume, while still
|
||||
// allowing the newer request to run after an already-started operation.
|
||||
const operation = enqueuePropertiesAction(actionChains, actionKey, () => processAction(request));
|
||||
actionOperations.set(requestKey, operation);
|
||||
const clearOperation = () => {
|
||||
if (actionOperations.get(requestKey) === operation) actionOperations.delete(requestKey);
|
||||
};
|
||||
// Consume either outcome while removing the in-flight marker. An
|
||||
// unexpected host exception must not become an unhandled rejection.
|
||||
void operation.then(clearOperation, clearOperation);
|
||||
};
|
||||
|
||||
attachAsyncPropertiesListener(
|
||||
listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => {
|
||||
if (!disposed) void handleReady(event.payload);
|
||||
}, { target: mainWindowTarget }),
|
||||
() => disposed,
|
||||
value => { unlistenReady = value; },
|
||||
);
|
||||
attachAsyncPropertiesListener(
|
||||
listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => {
|
||||
if (!disposed) void handleAction(event.payload);
|
||||
}, { target: mainWindowTarget }),
|
||||
() => disposed,
|
||||
value => { unlistenAction = value; },
|
||||
);
|
||||
attachAsyncPropertiesListener(
|
||||
listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
||||
if (disposed) return;
|
||||
const registration = windows.get(event.payload);
|
||||
windows.delete(event.payload);
|
||||
snapshotRevisions.delete(event.payload);
|
||||
clearWindowActionState(event.payload);
|
||||
snapshotCoalescer.cancel(event.payload);
|
||||
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
||||
}, { target: mainWindowTarget }),
|
||||
() => disposed,
|
||||
value => { unlistenClosed = value; },
|
||||
);
|
||||
|
||||
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
|
||||
for (const [windowLabel, registration] of windows) {
|
||||
const { downloadId } = registration;
|
||||
const next = state.downloads.find(download => download.id === downloadId);
|
||||
const before = previous.downloads.find(download => download.id === downloadId);
|
||||
if (!next) {
|
||||
snapshotCoalescer.cancel(windowLabel);
|
||||
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
|
||||
windows.delete(windowLabel);
|
||||
snapshotRevisions.delete(windowLabel);
|
||||
clearWindowActionState(windowLabel);
|
||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||
} else if (
|
||||
next !== before
|
||||
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
|
||||
) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
}
|
||||
}
|
||||
});
|
||||
const unsubscribeProgress = useDownloadProgressStore.subscribe((state, previous) => {
|
||||
for (const [windowLabel, registration] of windows) {
|
||||
const { downloadId } = registration;
|
||||
if (state.progressMap[downloadId] !== previous.progressMap[downloadId]
|
||||
|| state.moveProgressMap[downloadId] !== previous.moveProgressMap[downloadId]) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
}
|
||||
}
|
||||
});
|
||||
const unsubscribeSettings = useSettingsStore.subscribe((state, previous) => {
|
||||
if (state.theme === previous.theme
|
||||
&& state.fontFamily === previous.fontFamily
|
||||
&& state.appFontSize === previous.appFontSize
|
||||
&& state.listRowDensity === previous.listRowDensity
|
||||
&& state.language === previous.language
|
||||
&& state.windowControlStyle === previous.windowControlStyle
|
||||
&& state.sidebarPosition === previous.sidebarPosition) {
|
||||
return;
|
||||
}
|
||||
for (const windowLabel of windows.keys()) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
}
|
||||
});
|
||||
const handleLanguageChanged = () => {
|
||||
for (const windowLabel of windows.keys()) {
|
||||
snapshotCoalescer.schedule(windowLabel);
|
||||
}
|
||||
};
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
|
||||
void getPlatformInfo().then(info => {
|
||||
if (disposed) return;
|
||||
platformOs = info.os;
|
||||
for (const windowLabel of windows.keys()) snapshotCoalescer.schedule(windowLabel);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
snapshotCoalescer.cancelAll();
|
||||
unsubscribeStore();
|
||||
unsubscribeProgress();
|
||||
unsubscribeSettings();
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
unlistenReady?.();
|
||||
unlistenAction?.();
|
||||
unlistenClosed?.();
|
||||
actionOperations.clear();
|
||||
actionResults.clear();
|
||||
actionChains.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -12,12 +12,6 @@ import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
consumeSchedulerHandoffIds,
|
||||
handoffSupersededSchedulerIds,
|
||||
isSchedulerControlCurrent
|
||||
} from '../utils/schedulerControl';
|
||||
|
||||
const days = [
|
||||
{ value: 0, key: 'su' },
|
||||
@@ -36,8 +30,7 @@ const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
|
||||
{ value: 'shutdown', icon: Power },
|
||||
];
|
||||
|
||||
const minuteOfDay = (value: string): number | null => {
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return null;
|
||||
const minuteOfDay = (value: string) => {
|
||||
const [hour, minute] = value.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
};
|
||||
@@ -45,10 +38,7 @@ const minuteOfDay = (value: string): number | null => {
|
||||
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
|
||||
if (!settings.enabled) return 'disabled';
|
||||
|
||||
const startMinute = minuteOfDay(settings.startTime);
|
||||
if (startMinute === null) return 'none';
|
||||
const hour = Math.floor(startMinute / 60);
|
||||
const minute = startMinute % 60;
|
||||
const [hour, minute] = settings.startTime.split(':').map(Number);
|
||||
const now = new Date();
|
||||
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
@@ -146,13 +136,7 @@ export default function SchedulerView() {
|
||||
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
const startMinute = minuteOfDay(draft.startTime);
|
||||
const stopMinute = minuteOfDay(draft.stopTime);
|
||||
if (draft.enabled && (startMinute === null || (draft.stopTimeEnabled && stopMinute === null))) {
|
||||
addToast({ message: t($ => $.scheduler.validationTime), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (draft.enabled && draft.stopTimeEnabled && stopMinute === startMinute) {
|
||||
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
|
||||
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
@@ -167,29 +151,15 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const generation = beginSchedulerControl(effectiveSelectedQueueIds);
|
||||
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
|
||||
const results = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = results.flat();
|
||||
if (!isSchedulerControlCurrent(generation)) {
|
||||
const handoffIds = handoffSupersededSchedulerIds(
|
||||
acceptedIds,
|
||||
id => useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID
|
||||
);
|
||||
await Promise.allSettled(
|
||||
acceptedIds
|
||||
.filter(id => !handoffIds.has(id))
|
||||
.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const handoffIds = consumeSchedulerHandoffIds(generation);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
(previouslyTrackedIds.has(download.id) || handoffIds.has(download.id)) &&
|
||||
previouslyTrackedIds.has(download.id) &&
|
||||
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
@@ -210,24 +180,10 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const pauseNow = async () => {
|
||||
const generation = beginSchedulerControl();
|
||||
const savedQueueIds = savedSettings.selectedQueueIds
|
||||
.filter(queueId => availableQueueIds.has(queueId));
|
||||
const savedQueueSet = new Set(savedQueueIds);
|
||||
const trackedIdsOutsideSavedQueues = useSettingsStore.getState().schedulerActiveDownloadIds
|
||||
.filter(id => {
|
||||
const queueId = useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||
return !savedQueueSet.has(queueId);
|
||||
});
|
||||
const counts = await Promise.all(
|
||||
savedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
);
|
||||
const directPauseResults = await Promise.allSettled(
|
||||
trackedIdsOutsideSavedQueues.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
if (!isSchedulerControlCurrent(generation)) return;
|
||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
|
||||
+ directPauseResults.filter(result => result.status === 'fulfilled').length;
|
||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
||||
useSettingsStore.getState().setSchedulerRunning(false);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
||||
addToast({
|
||||
|
||||
@@ -34,24 +34,8 @@ import {
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
import { shouldApplyTorrentNetworkInputResult } from '../utils/torrentNetworkInput';
|
||||
import {
|
||||
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
MAX_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
MIN_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MAX_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MIN_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
MAX_TORRENT_MAX_OPEN_FILES,
|
||||
MIN_TORRENT_MAX_OPEN_FILES,
|
||||
normalizeSpeedLimitForBackend,
|
||||
normalizeTorrentDhtMessageTimeout,
|
||||
normalizeTorrentMaxConcurrentSeeds,
|
||||
normalizeTorrentMaxOpenFiles
|
||||
} from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { localeDirection, resolveAppLocale } from '../i18n';
|
||||
import { createEngineStatusRequestTracker } from '../utils/engineStatusRequests';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', icon: Download },
|
||||
@@ -101,129 +85,8 @@ type ManualUpdateStatus =
|
||||
|
||||
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
|
||||
|
||||
type TorrentNetworkTextField =
|
||||
| 'torrentListenPort'
|
||||
| 'torrentDhtListenPort'
|
||||
| 'torrentExternalIp'
|
||||
| 'torrentDhtEntryPoint'
|
||||
| 'torrentDhtEntryPoint6'
|
||||
| 'torrentDhtListenAddr6'
|
||||
| 'torrentLpdInterface'
|
||||
| 'torrentPeerIdPrefix'
|
||||
| 'torrentPeerAgent'
|
||||
| 'torrentBindAddress'
|
||||
| 'aria2DiskCache';
|
||||
|
||||
const TorrentNetworkTextInput = ({
|
||||
field,
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
onCommit,
|
||||
onError,
|
||||
maxLength,
|
||||
className = 'app-control settings-network-input'
|
||||
}: {
|
||||
field: TorrentNetworkTextField;
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder?: string;
|
||||
onCommit: (value: string) => boolean | void;
|
||||
onError: (error: unknown) => void;
|
||||
maxLength?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const commitId = useRef(0);
|
||||
const editId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
editId.current += 1;
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const commit = async () => {
|
||||
const requestId = ++commitId.current;
|
||||
const editRequestId = editId.current;
|
||||
try {
|
||||
const normalized = await invoke('canonicalize_torrent_network_setting', {
|
||||
field,
|
||||
value: draft
|
||||
});
|
||||
if (!shouldApplyTorrentNetworkInputResult(
|
||||
requestId,
|
||||
commitId.current,
|
||||
editRequestId,
|
||||
editId.current
|
||||
)) return;
|
||||
if (onCommit(normalized) === false) {
|
||||
setDraft(value);
|
||||
onError(new Error('This Torrent network value conflicts with another setting.'));
|
||||
return;
|
||||
}
|
||||
setDraft(normalized);
|
||||
} catch (error) {
|
||||
if (!shouldApplyTorrentNetworkInputResult(
|
||||
requestId,
|
||||
commitId.current,
|
||||
editRequestId,
|
||||
editId.current
|
||||
)) return;
|
||||
setDraft(value);
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{label}</span>
|
||||
<small>{description}</small>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
editId.current += 1;
|
||||
setDraft(event.target.value);
|
||||
}}
|
||||
onBlur={() => { void commit(); }}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
className={className}
|
||||
aria-label={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type NetworkSettingsSection = 'general' | 'discovery' | 'connection' | 'limits' | 'advanced';
|
||||
|
||||
const networkSettingsSections: NetworkSettingsSection[] = [
|
||||
'general',
|
||||
'discovery',
|
||||
'connection',
|
||||
'limits',
|
||||
'advanced',
|
||||
];
|
||||
|
||||
const networkSettingsSectionFromStorage = (): NetworkSettingsSection => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('firelink-network-settings-section');
|
||||
return networkSettingsSections.includes(stored as NetworkSettingsSection)
|
||||
? stored as NetworkSettingsSection
|
||||
: 'general';
|
||||
} catch {
|
||||
return 'general';
|
||||
}
|
||||
};
|
||||
|
||||
const engineStatusCache = new Map<string, EngineStatusItem>();
|
||||
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
|
||||
const engineStatusRequests = createEngineStatusRequestTracker();
|
||||
|
||||
const upsertEngineStatus = (items: EngineStatusItem[], item: EngineStatusItem) => {
|
||||
const next = items.filter(existing => existing.kind !== item.kind);
|
||||
@@ -311,13 +174,10 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
|
||||
}
|
||||
|
||||
if (force) engineStatusCache.delete(check.kind);
|
||||
const requestId = engineStatusRequests.begin(check.kind);
|
||||
|
||||
const promise = invoke(check.command)
|
||||
.then(item => {
|
||||
if (item.ready && engineStatusRequests.isCurrent(check.kind, requestId)) {
|
||||
engineStatusCache.set(item.kind, item);
|
||||
}
|
||||
if (item.ready) engineStatusCache.set(item.kind, item);
|
||||
return item;
|
||||
})
|
||||
.catch(error => buildEngineStatusError(check, error))
|
||||
@@ -414,7 +274,6 @@ export default function SettingsView() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
const [networkSection, setNetworkSection] = useState<NetworkSettingsSection>(networkSettingsSectionFromStorage);
|
||||
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||
const isSidebarOnRight = settings.sidebarPosition === 'right'
|
||||
|| (settings.sidebarPosition === 'auto' && isRtl);
|
||||
@@ -444,14 +303,6 @@ export default function SettingsView() {
|
||||
const userAgentMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem('firelink-network-settings-section', networkSection);
|
||||
} catch {
|
||||
// Restricted WebViews may not expose local storage.
|
||||
}
|
||||
}, [networkSection]);
|
||||
|
||||
// Local state for engine status
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
@@ -467,20 +318,6 @@ const engineRunId = useRef(0);
|
||||
() => String(settings.maxConcurrentDownloads)
|
||||
);
|
||||
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
||||
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||
() => String(settings.torrentMaxOpenFiles)
|
||||
);
|
||||
const [torrentDhtMessageTimeoutInput, setTorrentDhtMessageTimeoutInput] = useState(
|
||||
() => String(settings.torrentDhtMessageTimeout)
|
||||
);
|
||||
const [torrentMaxConcurrentSeedsInput, setTorrentMaxConcurrentSeedsInput] = useState(
|
||||
() => String(settings.torrentMaxConcurrentSeeds)
|
||||
);
|
||||
const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState(
|
||||
() => settings.torrentOverallUploadLimit
|
||||
);
|
||||
const torrentMaxOpenFilesCommitRef = useRef(0);
|
||||
const torrentOverallUploadLimitCommitRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPerServerConnectionsInput(String(settings.perServerConnections));
|
||||
@@ -494,22 +331,6 @@ const engineRunId = useRef(0);
|
||||
setProxyPortInput(String(settings.proxyPort));
|
||||
}, [settings.proxyPort]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
||||
}, [settings.torrentMaxOpenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentDhtMessageTimeoutInput(String(settings.torrentDhtMessageTimeout));
|
||||
}, [settings.torrentDhtMessageTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentMaxConcurrentSeedsInput(String(settings.torrentMaxConcurrentSeeds));
|
||||
}, [settings.torrentMaxConcurrentSeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
|
||||
}, [settings.torrentOverallUploadLimit]);
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
@@ -525,71 +346,6 @@ const engineRunId = useRef(0);
|
||||
|
||||
// Toast notifications
|
||||
const { addToast } = useToast();
|
||||
const showTorrentNetworkInputError = (error: unknown) => {
|
||||
addToast({
|
||||
message: t($ => $.settings.network.torrentNetworkInputInvalid, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
};
|
||||
const commitTorrentMaxOpenFiles = (raw: string) => {
|
||||
const next = normalizeTorrentMaxOpenFiles(raw) ?? settings.torrentMaxOpenFiles;
|
||||
const requestId = ++torrentMaxOpenFilesCommitRef.current;
|
||||
setTorrentMaxOpenFilesInput(String(next));
|
||||
void settings.setTorrentMaxOpenFiles(next).catch(error => {
|
||||
if (requestId !== torrentMaxOpenFilesCommitRef.current) return;
|
||||
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
||||
addToast({
|
||||
message: t($ => $.settings.network.torrentMaxOpenFilesUpdateFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
const commitTorrentDhtMessageTimeout = (raw: string) => {
|
||||
const next = normalizeTorrentDhtMessageTimeout(raw)
|
||||
?? settings.torrentDhtMessageTimeout
|
||||
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT;
|
||||
setTorrentDhtMessageTimeoutInput(String(next));
|
||||
settings.setTorrentDhtMessageTimeout(next);
|
||||
};
|
||||
const commitTorrentMaxConcurrentSeeds = (raw: string) => {
|
||||
const next = normalizeTorrentMaxConcurrentSeeds(raw)
|
||||
?? settings.torrentMaxConcurrentSeeds
|
||||
?? DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS;
|
||||
setTorrentMaxConcurrentSeedsInput(String(next));
|
||||
settings.setTorrentMaxConcurrentSeeds(next);
|
||||
};
|
||||
const commitTorrentOverallUploadLimit = (raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const normalized = trimmed ? (normalizeSpeedLimitForBackend(trimmed) ?? '') : '';
|
||||
if (trimmed && !normalized) {
|
||||
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
|
||||
addToast({
|
||||
message: t($ => $.settings.network.torrentOverallUploadLimitInvalid),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
const requestId = ++torrentOverallUploadLimitCommitRef.current;
|
||||
setTorrentOverallUploadLimitInput(normalized);
|
||||
void settings.setTorrentOverallUploadLimit(normalized).catch(error => {
|
||||
if (requestId !== torrentOverallUploadLimitCommitRef.current) return;
|
||||
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
|
||||
addToast({
|
||||
message: t($ => $.settings.network.torrentOverallUploadLimitUpdateFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
};
|
||||
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
||||
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
|
||||
|
||||
@@ -927,7 +683,6 @@ runEngineChecks(false);
|
||||
case 'Documents': return t($ => $.navigation.categories.documents);
|
||||
case 'Pictures': return t($ => $.navigation.categories.pictures);
|
||||
case 'Applications': return t($ => $.navigation.categories.applications);
|
||||
case 'Torrents': return t($ => $.navigation.categories.torrents);
|
||||
default: return t($ => $.navigation.categories.other);
|
||||
}
|
||||
};
|
||||
@@ -1003,7 +758,7 @@ runEngineChecks(false);
|
||||
<div className="settings-content-shell w-full">
|
||||
<div key={activeTab} className="settings-page-transition">
|
||||
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
|
||||
<div className={`settings-content ${activeTab === 'network' ? 'settings-content--network' : 'max-w-[720px]'}`}>
|
||||
<div className="settings-content max-w-[720px]">
|
||||
|
||||
{/* Downloads Pane */}
|
||||
{activeTab === 'downloads' && (
|
||||
@@ -1078,43 +833,6 @@ runEngineChecks(false);
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.minimumNormalDownloadSpeed)}</span>
|
||||
<small>{t($ => $.settings.downloads.minimumNormalDownloadSpeedDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="0" max="1048576"
|
||||
value={settings.minimumNormalDownloadSpeedKiB}
|
||||
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
|
||||
/>
|
||||
</div>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.retryNotFoundErrors)}</span>
|
||||
<small>{t($ => $.settings.downloads.retryNotFoundErrorsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.retryNotFoundErrors}
|
||||
onChange={(event) => settings.setRetryNotFoundErrors(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.downloads.adaptiveMirrorSelection)}</span>
|
||||
<small>{t($ => $.settings.downloads.adaptiveMirrorSelectionDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.adaptiveMirrorSelection}
|
||||
onChange={(event) => settings.setAdaptiveMirrorSelection(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mac-settings-group">
|
||||
@@ -1351,51 +1069,8 @@ runEngineChecks(false);
|
||||
|
||||
{/* Network Pane */}
|
||||
{activeTab === 'network' && (
|
||||
<div className="settings-pane settings-network-pane">
|
||||
<nav className="network-settings-tabs" role="tablist" aria-label={t($ => $.settings.tabs.network)}>
|
||||
{networkSettingsSections.map(section => {
|
||||
const label = section === 'general'
|
||||
? t($ => $.settings.network.proxy)
|
||||
: section === 'discovery'
|
||||
? t($ => $.settings.network.torrentTabs.discovery)
|
||||
: section === 'connection'
|
||||
? t($ => $.settings.network.torrentTabs.connection)
|
||||
: section === 'limits'
|
||||
? t($ => $.settings.network.torrentTabs.limits)
|
||||
: t($ => $.settings.network.torrentTabs.advanced);
|
||||
return (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={networkSection === section}
|
||||
aria-controls={`network-settings-panel-${section}`}
|
||||
tabIndex={networkSection === section ? 0 : -1}
|
||||
className="network-settings-tab"
|
||||
onClick={() => setNetworkSection(section)}
|
||||
onKeyDown={event => {
|
||||
const index = networkSettingsSections.indexOf(section);
|
||||
const nextIndex = (event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight'))
|
||||
? (index + 1) % networkSettingsSections.length
|
||||
: event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')
|
||||
? (index - 1 + networkSettingsSections.length) % networkSettingsSections.length
|
||||
: event.key === 'Home' ? 0 : event.key === 'End' ? networkSettingsSections.length - 1 : -1;
|
||||
if (nextIndex < 0) return;
|
||||
event.preventDefault();
|
||||
const next = networkSettingsSections[nextIndex];
|
||||
setNetworkSection(next);
|
||||
window.setTimeout(() => document.getElementById(`network-settings-tab-${next}`)?.focus(), 0);
|
||||
}}
|
||||
id={`network-settings-tab-${section}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div id="network-settings-panel-general" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-general" hidden={networkSection !== 'general'} tabIndex={0}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.proxy)}</h2>
|
||||
<div className="settings-pane max-w-[720px]">
|
||||
<h2 className="settings-section-title">{t($ => $.settings.network.proxy)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-network-row settings-choice-row">
|
||||
<div className="settings-row-label">
|
||||
@@ -1481,290 +1156,8 @@ runEngineChecks(false);
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="network-settings-panel-discovery" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-discovery" hidden={networkSection !== 'discovery'} tabIndex={0}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.torrentPeerDiscovery)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<label className="mac-settings-row settings-network-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentDht)}</span>
|
||||
<small>{t($ => $.settings.network.torrentDhtDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentEnableDht}
|
||||
onChange={(event) => settings.setTorrentEnableDht(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row settings-network-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentDht6)}</span>
|
||||
<small>{t($ => $.settings.network.torrentDht6Description)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentEnableDht6}
|
||||
onChange={(event) => settings.setTorrentEnableDht6(event.target.checked)}
|
||||
disabled={!settings.torrentIpv6Enabled}
|
||||
className="mac-switch disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row settings-network-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentIpv6Enabled)}</span>
|
||||
<small>{t($ => $.settings.network.torrentIpv6EnabledDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentIpv6Enabled}
|
||||
onChange={(event) => settings.setTorrentIpv6Enabled(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row settings-network-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentPex)}</span>
|
||||
<small>{t($ => $.settings.network.torrentPexDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentEnablePex}
|
||||
onChange={(event) => settings.setTorrentEnablePex(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row settings-network-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentLpd)}</span>
|
||||
<small>{t($ => $.settings.network.torrentLpdDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentEnableLpd}
|
||||
onChange={(event) => settings.setTorrentEnableLpd(event.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<p className="settings-group-footer settings-network-note">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
<span>{t($ => $.settings.network.torrentPeerDiscoveryRestartNote)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="network-settings-panel-connection" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-connection" hidden={networkSection !== 'connection'} tabIndex={0}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.torrentNetwork)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentListenPort"
|
||||
value={settings.torrentListenPort}
|
||||
label={t($ => $.settings.network.torrentListenPort)}
|
||||
description={t($ => $.settings.network.torrentListenPortDescription)}
|
||||
placeholder="6881-6999"
|
||||
onCommit={settings.setTorrentListenPort}
|
||||
onError={showTorrentNetworkInputError}
|
||||
className="app-control settings-port-input text-center"
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentBindAddress"
|
||||
value={settings.torrentBindAddress}
|
||||
label={t($ => $.settings.network.torrentBindAddress)}
|
||||
description={t($ => $.settings.network.torrentBindAddressDescription)}
|
||||
placeholder="192.0.2.10 or 2001:db8::10"
|
||||
onCommit={settings.setTorrentBindAddress}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentDhtListenPort"
|
||||
value={settings.torrentDhtListenPort}
|
||||
label={t($ => $.settings.network.torrentDhtListenPort)}
|
||||
description={t($ => $.settings.network.torrentDhtListenPortDescription)}
|
||||
placeholder="6881-6999"
|
||||
onCommit={settings.setTorrentDhtListenPort}
|
||||
onError={showTorrentNetworkInputError}
|
||||
className="app-control settings-port-input text-center"
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentExternalIp"
|
||||
value={settings.torrentExternalIp}
|
||||
label={t($ => $.settings.network.torrentExternalIp)}
|
||||
description={t($ => $.settings.network.torrentExternalIpDescription)}
|
||||
placeholder={t($ => $.settings.network.torrentExternalIpPlaceholder)}
|
||||
onCommit={settings.setTorrentExternalIp}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentDhtEntryPoint"
|
||||
value={settings.torrentDhtEntryPoint}
|
||||
label={t($ => $.settings.network.torrentDhtEntryPoint)}
|
||||
description={t($ => $.settings.network.torrentDhtEntryPointDescription)}
|
||||
placeholder="router.example:6881"
|
||||
onCommit={settings.setTorrentDhtEntryPoint}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentDhtEntryPoint6"
|
||||
value={settings.torrentDhtEntryPoint6}
|
||||
label={t($ => $.settings.network.torrentDhtEntryPoint6)}
|
||||
description={t($ => $.settings.network.torrentDhtEntryPoint6Description)}
|
||||
placeholder="[2001:db8::1]:6881"
|
||||
onCommit={settings.setTorrentDhtEntryPoint6}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentDhtListenAddr6"
|
||||
value={settings.torrentDhtListenAddr6}
|
||||
label={t($ => $.settings.network.torrentDhtListenAddr6)}
|
||||
description={t($ => $.settings.network.torrentDhtListenAddr6Description)}
|
||||
placeholder="2001:db8::2"
|
||||
onCommit={settings.setTorrentDhtListenAddr6}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentLpdInterface"
|
||||
value={settings.torrentLpdInterface}
|
||||
label={t($ => $.settings.network.torrentLpdInterface)}
|
||||
description={t($ => $.settings.network.torrentLpdInterfaceDescription)}
|
||||
placeholder="en0"
|
||||
onCommit={settings.setTorrentLpdInterface}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentPeerIdPrefix"
|
||||
value={settings.torrentPeerIdPrefix}
|
||||
label={t($ => $.settings.network.torrentPeerIdPrefix)}
|
||||
description={t($ => $.settings.network.torrentPeerIdPrefixDescription)}
|
||||
placeholder="-FL-1-4-0-"
|
||||
maxLength={20}
|
||||
onCommit={settings.setTorrentPeerIdPrefix}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<TorrentNetworkTextInput
|
||||
field="torrentPeerAgent"
|
||||
value={settings.torrentPeerAgent}
|
||||
label={t($ => $.settings.network.torrentPeerAgent)}
|
||||
description={t($ => $.settings.network.torrentPeerAgentDescription)}
|
||||
placeholder="Firelink/1.4.0"
|
||||
maxLength={128}
|
||||
onCommit={settings.setTorrentPeerAgent}
|
||||
onError={showTorrentNetworkInputError}
|
||||
/>
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span>
|
||||
<small>{t($ => $.settings.network.torrentSeparateSeedSlotsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.torrentSeparateSeedSlots}
|
||||
onChange={(event) => settings.setTorrentSeparateSeedSlots(event.target.checked)}
|
||||
className="mac-switch"
|
||||
aria-label={t($ => $.settings.network.torrentSeparateSeedSlots)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentMaxConcurrentSeeds)}</span>
|
||||
<small>{t($ => $.settings.network.torrentMaxConcurrentSeedsDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_TORRENT_MAX_CONCURRENT_SEEDS}
|
||||
max={MAX_TORRENT_MAX_CONCURRENT_SEEDS}
|
||||
step={1}
|
||||
value={torrentMaxConcurrentSeedsInput}
|
||||
onChange={(event) => setTorrentMaxConcurrentSeedsInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentMaxConcurrentSeeds(event.target.value)}
|
||||
className="app-control settings-port-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentMaxConcurrentSeeds)}
|
||||
/>
|
||||
</div>
|
||||
<p className="settings-group-footer settings-network-note">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
<span>{t($ => $.settings.network.torrentNetworkRestartNote)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="network-settings-panel-limits" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-limits" hidden={networkSection !== 'limits'} tabIndex={0}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.torrentResourceLimits)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentMaxOpenFiles)}</span>
|
||||
<small>{t($ => $.settings.network.torrentMaxOpenFilesDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_TORRENT_MAX_OPEN_FILES}
|
||||
max={MAX_TORRENT_MAX_OPEN_FILES}
|
||||
step={1}
|
||||
value={torrentMaxOpenFilesInput}
|
||||
onChange={(event) => setTorrentMaxOpenFilesInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentMaxOpenFiles(event.target.value)}
|
||||
className="app-control settings-port-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
|
||||
/>
|
||||
</div>
|
||||
<TorrentNetworkTextInput
|
||||
field="aria2DiskCache"
|
||||
value={settings.aria2DiskCache}
|
||||
label={t($ => $.settings.network.aria2DiskCache)}
|
||||
description={t($ => $.settings.network.aria2DiskCacheDescription)}
|
||||
placeholder="16M"
|
||||
onCommit={settings.setAria2DiskCache}
|
||||
onError={showTorrentNetworkInputError}
|
||||
className="app-control settings-network-input text-center"
|
||||
/>
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span>
|
||||
<small>{t($ => $.settings.network.torrentOverallUploadLimitDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={torrentOverallUploadLimitInput}
|
||||
onChange={(event) => setTorrentOverallUploadLimitInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentOverallUploadLimit(event.target.value)}
|
||||
placeholder="1M"
|
||||
className="app-control settings-network-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentOverallUploadLimit)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="network-settings-panel-advanced" className="settings-network-panel" role="tabpanel" aria-labelledby="network-settings-tab-advanced" hidden={networkSection !== 'advanced'} tabIndex={0}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
|
||||
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
|
||||
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
|
||||
step={1}
|
||||
value={torrentDhtMessageTimeoutInput}
|
||||
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
|
||||
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
|
||||
className="app-control settings-port-input text-center"
|
||||
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
|
||||
/>
|
||||
</div>
|
||||
<p className="settings-group-footer settings-network-note">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
<span>{t($ => $.settings.network.torrentNetworkRestartNote)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="network-settings-group-general-identity" className="settings-network-panel" role="region" aria-label={t($ => $.settings.network.identity)} hidden={networkSection !== 'general'}>
|
||||
<h2 className="settings-section-title settings-network-section-title">{t($ => $.settings.network.identity)}</h2>
|
||||
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
|
||||
<div className="mac-settings-group settings-popup-group">
|
||||
<div className="mac-settings-row settings-network-row">
|
||||
<div className="settings-row-label">
|
||||
@@ -1827,7 +1220,6 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<p className="settings-group-footer">{t($ => $.settings.network.userAgentOverrides)}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+18
-63
@@ -2,17 +2,16 @@ import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, Magnet,
|
||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||
List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
|
||||
ChevronDown,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useDownloadStore, DownloadCategory, Queue, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isTransferActiveStatus } from '../utils/downloads';
|
||||
import { canStartDownload } from '../utils/downloadActions';
|
||||
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -20,20 +19,13 @@ export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | Down
|
||||
|
||||
interface SidebarProps {
|
||||
selectedFilter: SidebarFilter;
|
||||
onToggleSidebar?: () => void;
|
||||
onSelectFilter: (filter: SidebarFilter) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
|
||||
const { selectedFilter, onSelectFilter } = props;
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||
const {
|
||||
activeView,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
isFoldersCollapsed: foldersCollapsed,
|
||||
toggleFoldersCollapsed
|
||||
} = useSettingsStore();
|
||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||
const { addToast } = useToast();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
@@ -44,9 +36,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
|
||||
window.localStorage.getItem('firelink-folders-collapsed') === 'true'
|
||||
);
|
||||
const foldersToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const foldersListRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -99,21 +93,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
};
|
||||
}, [contextMenu, queues, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) {
|
||||
const trigger = contextMenuTriggerRef.current;
|
||||
if (trigger?.isConnected && document.activeElement === document.body) {
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
contextMenuRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
@@ -135,6 +114,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
if (renamingQueueId) renameInputRef.current?.focus();
|
||||
}, [renamingQueueId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('firelink-folders-collapsed', String(foldersCollapsed));
|
||||
}, [foldersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (foldersCollapsed && foldersListRef.current?.contains(document.activeElement)) {
|
||||
foldersToggleRef.current?.focus();
|
||||
@@ -145,7 +128,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
if (foldersListRef.current?.contains(document.activeElement)) {
|
||||
foldersToggleRef.current?.focus();
|
||||
}
|
||||
toggleFoldersCollapsed();
|
||||
setFoldersCollapsed(collapsed => !collapsed);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,24 +197,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const openQueueContextMenu = (id: string, x: number, y: number, trigger?: HTMLButtonElement) => {
|
||||
if (trigger) contextMenuTriggerRef.current = trigger;
|
||||
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenuPosition(null);
|
||||
setContextMenu({ x, y, id });
|
||||
};
|
||||
|
||||
const handleQueueContextMenu = (e: React.MouseEvent<HTMLButtonElement>, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openQueueContextMenu(id, e.clientX, e.clientY, e.currentTarget);
|
||||
};
|
||||
|
||||
const handleQueueKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, id: string) => {
|
||||
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openQueueContextMenu(id, rect.left, rect.bottom, e.currentTarget);
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
||||
};
|
||||
|
||||
const handleAddQueueSubmit = (trigger: 'submit' | 'blur' = 'submit') => {
|
||||
@@ -351,8 +321,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
data-active={isSelected}
|
||||
data-sidebar-queue-id={queue.id}
|
||||
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
||||
onKeyDown={e => handleQueueKeyDown(e, queue.id)}
|
||||
aria-keyshortcuts="Shift+F10"
|
||||
onClick={() => onSelectFilter(filterId)}
|
||||
className="sidebar-nav-item group flex w-full items-center text-[13px] text-start cursor-default font-medium"
|
||||
>
|
||||
@@ -389,7 +357,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar ?? toggleSidebar}
|
||||
onClick={toggleSidebar}
|
||||
className="sidebar-toggle-button"
|
||||
title={t($ => $.actions.hideSidebar)}
|
||||
>
|
||||
@@ -435,7 +403,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
|
||||
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
|
||||
<NavItem icon={Magnet} label={t($ => $.navigation.categories.torrents)} filter="Torrents" />
|
||||
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,7 +479,6 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
{contextMenu && createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
id="sidebar-queue-context-menu"
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-[70] w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
|
||||
style={{
|
||||
@@ -525,19 +491,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
const credentialMarkedIds = downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId
|
||||
&& download.credentialsRequired === true
|
||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
||||
)
|
||||
.map(download => download.id);
|
||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
||||
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
setContextMenu(null);
|
||||
void startQueue(queueId, {
|
||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
||||
}).catch(error => {
|
||||
void startQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (selector: (catalog: { properties: Record<string, string> }) => string) => selector({
|
||||
properties: {
|
||||
torrentWebSeedsFile: 'File',
|
||||
torrentWebSeedsUri: 'URI',
|
||||
torrentWebSeedsRemove: 'Remove',
|
||||
torrentWebSeedsAdd: 'Add',
|
||||
torrentWebSeedsInvalid: 'Invalid',
|
||||
torrentWebSeedsEmpty: 'Empty',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||
|
||||
describe('TorrentWebSeedEditor file indices', () => {
|
||||
it('renders the native one-based file indices without adding an offset', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TorrentWebSeedEditor
|
||||
files={[
|
||||
{ index: 1, path: 'first.bin' },
|
||||
{ index: 2, path: 'second.bin' },
|
||||
]}
|
||||
rows={[{ fileIndex: 2, uri: 'https://mirror.example/torrent/' }]}
|
||||
onChange={() => undefined}
|
||||
idPrefix="test"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('1: first.bin');
|
||||
expect(markup).toContain('2: second.bin');
|
||||
expect(markup).not.toContain('3: second.bin');
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import type { TorrentFileSelectionEntry } from '../bindings/TorrentFileSelectionEntry';
|
||||
import { normalizeTorrentWebSeedDrafts, type TorrentWebSeedDraft } from '../utils/downloads';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type TorrentWebSeedFile = Pick<TorrentFile, 'index' | 'path'> | Pick<TorrentFileSelectionEntry, 'index' | 'relativePath'>;
|
||||
|
||||
type Props = {
|
||||
files: readonly TorrentWebSeedFile[];
|
||||
rows: readonly TorrentWebSeedDraft[];
|
||||
onChange: (rows: TorrentWebSeedDraft[]) => void;
|
||||
disabled?: boolean;
|
||||
idPrefix: string;
|
||||
};
|
||||
|
||||
const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.path : file.relativePath;
|
||||
|
||||
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const focusAfterRemoveRef = useRef<number | null>(null);
|
||||
const filesForValidation = files.map(file => ({ index: file.index }));
|
||||
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
|
||||
useEffect(() => {
|
||||
const rowIndex = focusAfterRemoveRef.current;
|
||||
focusAfterRemoveRef.current = null;
|
||||
if (rowIndex === -1) {
|
||||
addButtonRef.current?.focus();
|
||||
} else if (rowIndex !== null) {
|
||||
uriRefs.current[rowIndex]?.focus();
|
||||
}
|
||||
}, [rows]);
|
||||
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
|
||||
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
|
||||
rows.map((row, index) => index === rowIndex ? { ...row, ...update } : row)
|
||||
);
|
||||
const removeRow = (rowIndex: number) => {
|
||||
const nextRows = rows.filter((_, index) => index !== rowIndex);
|
||||
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : -1;
|
||||
onChange(nextRows);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.length === 0 && (
|
||||
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentWebSeedsEmpty)}</p>
|
||||
)}
|
||||
{rows.map((row, rowIndex) => {
|
||||
const rowId = `${idPrefix}-${rowIndex}`;
|
||||
const rowIsValid = normalizeTorrentWebSeedDrafts([row], filesForValidation) !== null;
|
||||
return (
|
||||
<div key={rowId} className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] gap-2 items-end">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`${rowId}-file`} className="block text-[10px] text-text-muted mb-1">
|
||||
{t($ => $.properties.torrentWebSeedsFile)}
|
||||
</label>
|
||||
{files.length === 1 ? (
|
||||
<select
|
||||
id={`${rowId}-file`}
|
||||
value={files[0].index}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
||||
>
|
||||
<option value={files[0].index}>{files[0].index}: {filePath(files[0])}</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
id={`${rowId}-file`}
|
||||
value={row.fileIndex ?? ''}
|
||||
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
|
||||
disabled={disabled || files.length === 0}
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
|
||||
{files.map(file => (
|
||||
<option key={file.index} value={file.index}>
|
||||
{file.index}: {filePath(file)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`${rowId}-uri`} className="block text-[10px] text-text-muted mb-1">
|
||||
{t($ => $.properties.torrentWebSeedsUri)}
|
||||
</label>
|
||||
<input
|
||||
id={`${rowId}-uri`}
|
||||
type="url"
|
||||
ref={element => { uriRefs.current[rowIndex] = element; }}
|
||||
value={row.uri}
|
||||
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
|
||||
disabled={disabled}
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
placeholder="https://mirror.example/torrent/"
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
disabled={disabled}
|
||||
aria-label={t($ => $.properties.torrentWebSeedsRemove)}
|
||||
className="app-button min-h-[30px] px-2 text-xs disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.length > 0 && !rowsAreValid && (
|
||||
<p className="text-[11px] text-red-500" role="alert">
|
||||
{t($ => $.properties.torrentWebSeedsInvalid)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
ref={addButtonRef}
|
||||
onClick={addRow}
|
||||
disabled={disabled || files.length === 0}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
+ {t($ => $.properties.torrentWebSeedsAdd)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,6 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||
<div
|
||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||
aria-label={t($ => $.window.controls)}
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -32,9 +31,7 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||
onPointerDown={stopTitlebarDrag}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void appWindow.close().catch(error => {
|
||||
console.error('[WindowControls] close failed', error);
|
||||
});
|
||||
void appWindow.close();
|
||||
}}
|
||||
>
|
||||
<X size={10} strokeWidth={3} />
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const common = {
|
||||
documents: 'Documents',
|
||||
pictures: 'Pictures',
|
||||
applications: 'Applications',
|
||||
torrents: 'Torrents',
|
||||
other: 'Other',
|
||||
},
|
||||
folders: 'Folders',
|
||||
@@ -60,7 +59,6 @@ const common = {
|
||||
title: 'Remove Download',
|
||||
confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.',
|
||||
confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.',
|
||||
mixedRemovalPolicy: 'If you choose Delete File, unfinished files are permanently removed; completed files continue to use Trash.',
|
||||
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
|
||||
remove: 'Remove',
|
||||
deleteFile: 'Delete file',
|
||||
@@ -79,7 +77,6 @@ const common = {
|
||||
pause: 'Pause',
|
||||
start: 'Start',
|
||||
resume: 'Resume',
|
||||
retry: 'Retry',
|
||||
options: 'Options',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const common = {
|
||||
staged: 'In queue',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
waitingForPeers: 'Waiting for peers',
|
||||
processing: 'Processing',
|
||||
verifying: 'Verifying',
|
||||
seeding: 'Seeding',
|
||||
waitingToSeed: 'Waiting to seed',
|
||||
paused: 'Paused',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
retrying: 'Retrying',
|
||||
moving: 'Moving data',
|
||||
allocatingFiles: 'Allocating files…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'Retrying with system network resolver',
|
||||
nameResolutionFailed: 'Could not resolve the server name. Check your VPN or network DNS settings.',
|
||||
},
|
||||
values: {
|
||||
processing: 'Processing...',
|
||||
@@ -199,7 +186,6 @@ const common = {
|
||||
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
|
||||
validationDay: 'Select at least one day for the scheduler',
|
||||
validationQueue: 'Select at least one queue for the scheduler',
|
||||
validationTime: 'Enter valid times in HH:MM format',
|
||||
validationStopTime: 'Stop time must be later than start time',
|
||||
saved: 'Scheduler settings saved',
|
||||
trackingOne: 'Tracking 1 scheduled download',
|
||||
@@ -225,48 +211,15 @@ const common = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: 'Discard changes',
|
||||
keepEditing: 'Keep editing',
|
||||
progress: 'Progress',
|
||||
size: 'Size',
|
||||
speed: 'Speed',
|
||||
eta: 'ETA',
|
||||
connections: 'Connections',
|
||||
fragmentConcurrency: 'Fragment concurrency',
|
||||
fragmentConcurrencyHint: 'Maximum number of media fragments yt-dlp may process concurrently. Firelink does not report a live fragment count; this is the configured value used when the transfer starts or resumes.',
|
||||
connectedPeers: 'connected peers',
|
||||
details: 'Details',
|
||||
tabs: {
|
||||
label: 'Properties sections',
|
||||
overview: 'Overview',
|
||||
files: 'Files',
|
||||
trackers: 'Trackers',
|
||||
peers: 'Peers',
|
||||
transfer: 'Transfer',
|
||||
options: 'Options',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
queueId: 'Queue',
|
||||
queuePosition: 'Position {{position}}',
|
||||
resumable: 'Resumable',
|
||||
connectionCount: '{{active}}/{{total}}',
|
||||
connectionCountUnknown: '—/{{total}}',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Speed cap',
|
||||
inputFormat: 'Format: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M, or 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 means unlimited',
|
||||
inputFormatSeedTime: 'minutes, e.g. 60',
|
||||
inputFormatSeedRatio: 'decimal, e.g. 1.5; 0 means time-only',
|
||||
inputFormatStopTimeout: 'whole seconds; 0 disables it',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'e.g. 512K',
|
||||
inputExampleMaxPeers: 'e.g. 55',
|
||||
inputExampleSeedTime: 'e.g. 60',
|
||||
inputExampleSeedRatio: 'e.g. 1.5',
|
||||
inputExampleStopTimeout: 'e.g. 300',
|
||||
inputExamplePiecePriority: 'e.g. head=1M,tail=1M',
|
||||
speedLimitHint: 'Leave blank to use the global default; enter a value to set a cap for this download.',
|
||||
liveSpeedLimit: 'Live speed cap',
|
||||
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
|
||||
liveSpeedLimitPlaceholder: 'e.g. 1024K',
|
||||
@@ -274,166 +227,6 @@ const common = {
|
||||
liveSpeedLimitClear: 'Clear',
|
||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
||||
credentialsRequired: 'Credentials, cookies, or request headers from the previous session were not saved. Add them in Advanced, or confirm a retry without them.',
|
||||
resumeWithoutCredentialsConfirm: 'This download used credentials, cookies, or request headers that are no longer available. Retry without them? If access is required, the server may reject the request.',
|
||||
retryWithoutCredentials: 'Retry without saved credentials',
|
||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Could not update the live Torrent upload limit: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Live Torrent peer controls',
|
||||
liveTorrentPeerOptionsApply: 'Apply peer controls',
|
||||
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
|
||||
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentExcludeTrackers: 'Excluded Torrent trackers',
|
||||
torrentExcludeTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line, or * to exclude all announce URLs. Credentials are not allowed; DHT and PEX settings are unchanged.',
|
||||
torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
|
||||
torrentTrackerConnectTimeout: 'Tracker connect timeout',
|
||||
torrentTrackerTimeout: 'Tracker request timeout',
|
||||
torrentTrackerInterval: 'Tracker interval',
|
||||
torrentTrackerTimingHint: 'Connect timeout covers establishing a tracker connection; request timeout covers the response afterward. Blank values keep Aria2’s 60-second defaults, and interval 0 follows tracker response and download progress.',
|
||||
torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds',
|
||||
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
|
||||
torrentVerifyNow: 'Verify now',
|
||||
torrentVerifyNowLoading: 'Verifying…',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||
torrentPeerDiagnostics: 'Torrent peer details',
|
||||
torrentPeerDiagnosticsRefresh: 'Refresh',
|
||||
torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…',
|
||||
torrentPeerDiagnosticsStale: 'Showing the last validated result; refresh to check again.',
|
||||
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active or paused.',
|
||||
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
|
||||
torrentPeerDiagnosticsHint: 'Validated peer addresses and ports are shown ephemerally; peer IDs and raw bitfields are never retained. The connected count above is live Torrent status; this table is a separate peer-list response and may contain a different number.',
|
||||
torrentPeerAddress: 'Peer address',
|
||||
torrentPeerId: 'Peer ID',
|
||||
torrentFileProgress: 'Torrent file progress',
|
||||
torrentFileSelection: 'Torrent file selection',
|
||||
torrentFileSelectionHint: 'Choose which files to download. Selecting every file removes the filter; at least one file must remain selected.',
|
||||
torrentFileSelectionRequired: 'Select at least one Torrent file.',
|
||||
torrentFileSelectionAll: 'Select all',
|
||||
torrentFileSelectionClear: 'Clear',
|
||||
torrentFileProgressRefresh: 'Refresh',
|
||||
torrentFileProgressLoading: 'Loading file progress…',
|
||||
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
|
||||
torrentFileProgressFailed: 'Could not read Torrent file progress.',
|
||||
torrentFileProgressHint: 'Validated relative paths and completed bytes are shown; daemon paths and URIs are not exposed.',
|
||||
torrentFileProgressPath: 'File',
|
||||
torrentFileProgressCompleted: 'Completed',
|
||||
torrentFileProgressSelected: 'Selected',
|
||||
torrentFileProgressUnselected: 'Not selected',
|
||||
torrentPieceProgress: 'Torrent piece progress',
|
||||
torrentPieceProgressRefresh: 'Refresh',
|
||||
torrentPieceProgressLoading: 'Loading piece progress…',
|
||||
torrentPieceProgressUnavailable: 'Piece progress is available while this Torrent is active or paused.',
|
||||
torrentPieceProgressFailed: 'Could not read Torrent piece progress.',
|
||||
torrentPieceProgressHint: 'Each cell summarizes adjacent pieces. Raw bitfields are never exposed.',
|
||||
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
|
||||
torrentPieceProgressMap: 'Torrent piece completion map',
|
||||
torrentWebSeeds: 'Torrent web seeds',
|
||||
torrentWebSeedsHint: 'Add one HTTP(S) base URI per Torrent file. Firelink expands multi-file paths natively.',
|
||||
torrentWebSeedsApply: 'Apply web seeds',
|
||||
torrentWebSeedsLoading: 'Applying…',
|
||||
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
|
||||
torrentWebSeedsEmpty: 'No web seeds configured.',
|
||||
torrentWebSeedsFile: 'File',
|
||||
torrentWebSeedsUri: 'HTTP(S) base URI',
|
||||
torrentWebSeedsAdd: 'Add web seed',
|
||||
torrentWebSeedsRemove: 'Remove web seed',
|
||||
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
||||
torrentPeerCount: '{{listed}} listed peers — {{seeders}} listed seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
torrentPeerSeeder: 'Seeder',
|
||||
torrentPeerAmChoking: 'Firelink choking',
|
||||
torrentPeerChoking: 'Peer choking',
|
||||
torrentPeerShowing: 'Showing {{shown}} of {{total}} listed peers.',
|
||||
torrentStatistics: 'Torrent statistics',
|
||||
torrentUploaded: 'Uploaded',
|
||||
torrentRatio: 'Ratio',
|
||||
torrentSeededDuration: 'Seeded',
|
||||
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
|
||||
torrentConnectedPeers: 'Peers',
|
||||
torrentPeersSeeders: 'Peers/Seeds',
|
||||
torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders',
|
||||
torrentPeerDetailsUnavailable: '{{connected}} connected peers reported, but peer details are not available yet.',
|
||||
torrentPeerCountDifference: 'Connected status: {{connectedPeers}} peers / {{connectedSeeders}} seeders. The peer-details response lists {{listedPeers}} peers / {{listedSeeders}} seeders.',
|
||||
torrentSeeders: 'Seeders',
|
||||
torrentUploadSpeed: 'Upload speed',
|
||||
seconds: 'seconds',
|
||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.',
|
||||
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||
torrentPrioritizePieceHead: 'Prioritize first pieces',
|
||||
torrentPrioritizePieceTail: 'Prioritize last pieces',
|
||||
torrentPrioritizePieceSize: 'Preview piece range size',
|
||||
torrentPrioritizePieceHint: 'Optional preview policy. Each enabled range defaults to 1M and applies when the Torrent starts or retries.',
|
||||
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
|
||||
torrentEncryptionPolicy: 'Torrent encryption policy',
|
||||
torrentFileAllocation: 'Torrent file allocation',
|
||||
torrentFileAllocationPrealloc: 'Preallocate files',
|
||||
torrentFileAllocationNone: 'Allocate as needed',
|
||||
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
|
||||
torrentOptionsBehavior: 'Torrent behavior',
|
||||
torrentOptionsBehaviorHint: 'Controls verification, storage allocation, encryption, and cleanup for this Torrent.',
|
||||
torrentDetails: 'Torrent details',
|
||||
torrentCopyMagnet: 'Copy magnet link',
|
||||
torrentExportMetadata: 'Export .torrent',
|
||||
torrentMagnetCopied: 'Identity-only magnet copied.',
|
||||
torrentMagnetCopyFailed: 'Could not copy the magnet link.',
|
||||
torrentMetadataExported: 'Torrent metadata exported.',
|
||||
torrentMetadataExportFailed: 'Could not export Torrent metadata.',
|
||||
torrentMove: 'Move data…',
|
||||
torrentMoveLoading: 'Moving…',
|
||||
torrentMoveCancel: 'Cancel move',
|
||||
torrentMoveCancelRequested: 'Canceling move…',
|
||||
torrentMoveConfirm: 'Move the managed Torrent data to this folder? Existing files are never overwritten.',
|
||||
torrentMoveCompleted: 'Torrent data moved.',
|
||||
torrentMoveFailed: 'Could not move Torrent data.',
|
||||
torrentAvailability: 'Swarm availability',
|
||||
torrentAvailabilityRefresh: 'Refresh',
|
||||
torrentAvailabilityLoading: 'Loading availability…',
|
||||
torrentAvailabilityUnavailable: 'Availability is available for an active or paused Torrent.',
|
||||
torrentAvailabilityFailed: 'Could not read Torrent availability.',
|
||||
torrentAvailabilityHint: 'Only aggregate copy counts are shown; peer identities and raw bitfields are never exposed.',
|
||||
torrentAvailabilitySummary: '{{availability}} copies available · {{peers}} connected peers · {{pieces}} pieces',
|
||||
torrentAvailabilityMap: 'Torrent swarm availability map',
|
||||
torrentAvailabilityBucket: 'At least {{copies}} copies in this range',
|
||||
torrentDetailsLoading: 'Loading Torrent details…',
|
||||
torrentDetailsUnavailable: 'Torrent details are not available.',
|
||||
torrentDetailsDisplayName: 'Display name',
|
||||
torrentDetailsInfoHash: 'Info hash',
|
||||
torrentDetailsSize: 'Total size',
|
||||
torrentDetailsFiles: 'Files',
|
||||
torrentDetailsPieces: 'Pieces',
|
||||
torrentDetailsPrivate: 'Private',
|
||||
torrentDetailsPrivateYes: 'Yes',
|
||||
torrentDetailsPrivateNo: 'No',
|
||||
torrentDetailsCreated: 'Created',
|
||||
torrentDetailsCreator: 'Creator',
|
||||
torrentDetailsComment: 'Comment',
|
||||
torrentDetailsTrackers: 'Trackers',
|
||||
torrentDetailsWebSeeds: 'Embedded web seeds',
|
||||
torrentDetailsPrivateHint: 'This private Torrent disables DHT, DHT6, PEX, and LPD discovery regardless of broader settings.',
|
||||
torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.',
|
||||
torrentEncryptionDisabled: 'Disabled',
|
||||
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
|
||||
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
|
||||
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
|
||||
torrentRemoveUnselectedFileHint: 'Only applies when a subset of files is selected. Aria2 permanently deletes the other files after the Torrent completes.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Delete {{count}} unselected Torrent files after completion? This cannot be undone.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.',
|
||||
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
|
||||
category: 'Category',
|
||||
lastTry: 'Last try',
|
||||
dateAdded: 'Date added',
|
||||
@@ -443,15 +236,10 @@ const common = {
|
||||
defaultValue: ' (default)',
|
||||
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
|
||||
defaultTooltip: 'Using the current default for new downloads.',
|
||||
blankUsesDefault: 'Leave blank · use default',
|
||||
usingDefault: 'Using default',
|
||||
customPerDownload: 'Custom for this download',
|
||||
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
|
||||
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
|
||||
download: 'Download',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Show full address',
|
||||
urlShowLess: 'Show less',
|
||||
fileName: 'File name',
|
||||
saveLocation: 'Save location',
|
||||
select: 'Select',
|
||||
@@ -472,15 +260,11 @@ const common = {
|
||||
algorithm: 'Algorithm',
|
||||
digest: 'Digest',
|
||||
expectedDigest: 'Expected digest',
|
||||
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||
cookies: 'Cookies',
|
||||
headers: 'Headers',
|
||||
mirrors: 'Mirrors',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
clear: 'Clear',
|
||||
enterValidUrl: 'Enter a valid URL.',
|
||||
fileNameEmpty: 'File name cannot be empty.',
|
||||
cancel: 'Cancel',
|
||||
@@ -523,7 +307,6 @@ const common = {
|
||||
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
|
||||
systemActionCountdown: '{{action}} in 10 seconds.',
|
||||
systemActionCancelled: 'System action cancelled because another download is active or queued.',
|
||||
systemActionProceedAnyway: 'Proceed anyway',
|
||||
systemActionFailed: 'Scheduled system action failed: {{detail}}',
|
||||
downloadCompleteTitle: 'Download Complete',
|
||||
downloadCompleteBody: '{{fileName}} has finished downloading.',
|
||||
@@ -618,14 +401,12 @@ const common = {
|
||||
moveOneFailed: 'Could not move download to queue',
|
||||
copyAddressesFailed: 'Could not copy addresses',
|
||||
copyAddressFailed: 'Could not copy address',
|
||||
copyMagnetFailed: 'Could not copy the magnet link',
|
||||
copyPathFailed: 'Could not copy file path',
|
||||
missingFileName: 'File name is missing',
|
||||
redownloadFailed: 'Redownload failed',
|
||||
startResume: 'Start/Resume',
|
||||
addToQueue: 'Add to Queue',
|
||||
copyAddress: 'Copy Address',
|
||||
copyMagnet: 'Copy magnet link',
|
||||
remove: 'Remove',
|
||||
open: 'Open',
|
||||
showInFolder: 'Show in Folder',
|
||||
@@ -665,75 +446,14 @@ const common = {
|
||||
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
||||
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
||||
downloadLinks: 'Download Links',
|
||||
pastePlaceholder: 'Paste HTTP(S), FTP/SFTP, magnet, or media URLs…',
|
||||
pasteHint: 'Media links from YouTube, X, TikTok, Instagram, and Reddit are supported.',
|
||||
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
|
||||
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (safe entry limit reached)',
|
||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||
selectedSummaryReady: 'Ready',
|
||||
selectedSummaryFallback: 'Fallback',
|
||||
selectedSummaryMediaRetry: 'Media retry',
|
||||
selectedSummaryBlocked: 'Blocked',
|
||||
torrentAdvancedOptions: 'Advanced Torrent options',
|
||||
torrentAdvancedOptionsCustom: 'Custom settings',
|
||||
clearSelection: 'Clear selection',
|
||||
selectAll: 'Select all',
|
||||
refreshMetadata: 'Refresh Metadata',
|
||||
files: 'Files',
|
||||
torrentFiles: 'Torrent files',
|
||||
torrent: 'Torrent',
|
||||
chooseTorrentFiles: 'Add .torrent files',
|
||||
torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.',
|
||||
torrentSeeding: 'Torrent seeding',
|
||||
seedAfterDownload: 'Seed after download completes',
|
||||
seedTime: 'Seed time',
|
||||
minutes: 'minutes',
|
||||
seconds: 'seconds',
|
||||
seedRatio: 'Seed ratio',
|
||||
seedRatioHint: '0 means time-only seeding; otherwise seeding stops at the first limit reached.',
|
||||
limitTorrentUpload: 'Limit torrent upload',
|
||||
torrentUploadLimit: 'Torrent upload limit',
|
||||
torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero',
|
||||
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
|
||||
torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero',
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'Saved with this Torrent and applied on its next start or retry.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentExcludeTrackers: 'Excluded Torrent trackers',
|
||||
torrentExcludeTrackersHint: 'Saved with this Torrent and applied on its next start or retry. * excludes all announce URLs; DHT and PEX settings are unchanged.',
|
||||
torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
|
||||
torrentTrackerConnectTimeout: 'Tracker connect timeout',
|
||||
torrentTrackerTimeout: 'Tracker request timeout',
|
||||
torrentTrackerInterval: 'Tracker interval',
|
||||
torrentTrackerTimingHint: 'Saved with this Torrent and applied on its next start or retry. Connect timeout covers establishing the connection; request timeout covers the response afterward. Blank values keep Aria2’s 60-second defaults, and interval 0 follows tracker response and download progress.',
|
||||
torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds',
|
||||
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
|
||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.',
|
||||
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||
torrentPrioritizePieceHead: 'Prioritize first pieces',
|
||||
torrentPrioritizePieceTail: 'Prioritize last pieces',
|
||||
torrentPrioritizePieceSize: 'Preview piece range size',
|
||||
torrentPrioritizePieceHint: 'Choose first pieces, last pieces, or both for preview. Each enabled range defaults to 1M and applies on the next start or retry.',
|
||||
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
|
||||
torrentEncryptionPolicy: 'Torrent encryption policy',
|
||||
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
|
||||
torrentEncryptionDisabled: 'Disabled',
|
||||
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
|
||||
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
|
||||
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
|
||||
torrentRemoveUnselectedFileHint: 'Only applies when a selected subset is configured. The unselected files are not Firelink-owned and are permanently removed when the Torrent completes.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Enable permanent deletion of unselected Torrent files after completion? This cannot be undone.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.',
|
||||
required: 'Required',
|
||||
free: 'Free',
|
||||
preview: 'Preview',
|
||||
@@ -788,9 +508,6 @@ const common = {
|
||||
verifyChecksum: 'Verify Checksum',
|
||||
checksumAlgorithm: 'Checksum algorithm',
|
||||
expectedDigest: 'Expected digest',
|
||||
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||
headers: 'Headers',
|
||||
requestHeaders: 'Request headers',
|
||||
cookies: 'Cookies',
|
||||
@@ -864,12 +581,6 @@ const common = {
|
||||
parallelDownloadsDescription: 'Max simultaneous active files',
|
||||
automaticRetries: 'Automatic retries:',
|
||||
automaticRetriesDescription: 'If a connection fails',
|
||||
minimumNormalDownloadSpeed: 'Minimum normal-download speed (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'Retry HTTP, FTP, and SFTP transfers that remain below this speed. Use 0 to disable.',
|
||||
retryNotFoundErrors: 'Retry temporary not-found errors',
|
||||
retryNotFoundErrorsDescription: 'Treat HTTP/FTP resource-not-found responses as retryable within the automatic retry limit. Off by default.',
|
||||
adaptiveMirrorSelection: 'Adaptive mirror selection',
|
||||
adaptiveMirrorSelectionDescription: 'Use recent transfer performance to choose among multiple mirrors. Server statistics remain private on this device.',
|
||||
systemNotification: 'Show system notification when download completes',
|
||||
systemNotificationDescription: 'Uses your operating system notification settings',
|
||||
completionChime: 'Play in-app completion chime',
|
||||
@@ -970,65 +681,6 @@ const common = {
|
||||
detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.',
|
||||
noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.',
|
||||
systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.',
|
||||
torrentTabs: {
|
||||
discovery: 'Discovery',
|
||||
connection: 'Connection',
|
||||
limits: 'Limits',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
torrentPeerDiscovery: 'BitTorrent peer discovery',
|
||||
torrentDht: 'IPv4 DHT and UDP trackers',
|
||||
torrentDhtDescription: 'Find peers without relying only on trackers. Disabling this also disables UDP tracker support.',
|
||||
torrentDht6: 'IPv6 DHT',
|
||||
torrentDht6Description: 'Use IPv6 for distributed peer discovery when the network provides a usable IPv6 path.',
|
||||
torrentIpv6Enabled: 'Enable IPv6 for Torrent networking',
|
||||
torrentIpv6EnabledDescription: 'Keep IPv6 available to BitTorrent, DHT, and peer discovery. Disabling this overrides IPv6 DHT even when its preference remains enabled.',
|
||||
torrentPex: 'Peer Exchange (PEX)',
|
||||
torrentPexDescription: 'Allow connected peers to share additional peer addresses.',
|
||||
torrentLpd: 'Local Peer Discovery (LPD)',
|
||||
torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.',
|
||||
torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.',
|
||||
torrentNetwork: 'BitTorrent network binding',
|
||||
torrentAdvanced: 'Advanced Torrent network',
|
||||
torrentDhtMessageTimeout: 'DHT message timeout',
|
||||
torrentDhtMessageTimeoutDescription: 'Whole seconds for DHT and UDP message waits. This does not affect remote .torrent HTTP fetches or HTTP tracker requests. Applies after Firelink restarts.',
|
||||
torrentSeparateSeedSlots: 'Separate seeding capacity',
|
||||
torrentSeparateSeedSlotsDescription: 'Keep seeding outside the download limit and cap it with a Firelink-managed pool.',
|
||||
torrentMaxConcurrentSeeds: 'Maximum concurrent seeds',
|
||||
torrentMaxConcurrentSeedsDescription: 'Maximum number of Torrents Firelink lets seed at once when separate capacity is enabled.',
|
||||
torrentListenPort: 'TCP peer ports',
|
||||
torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2’s default range.',
|
||||
torrentBindAddress: 'Torrent bind address',
|
||||
torrentBindAddressDescription: 'Optional local IPv4 or IPv6 address for Aria2 sockets. Invalid addresses are rejected; changes apply after restart.',
|
||||
torrentDhtListenPort: 'UDP/DHT ports',
|
||||
torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2’s default range.',
|
||||
torrentExternalIp: 'External IP address',
|
||||
torrentExternalIpDescription: 'Address announced to peers and trackers when the host is behind NAT. Leave blank unless you know the reachable address.',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'IPv4 DHT entry point',
|
||||
torrentDhtEntryPointDescription: 'Optional bootstrap host and port, for example router.example:6881.',
|
||||
torrentDhtEntryPoint6: 'IPv6 DHT entry point',
|
||||
torrentDhtEntryPoint6Description: 'Optional IPv6 bootstrap address and port in bracketed form, for example [2001:db8::1]:6881.',
|
||||
torrentDhtListenAddr6: 'IPv6 DHT listen address',
|
||||
torrentDhtListenAddr6Description: 'IPv6 address for the DHT socket. Leave blank to let Aria2 choose.',
|
||||
torrentLpdInterface: 'LPD interface',
|
||||
torrentLpdInterfaceDescription: 'Network interface name or address used for Local Peer Discovery. Leave blank for the default interface.',
|
||||
torrentPeerIdPrefix: 'Peer ID prefix',
|
||||
torrentPeerIdPrefixDescription: 'Overrides the BitTorrent peer-ID prefix. Use only if you understand the privacy and protocol-identity impact; leave blank for Aria2’s default.',
|
||||
torrentPeerAgent: 'Peer agent',
|
||||
torrentPeerAgentDescription: 'Overrides the client string sent in the BitTorrent extended handshake. This changes protocol identity and may affect compatibility; leave blank for Aria2’s default.',
|
||||
torrentNetworkRestartNote: 'These settings are launch-scoped and apply after Firelink restarts. Opening ports may require router port forwarding and an operating-system firewall rule; availability depends on the platform and network.',
|
||||
torrentResourceLimits: 'BitTorrent resource limits',
|
||||
torrentMaxOpenFiles: 'Maximum open Torrent files',
|
||||
torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.',
|
||||
aria2DiskCache: 'Aria2 disk cache',
|
||||
aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.',
|
||||
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
|
||||
torrentNetworkInputInvalid: 'Could not apply this Torrent network setting: {{detail}}',
|
||||
torrentOverallUploadLimit: 'Overall Aria2 upload limit',
|
||||
torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.',
|
||||
torrentOverallUploadLimitInvalid: 'Enter a valid upload limit, such as 512K or 2M.',
|
||||
torrentOverallUploadLimitUpdateFailed: 'Could not apply the overall Aria2 upload limit: {{detail}}',
|
||||
identity: 'Identity',
|
||||
customUserAgent: 'Custom User-Agent',
|
||||
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
||||
@@ -1162,7 +814,6 @@ const common = {
|
||||
active: '{{count}} active',
|
||||
queued: '{{count}} queued',
|
||||
done: '{{count}} done',
|
||||
seeding: 'Seeding',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const fa = {
|
||||
documents: 'اسناد',
|
||||
pictures: 'تصاویر',
|
||||
applications: 'برنامهها',
|
||||
torrents: 'تورنتها',
|
||||
other: 'سایر',
|
||||
},
|
||||
folders: 'پوشهها',
|
||||
@@ -60,7 +59,6 @@ const fa = {
|
||||
title: 'حذف دانلود',
|
||||
confirmationSingle: 'آیا مطمئن هستید که میخواهید این مورد را از لیست حذف کنید؟ همچنین میتوانید فایل اصلی را از هارد دیسک خود حذف کنید.',
|
||||
confirmationMultiple: 'آیا مطمئن هستید که میخواهید این {{count}} مورد را از لیست حذف کنید؟ همچنین میتوانید فایلهای اصلی را از هارد دیسک خود حذف کنید.',
|
||||
mixedRemovalPolicy: 'اگر «حذف فایل» را انتخاب کنید، فایلهای ناتمام برای همیشه حذف میشوند؛ فایلهای کاملشده همچنان به سطل زباله میروند.',
|
||||
errorSummary: '{{succeeded}} مورد حذف شد، {{failed}} مورد ناموفق: {{detail}}',
|
||||
remove: 'حذف',
|
||||
deleteFile: 'حذف فایل',
|
||||
@@ -79,7 +77,6 @@ const fa = {
|
||||
pause: 'توقف',
|
||||
start: 'شروع',
|
||||
resume: 'ادامه',
|
||||
retry: 'تلاش مجدد',
|
||||
options: 'گزینهها',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const fa = {
|
||||
staged: 'در صف',
|
||||
queued: 'در صف',
|
||||
downloading: 'در حال دانلود',
|
||||
waitingForPeers: 'در انتظار همتاها',
|
||||
processing: 'در حال پردازش',
|
||||
verifying: 'در حال بررسی صحت',
|
||||
seeding: 'در حال اشتراکگذاری',
|
||||
waitingToSeed: 'در انتظار اشتراکگذاری',
|
||||
paused: 'متوقفشده',
|
||||
completed: 'تکمیلشده',
|
||||
failed: 'ناموفق',
|
||||
retrying: 'در حال تلاش مجدد',
|
||||
moving: 'در حال جابهجایی داده',
|
||||
allocatingFiles: 'در حال تخصیص فایلها…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
|
||||
nameResolutionFailed: 'نام سرور پیدا نشد. VPN یا DNS شبکه را بررسی کنید.',
|
||||
},
|
||||
values: {
|
||||
processing: 'در حال پردازش…',
|
||||
@@ -199,7 +186,6 @@ const fa = {
|
||||
linuxActionsDescription: 'خوابیدن، راهاندازی مجدد و خاموش کردن از دسکتاپ و سیاستهای سیستم Linux استفاده میکنند. Firelink هنگام اجرا هر اقدام ردشدهای را گزارش میدهد؛ هیچ مجوز دائمی از قبل درخواست نمیشود.',
|
||||
validationDay: 'حداقل یک روز برای زمانبند انتخاب کنید',
|
||||
validationQueue: 'حداقل یک صف برای زمانبند انتخاب کنید',
|
||||
validationTime: 'زمانها را با قالب معتبر HH:MM وارد کنید',
|
||||
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
|
||||
saved: 'تنظیمات زمانبند ذخیره شد',
|
||||
trackingOne: 'در حال پیگیری ۱ دانلود زمانبندیشده',
|
||||
@@ -225,48 +211,15 @@ const fa = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: 'صرفنظر از تغییرات',
|
||||
keepEditing: 'ادامه ویرایش',
|
||||
progress: 'پیشرفت',
|
||||
size: 'اندازه',
|
||||
speed: 'سرعت',
|
||||
eta: 'زمان باقیمانده',
|
||||
connections: 'اتصالات',
|
||||
fragmentConcurrency: 'همزمانی قطعهها',
|
||||
fragmentConcurrencyHint: 'حداکثر تعداد قطعههای رسانهای که yt-dlp میتواند همزمان پردازش کند. Firelink تعداد قطعههای فعال را بهصورت زنده گزارش نمیکند؛ این مقدار هنگام شروع یا ازسرگیری انتقال استفاده میشود.',
|
||||
connectedPeers: 'همتای متصل',
|
||||
details: 'جزئیات',
|
||||
tabs: {
|
||||
label: 'بخشهای ویژگیها',
|
||||
overview: 'نمای کلی',
|
||||
files: 'فایلها',
|
||||
trackers: 'Trackerها',
|
||||
peers: 'همتاها',
|
||||
transfer: 'انتقال',
|
||||
options: 'گزینهها',
|
||||
advanced: 'پیشرفته',
|
||||
},
|
||||
queueId: 'صف',
|
||||
queuePosition: 'موقعیت {{position}}',
|
||||
resumable: 'قابل ادامه',
|
||||
connectionCount: '{{active}}/{{total}} فعال',
|
||||
connectionCountUnknown: '—/{{total}} فعال',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'سقف سرعت',
|
||||
inputFormat: 'قالب: {{format}}',
|
||||
inputFormatSpeedLimit: '512K، 2M یا 1G',
|
||||
inputFormatMaxPeers: '0 تا 1000؛ 0 یعنی نامحدود',
|
||||
inputFormatSeedTime: 'دقیقه، مثلاً 60',
|
||||
inputFormatSeedRatio: 'عدد اعشاری، مثلاً 1.5؛ 0 یعنی فقط بر اساس زمان',
|
||||
inputFormatStopTimeout: 'تعداد ثانیهٔ کامل؛ 0 غیرفعال میکند',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'مثلاً 512K',
|
||||
inputExampleMaxPeers: 'مثلاً 55',
|
||||
inputExampleSeedTime: 'مثلاً 60',
|
||||
inputExampleSeedRatio: 'مثلاً 1.5',
|
||||
inputExampleStopTimeout: 'مثلاً 300',
|
||||
inputExamplePiecePriority: 'مثلاً head=1M,tail=1M',
|
||||
speedLimitHint: 'برای استفاده از مقدار سراسری خالی بگذارید؛ برای این دانلود یک سقف مشخص وارد کنید.',
|
||||
liveSpeedLimit: 'سقف سرعت زنده',
|
||||
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال میشود. سرعت دانلودهای رسانهای هنگام اجرا قابل تغییر نیست.',
|
||||
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
|
||||
@@ -274,166 +227,6 @@ const fa = {
|
||||
liveSpeedLimitClear: 'پاک کردن',
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
||||
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
||||
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیرهشده',
|
||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
liveTorrentUploadLimitFailed: 'بهروزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
|
||||
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
|
||||
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
|
||||
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال میشود. برای استفاده از پیشفرض آریا۲ خالی بگذارید.',
|
||||
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره میشود. صفر یعنی نامحدود؛ خالی یعنی پیشفرض آریا۲.',
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
|
||||
torrentExcludeTrackersHint: 'در هر خط یک Tracker از نوع HTTP، HTTPS یا UDP، یا * برای حذف همه آدرسهای announce وارد کنید. اطلاعات ورود مجاز نیست؛ تنظیمات DHT و PEX تغییر نمیکند.',
|
||||
torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
|
||||
torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker',
|
||||
torrentTrackerTimeout: 'مهلت درخواست Tracker',
|
||||
torrentTrackerInterval: 'فاصله درخواست Tracker',
|
||||
torrentTrackerTimingHint: 'مهلت اتصال برای برقراری اتصال به Tracker و مهلت درخواست برای پاسخ پس از آن است. مقدار خالی پیشفرض ۶۰ ثانیهای آریا۲ را نگه میدارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی میکند.',
|
||||
torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال میشود. ممکن است قطعهها دوباره بررسی و دادههای خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
|
||||
torrentVerifyNow: 'بررسی صحت اکنون',
|
||||
torrentVerifyNowLoading: 'در حال بررسی صحت…',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||
torrentPeerDiagnostics: 'جزئیات همتاهای تورنت',
|
||||
torrentPeerDiagnosticsRefresh: 'تازهسازی',
|
||||
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
|
||||
torrentPeerDiagnosticsStale: 'آخرین نتیجهٔ معتبر نمایش داده میشود؛ برای بررسی دوباره تازهسازی کنید.',
|
||||
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.',
|
||||
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
|
||||
torrentPeerDiagnosticsHint: 'آدرس و پورت معتبر همتاها فقط بهصورت موقت نمایش داده میشوند؛ شناسه همتا و بیتفیلد خام هرگز نگهداری نمیشود. تعداد متصلِ بالا وضعیت زندهٔ تورنت است؛ این جدول پاسخ جداگانهای از فهرست همتاهاست و ممکن است تعداد متفاوتی داشته باشد.',
|
||||
torrentPeerAddress: 'نشانی همتا',
|
||||
torrentPeerId: 'شناسهٔ همتا',
|
||||
torrentFileProgress: 'پیشرفت فایلهای تورنت',
|
||||
torrentFileSelection: 'انتخاب فایلهای تورنت',
|
||||
torrentFileSelectionHint: 'فایلهای موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایلها فیلتر را حذف میکند؛ حداقل یک فایل باید انتخاب شود.',
|
||||
torrentFileSelectionRequired: 'حداقل یک فایل تورنت را انتخاب کنید.',
|
||||
torrentFileSelectionAll: 'انتخاب همه',
|
||||
torrentFileSelectionClear: 'پاککردن',
|
||||
torrentFileProgressRefresh: 'تازهسازی',
|
||||
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایلها…',
|
||||
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقفبودن تورنت در دسترس است.',
|
||||
torrentFileProgressFailed: 'خواندن پیشرفت فایلهای تورنت ممکن نیست.',
|
||||
torrentFileProgressHint: 'مسیرهای نسبی معتبر و حجم تکمیلشده نمایش داده میشود؛ مسیرهای داخلی و URIهای daemon نمایش داده نمیشوند.',
|
||||
torrentFileProgressPath: 'فایل',
|
||||
torrentFileProgressCompleted: 'تکمیلشده',
|
||||
torrentFileProgressSelected: 'انتخابشده',
|
||||
torrentFileProgressUnselected: 'انتخابنشده',
|
||||
torrentPieceProgress: 'پیشرفت قطعههای تورنت',
|
||||
torrentPieceProgressRefresh: 'تازهسازی',
|
||||
torrentPieceProgressLoading: 'در حال دریافت پیشرفت قطعهها…',
|
||||
torrentPieceProgressUnavailable: 'پیشرفت قطعهها هنگام فعال یا متوقفبودن تورنت در دسترس است.',
|
||||
torrentPieceProgressFailed: 'خواندن پیشرفت قطعههای تورنت ممکن نیست.',
|
||||
torrentPieceProgressHint: 'هر خانه خلاصهای از قطعههای مجاور است؛ bitfield خام نمایش داده نمیشود.',
|
||||
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
|
||||
torrentPieceProgressMap: 'نقشه تکمیل قطعههای تورنت',
|
||||
torrentWebSeeds: 'وبسیدهای تورنت',
|
||||
torrentWebSeedsHint: 'برای هر فایل تورنت یک نشانی پایهٔ HTTP(S) اضافه کنید. Firelink مسیر فایلهای چندفایلی را خودش میسازد.',
|
||||
torrentWebSeedsApply: 'اعمال وبسیدها',
|
||||
torrentWebSeedsLoading: 'در حال اعمال…',
|
||||
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وبسیدهای تورنت انجام نشد.',
|
||||
torrentWebSeedsEmpty: 'وبسیدی تنظیم نشده است.',
|
||||
torrentWebSeedsFile: 'فایل',
|
||||
torrentWebSeedsUri: 'نشانی پایهٔ HTTP(S)',
|
||||
torrentWebSeedsAdd: 'افزودن وبسید',
|
||||
torrentWebSeedsRemove: 'حذف وبسید',
|
||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||
torrentPeerCount: '{{listed}} همتای فهرستشده — {{seeders}} سید فهرستشده',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
torrentPeerSeeder: 'سید',
|
||||
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
|
||||
torrentPeerChoking: 'محدودسازی از طرف همتا',
|
||||
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتای فهرستشده.',
|
||||
torrentStatistics: 'آمار تورنت',
|
||||
torrentUploaded: 'آپلودشده',
|
||||
torrentRatio: 'نسبت',
|
||||
torrentSeededDuration: 'مدت سید',
|
||||
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه میدهد. برای استفاده از پیشفرض خالی بگذارید.',
|
||||
torrentConnectedPeers: 'همتاها',
|
||||
torrentPeersSeeders: 'همتاها / سیدها',
|
||||
torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل',
|
||||
torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.',
|
||||
torrentPeerCountDifference: 'وضعیت اتصال: {{connectedPeers}} همتا / {{connectedSeeders}} سید. پاسخ جزئیات همتاها {{listedPeers}} همتا / {{listedSeeders}} سید را فهرست کرده است.',
|
||||
torrentSeeders: 'سیدها',
|
||||
torrentUploadSpeed: 'سرعت آپلود',
|
||||
seconds: 'ثانیه',
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش. هر بازهٔ فعال بهطور پیشفرض 1M است و هنگام شروع یا تلاش مجدد اعمال میشود.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
|
||||
torrentFileAllocationPrealloc: 'تخصیص از پیش',
|
||||
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
|
||||
torrentFileAllocationHint: 'تخصیص از پیش فضای فایلهای انتخابشده را قبل از انتقال رزرو میکند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمیدهد.',
|
||||
torrentOptionsBehavior: 'رفتار تورنت',
|
||||
torrentOptionsBehaviorHint: 'بررسی صحت، تخصیص فضا، رمزنگاری و پاکسازی این تورنت را کنترل میکند.',
|
||||
torrentDetails: 'جزئیات تورنت',
|
||||
torrentCopyMagnet: 'کپی پیوند مگنت',
|
||||
torrentExportMetadata: 'خروجی .torrent',
|
||||
torrentMagnetCopied: 'مگنت فقط-هویتی کپی شد.',
|
||||
torrentMagnetCopyFailed: 'کپی پیوند مگنت ناموفق بود.',
|
||||
torrentMetadataExported: 'فراداده تورنت خروجی گرفته شد.',
|
||||
torrentMetadataExportFailed: 'خروجی فراداده تورنت ناموفق بود.',
|
||||
torrentMove: 'جابهجایی داده…',
|
||||
torrentMoveLoading: 'در حال جابهجایی…',
|
||||
torrentMoveCancel: 'لغو جابهجایی',
|
||||
torrentMoveCancelRequested: 'درخواست لغو جابهجایی ارسال شد…',
|
||||
torrentMoveConfirm: 'داده تورنت مدیریتشده به این پوشه منتقل شود؟ فایلهای موجود هرگز بازنویسی نمیشوند.',
|
||||
torrentMoveCompleted: 'داده تورنت جابهجا شد.',
|
||||
torrentMoveFailed: 'جابهجایی داده تورنت ناموفق بود.',
|
||||
torrentAvailability: 'دسترسپذیری شبکه',
|
||||
torrentAvailabilityRefresh: 'تازهسازی',
|
||||
torrentAvailabilityLoading: 'در حال بارگیری دسترسپذیری…',
|
||||
torrentAvailabilityUnavailable: 'دسترسپذیری برای تورنت فعال یا متوقف در دسترس است.',
|
||||
torrentAvailabilityFailed: 'خواندن دسترسپذیری تورنت ناموفق بود.',
|
||||
torrentAvailabilityHint: 'فقط شمارش کلی کپیها نمایش داده میشود؛ هویت همتاها و بیتفیلد خام هرگز نمایش داده نمیشوند.',
|
||||
torrentAvailabilitySummary: '{{availability}} کپی در دسترس · {{peers}} همتای متصل · {{pieces}} قطعه',
|
||||
torrentAvailabilityMap: 'نقشه دسترسپذیری شبکه تورنت',
|
||||
torrentAvailabilityBucket: 'حداقل {{copies}} کپی در این بازه',
|
||||
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
|
||||
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
|
||||
torrentDetailsDisplayName: 'نام نمایشی',
|
||||
torrentDetailsInfoHash: 'هش اطلاعات',
|
||||
torrentDetailsSize: 'حجم کل',
|
||||
torrentDetailsFiles: 'فایلها',
|
||||
torrentDetailsPieces: 'قطعهها',
|
||||
torrentDetailsPrivate: 'خصوصی',
|
||||
torrentDetailsPrivateYes: 'بله',
|
||||
torrentDetailsPrivateNo: 'خیر',
|
||||
torrentDetailsCreated: 'ایجادشده',
|
||||
torrentDetailsCreator: 'سازنده',
|
||||
torrentDetailsComment: 'توضیح',
|
||||
torrentDetailsTrackers: 'ترکرها',
|
||||
torrentDetailsWebSeeds: 'وبسیدهای داخلی',
|
||||
torrentDetailsPrivateHint: 'این تورنت خصوصی، مستقل از تنظیمات کلی، کشف DHT، DHT6، PEX و LPD را غیرفعال میکند.',
|
||||
torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال میشود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.',
|
||||
torrentEncryptionDisabled: 'غیرفعال',
|
||||
torrentEncryptionRequireCrypto: 'الزام handshake مبهمسازیشده',
|
||||
torrentEncryptionForceEncryption: 'الزام payload رمزنگاریشده (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
|
||||
torrentRemoveUnselectedFile: 'حذف فایلهای انتخابنشده تورنت پس از تکمیل',
|
||||
torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال میشود که زیرمجموعهای از فایلها انتخاب شده باشد. آریا۲ فایلهای دیگر را پس از تکمیل تورنت برای همیشه حذف میکند.',
|
||||
torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخابنشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعالکردن حذف فایلهای انتخابنشده، زیرمجموعهای از فایلهای تورنت را انتخاب کنید.',
|
||||
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
|
||||
category: 'دسته',
|
||||
lastTry: 'آخرین تلاش',
|
||||
dateAdded: 'تاریخ افزودن',
|
||||
@@ -443,15 +236,10 @@ const fa = {
|
||||
defaultValue: ' (پیشفرض)',
|
||||
savedTooltip: 'برای این دانلود ذخیرهشده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال میشود.',
|
||||
defaultTooltip: 'استفاده از پیشفرض کنونی برای دانلودهای جدید.',
|
||||
blankUsesDefault: 'خالی = استفاده از پیشفرض',
|
||||
usingDefault: 'استفاده از پیشفرض',
|
||||
customPerDownload: 'سفارشی برای این دانلود',
|
||||
identityReadOnly: 'هویت فایل فقطخواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره میشوند.',
|
||||
transferSettings: 'تنظیمات انتقال را میتوان پس از توقف تغییر داد. انتقالهای کنونی گزینههای فعلی خود را حفظ میکنند.',
|
||||
download: 'دانلود',
|
||||
url: 'URL',
|
||||
urlShowMore: 'نمایش نشانی کامل',
|
||||
urlShowLess: 'نمایش کمتر',
|
||||
fileName: 'نام فایل',
|
||||
saveLocation: 'محل ذخیره',
|
||||
select: 'انتخاب',
|
||||
@@ -472,15 +260,11 @@ const fa = {
|
||||
algorithm: 'الگوریتم',
|
||||
digest: 'هش',
|
||||
expectedDigest: 'هش مورد انتظار',
|
||||
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||
cookies: 'کوکیها',
|
||||
headers: 'هدرها',
|
||||
mirrors: 'آینهها',
|
||||
username: 'نام کاربری',
|
||||
password: 'رمز عبور',
|
||||
clear: 'پاک کردن',
|
||||
enterValidUrl: 'یک URL معتبر وارد کنید.',
|
||||
fileNameEmpty: 'نام فایل نمیتواند خالی باشد.',
|
||||
cancel: 'لغو',
|
||||
@@ -523,7 +307,6 @@ const fa = {
|
||||
settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسیهای ذخیرهسازی را بررسی کرده و دوباره امتحان کنید.',
|
||||
systemActionCountdown: '{{action}} در ۱۰ ثانیه.',
|
||||
systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.',
|
||||
systemActionProceedAnyway: 'ادامه دادن به هر حال',
|
||||
systemActionFailed: 'اقدام سیستم زمانبندیشده ناموفق بود: {{detail}}',
|
||||
downloadCompleteTitle: 'دانلود تکمیلشده',
|
||||
downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.',
|
||||
@@ -618,14 +401,12 @@ const fa = {
|
||||
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
|
||||
copyAddressesFailed: 'آدرسها کپی نشدند',
|
||||
copyAddressFailed: 'آدرس کپی نشد',
|
||||
copyMagnetFailed: 'پیوند مگنت کپی نشد',
|
||||
copyPathFailed: 'مسیر فایل کپی نشد',
|
||||
missingFileName: 'نام فایل وجود ندارد',
|
||||
redownloadFailed: 'دانلود مجدد ناموفق بود',
|
||||
startResume: 'شروع/ادامه',
|
||||
addToQueue: 'افزودن به صف',
|
||||
copyAddress: 'کپی آدرس',
|
||||
copyMagnet: 'کپی پیوند مگنت',
|
||||
remove: 'حذف',
|
||||
open: 'باز کردن',
|
||||
showInFolder: 'نمایش در پوشه',
|
||||
@@ -665,75 +446,14 @@ const fa = {
|
||||
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
|
||||
cannotReplace: 'نمیتوان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
|
||||
downloadLinks: 'پیوندهای دانلود',
|
||||
pastePlaceholder: 'URLهای \u2066HTTP(S)\u2069، \u2066FTP/SFTP\u2069، \u2066magnet\u2069 یا رسانه را اینجا جایگذاری کنید…',
|
||||
pasteHint: 'پیوندهای \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069 و \u2066Reddit\u2069 پشتیبانی میشوند.',
|
||||
pastePlaceholder: '\u2066URL\u2069های \u2066HTTP\u2069، \u2066HTTPS\u2069، \u2066FTP\u2069 یا \u2066SFTP\u2069 را در اینجا جایگذاری کنید…\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جایگذاری کنید.',
|
||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||
selectedSummaryReady: 'آماده',
|
||||
selectedSummaryFallback: 'جایگزین',
|
||||
selectedSummaryMediaRetry: 'تلاش مجدد رسانه',
|
||||
selectedSummaryBlocked: 'مسدود',
|
||||
torrentAdvancedOptions: 'گزینههای پیشرفتهٔ تورنت',
|
||||
torrentAdvancedOptionsCustom: 'تنظیمات سفارشی',
|
||||
clearSelection: 'پاک کردن انتخاب',
|
||||
selectAll: 'انتخاب همه',
|
||||
refreshMetadata: 'تازهسازی متادیتا',
|
||||
files: 'فایلها',
|
||||
torrentFiles: 'فایلهای تورنت',
|
||||
torrent: 'تورنت',
|
||||
chooseTorrentFiles: 'افزودن فایلهای .torrent',
|
||||
torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت میکند.',
|
||||
torrentSeeding: 'اشتراکگذاری تورنت',
|
||||
seedAfterDownload: 'پس از پایان دانلود سید شود',
|
||||
seedTime: 'مدت سید',
|
||||
minutes: 'دقیقه',
|
||||
seconds: 'ثانیه',
|
||||
seedRatio: 'نسبت سید',
|
||||
seedRatioHint: '۰ یعنی فقط مدت زمان تعیینشده ملاک است؛ در غیر این صورت با رسیدن به اولین حد متوقف میشود.',
|
||||
limitTorrentUpload: 'محدود کردن آپلود تورنت',
|
||||
torrentUploadLimit: 'محدودیت آپلود تورنت',
|
||||
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
|
||||
torrentSeedRatioInvalid: 'نسبت سید تورنت نمیتواند منفی باشد',
|
||||
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
|
||||
torrentExcludeTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. * همه آدرسهای announce را حذف میکند؛ تنظیمات DHT و PEX تغییر نمیکند.',
|
||||
torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
|
||||
torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker',
|
||||
torrentTrackerTimeout: 'مهلت درخواست Tracker',
|
||||
torrentTrackerInterval: 'فاصله درخواست Tracker',
|
||||
torrentTrackerTimingHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. مهلت اتصال برای برقراری اتصال و مهلت درخواست برای پاسخ پس از آن است؛ مقدار خالی پیشفرض ۶۰ ثانیهای آریا۲ را نگه میدارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی میکند.',
|
||||
torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||
torrentPeerOptionsHint: 'برای استفاده از پیشفرضهای آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
|
||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||
torrentPrioritizePieceHint: 'قطعههای ابتدا، انتها یا هر دو را برای پیشنمایش انتخاب کنید. هر بازهٔ فعال بهطور پیشفرض 1M است و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه میدارد.',
|
||||
torrentEncryptionDisabled: 'غیرفعال',
|
||||
torrentEncryptionRequireCrypto: 'الزام handshake مبهمسازیشده',
|
||||
torrentEncryptionForceEncryption: 'الزام payload رمزنگاریشده (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
|
||||
torrentRemoveUnselectedFile: 'حذف فایلهای انتخابنشده تورنت پس از تکمیل',
|
||||
torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخابشده اعمال میشود. فایلهای انتخابنشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف میشوند.',
|
||||
torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایلهای انتخابنشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعالکردن حذف فایلهای انتخابنشده، زیرمجموعهای از فایلهای تورنت را انتخاب کنید.',
|
||||
required: 'الزامی',
|
||||
free: 'فضای آزاد',
|
||||
preview: 'پیشنمایش',
|
||||
@@ -788,9 +508,6 @@ const fa = {
|
||||
verifyChecksum: 'تأیید چکسام',
|
||||
checksumAlgorithm: 'الگوریتم چکسام',
|
||||
expectedDigest: 'هش مورد انتظار',
|
||||
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||
headers: 'هدرها',
|
||||
requestHeaders: 'هدرهای درخواست',
|
||||
cookies: 'کوکیها',
|
||||
@@ -864,12 +581,6 @@ const fa = {
|
||||
parallelDownloadsDescription: 'حداکثر فایلهای فعال همزمان',
|
||||
automaticRetries: 'تلاشهای مجدد خودکار:',
|
||||
automaticRetriesDescription: 'اگر اتصالی ناموفق باشد',
|
||||
minimumNormalDownloadSpeed: 'حداقل سرعت دانلود عادی (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'دانلودهای HTTP، FTP و SFTP که سرعتشان پایینتر از این مقدار میماند دوباره تلاش میشوند. برای غیرفعالکردن ۰ را وارد کنید.',
|
||||
retryNotFoundErrors: 'تلاش دوباره برای خطای موقت «پیدا نشد»',
|
||||
retryNotFoundErrorsDescription: 'پاسخهای «منبع پیدا نشد» در HTTP/FTP را تا سقف تلاشهای خودکار دوباره امتحان میکند. بهطور پیشفرض خاموش است.',
|
||||
adaptiveMirrorSelection: 'انتخاب هوشمند میرور',
|
||||
adaptiveMirrorSelectionDescription: 'برای انتخاب بین چند میرور از عملکرد دانلودهای اخیر استفاده میکند. آمار سرورها فقط روی همین دستگاه نگهداری میشود.',
|
||||
systemNotification: 'نمایش اعلان سیستم هنگام تکمیل دانلود',
|
||||
systemNotificationDescription: 'از تنظیمات اعلان سیستمعامل شما استفاده میکند',
|
||||
completionChime: 'پخش صدای تکمیل درون برنامهای',
|
||||
@@ -970,65 +681,6 @@ const fa = {
|
||||
detectedSystemProxy: 'یک پروکسی سیستم شناسایی شد. دانلودهای فایل عادی به یک نقطه پایانی HTTP یا HTTPS نیاز دارند؛ دانلودهای رسانه میتوانند از SOCKS استفاده کنند.',
|
||||
noSystemProxy: 'هیچ پروکسی سیستم قابل استفادهای شناسایی نشد. دانلودها از هیچ پروکسیای استفاده نخواهند کرد.',
|
||||
systemProxyReadFailed: 'پیکربندی پروکسی سیستم قابل خواندن نیست. بدون پروکسی را انتخاب کنید یا هنگامی که در دسترس است دوباره امتحان کنید.',
|
||||
torrentTabs: {
|
||||
discovery: 'کشف همتا',
|
||||
connection: 'اتصال',
|
||||
limits: 'محدودیتها',
|
||||
advanced: 'پیشرفته',
|
||||
},
|
||||
torrentPeerDiscovery: 'کشف همتا در بیتتورنت',
|
||||
torrentDht: 'DHT نسخه IPv4 و ترکرهای UDP',
|
||||
torrentDhtDescription: 'همتاها را بدون تکیه صرف بر ترکرها پیدا میکند. خاموش کردن آن پشتیبانی از ترکرهای UDP را هم خاموش میکند.',
|
||||
torrentDht6: 'DHT نسخه IPv6',
|
||||
torrentDht6Description: 'وقتی مسیر IPv6 قابل استفاده باشد، از آن برای کشف توزیعشده همتاها استفاده میکند.',
|
||||
torrentIpv6Enabled: 'فعالسازی IPv6 برای تورنت',
|
||||
torrentIpv6EnabledDescription: 'IPv6 را برای BitTorrent، DHT و کشف همتاها فعال نگه میدارد. غیرفعالکردن آن IPv6 DHT را خاموش میکند.',
|
||||
torrentPex: 'تبادل همتا (PEX)',
|
||||
torrentPexDescription: 'به همتاهای متصل اجازه میدهد آدرس همتاهای بیشتری را به اشتراک بگذارند.',
|
||||
torrentLpd: 'کشف همتای محلی (LPD)',
|
||||
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا میکند و دیدهشدن ترافیک در شبکه محلی را افزایش میدهد.',
|
||||
torrentPeerDiscoveryRestartNote: 'این گزینهها سراسری و مربوط به Aria2 هستند و پس از راهاندازی مجدد Firelink اعمال میشوند. Aria2 همچنان کشف همتا را برای تورنتهای خصوصی خاموش میکند.',
|
||||
torrentNetwork: 'اتصال شبکه بیتتورنت',
|
||||
torrentAdvanced: 'شبکه پیشرفته تورنت',
|
||||
torrentDhtMessageTimeout: 'مهلت پیام DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'مدت انتظار پیامهای DHT و UDP برحسب ثانیه. روی دریافت HTTP فایل .torrent یا درخواستهای tracker از نوع HTTP اثر ندارد و پس از راهاندازی دوباره اعمال میشود.',
|
||||
torrentSeparateSeedSlots: 'ظرفیت جداگانهٔ سید کردن',
|
||||
torrentSeparateSeedSlotsDescription: 'سید کردن را از سقف دانلود جدا میکند و آن را با ظرفیت مدیریتشدهٔ Firelink محدود میکند.',
|
||||
torrentMaxConcurrentSeeds: 'حداکثر سید همزمان',
|
||||
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنتهایی که Firelink همزمان سید میکند.',
|
||||
torrentListenPort: 'پورتهای همتای TCP',
|
||||
torrentListenPortDescription: 'پورتهای TCP برای اتصالهای ورودی همتاهای بیتتورنت. برای محدوده پیشفرض Aria2 خالی بگذارید.',
|
||||
torrentBindAddress: 'نشانی اتصال تورنت',
|
||||
torrentBindAddressDescription: 'نشانی محلی اختیاری IPv4 یا IPv6 برای سوکتهای Aria2. نشانی نامعتبر رد میشود و تغییر پس از راهاندازی مجدد اعمال میشود.',
|
||||
torrentDhtListenPort: 'پورتهای UDP/DHT',
|
||||
torrentDhtListenPortDescription: 'پورتهای UDP برای DHT و ترکرهای UDP. برای محدوده پیشفرض Aria2 خالی بگذارید.',
|
||||
torrentExternalIp: 'آدرس IP خارجی',
|
||||
torrentExternalIpDescription: 'آدرسی که هنگام قرار گرفتن میزبان پشت NAT به همتاها و ترکرها اعلام میشود. مگر از آدرس قابل دسترس مطمئن باشید، خالی بگذارید.',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'نقطه ورود DHT نسخه IPv4',
|
||||
torrentDhtEntryPointDescription: 'میزبان و پورت اختیاری برای شروع DHT؛ مانند router.example:6881.',
|
||||
torrentDhtEntryPoint6: 'نقطه ورود DHT نسخه IPv6',
|
||||
torrentDhtEntryPoint6Description: 'آدرس و پورت اختیاری IPv6 برای شروع؛ مانند [2001:db8::1]:6881.',
|
||||
torrentDhtListenAddr6: 'آدرس شنود DHT نسخه IPv6',
|
||||
torrentDhtListenAddr6Description: 'آدرس IPv6 سوکت DHT. برای انتخاب خودکار توسط Aria2 خالی بگذارید.',
|
||||
torrentLpdInterface: 'رابط LPD',
|
||||
torrentLpdInterfaceDescription: 'نام رابط شبکه یا آدرس مورد استفاده برای کشف همتای محلی. برای رابط پیشفرض خالی بگذارید.',
|
||||
torrentPeerIdPrefix: 'پیشوند شناسه همتا',
|
||||
torrentPeerIdPrefixDescription: 'پیشوند شناسه همتای بیتتورنت را تغییر میدهد. فقط در صورت آگاهی از پیامدهای حریم خصوصی و هویت پروتکل استفاده کنید؛ برای پیشفرض Aria2 خالی بگذارید.',
|
||||
torrentPeerAgent: 'عامل همتا',
|
||||
torrentPeerAgentDescription: 'رشته کلاینت ارسالشده در دستدهی توسعهیافته بیتتورنت را تغییر میدهد. این کار هویت پروتکل را تغییر میدهد و ممکن است بر سازگاری اثر بگذارد؛ برای پیشفرض Aria2 خالی بگذارید.',
|
||||
torrentNetworkRestartNote: 'این تنظیمات هنگام راهاندازی اعمال میشوند و پس از راهاندازی مجدد Firelink اثر میکنند. باز کردن پورتها ممکن است به port forwarding روتر و قانون فایروال سیستمعامل نیاز داشته باشد؛ دسترسی به آنها به سیستمعامل و شبکه بستگی دارد.',
|
||||
torrentResourceLimits: 'محدودیت منابع بیتتورنت',
|
||||
torrentMaxOpenFiles: 'حداکثر فایلهای باز تورنت',
|
||||
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایلهای همزمان باز در تورنتهای چندفایلی. مقدار کمتر مصرف file descriptor را کم میکند؛ پیشفرض ۱۰۰ است. تغییرات برای تورنتهای جدید و بدون راهاندازی مجدد Aria2 اعمال میشوند و محدودیت سیستمعامل را افزایش نمیدهند.',
|
||||
aria2DiskCache: 'کش دیسک Aria2',
|
||||
aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته میشوند و پس از راهاندازی مجدد اعمال میشوند.',
|
||||
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایلهای باز تورنت ممکن نشد: {{detail}}',
|
||||
torrentNetworkInputInvalid: 'اعمال این تنظیم شبکه تورنت ممکن نیست: {{detail}}',
|
||||
torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2',
|
||||
torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود میکند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنتهاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال میشود و پس از راهاندازی مجدد Firelink برمیگردد.',
|
||||
torrentOverallUploadLimitInvalid: 'یک محدودیت معتبر مثل 512K یا 2M برای آپلود وارد کنید.',
|
||||
torrentOverallUploadLimitUpdateFailed: 'اعمال محدودیت کلی آپلود Aria2 ممکن نشد: {{detail}}',
|
||||
identity: 'هویت',
|
||||
customUserAgent: 'User-Agent سفارشی',
|
||||
userAgentDescription: 'در دریافتهای متادیتا و موتورهای دانلود اعمال میشود.',
|
||||
@@ -1162,7 +814,6 @@ const fa = {
|
||||
active: '{{count}} فعال',
|
||||
queued: '{{count}} در صف',
|
||||
done: '{{count}} تکمیلشده',
|
||||
seeding: 'در حال اشتراکگذاری',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const he = {
|
||||
documents: 'מסמכים',
|
||||
pictures: 'תמונות',
|
||||
applications: 'יישומים',
|
||||
torrents: 'טורנטים',
|
||||
other: 'אחר',
|
||||
},
|
||||
folders: 'תיקיות',
|
||||
@@ -60,7 +59,6 @@ const he = {
|
||||
title: 'הסרת הורדה',
|
||||
confirmationSingle: 'האם ברצונך להסיר פריט זה מהרשימה? ניתן לבחור למחוק גם את הקובץ מהכונן הקשיח.',
|
||||
confirmationMultiple: 'האם ברצונך להסיר {{count}} פריטים אלו מהרשימה? ניתן לבחור למחוק גם את הקבצים מהכונן הקשיח.',
|
||||
mixedRemovalPolicy: 'אם בוחרים ב״מחיקת קובץ״, קבצים שלא הסתיימו יימחקו לצמיתות; קבצים שהושלמו ימשיכו לעבור לאשפה.',
|
||||
errorSummary: '{{succeeded}} הוסרו, {{failed}} נכשלו: {{detail}}',
|
||||
remove: 'הסרה',
|
||||
deleteFile: 'מחיקת קובץ',
|
||||
@@ -79,7 +77,6 @@ const he = {
|
||||
pause: 'השהייה',
|
||||
start: 'הפעלה',
|
||||
resume: 'חידוש',
|
||||
retry: 'ניסיון חוזר',
|
||||
options: 'אפשרויות',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const he = {
|
||||
staged: 'בתור',
|
||||
queued: 'בתור',
|
||||
downloading: 'מוריד',
|
||||
waitingForPeers: 'ממתין לעמיתים',
|
||||
processing: 'מעבד',
|
||||
verifying: 'מאמת',
|
||||
seeding: 'משתף',
|
||||
waitingToSeed: 'ממתין לשיתוף',
|
||||
paused: 'מושהה',
|
||||
completed: 'הושלם',
|
||||
failed: 'נכשל',
|
||||
retrying: 'ניסיון חוזר',
|
||||
moving: 'מעביר נתונים',
|
||||
allocatingFiles: 'מקצה קבצים…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
|
||||
nameResolutionFailed: 'לא ניתן לפתור את שם השרת. בדקו את ה‑VPN או את ה‑DNS של הרשת.',
|
||||
},
|
||||
values: {
|
||||
processing: 'מעבד…',
|
||||
@@ -199,7 +186,6 @@ const he = {
|
||||
linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
|
||||
validationDay: 'יש לבחור לפחות יום אחד למתזמן',
|
||||
validationQueue: 'יש לבחור לפחות תור אחד למתזמן',
|
||||
validationTime: 'יש להזין שעות תקינות בתבנית HH:MM',
|
||||
validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה',
|
||||
saved: 'הגדרות המתזמן נשמרו',
|
||||
trackingOne: 'עוקב אחר הורדה מתוזמנת אחת',
|
||||
@@ -225,48 +211,15 @@ const he = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: 'השלכת השינויים',
|
||||
keepEditing: 'להמשיך לערוך',
|
||||
progress: 'התקדמות',
|
||||
size: 'גודל',
|
||||
speed: 'מהירות',
|
||||
eta: 'זמן נותר',
|
||||
connections: 'חיבורים',
|
||||
fragmentConcurrency: 'מקביליות מקטעים',
|
||||
fragmentConcurrencyHint: 'מספר מקטעי המדיה המרבי ש-yt-dlp יכול לעבד במקביל. Firelink אינו מדווח על מספר המקטעים הפעילים בזמן אמת; זהו הערך המוגדר שבו נעשה שימוש כשההעברה מתחילה או מתחדשת.',
|
||||
connectedPeers: 'עמיתים מחוברים',
|
||||
details: 'פרטים',
|
||||
tabs: {
|
||||
label: 'מקטעי המאפיינים',
|
||||
overview: 'סקירה',
|
||||
files: 'קבצים',
|
||||
trackers: 'עוקבים',
|
||||
peers: 'עמיתים',
|
||||
transfer: 'העברה',
|
||||
options: 'אפשרויות',
|
||||
advanced: 'מתקדם',
|
||||
},
|
||||
queueId: 'תור',
|
||||
queuePosition: 'מיקום {{position}}',
|
||||
resumable: 'ניתן להמשך',
|
||||
connectionCount: '{{active}}/{{total}} פעילות',
|
||||
connectionCountUnknown: '—/{{total}} פעילות',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'הגבלת מהירות',
|
||||
inputFormat: 'תבנית: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M או 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 פירושו ללא הגבלה',
|
||||
inputFormatSeedTime: 'בדקות, למשל 60',
|
||||
inputFormatSeedRatio: 'מספר עשרוני, למשל 1.5; 0 פירושו לפי זמן בלבד',
|
||||
inputFormatStopTimeout: 'שניות שלמות; 0 משבית',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'לדוגמה 512K',
|
||||
inputExampleMaxPeers: 'לדוגמה 55',
|
||||
inputExampleSeedTime: 'לדוגמה 60',
|
||||
inputExampleSeedRatio: 'לדוגמה 1.5',
|
||||
inputExampleStopTimeout: 'לדוגמה 300',
|
||||
inputExamplePiecePriority: 'לדוגמה head=1M,tail=1M',
|
||||
speedLimitHint: 'השאר ריק כדי להשתמש בערך הגלובלי, או הזן הגבלה עבור הורדה זו.',
|
||||
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
|
||||
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
|
||||
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
|
||||
@@ -274,166 +227,6 @@ const he = {
|
||||
liveSpeedLimitClear: 'נקה',
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
||||
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
||||
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
|
||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
|
||||
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
|
||||
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
|
||||
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
|
||||
torrentExcludeTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה, או * כדי להחריג את כל כתובות ההכרזה. פרטי התחברות אינם מותרים; הגדרות DHT ו-PEX לא ישתנו.',
|
||||
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
|
||||
torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker',
|
||||
torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker',
|
||||
torrentTrackerInterval: 'מרווח בין בקשות Tracker',
|
||||
torrentTrackerTimingHint: 'זמן קצוב לחיבור חל על יצירת החיבור ל-Tracker, וזמן קצוב לבקשה חל על התגובה שלאחר מכן. ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות; מרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.',
|
||||
torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות',
|
||||
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
|
||||
torrentVerifyNow: 'אמת עכשיו',
|
||||
torrentVerifyNowLoading: 'מאמת…',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||
torrentPeerDiagnostics: 'פרטי עמיתי טורנט',
|
||||
torrentPeerDiagnosticsRefresh: 'רענון',
|
||||
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
|
||||
torrentPeerDiagnosticsStale: 'מוצגת התוצאה המאומתת האחרונה; רענן כדי לבדוק שוב.',
|
||||
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.',
|
||||
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
|
||||
torrentPeerDiagnosticsHint: 'כתובות ויציאות מאומתות של עמיתים מוצגות באופן זמני בלבד; מזהי עמיתים ושדות סיביות גולמיים לעולם אינם נשמרים. המספר המחובר למעלה הוא מצב הטורנט בזמן אמת; הטבלה הזו היא תגובה נפרדת של רשימת העמיתים וייתכן שתציג מספר אחר.',
|
||||
torrentPeerAddress: 'כתובת עמית',
|
||||
torrentPeerId: 'מזהה עמית',
|
||||
torrentFileProgress: 'התקדמות קובצי הטורנט',
|
||||
torrentFileSelection: 'בחירת קובצי טורנט',
|
||||
torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.',
|
||||
torrentFileSelectionRequired: 'בחר לפחות קובץ טורנט אחד.',
|
||||
torrentFileSelectionAll: 'בחר הכול',
|
||||
torrentFileSelectionClear: 'נקה',
|
||||
torrentFileProgressRefresh: 'רענון',
|
||||
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
|
||||
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
|
||||
torrentFileProgressFailed: 'לא ניתן לקרוא את התקדמות קובצי הטורנט.',
|
||||
torrentFileProgressHint: 'מוצגים נתיבים יחסיים מאומתים ובייטים שהושלמו; נתיבי daemon וכתובות URI אינם נחשפים.',
|
||||
torrentFileProgressPath: 'קובץ',
|
||||
torrentFileProgressCompleted: 'הושלם',
|
||||
torrentFileProgressSelected: 'נבחר',
|
||||
torrentFileProgressUnselected: 'לא נבחר',
|
||||
torrentPieceProgress: 'התקדמות חלקי הטורנט',
|
||||
torrentPieceProgressRefresh: 'רענון',
|
||||
torrentPieceProgressLoading: 'טוען את התקדמות החלקים…',
|
||||
torrentPieceProgressUnavailable: 'התקדמות החלקים זמינה כשהטורנט פעיל או מושהה.',
|
||||
torrentPieceProgressFailed: 'לא ניתן לקרוא את התקדמות חלקי הטורנט.',
|
||||
torrentPieceProgressHint: 'כל תא מסכם חלקים סמוכים; מפת הסיביות הגולמית אינה נחשפת.',
|
||||
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
|
||||
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
|
||||
torrentWebSeeds: 'זריעות Web של טורנט',
|
||||
torrentWebSeedsHint: 'הוסף כתובת בסיס HTTP(S) אחת לכל קובץ טורנט. Firelink מרחיב נתיבים של טורנטים מרובי-קבצים.',
|
||||
torrentWebSeedsApply: 'החל זריעות Web',
|
||||
torrentWebSeedsLoading: 'מיישם…',
|
||||
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
|
||||
torrentWebSeedsEmpty: 'לא הוגדרו זריעות Web.',
|
||||
torrentWebSeedsFile: 'קובץ',
|
||||
torrentWebSeedsUri: 'כתובת בסיס HTTP(S)',
|
||||
torrentWebSeedsAdd: 'הוסף זריעת Web',
|
||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||
torrentPeerCount: '{{listed}} עמיתים ברשימה — {{seeders}} משתפים ברשימה',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
torrentPeerSeeder: 'משתף',
|
||||
torrentPeerAmChoking: 'Firelink מגביל',
|
||||
torrentPeerChoking: 'העמית מגביל',
|
||||
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים ברשימה.',
|
||||
torrentStatistics: 'סטטיסטיקות טורנט',
|
||||
torrentUploaded: 'הועלה',
|
||||
torrentRatio: 'יחס',
|
||||
torrentSeededDuration: 'משך שיתוף',
|
||||
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
|
||||
torrentConnectedPeers: 'עמיתים',
|
||||
torrentPeersSeeders: 'עמיתים / משתפים',
|
||||
torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים',
|
||||
torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.',
|
||||
torrentPeerCountDifference: 'מחוברים: {{connectedPeers}} עמיתים / {{connectedSeeders}} משתפים. תגובת פרטי העמיתים מציגה {{listedPeers}} עמיתים / {{listedSeeders}} משתפים.',
|
||||
torrentSeeders: 'משתפים',
|
||||
torrentUploadSpeed: 'מהירות העלאה',
|
||||
seconds: 'שניות',
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית. כל טווח מופעל מתחיל ב־1M ומוחל כשהטורנט מופעל או מנסה שוב.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||
torrentFileAllocation: 'הקצאת קובצי Torrent',
|
||||
torrentFileAllocationPrealloc: 'הקצאה מראש',
|
||||
torrentFileAllocationNone: 'הקצאה לפי הצורך',
|
||||
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
|
||||
torrentOptionsBehavior: 'התנהגות Torrent',
|
||||
torrentOptionsBehaviorHint: 'שולט באימות, הקצאת האחסון, ההצפנה והניקוי של Torrent זה.',
|
||||
torrentDetails: 'פרטי טורנט',
|
||||
torrentCopyMagnet: 'העתקת קישור מגנט',
|
||||
torrentExportMetadata: 'ייצוא .torrent',
|
||||
torrentMagnetCopied: 'קישור מגנט זהותי הועתק.',
|
||||
torrentMagnetCopyFailed: 'לא ניתן להעתיק את קישור המגנט.',
|
||||
torrentMetadataExported: 'מטא־נתוני הטורנט יוצאו.',
|
||||
torrentMetadataExportFailed: 'לא ניתן לייצא את מטא־נתוני הטורנט.',
|
||||
torrentMove: 'העברת נתונים…',
|
||||
torrentMoveLoading: 'מעביר…',
|
||||
torrentMoveCancel: 'ביטול ההעברה',
|
||||
torrentMoveCancelRequested: 'בקשת ביטול נשלחה…',
|
||||
torrentMoveConfirm: 'להעביר את נתוני הטורנט המנוהלים לתיקייה זו? קבצים קיימים לעולם לא יוחלפו.',
|
||||
torrentMoveCompleted: 'נתוני הטורנט הועברו.',
|
||||
torrentMoveFailed: 'לא ניתן להעביר את נתוני הטורנט.',
|
||||
torrentAvailability: 'זמינות הנחיל',
|
||||
torrentAvailabilityRefresh: 'רענון',
|
||||
torrentAvailabilityLoading: 'טוען זמינות…',
|
||||
torrentAvailabilityUnavailable: 'הזמינות זמינה עבור טורנט פעיל או מושהה.',
|
||||
torrentAvailabilityFailed: 'לא ניתן לקרוא את זמינות הטורנט.',
|
||||
torrentAvailabilityHint: 'מוצגים רק מספרי עותקים מצטברים; זהויות עמיתים וביטפילדים גולמיים לעולם אינם נחשפים.',
|
||||
torrentAvailabilitySummary: '{{availability}} עותקים זמינים · {{peers}} עמיתים מחוברים · {{pieces}} חלקים',
|
||||
torrentAvailabilityMap: 'מפת זמינות נחיל הטורנט',
|
||||
torrentAvailabilityBucket: 'לפחות {{copies}} עותקים בטווח זה',
|
||||
torrentDetailsLoading: 'טוען פרטי טורנט…',
|
||||
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
|
||||
torrentDetailsDisplayName: 'שם תצוגה',
|
||||
torrentDetailsInfoHash: 'גיבוב מידע',
|
||||
torrentDetailsSize: 'גודל כולל',
|
||||
torrentDetailsFiles: 'קבצים',
|
||||
torrentDetailsPieces: 'חלקים',
|
||||
torrentDetailsPrivate: 'פרטי',
|
||||
torrentDetailsPrivateYes: 'כן',
|
||||
torrentDetailsPrivateNo: 'לא',
|
||||
torrentDetailsCreated: 'נוצר',
|
||||
torrentDetailsCreator: 'יוצר',
|
||||
torrentDetailsComment: 'הערה',
|
||||
torrentDetailsTrackers: 'עוקבים',
|
||||
torrentDetailsWebSeeds: 'זרעי Web משובצים',
|
||||
torrentDetailsPrivateHint: 'טורנט פרטי זה משבית גילוי DHT, DHT6, PEX ו-LPD ללא קשר להגדרות הכלליות.',
|
||||
torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.',
|
||||
torrentEncryptionDisabled: 'מושבתת',
|
||||
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
|
||||
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
|
||||
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
|
||||
torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.',
|
||||
torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
|
||||
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
|
||||
category: 'קטגוריה',
|
||||
lastTry: 'ניסיון אחרון',
|
||||
dateAdded: 'תאריך הוספה',
|
||||
@@ -443,15 +236,10 @@ const he = {
|
||||
defaultValue: ' (ברירת מחדל)',
|
||||
savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.',
|
||||
defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.',
|
||||
blankUsesDefault: 'ריק · שימוש בברירת המחדל',
|
||||
usingDefault: 'ברירת מחדל',
|
||||
customPerDownload: 'מותאם להורדה זו',
|
||||
identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.',
|
||||
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
|
||||
download: 'הורדה',
|
||||
url: 'URL',
|
||||
urlShowMore: 'הצג כתובת מלאה',
|
||||
urlShowLess: 'הצג פחות',
|
||||
fileName: 'שם קובץ',
|
||||
saveLocation: 'מיקום שמירה',
|
||||
select: 'בחירה',
|
||||
@@ -472,15 +260,11 @@ const he = {
|
||||
algorithm: 'אלגוריתם',
|
||||
digest: 'ערך גיבוב',
|
||||
expectedDigest: 'ערך גיבוב צפוי',
|
||||
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||
cookies: 'עוגיות',
|
||||
headers: 'כותרות (Headers)',
|
||||
mirrors: 'מראות',
|
||||
username: 'שם משתמש',
|
||||
password: 'סיסמה',
|
||||
clear: 'ניקוי',
|
||||
enterValidUrl: 'נא להזין כתובת URL תקינה.',
|
||||
fileNameEmpty: 'שם הקובץ אינו יכול להיות ריק.',
|
||||
cancel: 'ביטול',
|
||||
@@ -523,7 +307,6 @@ const he = {
|
||||
settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.',
|
||||
systemActionCountdown: '{{action}} בעוד 10 שניות.',
|
||||
systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.',
|
||||
systemActionProceedAnyway: 'להמשיך בכל זאת',
|
||||
systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}',
|
||||
downloadCompleteTitle: 'ההורדה הושלמה',
|
||||
downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.',
|
||||
@@ -618,14 +401,12 @@ const he = {
|
||||
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
|
||||
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
|
||||
copyAddressFailed: 'לא ניתן להעתיק כתובת',
|
||||
copyMagnetFailed: 'לא ניתן להעתיק את קישור המגנט',
|
||||
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
|
||||
missingFileName: 'שם הקובץ חסר',
|
||||
redownloadFailed: 'הורדה מחדש נכשלה',
|
||||
startResume: 'הפעלה/חידוש',
|
||||
addToQueue: 'הוספה לתור',
|
||||
copyAddress: 'העתקת כתובת',
|
||||
copyMagnet: 'העתקת קישור מגנט',
|
||||
remove: 'הסרה',
|
||||
open: 'פתיחה',
|
||||
showInFolder: 'הצגה בתיקייה',
|
||||
@@ -665,75 +446,14 @@ const he = {
|
||||
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
|
||||
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
|
||||
downloadLinks: 'קישורי הורדה',
|
||||
pastePlaceholder: 'הדביקו כאן כתובות \u2066HTTP(S)\u2069, \u2066FTP/SFTP\u2069, \u2066magnet\u2069 או מדיה…',
|
||||
pasteHint: 'קישורי \u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069 ו-\u2066Reddit\u2069 נתמכים.',
|
||||
pastePlaceholder: 'הדבק כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069 או \u2066SFTP\u2069 כאן…\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
|
||||
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||
selectedSummaryReady: 'מוכנים',
|
||||
selectedSummaryFallback: 'גיבוי',
|
||||
selectedSummaryMediaRetry: 'ניסיון חוזר במדיה',
|
||||
selectedSummaryBlocked: 'חסומים',
|
||||
torrentAdvancedOptions: 'אפשרויות Torrent מתקדמות',
|
||||
torrentAdvancedOptionsCustom: 'הגדרות מותאמות',
|
||||
clearSelection: 'ניקוי בחירה',
|
||||
selectAll: 'בחירת הכל',
|
||||
refreshMetadata: 'רענון מטא נתונים',
|
||||
files: 'קבצים',
|
||||
torrentFiles: 'קובצי טורנט',
|
||||
torrent: 'טורנט',
|
||||
chooseTorrentFiles: 'הוספת קובצי .torrent',
|
||||
torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.',
|
||||
torrentSeeding: 'שיתוף טורנט',
|
||||
seedAfterDownload: 'לשתף לאחר סיום ההורדה',
|
||||
seedTime: 'זמן שיתוף',
|
||||
minutes: 'דקות',
|
||||
seconds: 'שניות',
|
||||
seedRatio: 'יחס שיתוף',
|
||||
seedRatioHint: '0 פירושו שיתוף לפי זמן בלבד; אחרת השיתוף ייפסק בהגעה למגבלה הראשונה.',
|
||||
limitTorrentUpload: 'הגבלת העלאת טורנט',
|
||||
torrentUploadLimit: 'מגבלת העלאת טורנט',
|
||||
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
|
||||
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
|
||||
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
|
||||
torrentExcludeTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא. * מחריג את כל כתובות ההכרזה; הגדרות DHT ו-PEX לא ישתנו.',
|
||||
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
|
||||
torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker',
|
||||
torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker',
|
||||
torrentTrackerInterval: 'מרווח בין בקשות Tracker',
|
||||
torrentTrackerTimingHint: 'נשמר עם ה-Torrent ומוחל בהפעלה או בניסיון חוזר. זמן קצוב לחיבור חל על יצירת החיבור, וזמן קצוב לבקשה חל על התגובה שלאחר מכן; ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות, ומרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.',
|
||||
torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות',
|
||||
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
|
||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||
torrentPrioritizePieceHint: 'בחר חלקים ראשונים, אחרונים או את שניהם לתצוגה מקדימה. כל טווח מופעל מתחיל ב־1M ומוחל בהפעלה או בניסיון חוזר הבא.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
|
||||
torrentEncryptionDisabled: 'מושבתת',
|
||||
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
|
||||
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
|
||||
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
|
||||
torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.',
|
||||
torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
|
||||
required: 'נדרש',
|
||||
free: 'פנוי',
|
||||
preview: 'תצוגה מקדימה',
|
||||
@@ -788,9 +508,6 @@ const he = {
|
||||
verifyChecksum: 'אימות סכום ביקורת',
|
||||
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
|
||||
expectedDigest: 'ערך גיבוב צפוי',
|
||||
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||
headers: 'כותרות (Headers)',
|
||||
requestHeaders: 'כותרות בקשה',
|
||||
cookies: 'עוגיות',
|
||||
@@ -864,12 +581,6 @@ const he = {
|
||||
parallelDownloadsDescription: 'מקסימום קבצים פעילים בו-זמנית',
|
||||
automaticRetries: 'ניסיונות חוזרים אוטומטיים:',
|
||||
automaticRetriesDescription: 'אם חיבור נכשל',
|
||||
minimumNormalDownloadSpeed: 'מהירות מזערית להורדה רגילה (KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'ניסיון חוזר להורדות HTTP, FTP ו-SFTP שנשארות מתחת למהירות זו. 0 משבית את האפשרות.',
|
||||
retryNotFoundErrors: 'ניסיון חוזר לשגיאות זמניות של משאב שלא נמצא',
|
||||
retryNotFoundErrorsDescription: 'מתייחס לתשובות HTTP/FTP של משאב שלא נמצא כניתנות לניסיון חוזר, עד למגבלת הניסיונות האוטומטיים. כבוי כברירת מחדל.',
|
||||
adaptiveMirrorSelection: 'בחירה מסתגלת של שרת מראה',
|
||||
adaptiveMirrorSelectionDescription: 'משתמש בביצועי העברות אחרונות כדי לבחור בין כמה שרתי מראה. סטטיסטיקת השרתים נשמרת באופן פרטי במכשיר זה.',
|
||||
systemNotification: 'הצג התראת מערכת כאשר ההורדה מסתיימת',
|
||||
systemNotificationDescription: 'משתמש בהגדרות ההתראות של מערכת ההפעלה שלך',
|
||||
completionChime: 'השמע צליל סיום בתוך האפליקציה',
|
||||
@@ -970,65 +681,6 @@ const he = {
|
||||
detectedSystemProxy: 'זוהה פרוקסי מערכת. הורדות קבצים רגילות דורשות נקודת קצה של HTTP או HTTPS; הורדות מדיה יכולות להשתמש ב-SOCKS.',
|
||||
noSystemProxy: 'לא זוהה פרוקסי מערכת שימושי. ההורדות לא ישתמשו בפרוקסי.',
|
||||
systemProxyReadFailed: 'לא ניתן לקרוא את תצורת פרוקסי המערכת. בחר "ללא פרוקסי" או נסה שוב כשהיא תהיה זמינה.',
|
||||
torrentTabs: {
|
||||
discovery: 'גילוי',
|
||||
connection: 'חיבור',
|
||||
limits: 'מגבלות',
|
||||
advanced: 'מתקדם',
|
||||
},
|
||||
torrentPeerDiscovery: 'גילוי עמיתים ב-BitTorrent',
|
||||
torrentDht: 'DHT של IPv4 ומעקבי UDP',
|
||||
torrentDhtDescription: 'מאתר עמיתים בלי להסתמך רק על מעקבים. השבתה מכבה גם תמיכה במעקבי UDP.',
|
||||
torrentDht6: 'DHT של IPv6',
|
||||
torrentDht6Description: 'משתמש ב-IPv6 לגילוי מבוזר של עמיתים כשיש נתיב IPv6 זמין.',
|
||||
torrentIpv6Enabled: 'הפעלת IPv6 עבור טורנטים',
|
||||
torrentIpv6EnabledDescription: 'משאיר את IPv6 זמין עבור BitTorrent, DHT וגילוי עמיתים. השבתה זו מכבה גם IPv6 DHT.',
|
||||
torrentPex: 'החלפת עמיתים (PEX)',
|
||||
torrentPexDescription: 'מאפשר לעמיתים מחוברים לשתף כתובות של עמיתים נוספים.',
|
||||
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
|
||||
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
|
||||
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
|
||||
torrentNetwork: 'קישור רשת BitTorrent',
|
||||
torrentAdvanced: 'רשת Torrent מתקדמת',
|
||||
torrentDhtMessageTimeout: 'זמן קצוב להודעות DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'משך ההמתנה להודעות DHT ו-UDP בשניות. אינו משפיע על הורדת קובצי .torrent ב-HTTP או על בקשות HTTP למעקבים. חל לאחר הפעלה מחדש של Firelink.',
|
||||
torrentSeparateSeedSlots: 'קיבולת זריעה נפרדת',
|
||||
torrentSeparateSeedSlotsDescription: 'הפרד זריעה ממגבלת ההורדות והגבל אותה למאגר בניהול Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'מקסימום זריעות במקביל',
|
||||
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
|
||||
torrentListenPort: 'יציאות עמיתי TCP',
|
||||
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
|
||||
torrentBindAddress: 'כתובת קישור לטורנטים',
|
||||
torrentBindAddressDescription: 'כתובת IPv4 או IPv6 מקומית ואופציונלית לשקעי Aria2. כתובות לא תקינות נדחות; השינוי חל לאחר הפעלה מחדש.',
|
||||
torrentDhtListenPort: 'יציאות UDP/DHT',
|
||||
torrentDhtListenPortDescription: 'יציאות UDP עבור DHT ועוקבי UDP. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
|
||||
torrentExternalIp: 'כתובת IP חיצונית',
|
||||
torrentExternalIpDescription: 'הכתובת שמוכרזת לעמיתים ולעוקבים כשהמחשב מאחורי NAT. השאר ריק אלא אם ידועה לך הכתובת הנגישה.',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'נקודת כניסה ל-DHT IPv4',
|
||||
torrentDhtEntryPointDescription: 'מארח ויציאה אופציונליים לאתחול, לדוגמה router.example:6881.',
|
||||
torrentDhtEntryPoint6: 'נקודת כניסה ל-DHT IPv6',
|
||||
torrentDhtEntryPoint6Description: 'כתובת ויציאת IPv6 אופציונליות לאתחול בסוגריים, לדוגמה [2001:db8::1]:6881.',
|
||||
torrentDhtListenAddr6: 'כתובת האזנה ל-DHT IPv6',
|
||||
torrentDhtListenAddr6Description: 'כתובת IPv6 לשקע DHT. השאר ריק כדי לאפשר ל-Aria2 לבחור.',
|
||||
torrentLpdInterface: 'ממשק LPD',
|
||||
torrentLpdInterfaceDescription: 'שם ממשק הרשת או כתובת עבור גילוי עמיתים מקומי. השאר ריק כדי להשתמש בממשק ברירת המחדל.',
|
||||
torrentPeerIdPrefix: 'קידומת מזהה עמית',
|
||||
torrentPeerIdPrefixDescription: 'עוקף את קידומת מזהה העמית של BitTorrent. השתמש רק אם ברורות לך השלכות הפרטיות וזהות הפרוטוקול; השאר ריק עבור ברירת המחדל של Aria2.',
|
||||
torrentPeerAgent: 'סוכן עמית',
|
||||
torrentPeerAgentDescription: 'עוקף את מחרוזת הלקוח שנשלחת בלחיצת היד המורחבת של BitTorrent. הדבר משנה את זהות הפרוטוקול ועלול להשפיע על תאימות; השאר ריק עבור ברירת המחדל של Aria2.',
|
||||
torrentNetworkRestartNote: 'הגדרות אלה חלות בעת הפעלת המנוע ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. פתיחת יציאות עשויה לדרוש העברת יציאות בנתב וכלל בחומת האש של מערכת ההפעלה; הזמינות תלויה בפלטפורמה וברשת.',
|
||||
torrentResourceLimits: 'מגבלות משאבי BitTorrent',
|
||||
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
|
||||
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
|
||||
aria2DiskCache: 'מטמון דיסק של Aria2',
|
||||
aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.',
|
||||
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
|
||||
torrentNetworkInputInvalid: 'לא ניתן להחיל את הגדרת רשת הטורנט: {{detail}}',
|
||||
torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2',
|
||||
torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.',
|
||||
torrentOverallUploadLimitInvalid: 'הזן מגבלת העלאה תקפה, למשל 512K או 2M.',
|
||||
torrentOverallUploadLimitUpdateFailed: 'לא ניתן להחיל את מגבלת ההעלאה הכוללת של Aria2: {{detail}}',
|
||||
identity: 'זהות',
|
||||
customUserAgent: 'User-Agent מותאם אישית',
|
||||
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
|
||||
@@ -1162,7 +814,6 @@ const he = {
|
||||
active: '{{count}} פעילים',
|
||||
queued: '{{count}} בתור',
|
||||
done: '{{count}} הושלמו',
|
||||
seeding: 'משתף',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const ru = {
|
||||
documents: 'Документы',
|
||||
pictures: 'Изображения',
|
||||
applications: 'Программы',
|
||||
torrents: 'Торренты',
|
||||
other: 'Другое',
|
||||
},
|
||||
folders: 'Папки',
|
||||
@@ -60,7 +59,6 @@ const ru = {
|
||||
title: 'Удалить загрузку',
|
||||
confirmationSingle: 'Вы действительно хотите удалить этот элемент из списка? Вы также можете удалить файл с диска.',
|
||||
confirmationMultiple: 'Вы действительно хотите удалить выбранные элементы ({{count}}) из списка? Вы также можете удалить файлы с диска.',
|
||||
mixedRemovalPolicy: 'Если выбрать «Удалить файл», незавершённые файлы будут удалены навсегда, а завершённые по-прежнему отправятся в корзину.',
|
||||
errorSummary: 'Удалено {{succeeded}}, с ошибкой {{failed}}: {{detail}}',
|
||||
remove: 'Удалить',
|
||||
deleteFile: 'Удалить файл',
|
||||
@@ -79,7 +77,6 @@ const ru = {
|
||||
pause: 'Приостановить',
|
||||
start: 'Запустить',
|
||||
resume: 'Возобновить',
|
||||
retry: 'Повторить',
|
||||
options: 'Параметры',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const ru = {
|
||||
staged: 'В очереди',
|
||||
queued: 'В очереди',
|
||||
downloading: 'Загрузка',
|
||||
waitingForPeers: 'Ожидание пиров',
|
||||
processing: 'Обработка',
|
||||
verifying: 'Проверка',
|
||||
seeding: 'Раздача',
|
||||
waitingToSeed: 'Ожидание раздачи',
|
||||
paused: 'Приостановлено',
|
||||
completed: 'Завершено',
|
||||
failed: 'Ошибка',
|
||||
retrying: 'Повторная попытка',
|
||||
moving: 'Перемещение данных',
|
||||
allocatingFiles: 'Выделение места под файлы…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'Повторная попытка через системный DNS',
|
||||
nameResolutionFailed: 'Не удалось разрешить имя сервера. Проверьте VPN или DNS сети.',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обработка…',
|
||||
@@ -199,7 +186,6 @@ const ru = {
|
||||
linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
|
||||
validationDay: 'Выберите хотя бы один день для планировщика',
|
||||
validationQueue: 'Выберите хотя бы одну очередь для планировщика',
|
||||
validationTime: 'Введите корректное время в формате HH:MM',
|
||||
validationStopTime: 'Время окончания должно быть позже времени начала',
|
||||
saved: 'Настройки планировщика сохранены',
|
||||
trackingOne: 'Отслеживается 1 запланированная загрузка',
|
||||
@@ -225,48 +211,15 @@ const ru = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: 'Отменить изменения',
|
||||
keepEditing: 'Продолжить редактирование',
|
||||
progress: 'Прогресс',
|
||||
size: 'Размер',
|
||||
speed: 'Скорость',
|
||||
eta: 'Осталось',
|
||||
connections: 'Соединения',
|
||||
fragmentConcurrency: 'Параллельность фрагментов',
|
||||
fragmentConcurrencyHint: 'Максимальное число медиафрагментов, которые yt-dlp может обрабатывать одновременно. Firelink не сообщает текущее число активных фрагментов; это настроенное значение используется при запуске или возобновлении передачи.',
|
||||
connectedPeers: 'подключённых пиров',
|
||||
details: 'Подробности',
|
||||
tabs: {
|
||||
label: 'Разделы свойств',
|
||||
overview: 'Обзор',
|
||||
files: 'Файлы',
|
||||
trackers: 'Трекеры',
|
||||
peers: 'Пиры',
|
||||
transfer: 'Передача',
|
||||
options: 'Параметры',
|
||||
advanced: 'Дополнительно',
|
||||
},
|
||||
queueId: 'Очередь',
|
||||
queuePosition: 'Позиция {{position}}',
|
||||
resumable: 'Возобновляемая',
|
||||
connectionCount: '{{active}}/{{total}} активных',
|
||||
connectionCountUnknown: '—/{{total}} активных',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Ограничение скорости',
|
||||
inputFormat: 'Формат: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M или 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 — без ограничений',
|
||||
inputFormatSeedTime: 'минуты, например 60',
|
||||
inputFormatSeedRatio: 'десятичное число, например 1.5; 0 — только по времени',
|
||||
inputFormatStopTimeout: 'целые секунды; 0 отключает',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'например, 512K',
|
||||
inputExampleMaxPeers: 'например, 55',
|
||||
inputExampleSeedTime: 'например, 60',
|
||||
inputExampleSeedRatio: 'например, 1.5',
|
||||
inputExampleStopTimeout: 'например, 300',
|
||||
inputExamplePiecePriority: 'например, head=1M,tail=1M',
|
||||
speedLimitHint: 'Оставьте пустым для глобального значения по умолчанию или задайте ограничение для этой загрузки.',
|
||||
liveSpeedLimit: 'Текущее ограничение скорости',
|
||||
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
|
||||
liveSpeedLimitPlaceholder: 'например, 1024K',
|
||||
@@ -274,166 +227,6 @@ const ru = {
|
||||
liveSpeedLimitClear: 'Очистить',
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
||||
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
|
||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
|
||||
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
|
||||
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
|
||||
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
|
||||
torrentExcludeTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке или * для исключения всех announce-адресов. Данные для входа не допускаются; настройки DHT и PEX не изменяются.',
|
||||
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
|
||||
torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру',
|
||||
torrentTrackerTimeout: 'Тайм-аут запроса к трекеру',
|
||||
torrentTrackerInterval: 'Интервал запросов к трекеру',
|
||||
torrentTrackerTimingHint: 'Тайм-аут подключения действует при установлении соединения с трекером, а тайм-аут запроса — после этого для ответа. Пустые значения сохраняют стандартные 60 секунд Aria2; интервал 0 следует ответу трекера и прогрессу загрузки.',
|
||||
torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд',
|
||||
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
|
||||
torrentVerifyNow: 'Проверить сейчас',
|
||||
torrentVerifyNowLoading: 'Проверка…',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||
torrentPeerDiagnostics: 'Сведения о пирах торрента',
|
||||
torrentPeerDiagnosticsRefresh: 'Обновить',
|
||||
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
|
||||
torrentPeerDiagnosticsStale: 'Показан последний проверенный результат; обновите, чтобы проверить снова.',
|
||||
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.',
|
||||
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Проверенные адреса и порты пиров показываются только временно; идентификаторы пиров и исходные битовые поля никогда не сохраняются. Число подключений выше — текущее состояние Torrent; эта таблица получена отдельным ответом со списком пиров и может содержать другое число.',
|
||||
torrentPeerAddress: 'Адрес пира',
|
||||
torrentPeerId: 'ID пира',
|
||||
torrentFileProgress: 'Прогресс файлов торрента',
|
||||
torrentFileSelection: 'Выбор файлов торрента',
|
||||
torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.',
|
||||
torrentFileSelectionRequired: 'Выберите хотя бы один файл торрента.',
|
||||
torrentFileSelectionAll: 'Выбрать все',
|
||||
torrentFileSelectionClear: 'Очистить',
|
||||
torrentFileProgressRefresh: 'Обновить',
|
||||
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
|
||||
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
|
||||
torrentFileProgressFailed: 'Не удалось получить прогресс файлов торрента.',
|
||||
torrentFileProgressHint: 'Показываются проверенные относительные пути и загруженные байты; пути демона и URI не раскрываются.',
|
||||
torrentFileProgressPath: 'Файл',
|
||||
torrentFileProgressCompleted: 'Завершено',
|
||||
torrentFileProgressSelected: 'Выбран',
|
||||
torrentFileProgressUnselected: 'Не выбран',
|
||||
torrentPieceProgress: 'Прогресс частей торрента',
|
||||
torrentPieceProgressRefresh: 'Обновить',
|
||||
torrentPieceProgressLoading: 'Загрузка прогресса частей…',
|
||||
torrentPieceProgressUnavailable: 'Прогресс частей доступен, пока торрент активен или приостановлен.',
|
||||
torrentPieceProgressFailed: 'Не удалось получить прогресс частей торрента.',
|
||||
torrentPieceProgressHint: 'Каждая ячейка объединяет соседние части; исходная битовая карта не раскрывается.',
|
||||
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
|
||||
torrentPieceProgressMap: 'Карта завершения частей торрента',
|
||||
torrentWebSeeds: 'Веб-сиды торрента',
|
||||
torrentWebSeedsHint: 'Добавьте по одному базовому HTTP(S)-адресу для каждого файла торрента. Firelink сам расширит пути многофайлового торрента.',
|
||||
torrentWebSeedsApply: 'Применить веб-сиды',
|
||||
torrentWebSeedsLoading: 'Применение…',
|
||||
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
|
||||
torrentWebSeedsEmpty: 'Веб-сиды не настроены.',
|
||||
torrentWebSeedsFile: 'Файл',
|
||||
torrentWebSeedsUri: 'Базовый HTTP(S)-адрес',
|
||||
torrentWebSeedsAdd: 'Добавить веб-сид',
|
||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||
torrentPeerCount: '{{listed}} пиров в списке — {{seeders}} сидов в списке',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
torrentPeerSeeder: 'Сидер',
|
||||
torrentPeerAmChoking: 'Firelink ограничивает',
|
||||
torrentPeerChoking: 'Пир ограничивает',
|
||||
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров в списке.',
|
||||
torrentStatistics: 'Статистика торрента',
|
||||
torrentUploaded: 'Отдано',
|
||||
torrentRatio: 'Коэффициент',
|
||||
torrentSeededDuration: 'Время раздачи',
|
||||
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
|
||||
torrentConnectedPeers: 'Пиры',
|
||||
torrentPeersSeeders: 'Пиры / Сиды',
|
||||
torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов',
|
||||
torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.',
|
||||
torrentPeerCountDifference: 'Подключено: {{connectedPeers}} пиров / {{connectedSeeders}} сидов. В ответе со сведениями о пирах указано: {{listedPeers}} пиров / {{listedSeeders}} сидов.',
|
||||
torrentSeeders: 'Сиды',
|
||||
torrentUploadSpeed: 'Скорость отдачи',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при запуске или повторной попытке.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||
torrentFileAllocation: 'Выделение места для файлов Torrent',
|
||||
torrentFileAllocationPrealloc: 'Предварительное выделение',
|
||||
torrentFileAllocationNone: 'Выделять по мере необходимости',
|
||||
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
|
||||
torrentOptionsBehavior: 'Поведение Torrent',
|
||||
torrentOptionsBehaviorHint: 'Управляет проверкой, выделением места, шифрованием и очисткой этого Torrent.',
|
||||
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
|
||||
torrentDetails: 'Сведения о Torrent',
|
||||
torrentCopyMagnet: 'Копировать magnet-ссылку',
|
||||
torrentExportMetadata: 'Экспортировать .torrent',
|
||||
torrentMagnetCopied: 'Magnet-ссылка только с идентификатором скопирована.',
|
||||
torrentMagnetCopyFailed: 'Не удалось скопировать magnet-ссылку.',
|
||||
torrentMetadataExported: 'Метаданные Torrent экспортированы.',
|
||||
torrentMetadataExportFailed: 'Не удалось экспортировать метаданные Torrent.',
|
||||
torrentMove: 'Переместить данные…',
|
||||
torrentMoveLoading: 'Перемещение…',
|
||||
torrentMoveCancel: 'Отменить перемещение',
|
||||
torrentMoveCancelRequested: 'Запрос на отмену отправлен…',
|
||||
torrentMoveConfirm: 'Переместить управляемые данные Torrent в эту папку? Существующие файлы не перезаписываются.',
|
||||
torrentMoveCompleted: 'Данные Torrent перемещены.',
|
||||
torrentMoveFailed: 'Не удалось переместить данные Torrent.',
|
||||
torrentAvailability: 'Доступность раздачи',
|
||||
torrentAvailabilityRefresh: 'Обновить',
|
||||
torrentAvailabilityLoading: 'Загрузка доступности…',
|
||||
torrentAvailabilityUnavailable: 'Доступность доступна для активного или приостановленного Torrent.',
|
||||
torrentAvailabilityFailed: 'Не удалось прочитать доступность Torrent.',
|
||||
torrentAvailabilityHint: 'Показываются только агрегированные копии; идентификаторы пиров и исходные битовые поля не раскрываются.',
|
||||
torrentAvailabilitySummary: '{{availability}} доступных копий · {{peers}} подключённых пиров · {{pieces}} частей',
|
||||
torrentAvailabilityMap: 'Карта доступности раздачи Torrent',
|
||||
torrentAvailabilityBucket: 'Не менее {{copies}} копий в этом диапазоне',
|
||||
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
|
||||
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
|
||||
torrentDetailsDisplayName: 'Отображаемое имя',
|
||||
torrentDetailsInfoHash: 'Инфохеш',
|
||||
torrentDetailsSize: 'Общий размер',
|
||||
torrentDetailsFiles: 'Файлы',
|
||||
torrentDetailsPieces: 'Части',
|
||||
torrentDetailsPrivate: 'Приватный',
|
||||
torrentDetailsPrivateYes: 'Да',
|
||||
torrentDetailsPrivateNo: 'Нет',
|
||||
torrentDetailsCreated: 'Создан',
|
||||
torrentDetailsCreator: 'Создатель',
|
||||
torrentDetailsComment: 'Комментарий',
|
||||
torrentDetailsTrackers: 'Трекеры',
|
||||
torrentDetailsWebSeeds: 'Встроенные веб-сиды',
|
||||
torrentDetailsPrivateHint: 'Этот приватный Torrent отключает обнаружение через DHT, DHT6, PEX и LPD независимо от общих настроек.',
|
||||
torrentEncryptionDisabled: 'Отключено',
|
||||
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
|
||||
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
|
||||
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
|
||||
torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
|
||||
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
|
||||
category: 'Категория',
|
||||
lastTry: 'Последняя попытка',
|
||||
dateAdded: 'Дата добавления',
|
||||
@@ -443,15 +236,10 @@ const ru = {
|
||||
defaultValue: ' (по умолчанию)',
|
||||
savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.',
|
||||
defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.',
|
||||
blankUsesDefault: 'Пусто · значение по умолчанию',
|
||||
usingDefault: 'По умолчанию',
|
||||
customPerDownload: 'Для этой загрузки',
|
||||
identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.',
|
||||
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
|
||||
download: 'Загрузка',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Показать полный адрес',
|
||||
urlShowLess: 'Свернуть',
|
||||
fileName: 'Имя файла',
|
||||
saveLocation: 'Место сохранения',
|
||||
select: 'Выбрать',
|
||||
@@ -472,15 +260,11 @@ const ru = {
|
||||
algorithm: 'Алгоритм',
|
||||
digest: 'Хеш',
|
||||
expectedDigest: 'Ожидаемый хеш',
|
||||
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||
cookies: 'Файлы cookie',
|
||||
headers: 'Заголовки',
|
||||
mirrors: 'Зеркала',
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
clear: 'Очистить',
|
||||
enterValidUrl: 'Введите корректный URL.',
|
||||
fileNameEmpty: 'Имя файла не может быть пустым.',
|
||||
cancel: 'Отмена',
|
||||
@@ -523,7 +307,6 @@ const ru = {
|
||||
settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.',
|
||||
systemActionCountdown: '{{action}} через 10 секунд.',
|
||||
systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.',
|
||||
systemActionProceedAnyway: 'Всё равно продолжить',
|
||||
systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}',
|
||||
downloadCompleteTitle: 'Загрузка завершена',
|
||||
downloadCompleteBody: 'Загрузка {{fileName}} завершена.',
|
||||
@@ -618,14 +401,12 @@ const ru = {
|
||||
moveOneFailed: 'Не удалось переместить загрузку в очередь',
|
||||
copyAddressesFailed: 'Не удалось скопировать адреса',
|
||||
copyAddressFailed: 'Не удалось скопировать адрес',
|
||||
copyMagnetFailed: 'Не удалось скопировать magnet-ссылку',
|
||||
copyPathFailed: 'Не удалось скопировать путь к файлу',
|
||||
missingFileName: 'Отсутствует имя файла',
|
||||
redownloadFailed: 'Не удалось скачать повторно',
|
||||
startResume: 'Запустить/Возобновить',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
copyAddress: 'Скопировать адрес',
|
||||
copyMagnet: 'Копировать magnet-ссылку',
|
||||
remove: 'Удалить',
|
||||
open: 'Открыть',
|
||||
showInFolder: 'Показать в папке',
|
||||
@@ -665,75 +446,14 @@ const ru = {
|
||||
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
|
||||
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
|
||||
downloadLinks: 'Ссылки для скачивания',
|
||||
pastePlaceholder: 'Вставьте URL HTTP(S), FTP/SFTP, magnet или медиа…',
|
||||
pasteHint: 'Поддерживаются ссылки YouTube, X, TikTok, Instagram и Reddit.',
|
||||
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP или SFTP…\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
|
||||
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||
selectedSummaryReady: 'Готово',
|
||||
selectedSummaryFallback: 'Резерв',
|
||||
selectedSummaryMediaRetry: 'Повтор медиа',
|
||||
selectedSummaryBlocked: 'Заблокировано',
|
||||
torrentAdvancedOptions: 'Расширенные параметры Torrent',
|
||||
torrentAdvancedOptionsCustom: 'Пользовательские настройки',
|
||||
clearSelection: 'Очистить выбор',
|
||||
selectAll: 'Выбрать все',
|
||||
refreshMetadata: 'Обновить метаданные',
|
||||
files: 'Файлы',
|
||||
torrentFiles: 'Торрент-файлы',
|
||||
torrent: 'Торрент',
|
||||
chooseTorrentFiles: 'Добавить файлы .torrent',
|
||||
torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.',
|
||||
torrentSeeding: 'Раздача торрента',
|
||||
seedAfterDownload: 'Раздавать после завершения загрузки',
|
||||
seedTime: 'Время раздачи',
|
||||
minutes: 'минут',
|
||||
seconds: 'секунд',
|
||||
seedRatio: 'Коэффициент раздачи',
|
||||
seedRatioHint: '0 означает раздачу только по времени; иначе раздача остановится при достижении первого ограничения.',
|
||||
limitTorrentUpload: 'Ограничить отдачу торрента',
|
||||
torrentUploadLimit: 'Лимит отдачи торрента',
|
||||
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
|
||||
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
|
||||
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
|
||||
torrentExcludeTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке. * исключает все announce-адреса; настройки DHT и PEX не изменяются.',
|
||||
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
|
||||
torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру',
|
||||
torrentTrackerTimeout: 'Тайм-аут запроса к трекеру',
|
||||
torrentTrackerInterval: 'Интервал запросов к трекеру',
|
||||
torrentTrackerTimingHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Тайм-аут подключения действует при установлении соединения, а тайм-аут запроса — для ответа после этого; пустые значения сохраняют стандартные 60 секунд Aria2, а интервал 0 следует ответу трекера и прогрессу загрузки.',
|
||||
torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд',
|
||||
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
|
||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||
torrentPrioritizePieceHint: 'Выберите первые, последние части или оба варианта для предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при следующем запуске или повторной попытке.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
|
||||
torrentEncryptionDisabled: 'Отключено',
|
||||
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
|
||||
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
|
||||
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
|
||||
torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
|
||||
required: 'Требуется',
|
||||
free: 'Свободно',
|
||||
preview: 'Предпросмотр',
|
||||
@@ -788,9 +508,6 @@ const ru = {
|
||||
verifyChecksum: 'Проверять контрольную сумму',
|
||||
checksumAlgorithm: 'Алгоритм контрольной суммы',
|
||||
expectedDigest: 'Ожидаемый хеш',
|
||||
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||
headers: 'Заголовки',
|
||||
requestHeaders: 'Заголовки запроса',
|
||||
cookies: 'Файлы cookie',
|
||||
@@ -864,12 +581,6 @@ const ru = {
|
||||
parallelDownloadsDescription: 'Максимальное число одновременно активных файлов',
|
||||
automaticRetries: 'Автоматические повторы:',
|
||||
automaticRetriesDescription: 'При сбое соединения',
|
||||
minimumNormalDownloadSpeed: 'Минимальная скорость обычной загрузки (КиБ/с):',
|
||||
minimumNormalDownloadSpeedDescription: 'Повторять HTTP-, FTP- и SFTP-загрузки, если скорость остаётся ниже указанной. Значение 0 отключает эту функцию.',
|
||||
retryNotFoundErrors: 'Повторять временные ошибки «не найдено»',
|
||||
retryNotFoundErrorsDescription: 'Считать ответы HTTP/FTP «ресурс не найден» повторяемыми в пределах лимита автоматических попыток. По умолчанию выключено.',
|
||||
adaptiveMirrorSelection: 'Адаптивный выбор зеркала',
|
||||
adaptiveMirrorSelectionDescription: 'Выбирать из нескольких зеркал по недавней скорости. Статистика серверов хранится конфиденциально на этом устройстве.',
|
||||
systemNotification: 'Показывать системное уведомление по завершении загрузки',
|
||||
systemNotificationDescription: 'Использует настройки уведомлений вашей операционной системы',
|
||||
completionChime: 'Воспроизводить звуковой сигнал завершения в приложении',
|
||||
@@ -970,65 +681,6 @@ const ru = {
|
||||
detectedSystemProxy: 'Обнаружен системный прокси. Для обычных загрузок файлов требуется HTTP- или HTTPS-прокси; для загрузки медиа можно использовать SOCKS.',
|
||||
noSystemProxy: 'Подходящий системный прокси не обнаружен. Загрузки будут выполняться без прокси.',
|
||||
systemProxyReadFailed: 'Не удалось прочитать конфигурацию системного прокси. Выберите «Без прокси» или повторите попытку, когда она станет доступна.',
|
||||
torrentTabs: {
|
||||
discovery: 'Обнаружение',
|
||||
connection: 'Подключение',
|
||||
limits: 'Лимиты',
|
||||
advanced: 'Дополнительно',
|
||||
},
|
||||
torrentPeerDiscovery: 'Обнаружение пиров BitTorrent',
|
||||
torrentDht: 'IPv4 DHT и UDP-трекеры',
|
||||
torrentDhtDescription: 'Ищет пиры не только через трекеры. Отключение также отключает поддержку UDP-трекеров.',
|
||||
torrentDht6: 'DHT по IPv6',
|
||||
torrentDht6Description: 'Использует IPv6 для распределённого поиска пиров, если доступен рабочий IPv6-маршрут.',
|
||||
torrentIpv6Enabled: 'Использовать IPv6 для торрентов',
|
||||
torrentIpv6EnabledDescription: 'Оставляет IPv6 доступным для BitTorrent, DHT и поиска пиров. Отключение также выключает IPv6 DHT.',
|
||||
torrentPex: 'Обмен пирами (PEX)',
|
||||
torrentPexDescription: 'Позволяет подключённым пирам передавать адреса дополнительных пиров.',
|
||||
torrentLpd: 'Локальное обнаружение пиров (LPD)',
|
||||
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
|
||||
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
|
||||
torrentNetwork: 'Сетевые параметры BitTorrent',
|
||||
torrentAdvanced: 'Расширенные параметры Torrent',
|
||||
torrentDhtMessageTimeout: 'Тайм-аут сообщений DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'Время ожидания сообщений DHT и UDP в секундах. Не влияет на загрузку .torrent по HTTP или HTTP-запросы к трекерам. Применяется после перезапуска Firelink.',
|
||||
torrentSeparateSeedSlots: 'Отдельная ёмкость раздачи',
|
||||
torrentSeparateSeedSlotsDescription: 'Вынести раздачу за пределы лимита загрузок и ограничить её пулом Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'Максимум одновременных раздач',
|
||||
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
|
||||
torrentListenPort: 'TCP-порты пиров',
|
||||
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
|
||||
torrentBindAddress: 'Адрес привязки торрентов',
|
||||
torrentBindAddressDescription: 'Необязательный локальный IPv4- или IPv6-адрес для сокетов Aria2. Недопустимые адреса отклоняются; применяется после перезапуска.',
|
||||
torrentDhtListenPort: 'Порты UDP/DHT',
|
||||
torrentDhtListenPortDescription: 'UDP-порты для DHT и UDP-трекеров. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
|
||||
torrentExternalIp: 'Внешний IP-адрес',
|
||||
torrentExternalIpDescription: 'Адрес, объявляемый пирам и трекерам, если хост находится за NAT. Оставьте пустым, если не уверены в доступном адресе.',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'Точка входа IPv4 DHT',
|
||||
torrentDhtEntryPointDescription: 'Необязательные хост и порт для начальной загрузки, например router.example:6881.',
|
||||
torrentDhtEntryPoint6: 'Точка входа IPv6 DHT',
|
||||
torrentDhtEntryPoint6Description: 'Необязательные адрес и порт IPv6 в скобках, например [2001:db8::1]:6881.',
|
||||
torrentDhtListenAddr6: 'Адрес прослушивания IPv6 DHT',
|
||||
torrentDhtListenAddr6Description: 'Адрес IPv6 для сокета DHT. Оставьте пустым, чтобы Aria2 выбрала его автоматически.',
|
||||
torrentLpdInterface: 'Интерфейс LPD',
|
||||
torrentLpdInterfaceDescription: 'Имя сетевого интерфейса или адрес для поиска локальных пиров. Оставьте пустым для интерфейса по умолчанию.',
|
||||
torrentPeerIdPrefix: 'Префикс ID пира',
|
||||
torrentPeerIdPrefixDescription: 'Переопределяет префикс ID пира BitTorrent. Используйте только понимая последствия для приватности и идентичности протокола; оставьте пустым для значения Aria2 по умолчанию.',
|
||||
torrentPeerAgent: 'Агент пира',
|
||||
torrentPeerAgentDescription: 'Переопределяет строку клиента в расширенном рукопожатии BitTorrent. Это меняет идентичность протокола и может повлиять на совместимость; оставьте пустым для значения Aria2 по умолчанию.',
|
||||
torrentNetworkRestartNote: 'Эти параметры применяются при запуске и вступают в силу после перезапуска Firelink. Для открытия портов могут потребоваться перенаправление портов на маршрутизаторе и правило системного брандмауэра; доступность зависит от платформы и сети.',
|
||||
torrentResourceLimits: 'Ограничения ресурсов BitTorrent',
|
||||
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
|
||||
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
|
||||
aria2DiskCache: 'Дисковый кэш Aria2',
|
||||
aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.',
|
||||
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
|
||||
torrentNetworkInputInvalid: 'Не удалось применить сетевую настройку Torrent: {{detail}}',
|
||||
torrentOverallUploadLimit: 'Общий лимит отдачи Aria2',
|
||||
torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.',
|
||||
torrentOverallUploadLimitInvalid: 'Введите корректный лимит отдачи, например 512K или 2M.',
|
||||
torrentOverallUploadLimitUpdateFailed: 'Не удалось применить общий лимит отдачи Aria2: {{detail}}',
|
||||
identity: 'Идентификация',
|
||||
customUserAgent: 'Собственный User-Agent',
|
||||
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
|
||||
@@ -1162,7 +814,6 @@ const ru = {
|
||||
active: '{{count}} активных',
|
||||
queued: '{{count}} в очереди',
|
||||
done: '{{count}} завершено',
|
||||
seeding: 'Раздача',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const uk = {
|
||||
documents: 'Документи',
|
||||
pictures: 'Зображення',
|
||||
applications: 'Програми',
|
||||
torrents: 'Торенти',
|
||||
other: 'Інше',
|
||||
},
|
||||
folders: 'Папки',
|
||||
@@ -60,7 +59,6 @@ const uk = {
|
||||
title: 'Видалити завантаження',
|
||||
confirmationSingle: 'Ви впевнені, що хочете видалити цей елемент зі списку? Ви також можете видалити сам файл з вашого диска.',
|
||||
confirmationMultiple: 'Ви впевнені, що хочете видалити вибрані елементи ({{count}}) зі списку? Ви також можете видалити самі файли з диска.',
|
||||
mixedRemovalPolicy: 'Якщо вибрати «Видалити файл», незавершені файли буде видалено назавжди, а завершені й надалі переміщуватимуться до кошика.',
|
||||
errorSummary: '{{succeeded}} видалено, {{failed}} не вдалося: {{detail}}',
|
||||
remove: 'Видалити',
|
||||
deleteFile: 'Видалити файл',
|
||||
@@ -79,7 +77,6 @@ const uk = {
|
||||
pause: 'Призупинити',
|
||||
start: 'Запустити',
|
||||
resume: 'Відновити',
|
||||
retry: 'Повторити',
|
||||
options: 'Опції',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const uk = {
|
||||
staged: 'У черзі',
|
||||
queued: 'У черзі',
|
||||
downloading: 'Завантаження',
|
||||
waitingForPeers: 'Очікування пірів',
|
||||
processing: 'Обробка',
|
||||
verifying: 'Перевірка',
|
||||
seeding: 'Роздача',
|
||||
waitingToSeed: 'Очікування роздачі',
|
||||
paused: 'Призупинено',
|
||||
completed: 'Завершено',
|
||||
failed: 'Помилка',
|
||||
retrying: 'Повторна спроба',
|
||||
moving: 'Переміщення даних',
|
||||
allocatingFiles: 'Виділення місця для файлів…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'Повторна спроба через системний DNS',
|
||||
nameResolutionFailed: 'Не вдалося визначити ім’я сервера. Перевірте VPN або DNS мережі.',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обробка…',
|
||||
@@ -199,7 +186,6 @@ const uk = {
|
||||
linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
|
||||
validationDay: 'Виберіть принаймні один день для планувальника',
|
||||
validationQueue: 'Виберіть принаймні одну чергу для планувальника',
|
||||
validationTime: 'Введіть коректний час у форматі HH:MM',
|
||||
validationStopTime: 'Час зупинки має бути пізнішим за час початку',
|
||||
saved: 'Налаштування планувальника збережено',
|
||||
trackingOne: 'Відстеження 1 запланованого завантаження',
|
||||
@@ -225,48 +211,15 @@ const uk = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: 'Відкинути зміни',
|
||||
keepEditing: 'Продовжити редагування',
|
||||
progress: 'Прогрес',
|
||||
size: 'Розмір',
|
||||
speed: 'Швидкість',
|
||||
eta: 'Залишилось',
|
||||
connections: 'З\'єднання',
|
||||
fragmentConcurrency: 'Паралельність фрагментів',
|
||||
fragmentConcurrencyHint: 'Максимальна кількість медіафрагментів, які yt-dlp може обробляти одночасно. Firelink не повідомляє поточну кількість активних фрагментів; це налаштоване значення використовується під час запуску або відновлення передачі.',
|
||||
connectedPeers: 'підключених пірів',
|
||||
details: 'Деталі',
|
||||
tabs: {
|
||||
label: 'Розділи властивостей',
|
||||
overview: 'Огляд',
|
||||
files: 'Файли',
|
||||
trackers: 'Трекери',
|
||||
peers: 'Піри',
|
||||
transfer: 'Передавання',
|
||||
options: 'Параметри',
|
||||
advanced: 'Додатково',
|
||||
},
|
||||
queueId: 'Черга',
|
||||
queuePosition: 'Позиція {{position}}',
|
||||
resumable: 'Можна продовжити',
|
||||
connectionCount: '{{active}}/{{total}} активних',
|
||||
connectionCountUnknown: '—/{{total}} активних',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Обмеження швидкості',
|
||||
inputFormat: 'Формат: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M або 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 означає без обмежень',
|
||||
inputFormatSeedTime: 'хвилини, наприклад 60',
|
||||
inputFormatSeedRatio: 'десяткове число, наприклад 1.5; 0 — лише за часом',
|
||||
inputFormatStopTimeout: 'цілі секунди; 0 вимикає',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'наприклад, 512K',
|
||||
inputExampleMaxPeers: 'наприклад, 55',
|
||||
inputExampleSeedTime: 'наприклад, 60',
|
||||
inputExampleSeedRatio: 'наприклад, 1.5',
|
||||
inputExampleStopTimeout: 'наприклад, 300',
|
||||
inputExamplePiecePriority: 'наприклад, head=1M,tail=1M',
|
||||
speedLimitHint: 'Залиште порожнім для глобального значення за замовчуванням або задайте обмеження для цього завантаження.',
|
||||
liveSpeedLimit: 'Поточне обмеження швидкості',
|
||||
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
|
||||
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
|
||||
@@ -274,166 +227,6 @@ const uk = {
|
||||
liveSpeedLimitClear: 'Очистити',
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
||||
retryWithoutCredentials: 'Повторити без збережених даних для входу',
|
||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
|
||||
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
|
||||
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentExcludeTrackers: 'Виключені трекери торрента',
|
||||
torrentExcludeTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку або * для виключення всіх announce-адрес. Дані для входу не дозволені; налаштування DHT і PEX не змінюються.',
|
||||
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
|
||||
torrentTrackerConnectTimeout: 'Час очікування підключення до трекера',
|
||||
torrentTrackerTimeout: 'Час очікування запиту до трекера',
|
||||
torrentTrackerInterval: 'Інтервал запитів до трекера',
|
||||
torrentTrackerTimingHint: 'Час очікування підключення діє під час встановлення з’єднання з трекером, а час очікування запиту — для відповіді після цього. Порожні значення зберігають стандартні 60 секунд Aria2; інтервал 0 використовує відповідь трекера та прогрес завантаження.',
|
||||
torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд',
|
||||
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
|
||||
torrentVerifyNow: 'Перевірити зараз',
|
||||
torrentVerifyNowLoading: 'Перевірка…',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||
torrentPeerDiagnostics: 'Відомості про піри торрента',
|
||||
torrentPeerDiagnosticsRefresh: 'Оновити',
|
||||
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
|
||||
torrentPeerDiagnosticsStale: 'Показано останній перевірений результат; оновіть, щоб перевірити ще раз.',
|
||||
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.',
|
||||
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Перевірені адреси й порти пірів показуються лише тимчасово; ідентифікатори пірів і сирі бітові поля ніколи не зберігаються. Кількість підключень вище — це поточний стан торента; ця таблиця отримана окремою відповіддю зі списком пірів і може містити інше число.',
|
||||
torrentPeerAddress: 'Адреса піра',
|
||||
torrentPeerId: 'ID піра',
|
||||
torrentFileProgress: 'Прогрес файлів торрента',
|
||||
torrentFileSelection: 'Вибір файлів торрента',
|
||||
torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.',
|
||||
torrentFileSelectionRequired: 'Виберіть хоча б один файл торрента.',
|
||||
torrentFileSelectionAll: 'Вибрати все',
|
||||
torrentFileSelectionClear: 'Очистити',
|
||||
torrentFileProgressRefresh: 'Оновити',
|
||||
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
|
||||
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
|
||||
torrentFileProgressFailed: 'Не вдалося отримати прогрес файлів торрента.',
|
||||
torrentFileProgressHint: 'Показуються перевірені відносні шляхи та завантажені байти; шляхи демона й URI не розкриваються.',
|
||||
torrentFileProgressPath: 'Файл',
|
||||
torrentFileProgressCompleted: 'Завершено',
|
||||
torrentFileProgressSelected: 'Вибрано',
|
||||
torrentFileProgressUnselected: 'Не вибрано',
|
||||
torrentPieceProgress: 'Прогрес частин торрента',
|
||||
torrentPieceProgressRefresh: 'Оновити',
|
||||
torrentPieceProgressLoading: 'Завантаження прогресу частин…',
|
||||
torrentPieceProgressUnavailable: 'Прогрес частин доступний, коли торрент активний або призупинений.',
|
||||
torrentPieceProgressFailed: 'Не вдалося отримати прогрес частин торрента.',
|
||||
torrentPieceProgressHint: 'Кожна клітинка узагальнює сусідні частини; необроблена бітова карта не розкривається.',
|
||||
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
|
||||
torrentPieceProgressMap: 'Карта завершення частин торрента',
|
||||
torrentWebSeeds: 'Вебсіди торента',
|
||||
torrentWebSeedsHint: 'Додайте одну базову HTTP(S)-адресу для кожного файлу торента. Firelink сам розгорне шляхи багатофайлового торента.',
|
||||
torrentWebSeedsApply: 'Застосувати вебсіди',
|
||||
torrentWebSeedsLoading: 'Застосування…',
|
||||
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
|
||||
torrentWebSeedsEmpty: 'Вебсіди не налаштовано.',
|
||||
torrentWebSeedsFile: 'Файл',
|
||||
torrentWebSeedsUri: 'Базова HTTP(S)-адреса',
|
||||
torrentWebSeedsAdd: 'Додати вебсід',
|
||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||
torrentPeerCount: '{{listed}} пірів у списку — {{seeders}} сідів у списку',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
torrentPeerSeeder: 'Сідер',
|
||||
torrentPeerAmChoking: 'Firelink обмежує',
|
||||
torrentPeerChoking: 'Пір обмежує',
|
||||
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів у списку.',
|
||||
torrentStatistics: 'Статистика торента',
|
||||
torrentUploaded: 'Віддано',
|
||||
torrentRatio: 'Коефіцієнт',
|
||||
torrentSeededDuration: 'Час роздачі',
|
||||
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
|
||||
torrentConnectedPeers: 'Піри',
|
||||
torrentPeersSeeders: 'Піри / Сіди',
|
||||
torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів',
|
||||
torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.',
|
||||
torrentPeerCountDifference: 'Підключено: {{connectedPeers}} пірів / {{connectedSeeders}} сідів. У відповіді з відомостями про піри зазначено: {{listedPeers}} пірів / {{listedSeeders}} сідів.',
|
||||
torrentSeeders: 'Сіди',
|
||||
torrentUploadSpeed: 'Швидкість віддачі',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час запуску або повторної спроби.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||
torrentFileAllocation: 'Виділення місця для файлів Torrent',
|
||||
torrentFileAllocationPrealloc: 'Попереднє виділення',
|
||||
torrentFileAllocationNone: 'Виділяти за потреби',
|
||||
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
|
||||
torrentOptionsBehavior: 'Поведінка Torrent',
|
||||
torrentOptionsBehaviorHint: 'Керує перевіркою, виділенням місця, шифруванням і очищенням цього Torrent.',
|
||||
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
|
||||
torrentDetails: 'Відомості про Torrent',
|
||||
torrentCopyMagnet: 'Копіювати magnet-посилання',
|
||||
torrentExportMetadata: 'Експортувати .torrent',
|
||||
torrentMagnetCopied: 'Magnet-посилання лише з ідентифікатором скопійовано.',
|
||||
torrentMagnetCopyFailed: 'Не вдалося скопіювати magnet-посилання.',
|
||||
torrentMetadataExported: 'Метадані Torrent експортовано.',
|
||||
torrentMetadataExportFailed: 'Не вдалося експортувати метадані Torrent.',
|
||||
torrentMove: 'Перемістити дані…',
|
||||
torrentMoveLoading: 'Переміщення…',
|
||||
torrentMoveCancel: 'Скасувати переміщення',
|
||||
torrentMoveCancelRequested: 'Запит на скасування надіслано…',
|
||||
torrentMoveConfirm: 'Перемістити керовані дані Torrent до цієї папки? Наявні файли не перезаписуються.',
|
||||
torrentMoveCompleted: 'Дані Torrent переміщено.',
|
||||
torrentMoveFailed: 'Не вдалося перемістити дані Torrent.',
|
||||
torrentAvailability: 'Доступність рою',
|
||||
torrentAvailabilityRefresh: 'Оновити',
|
||||
torrentAvailabilityLoading: 'Завантаження доступності…',
|
||||
torrentAvailabilityUnavailable: 'Доступність доступна для активного або призупиненого Torrent.',
|
||||
torrentAvailabilityFailed: 'Не вдалося прочитати доступність Torrent.',
|
||||
torrentAvailabilityHint: 'Показано лише агреговані копії; ідентифікатори пірів і сирі бітові поля не розкриваються.',
|
||||
torrentAvailabilitySummary: '{{availability}} доступних копій · {{peers}} підключених пірів · {{pieces}} частин',
|
||||
torrentAvailabilityMap: 'Мапа доступності рою Torrent',
|
||||
torrentAvailabilityBucket: 'Щонайменше {{copies}} копій у цьому діапазоні',
|
||||
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
|
||||
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
|
||||
torrentDetailsDisplayName: 'Назва',
|
||||
torrentDetailsInfoHash: 'Інфохеш',
|
||||
torrentDetailsSize: 'Загальний розмір',
|
||||
torrentDetailsFiles: 'Файли',
|
||||
torrentDetailsPieces: 'Частини',
|
||||
torrentDetailsPrivate: 'Приватний',
|
||||
torrentDetailsPrivateYes: 'Так',
|
||||
torrentDetailsPrivateNo: 'Ні',
|
||||
torrentDetailsCreated: 'Створено',
|
||||
torrentDetailsCreator: 'Автор',
|
||||
torrentDetailsComment: 'Коментар',
|
||||
torrentDetailsTrackers: 'Трекери',
|
||||
torrentDetailsWebSeeds: 'Вбудовані веб-сиди',
|
||||
torrentDetailsPrivateHint: 'Цей приватний Torrent вимикає виявлення через DHT, DHT6, PEX і LPD незалежно від загальних налаштувань.',
|
||||
torrentEncryptionDisabled: 'Вимкнено',
|
||||
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
|
||||
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
|
||||
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
|
||||
torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
|
||||
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
|
||||
category: 'Категорія',
|
||||
lastTry: 'Остання спроба',
|
||||
dateAdded: 'Дата додавання',
|
||||
@@ -443,15 +236,10 @@ const uk = {
|
||||
defaultValue: ' (за замовчуванням)',
|
||||
savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.',
|
||||
defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.',
|
||||
blankUsesDefault: 'Порожньо · значення за замовчуванням',
|
||||
usingDefault: 'За замовчуванням',
|
||||
customPerDownload: 'Для цього завантаження',
|
||||
identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.',
|
||||
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
|
||||
download: 'Завантаження',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Показати повну адресу',
|
||||
urlShowLess: 'Згорнути',
|
||||
fileName: 'Ім\'я файлу',
|
||||
saveLocation: 'Місце збереження',
|
||||
select: 'Вибрати',
|
||||
@@ -472,15 +260,11 @@ const uk = {
|
||||
algorithm: 'Алгоритм',
|
||||
digest: 'Хеш',
|
||||
expectedDigest: 'Очікуваний хеш',
|
||||
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||
cookies: 'Файли cookie',
|
||||
headers: 'Заголовки',
|
||||
mirrors: 'Дзеркала',
|
||||
username: 'Ім\'я користувача',
|
||||
password: 'Пароль',
|
||||
clear: 'Очистити',
|
||||
enterValidUrl: 'Введіть дійсну URL-адресу.',
|
||||
fileNameEmpty: 'Ім\'я файлу не може бути порожнім.',
|
||||
cancel: 'Скасувати',
|
||||
@@ -523,7 +307,6 @@ const uk = {
|
||||
settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.',
|
||||
systemActionCountdown: '{{action}} через 10 секунд.',
|
||||
systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.',
|
||||
systemActionProceedAnyway: 'Продовжити попри це',
|
||||
systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}',
|
||||
downloadCompleteTitle: 'Завантаження завершено',
|
||||
downloadCompleteBody: 'Завантаження {{fileName}} завершено.',
|
||||
@@ -618,14 +401,12 @@ const uk = {
|
||||
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
|
||||
copyAddressesFailed: 'Не вдалося скопіювати адреси',
|
||||
copyAddressFailed: 'Не вдалося скопіювати адресу',
|
||||
copyMagnetFailed: 'Не вдалося скопіювати magnet-посилання',
|
||||
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
|
||||
missingFileName: 'Ім\'я файлу відсутнє',
|
||||
redownloadFailed: 'Не вдалося повторно завантажити',
|
||||
startResume: 'Запустити/Відновити',
|
||||
addToQueue: 'Додати до черги',
|
||||
copyAddress: 'Скопіювати адресу',
|
||||
copyMagnet: 'Копіювати magnet-посилання',
|
||||
remove: 'Видалити',
|
||||
open: 'Відкрити',
|
||||
showInFolder: 'Показати в папці',
|
||||
@@ -665,75 +446,14 @@ const uk = {
|
||||
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
|
||||
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
|
||||
downloadLinks: 'Посилання для завантаження',
|
||||
pastePlaceholder: 'Вставте URL HTTP(S), FTP/SFTP, magnet або медіа…',
|
||||
pasteHint: 'Підтримуються посилання YouTube, X, TikTok, Instagram і Reddit.',
|
||||
pastePlaceholder: 'Вставте URL-адреси HTTP, HTTPS, FTP або SFTP сюди…\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
|
||||
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||
selectedSummaryReady: 'Готові',
|
||||
selectedSummaryFallback: 'Резервні',
|
||||
selectedSummaryMediaRetry: 'Повтор медіа',
|
||||
selectedSummaryBlocked: 'Заблоковано',
|
||||
torrentAdvancedOptions: 'Розширені параметри Torrent',
|
||||
torrentAdvancedOptionsCustom: 'Власні налаштування',
|
||||
clearSelection: 'Очистити вибір',
|
||||
selectAll: 'Вибрати всі',
|
||||
refreshMetadata: 'Оновити метадані',
|
||||
files: 'Файли',
|
||||
torrentFiles: 'Торрент-файли',
|
||||
torrent: 'Торрент',
|
||||
chooseTorrentFiles: 'Додати файли .torrent',
|
||||
torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.',
|
||||
torrentSeeding: 'Роздача торрента',
|
||||
seedAfterDownload: 'Роздавати після завершення завантаження',
|
||||
seedTime: 'Час роздачі',
|
||||
minutes: 'хвилин',
|
||||
seconds: 'секунд',
|
||||
seedRatio: 'Коефіцієнт роздачі',
|
||||
seedRatioHint: '0 означає роздачу лише за часом; інакше роздача зупиниться після досягнення першого обмеження.',
|
||||
limitTorrentUpload: 'Обмежити віддачу торрента',
|
||||
torrentUploadLimit: 'Ліміт віддачі торрента',
|
||||
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
|
||||
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
|
||||
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentExcludeTrackers: 'Виключені трекери торрента',
|
||||
torrentExcludeTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. * виключає всі announce-адреси; налаштування DHT і PEX не змінюються.',
|
||||
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
|
||||
torrentTrackerConnectTimeout: 'Час очікування підключення до трекера',
|
||||
torrentTrackerTimeout: 'Час очікування запиту до трекера',
|
||||
torrentTrackerInterval: 'Інтервал запитів до трекера',
|
||||
torrentTrackerTimingHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Час очікування підключення діє під час встановлення з’єднання, а час очікування запиту — для відповіді після цього; порожні значення зберігають стандартні 60 секунд Aria2, а інтервал 0 використовує відповідь трекера та прогрес завантаження.',
|
||||
torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд',
|
||||
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
|
||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||
torrentPrioritizePieceHint: 'Виберіть перші, останні частини або обидва варіанти для попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час наступного запуску або повторної спроби.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
|
||||
torrentEncryptionDisabled: 'Вимкнено',
|
||||
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
|
||||
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
|
||||
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
|
||||
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
|
||||
torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.',
|
||||
torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
|
||||
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
|
||||
required: 'Обов\'язково',
|
||||
free: 'Вільно',
|
||||
preview: 'Попередній перегляд',
|
||||
@@ -788,9 +508,6 @@ const uk = {
|
||||
verifyChecksum: 'Перевірити контрольну суму',
|
||||
checksumAlgorithm: 'Алгоритм контрольної суми',
|
||||
expectedDigest: 'Очікуваний хеш',
|
||||
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||
headers: 'Заголовки',
|
||||
requestHeaders: 'Заголовки запиту',
|
||||
cookies: 'Файли cookie',
|
||||
@@ -864,12 +581,6 @@ const uk = {
|
||||
parallelDownloadsDescription: 'Макс. одночасних активних файлів',
|
||||
automaticRetries: 'Автоматичні повторні спроби:',
|
||||
automaticRetriesDescription: 'Якщо з\'єднання перерветься',
|
||||
minimumNormalDownloadSpeed: 'Мінімальна швидкість звичайного завантаження (КіБ/с):',
|
||||
minimumNormalDownloadSpeedDescription: 'Повторювати HTTP-, FTP- і SFTP-завантаження, якщо швидкість залишається нижчою за вказану. Значення 0 вимикає цю функцію.',
|
||||
retryNotFoundErrors: 'Повторювати тимчасові помилки «не знайдено»',
|
||||
retryNotFoundErrorsDescription: 'Вважати відповіді HTTP/FTP «ресурс не знайдено» придатними до повтору в межах ліміту автоматичних спроб. Типово вимкнено.',
|
||||
adaptiveMirrorSelection: 'Адаптивний вибір дзеркала',
|
||||
adaptiveMirrorSelectionDescription: 'Вибирати з кількох дзеркал за нещодавньою швидкістю. Статистика серверів приватно зберігається на цьому пристрої.',
|
||||
systemNotification: 'Показувати системне сповіщення по завершенні завантаження',
|
||||
systemNotificationDescription: 'Використовує налаштування сповіщень вашої операційної системи',
|
||||
completionChime: 'Відтворювати звуковий сигнал по завершенні в програмі',
|
||||
@@ -970,65 +681,6 @@ const uk = {
|
||||
detectedSystemProxy: 'Виявлено системний проксі. Звичайні завантаження файлів вимагають HTTP або HTTPS проксі; медіазавантаження можуть використовувати SOCKS.',
|
||||
noSystemProxy: 'Не виявлено придатного системного проксі. Завантаження використовуватимуть пряме підключення (без проксі).',
|
||||
systemProxyReadFailed: 'Не вдалося прочитати конфігурацію системного проксі. Виберіть "Без проксі" або спробуйте ще раз, коли він стане доступним.',
|
||||
torrentTabs: {
|
||||
discovery: 'Пошук',
|
||||
connection: 'Підключення',
|
||||
limits: 'Обмеження',
|
||||
advanced: 'Розширені',
|
||||
},
|
||||
torrentPeerDiscovery: 'Пошук пірів BitTorrent',
|
||||
torrentDht: 'IPv4 DHT і UDP-трекери',
|
||||
torrentDhtDescription: 'Шукає пірів не лише через трекери. Вимкнення також вимикає підтримку UDP-трекерів.',
|
||||
torrentDht6: 'DHT через IPv6',
|
||||
torrentDht6Description: 'Використовує IPv6 для розподіленого пошуку пірів, якщо доступний робочий маршрут IPv6.',
|
||||
torrentIpv6Enabled: 'Використовувати IPv6 для торентів',
|
||||
torrentIpv6EnabledDescription: 'Залишає IPv6 доступним для BitTorrent, DHT і пошуку пірів. Вимкнення також вимикає IPv6 DHT.',
|
||||
torrentPex: 'Обмін пірами (PEX)',
|
||||
torrentPexDescription: 'Дозволяє підключеним пірам передавати адреси додаткових пірів.',
|
||||
torrentLpd: 'Локальний пошук пірів (LPD)',
|
||||
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
|
||||
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
|
||||
torrentNetwork: 'Мережеві параметри BitTorrent',
|
||||
torrentAdvanced: 'Розширені параметри Torrent',
|
||||
torrentDhtMessageTimeout: 'Час очікування повідомлень DHT',
|
||||
torrentDhtMessageTimeoutDescription: 'Час очікування повідомлень DHT і UDP у секундах. Не впливає на завантаження .torrent через HTTP або HTTP-запити до трекерів. Застосовується після перезапуску Firelink.',
|
||||
torrentSeparateSeedSlots: 'Окрема місткість роздачі',
|
||||
torrentSeparateSeedSlotsDescription: 'Винести роздачу за межі ліміту завантажень і обмежити її пулом Firelink.',
|
||||
torrentMaxConcurrentSeeds: 'Максимум одночасних роздач',
|
||||
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
|
||||
torrentListenPort: 'TCP-порти пірів',
|
||||
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
|
||||
torrentBindAddress: 'Адреса прив’язки торентів',
|
||||
torrentBindAddressDescription: 'Необов’язкова локальна IPv4- або IPv6-адреса для сокетів Aria2. Некоректні адреси відхиляються; застосовується після перезапуску.',
|
||||
torrentDhtListenPort: 'Порти UDP/DHT',
|
||||
torrentDhtListenPortDescription: 'UDP-порти для DHT і UDP-трекерів. Залиште порожнім, щоб використати типовий діапазон Aria2.',
|
||||
torrentExternalIp: 'Зовнішня IP-адреса',
|
||||
torrentExternalIpDescription: 'Адреса, яку оголошують пірам і трекерам, коли хост перебуває за NAT. Залиште порожнім, якщо не знаєте доступну адресу.',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'Точка входу IPv4 DHT',
|
||||
torrentDhtEntryPointDescription: 'Необов’язкові хост і порт для початкового підключення, наприклад router.example:6881.',
|
||||
torrentDhtEntryPoint6: 'Точка входу IPv6 DHT',
|
||||
torrentDhtEntryPoint6Description: 'Необов’язкові адреса й порт IPv6 у дужках, наприклад [2001:db8::1]:6881.',
|
||||
torrentDhtListenAddr6: 'Адреса прослуховування IPv6 DHT',
|
||||
torrentDhtListenAddr6Description: 'IPv6-адреса для сокета DHT. Залиште порожнім, щоб Aria2 вибрала її автоматично.',
|
||||
torrentLpdInterface: 'Інтерфейс LPD',
|
||||
torrentLpdInterfaceDescription: 'Назва мережевого інтерфейсу або адреса для пошуку локальних пірів. Залиште порожнім для типового інтерфейсу.',
|
||||
torrentPeerIdPrefix: 'Префікс ID піра',
|
||||
torrentPeerIdPrefixDescription: 'Перевизначає префікс ID піра BitTorrent. Використовуйте лише з розумінням наслідків для приватності та ідентичності протоколу; залиште порожнім для типового значення Aria2.',
|
||||
torrentPeerAgent: 'Агент піра',
|
||||
torrentPeerAgentDescription: 'Перевизначає рядок клієнта в розширеному рукостисканні BitTorrent. Це змінює ідентичність протоколу й може вплинути на сумісність; залиште порожнім для типового значення Aria2.',
|
||||
torrentNetworkRestartNote: 'Ці параметри застосовуються під час запуску й набувають чинності після перезапуску Firelink. Для відкриття портів можуть знадобитися перенаправлення портів на маршрутизаторі та правило брандмауера ОС; доступність залежить від платформи й мережі.',
|
||||
torrentResourceLimits: 'Обмеження ресурсів BitTorrent',
|
||||
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
|
||||
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
|
||||
aria2DiskCache: 'Дисковий кеш Aria2',
|
||||
aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.',
|
||||
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
|
||||
torrentNetworkInputInvalid: 'Не вдалося застосувати мережеве налаштування Torrent: {{detail}}',
|
||||
torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2',
|
||||
torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.',
|
||||
torrentOverallUploadLimitInvalid: 'Введіть коректне обмеження віддачі, наприклад 512K або 2M.',
|
||||
torrentOverallUploadLimitUpdateFailed: 'Не вдалося застосувати загальне обмеження віддачі Aria2: {{detail}}',
|
||||
identity: 'Ідентифікація',
|
||||
customUserAgent: 'Власний User-Agent',
|
||||
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
|
||||
@@ -1162,7 +814,6 @@ const uk = {
|
||||
active: '{{count}} активних',
|
||||
queued: '{{count}} в черзі',
|
||||
done: '{{count}} завершено',
|
||||
seeding: 'Роздача',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
+1
-350
@@ -14,7 +14,6 @@ const zhCN = {
|
||||
documents: '文档',
|
||||
pictures: '图片',
|
||||
applications: '应用程序',
|
||||
torrents: '种子',
|
||||
other: '其他',
|
||||
},
|
||||
folders: '文件夹',
|
||||
@@ -60,7 +59,6 @@ const zhCN = {
|
||||
title: '移除下载',
|
||||
confirmationSingle: '您确定要从列表中移除此项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
||||
confirmationMultiple: '您确定要从列表中移除这 {{count}} 个项目吗?您也可以选择同时从磁盘中删除底层文件。',
|
||||
mixedRemovalPolicy: '如果选择“删除文件”,未完成的文件将永久删除;已完成的文件仍会移入废纸篓。',
|
||||
errorSummary: '成功移除 {{succeeded}} 个,失败 {{failed}} 个:{{detail}}',
|
||||
remove: '移除',
|
||||
deleteFile: '删除文件',
|
||||
@@ -79,7 +77,6 @@ const zhCN = {
|
||||
pause: '暂停',
|
||||
start: '开始',
|
||||
resume: '恢复',
|
||||
retry: '重试',
|
||||
options: '选项',
|
||||
},
|
||||
size: {
|
||||
@@ -91,21 +88,11 @@ const zhCN = {
|
||||
staged: '在队列中',
|
||||
queued: '已排队',
|
||||
downloading: '下载中',
|
||||
waitingForPeers: '等待节点',
|
||||
processing: '处理中',
|
||||
verifying: '校验中',
|
||||
seeding: '做种中',
|
||||
waitingToSeed: '等待做种',
|
||||
paused: '已暂停',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
retrying: '重试中',
|
||||
moving: '正在移动数据',
|
||||
allocatingFiles: '正在分配文件空间…',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: '正在使用系统 DNS 重试',
|
||||
nameResolutionFailed: '无法解析服务器名称。请检查 VPN 或网络 DNS。',
|
||||
},
|
||||
values: {
|
||||
processing: '处理中…',
|
||||
@@ -199,7 +186,6 @@ const zhCN = {
|
||||
linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
|
||||
validationDay: '至少为计划任务选择一天',
|
||||
validationQueue: '至少为计划任务选择一个队列',
|
||||
validationTime: '请输入 HH:MM 格式的有效时间',
|
||||
validationStopTime: '停止时间必须晚于开始时间',
|
||||
saved: '计划任务设置已保存',
|
||||
trackingOne: '正在跟踪 1 个计划的下载',
|
||||
@@ -225,48 +211,15 @@ const zhCN = {
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
discardChanges: '放弃更改',
|
||||
keepEditing: '继续编辑',
|
||||
progress: '进度',
|
||||
size: '大小',
|
||||
speed: '速度',
|
||||
eta: '剩余时间',
|
||||
connections: '连接数',
|
||||
fragmentConcurrency: '分片并发数',
|
||||
fragmentConcurrencyHint: 'yt-dlp 可同时处理的媒体分片最大数量。Firelink 不会报告实时活动分片数量;此配置值会在传输开始或恢复时使用。',
|
||||
connectedPeers: '已连接对等端',
|
||||
details: '详细信息',
|
||||
tabs: {
|
||||
label: '属性部分',
|
||||
overview: '概览',
|
||||
files: '文件',
|
||||
trackers: 'Tracker',
|
||||
peers: '对等端',
|
||||
transfer: '传输',
|
||||
options: '选项',
|
||||
advanced: '高级',
|
||||
},
|
||||
queueId: '队列',
|
||||
queuePosition: '位置 {{position}}',
|
||||
resumable: '可续传',
|
||||
connectionCount: '{{active}}/{{total}} 个连接',
|
||||
connectionCountUnknown: '—/{{total}} 个连接',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: '速度上限',
|
||||
inputFormat: '格式:{{format}}',
|
||||
inputFormatSpeedLimit: '512K、2M 或 1G',
|
||||
inputFormatMaxPeers: '0–1000;0 表示不限',
|
||||
inputFormatSeedTime: '分钟,例如 60',
|
||||
inputFormatSeedRatio: '小数,例如 1.5;0 表示仅按时间',
|
||||
inputFormatStopTimeout: '整数秒;0 表示禁用',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: '例如 512K',
|
||||
inputExampleMaxPeers: '例如 55',
|
||||
inputExampleSeedTime: '例如 60',
|
||||
inputExampleSeedRatio: '例如 1.5',
|
||||
inputExampleStopTimeout: '例如 300',
|
||||
inputExamplePiecePriority: '例如 head=1M,tail=1M',
|
||||
speedLimitHint: '留空以使用全局默认值;输入数值可为此下载设置上限。',
|
||||
liveSpeedLimit: '实时速度上限',
|
||||
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
|
||||
liveSpeedLimitPlaceholder: '例如 1024K',
|
||||
@@ -274,166 +227,6 @@ const zhCN = {
|
||||
liveSpeedLimitClear: '清除',
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
||||
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
||||
retryWithoutCredentials: '不使用已保存凭据重试',
|
||||
liveTorrentUploadLimit: '实时种子上传限速',
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
|
||||
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
|
||||
liveTorrentPeerOptionsApply: '应用节点控制',
|
||||
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
|
||||
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentExcludeTrackers: '排除的 Torrent Tracker',
|
||||
torrentExcludeTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker,或使用 * 排除所有 announce 地址。不允许填写凭据;不会更改 DHT 和 PEX 设置。',
|
||||
torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
|
||||
torrentTrackerConnectTimeout: 'Tracker 连接超时',
|
||||
torrentTrackerTimeout: 'Tracker 请求超时',
|
||||
torrentTrackerInterval: 'Tracker 请求间隔',
|
||||
torrentTrackerTimingHint: '连接超时用于建立 Tracker 连接,请求超时用于连接后的响应。留空会保留 Aria2 的 60 秒默认值;间隔 0 会遵循 Tracker 响应和下载进度。',
|
||||
torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数',
|
||||
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
|
||||
torrentVerifyNow: '立即验证',
|
||||
torrentVerifyNowLoading: '验证中…',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||
torrentPeerDiagnostics: 'Torrent 对等节点详情',
|
||||
torrentPeerDiagnosticsRefresh: '刷新',
|
||||
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
|
||||
torrentPeerDiagnosticsStale: '当前显示最近一次验证的结果;刷新以再次检查。',
|
||||
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。',
|
||||
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
|
||||
torrentPeerDiagnosticsHint: '仅临时显示经过验证的对等端地址和端口;永不保留对等端 ID 或原始位域。上方的连接数是 Torrent 的实时状态;此表来自单独的对等节点列表响应,数量可能不同。',
|
||||
torrentPeerAddress: '节点地址',
|
||||
torrentPeerId: '节点 ID',
|
||||
torrentFileProgress: 'Torrent 文件进度',
|
||||
torrentFileSelection: 'Torrent 文件选择',
|
||||
torrentFileSelectionHint: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。',
|
||||
torrentFileSelectionRequired: '请至少选择一个 Torrent 文件。',
|
||||
torrentFileSelectionAll: '全选',
|
||||
torrentFileSelectionClear: '清除',
|
||||
torrentFileProgressRefresh: '刷新',
|
||||
torrentFileProgressLoading: '正在加载文件进度…',
|
||||
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
|
||||
torrentFileProgressFailed: '无法读取 Torrent 文件进度。',
|
||||
torrentFileProgressHint: '仅显示已验证的相对路径和已完成字节数;不会暴露守护进程路径或 URI。',
|
||||
torrentFileProgressPath: '文件',
|
||||
torrentFileProgressCompleted: '已完成',
|
||||
torrentFileProgressSelected: '已选择',
|
||||
torrentFileProgressUnselected: '未选择',
|
||||
torrentPieceProgress: 'Torrent 分片进度',
|
||||
torrentPieceProgressRefresh: '刷新',
|
||||
torrentPieceProgressLoading: '正在加载分片进度…',
|
||||
torrentPieceProgressUnavailable: 'Torrent 活跃或暂停时可查看分片进度。',
|
||||
torrentPieceProgressFailed: '无法读取 Torrent 分片进度。',
|
||||
torrentPieceProgressHint: '每个单元格汇总相邻分片;不会暴露原始位图。',
|
||||
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
|
||||
torrentPieceProgressMap: 'Torrent 分片完成度地图',
|
||||
torrentWebSeeds: 'Torrent Web 做种',
|
||||
torrentWebSeedsHint: '为每个 Torrent 文件添加一个 HTTP(S) 基础地址。多文件路径由 Firelink 原生展开。',
|
||||
torrentWebSeedsApply: '应用 Web 做种',
|
||||
torrentWebSeedsLoading: '正在应用…',
|
||||
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
|
||||
torrentWebSeedsEmpty: '尚未配置 Web 做种。',
|
||||
torrentWebSeedsFile: '文件',
|
||||
torrentWebSeedsUri: 'HTTP(S) 基础地址',
|
||||
torrentWebSeedsAdd: '添加 Web 做种',
|
||||
torrentWebSeedsRemove: '移除 Web 做种',
|
||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||
torrentPeerCount: '{{listed}} 个列表节点 — {{seeders}} 个列表做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
torrentPeerSeeder: '做种',
|
||||
torrentPeerAmChoking: 'Firelink 限制中',
|
||||
torrentPeerChoking: '对等节点限制中',
|
||||
torrentPeerShowing: '显示列表中的 {{shown}}/{{total}} 个节点。',
|
||||
torrentStatistics: '种子统计',
|
||||
torrentUploaded: '已上传',
|
||||
torrentRatio: '分享率',
|
||||
torrentSeededDuration: '做种时长',
|
||||
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
|
||||
torrentConnectedPeers: '连接数',
|
||||
torrentPeersSeeders: '节点 / 做种',
|
||||
torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点',
|
||||
torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。',
|
||||
torrentPeerCountDifference: '连接状态:{{connectedPeers}} 个节点 / {{connectedSeeders}} 个做种节点。对等节点详情响应列出 {{listedPeers}} 个节点 / {{listedSeeders}} 个做种节点。',
|
||||
torrentSeeders: '种子数',
|
||||
torrentUploadSpeed: '上传速度',
|
||||
seconds: '秒',
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||
torrentPrioritizePieceHint: '可选的预览策略。每个启用的范围默认为 1M,并在 Torrent 启动或重试时应用。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||
torrentFileAllocation: 'Torrent 文件分配',
|
||||
torrentFileAllocationPrealloc: '预分配文件',
|
||||
torrentFileAllocationNone: '按需分配',
|
||||
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
|
||||
torrentOptionsBehavior: 'Torrent 行为',
|
||||
torrentOptionsBehaviorHint: '控制此 Torrent 的校验、存储分配、加密和清理。',
|
||||
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
|
||||
torrentDetails: 'Torrent 详细信息',
|
||||
torrentCopyMagnet: '复制磁力链接',
|
||||
torrentExportMetadata: '导出 .torrent',
|
||||
torrentMagnetCopied: '仅包含身份信息的磁力链接已复制。',
|
||||
torrentMagnetCopyFailed: '无法复制磁力链接。',
|
||||
torrentMetadataExported: 'Torrent 元数据已导出。',
|
||||
torrentMetadataExportFailed: '无法导出 Torrent 元数据。',
|
||||
torrentMove: '移动数据…',
|
||||
torrentMoveLoading: '正在移动…',
|
||||
torrentMoveCancel: '取消移动',
|
||||
torrentMoveCancelRequested: '已请求取消移动…',
|
||||
torrentMoveConfirm: '要将托管的 Torrent 数据移动到此文件夹吗?不会覆盖现有文件。',
|
||||
torrentMoveCompleted: 'Torrent 数据已移动。',
|
||||
torrentMoveFailed: '无法移动 Torrent 数据。',
|
||||
torrentAvailability: '种群可用性',
|
||||
torrentAvailabilityRefresh: '刷新',
|
||||
torrentAvailabilityLoading: '正在加载可用性…',
|
||||
torrentAvailabilityUnavailable: '活动或暂停的 Torrent 可查看可用性。',
|
||||
torrentAvailabilityFailed: '无法读取 Torrent 可用性。',
|
||||
torrentAvailabilityHint: '仅显示聚合副本数量;不会暴露节点身份或原始位字段。',
|
||||
torrentAvailabilitySummary: '{{availability}} 个可用副本 · {{peers}} 个已连接节点 · {{pieces}} 个分片',
|
||||
torrentAvailabilityMap: 'Torrent 种群可用性图',
|
||||
torrentAvailabilityBucket: '此范围至少有 {{copies}} 个副本',
|
||||
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
|
||||
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
|
||||
torrentDetailsDisplayName: '显示名称',
|
||||
torrentDetailsInfoHash: '信息哈希',
|
||||
torrentDetailsSize: '总大小',
|
||||
torrentDetailsFiles: '文件',
|
||||
torrentDetailsPieces: '分片',
|
||||
torrentDetailsPrivate: '私有',
|
||||
torrentDetailsPrivateYes: '是',
|
||||
torrentDetailsPrivateNo: '否',
|
||||
torrentDetailsCreated: '创建时间',
|
||||
torrentDetailsCreator: '创建者',
|
||||
torrentDetailsComment: '备注',
|
||||
torrentDetailsTrackers: 'Tracker',
|
||||
torrentDetailsWebSeeds: '内嵌 Web seed',
|
||||
torrentDetailsPrivateHint: '此私有 Torrent 会独立于全局设置禁用 DHT、DHT6、PEX 和 LPD 发现。',
|
||||
torrentEncryptionDisabled: '已禁用',
|
||||
torrentEncryptionRequireCrypto: '要求加密握手',
|
||||
torrentEncryptionForceEncryption: '强制加密 payload(ARC4)',
|
||||
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
|
||||
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
|
||||
torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。',
|
||||
torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。',
|
||||
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
|
||||
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
|
||||
category: '类别',
|
||||
lastTry: '上次尝试',
|
||||
dateAdded: '添加日期',
|
||||
@@ -443,15 +236,10 @@ const zhCN = {
|
||||
defaultValue: ' (默认)',
|
||||
savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。',
|
||||
defaultTooltip: '对新的下载使用当前默认值。',
|
||||
blankUsesDefault: '留空 · 使用默认值',
|
||||
usingDefault: '使用默认值',
|
||||
customPerDownload: '此下载的自定义值',
|
||||
identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。',
|
||||
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
|
||||
download: '下载',
|
||||
url: 'URL',
|
||||
urlShowMore: '显示完整地址',
|
||||
urlShowLess: '收起',
|
||||
fileName: '文件名',
|
||||
saveLocation: '保存位置',
|
||||
select: '选择',
|
||||
@@ -472,15 +260,11 @@ const zhCN = {
|
||||
algorithm: '算法',
|
||||
digest: '哈希值',
|
||||
expectedDigest: '预期哈希值',
|
||||
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||
cookies: 'Cookie',
|
||||
headers: '请求头',
|
||||
mirrors: '镜像源',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
clear: '清除',
|
||||
enterValidUrl: '请输入有效的 URL。',
|
||||
fileNameEmpty: '文件名不能为空。',
|
||||
cancel: '取消',
|
||||
@@ -523,7 +307,6 @@ const zhCN = {
|
||||
settingsSaveFailed: '无法保存设置。请检查存储权限并重试。',
|
||||
systemActionCountdown: '10 秒后{{action}}。',
|
||||
systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。',
|
||||
systemActionProceedAnyway: '仍然继续',
|
||||
systemActionFailed: '计划的系统操作失败:{{detail}}',
|
||||
downloadCompleteTitle: '下载完成',
|
||||
downloadCompleteBody: '{{fileName}} 已下载完成。',
|
||||
@@ -618,14 +401,12 @@ const zhCN = {
|
||||
moveOneFailed: '无法将下载移动到队列',
|
||||
copyAddressesFailed: '无法复制地址',
|
||||
copyAddressFailed: '无法复制地址',
|
||||
copyMagnetFailed: '无法复制磁力链接',
|
||||
copyPathFailed: '无法复制文件路径',
|
||||
missingFileName: '缺少文件名',
|
||||
redownloadFailed: '重新下载失败',
|
||||
startResume: '开始/恢复',
|
||||
addToQueue: '添加到队列',
|
||||
copyAddress: '复制地址',
|
||||
copyMagnet: '复制磁力链接',
|
||||
remove: '移除',
|
||||
open: '打开',
|
||||
showInFolder: '在文件夹中显示',
|
||||
@@ -665,75 +446,14 @@ const zhCN = {
|
||||
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
|
||||
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
|
||||
downloadLinks: '下载链接',
|
||||
pastePlaceholder: '在此粘贴 HTTP(S)、FTP/SFTP、magnet 或媒体 URL…',
|
||||
pasteHint: '支持 YouTube、X、TikTok、Instagram 和 Reddit 链接。',
|
||||
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTP 或 SFTP URL…\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
|
||||
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (达到安全条目限制)',
|
||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||
selectedSummaryReady: '就绪',
|
||||
selectedSummaryFallback: '后备',
|
||||
selectedSummaryMediaRetry: '媒体重试',
|
||||
selectedSummaryBlocked: '已屏蔽',
|
||||
torrentAdvancedOptions: 'Torrent 高级选项',
|
||||
torrentAdvancedOptionsCustom: '自定义设置',
|
||||
clearSelection: '清除选择',
|
||||
selectAll: '全选',
|
||||
refreshMetadata: '刷新元数据',
|
||||
files: '文件',
|
||||
torrentFiles: '种子文件',
|
||||
torrent: '种子',
|
||||
chooseTorrentFiles: '添加 .torrent 文件',
|
||||
torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。',
|
||||
torrentSeeding: 'BT 做种',
|
||||
seedAfterDownload: '下载完成后继续做种',
|
||||
seedTime: '做种时间',
|
||||
minutes: '分钟',
|
||||
seconds: '秒',
|
||||
seedRatio: '做种比率',
|
||||
seedRatioHint: '0 表示仅按时间做种;否则达到第一个限制时停止做种。',
|
||||
limitTorrentUpload: '限制种子上传',
|
||||
torrentUploadLimit: '种子上传限速',
|
||||
torrentSeedTimeInvalid: '做种时间必须大于零',
|
||||
torrentSeedRatioInvalid: '做种比率不能小于零',
|
||||
torrentUploadLimitInvalid: '种子上传限速必须大于零',
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentExcludeTrackers: '排除的 Torrent Tracker',
|
||||
torrentExcludeTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。* 会排除所有 announce 地址;不会更改 DHT 和 PEX 设置。',
|
||||
torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
|
||||
torrentTrackerConnectTimeout: 'Tracker 连接超时',
|
||||
torrentTrackerTimeout: 'Tracker 请求超时',
|
||||
torrentTrackerInterval: 'Tracker 请求间隔',
|
||||
torrentTrackerTimingHint: '随 Torrent 保存,并在下次启动或重试时应用。连接超时用于建立连接,请求超时用于之后的响应;留空会保留 Aria2 的 60 秒默认值,间隔 0 会遵循 Tracker 响应和下载进度。',
|
||||
torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数',
|
||||
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
|
||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||
torrentPrioritizePieceHint: '选择开头片段、结尾片段或两者用于预览。每个启用的范围默认为 1M,并在下次启动或重试时应用。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
|
||||
torrentEncryptionDisabled: '已禁用',
|
||||
torrentEncryptionRequireCrypto: '要求加密握手',
|
||||
torrentEncryptionForceEncryption: '强制加密 payload(ARC4)',
|
||||
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
|
||||
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
|
||||
torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。',
|
||||
torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。',
|
||||
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
|
||||
required: '必需',
|
||||
free: '可用空间',
|
||||
preview: '预览',
|
||||
@@ -788,9 +508,6 @@ const zhCN = {
|
||||
verifyChecksum: '验证校验和',
|
||||
checksumAlgorithm: '校验和算法',
|
||||
expectedDigest: '预期摘要',
|
||||
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||
headers: '请求头',
|
||||
requestHeaders: '请求头',
|
||||
cookies: 'Cookie',
|
||||
@@ -864,12 +581,6 @@ const zhCN = {
|
||||
parallelDownloadsDescription: '最大同时活动文件数',
|
||||
automaticRetries: '自动重试:',
|
||||
automaticRetriesDescription: '如果连接失败',
|
||||
minimumNormalDownloadSpeed: '普通下载最低速度(KiB/s):',
|
||||
minimumNormalDownloadSpeedDescription: 'HTTP、FTP 或 SFTP 下载持续低于此速度时重试。设为 0 可关闭。',
|
||||
retryNotFoundErrors: '重试临时“未找到”错误',
|
||||
retryNotFoundErrorsDescription: '在自动重试次数限制内,将 HTTP/FTP 的“资源未找到”响应视为可重试错误。默认关闭。',
|
||||
adaptiveMirrorSelection: '自适应镜像选择',
|
||||
adaptiveMirrorSelectionDescription: '根据近期传输性能在多个镜像之间选择。服务器统计信息仅私密保存在此设备上。',
|
||||
systemNotification: '下载完成时显示系统通知',
|
||||
systemNotificationDescription: '使用操作系统的通知设置',
|
||||
completionChime: '播放应用内完成提示音',
|
||||
@@ -970,65 +681,6 @@ const zhCN = {
|
||||
detectedSystemProxy: '检测到系统代理。普通文件下载需要 HTTP 或 HTTPS 端点;媒体下载可以使用 SOCKS。',
|
||||
noSystemProxy: '未检测到可用的系统代理。下载将不使用代理。',
|
||||
systemProxyReadFailed: '无法读取系统代理配置。请选择“无代理”,或者在其可用时重试。',
|
||||
torrentTabs: {
|
||||
discovery: '发现',
|
||||
connection: '连接',
|
||||
limits: '限制',
|
||||
advanced: '高级',
|
||||
},
|
||||
torrentPeerDiscovery: 'BitTorrent 对等节点发现',
|
||||
torrentDht: 'IPv4 DHT 与 UDP Tracker',
|
||||
torrentDhtDescription: '不只依赖 Tracker 查找节点。关闭后也会禁用 UDP Tracker 支持。',
|
||||
torrentDht6: 'IPv6 分布式哈希表',
|
||||
torrentDht6Description: '当网络提供可用的 IPv6 路径时,使用 IPv6 进行分布式节点发现。',
|
||||
torrentIpv6Enabled: '为种子网络启用 IPv6',
|
||||
torrentIpv6EnabledDescription: '为 BitTorrent、DHT 和节点发现保留 IPv6。禁用后也会关闭 IPv6 DHT。',
|
||||
torrentPex: '节点交换(PEX)',
|
||||
torrentPexDescription: '允许已连接的节点共享其他节点的地址。',
|
||||
torrentLpd: '本地节点发现(LPD)',
|
||||
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
|
||||
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
|
||||
torrentNetwork: 'BitTorrent 网络绑定',
|
||||
torrentAdvanced: '高级 Torrent 网络设置',
|
||||
torrentDhtMessageTimeout: 'DHT 消息超时',
|
||||
torrentDhtMessageTimeoutDescription: 'DHT 和 UDP 消息的等待时间(秒)。不影响通过 HTTP 获取 .torrent 文件,也不影响 HTTP tracker 请求。Firelink 重启后生效。',
|
||||
torrentSeparateSeedSlots: '独立做种容量',
|
||||
torrentSeparateSeedSlotsDescription: '将做种从下载上限中分离,并使用 Firelink 管理的容量池限制做种。',
|
||||
torrentMaxConcurrentSeeds: '最大同时做种数',
|
||||
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
|
||||
torrentListenPort: 'TCP 节点端口',
|
||||
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
|
||||
torrentBindAddress: '种子绑定地址',
|
||||
torrentBindAddressDescription: '可选的本地 IPv4 或 IPv6 地址,用于 Aria2 套接字。无效地址会被拒绝;重启后生效。',
|
||||
torrentDhtListenPort: 'UDP/DHT 端口',
|
||||
torrentDhtListenPortDescription: '用于 DHT 和 UDP 跟踪器的 UDP 端口。留空以使用 Aria2 的默认范围。',
|
||||
torrentExternalIp: '外部 IP 地址',
|
||||
torrentExternalIpDescription: '主机位于 NAT 后时向节点和跟踪器公布的地址。如果不确定可访问地址,请留空。',
|
||||
torrentExternalIpPlaceholder: '203.0.113.7',
|
||||
torrentDhtEntryPoint: 'IPv4 DHT 入口',
|
||||
torrentDhtEntryPointDescription: '可选的引导主机和端口,例如 router.example:6881。',
|
||||
torrentDhtEntryPoint6: 'IPv6 DHT 入口',
|
||||
torrentDhtEntryPoint6Description: '可选的 IPv6 引导地址和端口,请使用方括号格式,例如 [2001:db8::1]:6881。',
|
||||
torrentDhtListenAddr6: 'IPv6 DHT 监听地址',
|
||||
torrentDhtListenAddr6Description: 'DHT 套接字使用的 IPv6 地址。留空以让 Aria2 自动选择。',
|
||||
torrentLpdInterface: 'LPD 接口',
|
||||
torrentLpdInterfaceDescription: '用于本地节点发现的网络接口名称或地址。留空以使用默认接口。',
|
||||
torrentPeerIdPrefix: '节点 ID 前缀',
|
||||
torrentPeerIdPrefixDescription: '覆盖 BitTorrent 节点 ID 前缀。只有了解其隐私和协议身份影响时才应修改;留空以使用 Aria2 默认值。',
|
||||
torrentPeerAgent: '节点代理标识',
|
||||
torrentPeerAgentDescription: '覆盖 BitTorrent 扩展握手中发送的客户端字符串。这会改变协议身份并可能影响兼容性;留空以使用 Aria2 默认值。',
|
||||
torrentNetworkRestartNote: '这些设置在启动时应用,并在重启 Firelink 后生效。开放端口可能需要路由器端口转发和操作系统防火墙规则;可用性取决于平台和网络。',
|
||||
torrentResourceLimits: 'BitTorrent 资源限制',
|
||||
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
|
||||
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
|
||||
aria2DiskCache: 'Aria2 磁盘缓存',
|
||||
aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。',
|
||||
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
|
||||
torrentNetworkInputInvalid: '无法应用此 Torrent 网络设置:{{detail}}',
|
||||
torrentOverallUploadLimit: 'Aria2 总上传限制',
|
||||
torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。',
|
||||
torrentOverallUploadLimitInvalid: '请输入有效的上传限制,例如 512K 或 2M。',
|
||||
torrentOverallUploadLimitUpdateFailed: '无法应用 Aria2 总上传限制:{{detail}}',
|
||||
identity: '身份',
|
||||
customUserAgent: '自定义 User-Agent',
|
||||
userAgentDescription: '应用于元数据获取和下载引擎。',
|
||||
@@ -1162,7 +814,6 @@ const zhCN = {
|
||||
active: '{{count}} 个进行中',
|
||||
queued: '{{count}} 个排队',
|
||||
done: '{{count}} 个完成',
|
||||
seeding: '做种',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -76,16 +76,6 @@ describe('translation catalogs', () => {
|
||||
expect(allMismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it('isolates mixed-direction Add Downloads guidance in RTL locales', () => {
|
||||
for (const locale of ['fa', 'he'] as const) {
|
||||
for (const key of ['addDownloads.pastePlaceholder', 'addDownloads.pasteHint']) {
|
||||
const value = catalogFor(locale).get(key) ?? '';
|
||||
expect(value).toContain('\u2066');
|
||||
expect(value).toContain('\u2069');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reports exact English duplicates for translation review', () => {
|
||||
const english = catalogFor('en');
|
||||
const duplicates = APP_LOCALES.filter((locale) => locale !== 'en').flatMap((locale) => {
|
||||
@@ -121,8 +111,6 @@ describe('translation catalogs', () => {
|
||||
'settings.network.firefoxWindows',
|
||||
'settings.network.firefoxMacos',
|
||||
'settings.network.safariMacos',
|
||||
'settings.network.torrentExternalIpPlaceholder',
|
||||
'properties.inputFormatPiecePriority',
|
||||
]);
|
||||
|
||||
const unexpectedDuplicates = duplicates
|
||||
|
||||
+25
-1253
File diff suppressed because it is too large
Load Diff
+3
-88
@@ -4,7 +4,6 @@ import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn
|
||||
import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { DownloadAllocationEvent } from './bindings/DownloadAllocationEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
@@ -17,18 +16,8 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
import type { KeychainGrantStatus } from './bindings/KeychainGrantStatus';
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { DownloadAssetRemovalPolicy } from './bindings/DownloadAssetRemovalPolicy';
|
||||
import type { DownloadTargetInfo } from './bindings/DownloadTargetInfo';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||
import type { TorrentDetails } from './bindings/TorrentDetails';
|
||||
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
|
||||
import type { TorrentAvailabilitySnapshot } from './bindings/TorrentAvailabilitySnapshot';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -43,27 +32,6 @@ type CommandMap = {
|
||||
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
|
||||
result: MediaPlaylistMetadata;
|
||||
};
|
||||
inspect_torrent: {
|
||||
args: {
|
||||
source: string;
|
||||
id: string;
|
||||
cache?: boolean;
|
||||
proxy?: string;
|
||||
headers?: string;
|
||||
cookies?: string;
|
||||
cookieScopes?: Array<ExtensionCookieScope>;
|
||||
torrent?: boolean;
|
||||
};
|
||||
result: TorrentMetadata;
|
||||
};
|
||||
rekey_torrent_metadata: {
|
||||
args: { sourceId: string; targetId: string };
|
||||
result: string;
|
||||
};
|
||||
remove_torrent_metadata: {
|
||||
args: { id: string };
|
||||
result: void;
|
||||
};
|
||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||
@@ -72,20 +40,8 @@ type CommandMap = {
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||
remove_download: {
|
||||
args: {
|
||||
id: string;
|
||||
deleteAssets: boolean;
|
||||
preserveResumable?: boolean;
|
||||
expectedLifecycleGeneration?: string;
|
||||
assetRemovalPolicy?: DownloadAssetRemovalPolicy;
|
||||
};
|
||||
result: void;
|
||||
};
|
||||
get_download_primary_path: { args: { id: string }; result: string | null };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
clear_torrent_removal_paths: { args: { id: string }; result: void };
|
||||
reconcile_torrent_removal_reservations: { args: undefined; result: number };
|
||||
begin_dock_badge_session: { args: undefined; result: number };
|
||||
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
@@ -95,32 +51,11 @@ type CommandMap = {
|
||||
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
|
||||
result: void;
|
||||
};
|
||||
perform_system_action: { args: { action: PostQueueAction; force: boolean }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_torrent_upload_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_torrent_peer_options: {
|
||||
args: { id: string; max_peers: number | null; peer_speed_limit: string | null };
|
||||
result: void;
|
||||
};
|
||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
||||
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
||||
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
||||
set_torrent_file_selection: { args: { id: string; selected_indices: number[] | null }; result: TorrentFileSelectionSnapshot };
|
||||
get_torrent_details: { args: { id: string }; result: TorrentDetails };
|
||||
get_torrent_availability: { args: { id: string }; result: TorrentAvailabilitySnapshot };
|
||||
verify_torrent_data: { args: { id: string }; result: void };
|
||||
get_torrent_magnet_link: { args: { id: string }; result: string };
|
||||
export_torrent_metadata: { args: { id: string; destination: string }; result: void };
|
||||
move_torrent_data: { args: { id: string; destination: string; sessionId?: string }; result: void };
|
||||
cancel_torrent_move_data: { args: { id: string; sessionId?: string }; result: void };
|
||||
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
|
||||
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
|
||||
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
||||
set_torrent_overall_upload_limit: { args: { limit: string | null }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
check_automation_permission: { args: undefined; result: void };
|
||||
@@ -134,7 +69,7 @@ type CommandMap = {
|
||||
result: void;
|
||||
};
|
||||
delete_site_login: { args: { id: string }; result: void };
|
||||
inspect_download_target: { args: { path: string }; result: DownloadTargetInfo };
|
||||
check_file_exists: { args: { path: string }; result: boolean };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
get_extension_server_port: { args: undefined; result: number | null };
|
||||
@@ -148,7 +83,6 @@ type CommandMap = {
|
||||
abandon_keychain_grant: { args: { requestId: string }; result: PairingTokenHydration | null };
|
||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
ack_frontend_exit: { args: undefined; result: void };
|
||||
ack_extension_download: { args: { requestId: string }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
@@ -156,16 +90,8 @@ type CommandMap = {
|
||||
get_supported_media_domains: { args: undefined; result: string[] };
|
||||
db_save_settings: { args: { data: string }; result: void };
|
||||
db_load_settings: { args: undefined; result: string | null };
|
||||
canonicalize_torrent_network_setting: {
|
||||
args: { field: string; value: string };
|
||||
result: string;
|
||||
};
|
||||
db_get_all_downloads: { args: undefined; result: string[] };
|
||||
db_replace_downloads: { args: { data: string }; result: void };
|
||||
db_commit_download_state: {
|
||||
args: { downloadsData: string; queuesData: string };
|
||||
result: void;
|
||||
};
|
||||
db_get_all_queues: { args: undefined; result: string[] };
|
||||
db_replace_queues: { args: { data: string }; result: void };
|
||||
create_category_directories: {
|
||||
@@ -185,14 +111,6 @@ type CommandMap = {
|
||||
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
|
||||
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down'; targetIndex?: number }; result: string[] };
|
||||
remove_from_queue: { args: { id: string }; result: boolean };
|
||||
open_download_properties_window: { args: { id: string }; result: string };
|
||||
get_properties_window_download_id: { args: undefined; result: string };
|
||||
properties_window_send_ready: { args: { sessionId: string }; result: void };
|
||||
properties_window_reveal: { args: { sessionId?: string }; result: void };
|
||||
properties_window_send_action: { args: { sessionId: string; requestId: number; action: string; payload?: unknown }; result: void };
|
||||
validate_properties_window_request: { args: { windowLabel: string; downloadId: string; sessionId: string; requestId?: number }; result: void };
|
||||
close_download_properties_window: { args: { id: string }; result: void };
|
||||
properties_window_registry_remove_for_download: { args: { id: string }; result: void };
|
||||
};
|
||||
|
||||
type CommandName = keyof CommandMap;
|
||||
@@ -212,15 +130,12 @@ export function invokeCommand<K extends CommandName>(
|
||||
type EventMap = {
|
||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-allocation': DownloadAllocationEvent;
|
||||
'download-state': DownloadStateEvent;
|
||||
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||
'download-complete': string;
|
||||
'download-failed': string;
|
||||
'extension-add-download': ExtensionDownload;
|
||||
'deep-link-add-download': string;
|
||||
'tray-action': 'pause-all' | 'resume-all';
|
||||
'app-exit-requested': null;
|
||||
};
|
||||
|
||||
export function listenEvent<K extends keyof EventMap>(
|
||||
|
||||
+8
-67
@@ -1,4 +1,4 @@
|
||||
import { StrictMode, type ComponentType } from "react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "@fontsource-variable/noto-sans-hebrew/wght.css";
|
||||
@@ -7,14 +7,11 @@ import "@fontsource-variable/outfit/wght.css";
|
||||
import "@fontsource-variable/roboto/wght.css";
|
||||
import "@fontsource-variable/vazirmatn/wght.css";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { i18nReady } from "./i18n";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { ToastProvider } from "./contexts/ToastContext";
|
||||
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { invokeCommand as invoke } from './ipc';
|
||||
|
||||
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
|
||||
|
||||
void initLogger();
|
||||
|
||||
@@ -44,80 +41,24 @@ console.warn = (...values: unknown[]) => {
|
||||
};
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
const renderRoot = (RootComponent: ComponentType) => {
|
||||
const renderApp = () => {
|
||||
if (!rootElement) return;
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<RootComponent />
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
};
|
||||
|
||||
const PropertiesStartupFailure = () => (
|
||||
<main className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
|
||||
<p role="alert">Download Properties could not be loaded.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button app-button-primary px-3 text-xs"
|
||||
onClick={() => {
|
||||
void getCurrentWindow().close().catch(error => {
|
||||
console.error('[PropertiesStartupFailure] close failed', error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
|
||||
const renderMainApp = async () => {
|
||||
if (!rootElement) return;
|
||||
|
||||
// Keep the child entrypoint isolated from the main application module. App
|
||||
// imports the persistent Zustand stores, whose module initialization issues
|
||||
// main-window-only IPC commands. Loading it in a Properties child creates a
|
||||
// second persistence owner and can race the bridge handshake.
|
||||
const RootComponent = (await import('./App')).default;
|
||||
renderRoot(RootComponent);
|
||||
};
|
||||
|
||||
const renderPropertiesApp = async () => {
|
||||
if (!rootElement) return;
|
||||
|
||||
try {
|
||||
// Properties starts with the synchronous English catalog and changes locale
|
||||
// after its first paint. Waiting for a lazy locale chunk here delays the
|
||||
// loading shell and makes native window startup visible to the user.
|
||||
const RootComponent = (await import('./components/PropertiesWindowApp')).PropertiesWindowApp;
|
||||
renderRoot(RootComponent);
|
||||
} catch (error) {
|
||||
// A failed lazy chunk must not leave the native window hidden forever. Show
|
||||
// a styled, closable failure state and use the same caller-validated native
|
||||
// reveal command as the normal child path.
|
||||
console.error('Failed to initialize the Properties window:', error);
|
||||
renderRoot(PropertiesStartupFailure);
|
||||
const fallbackSessionId = crypto.randomUUID();
|
||||
void invoke('properties_window_send_ready', { sessionId: fallbackSessionId })
|
||||
.then(() => invoke('properties_window_reveal', { sessionId: fallbackSessionId }))
|
||||
.catch(revealError => {
|
||||
console.error('Failed to reveal the Properties startup error:', revealError);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isPropertiesWindow) {
|
||||
void renderPropertiesApp();
|
||||
} else {
|
||||
void i18nReady.then(renderMainApp).catch(error => {
|
||||
console.error('Failed to initialize localization:', error);
|
||||
void renderMainApp();
|
||||
});
|
||||
}
|
||||
void i18nReady.then(renderApp).catch(error => {
|
||||
console.error('Failed to initialize localization:', error);
|
||||
renderApp();
|
||||
});
|
||||
|
||||
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
|
||||
// Individual components that provide custom context menus call preventDefault()
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { emitTo } from '@tauri-apps/api/event';
|
||||
import type { DownloadItem } from './store/useDownloadStore';
|
||||
|
||||
vi.mock('./ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
emit: vi.fn(),
|
||||
emitTo: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
applySecretPatch,
|
||||
attachAsyncPropertiesListener,
|
||||
beginExclusivePropertiesAction,
|
||||
classifyPropertiesActionRequest,
|
||||
createFrameCoalescer,
|
||||
decodePropertiesPatchValue,
|
||||
encodePropertiesPatchValue,
|
||||
enqueuePropertiesAction,
|
||||
formatPropertiesQueuePlacement,
|
||||
getPropertiesLifecycleAction,
|
||||
isExpectedPropertiesDiagnosticUnavailable,
|
||||
propertiesDiagnosticPhase,
|
||||
propertiesActionRequestKey,
|
||||
propertiesDiagnosticRequestState,
|
||||
propertiesTorrentPeerLimit,
|
||||
propertiesWindowEventTarget,
|
||||
resetPropertiesActionState,
|
||||
redactPropertiesError,
|
||||
sanitizePropertiesSnapshot,
|
||||
sendPropertiesSnapshot,
|
||||
shouldAcceptPropertiesActionRequest,
|
||||
} from './propertiesBridge';
|
||||
import { copyEditablePropertiesPatch, isLivePropertiesPatch } from './components/PropertiesWindowBridgeHost';
|
||||
|
||||
describe('Properties window bridge', () => {
|
||||
it('keeps optional override resets explicit across the JSON IPC boundary', () => {
|
||||
const encoded = encodePropertiesPatchValue<string>(undefined);
|
||||
|
||||
expect(encoded).toBeNull();
|
||||
expect(JSON.parse(JSON.stringify({ torrentEncryptionPolicy: encoded }))).toEqual({
|
||||
torrentEncryptionPolicy: null,
|
||||
});
|
||||
expect(decodePropertiesPatchValue(encoded)).toBeUndefined();
|
||||
expect(decodePropertiesPatchValue('prealloc')).toBe('prealloc');
|
||||
});
|
||||
|
||||
it('uses a WebviewWindow target for directed child events', () => {
|
||||
expect(propertiesWindowEventTarget('properties-1')).toEqual({
|
||||
kind: 'WebviewWindow',
|
||||
label: 'properties-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('targets the initial snapshot at the child WebviewWindow', async () => {
|
||||
vi.mocked(emitTo).mockClear();
|
||||
const payload = {} as Parameters<typeof sendPropertiesSnapshot>[1];
|
||||
|
||||
await sendPropertiesSnapshot('properties-1', payload);
|
||||
|
||||
expect(emitTo).toHaveBeenCalledWith(
|
||||
{ kind: 'WebviewWindow', label: 'properties-1' },
|
||||
'properties-window-snapshot',
|
||||
payload,
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes transfer secrets while preserving presence flags', () => {
|
||||
const item = {
|
||||
id: 'download-1',
|
||||
fileName: 'example.iso',
|
||||
url: 'https://example.test/file',
|
||||
password: 'password',
|
||||
cookies: 'sid=secret',
|
||||
headers: 'Authorization: Bearer secret',
|
||||
username: 'user',
|
||||
mirrors: 'https://user:secret@example.test/mirror',
|
||||
} as DownloadItem;
|
||||
|
||||
const snapshot = sanitizePropertiesSnapshot(item, {
|
||||
theme: 'nord',
|
||||
fontFamily: 'inter',
|
||||
appFontSize: 'large',
|
||||
listRowDensity: 'compact',
|
||||
locale: 'fa',
|
||||
});
|
||||
|
||||
expect(snapshot).not.toHaveProperty('password');
|
||||
expect(snapshot).not.toHaveProperty('cookies');
|
||||
expect(snapshot).not.toHaveProperty('headers');
|
||||
expect(snapshot).not.toHaveProperty('username');
|
||||
expect(snapshot).not.toHaveProperty('mirrors');
|
||||
expect(snapshot.hasPassword).toBe(true);
|
||||
expect(snapshot.hasCookies).toBe(true);
|
||||
expect(snapshot.hasHeaders).toBe(true);
|
||||
expect(snapshot.hasUsername).toBe(true);
|
||||
expect(snapshot.hasMirrors).toBe(true);
|
||||
expect(snapshot.appearance).toEqual({
|
||||
theme: 'nord',
|
||||
fontFamily: 'inter',
|
||||
appFontSize: 'large',
|
||||
listRowDensity: 'compact',
|
||||
locale: 'fa',
|
||||
});
|
||||
expect(snapshot.windowChrome).toEqual({ controlStyle: 'macos', side: 'left' });
|
||||
});
|
||||
|
||||
it('adds resolver error metadata without exposing the queue-internal mode', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'dns-1',
|
||||
fileName: 'example.bin',
|
||||
url: 'https://example.test/file',
|
||||
status: 'failed',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
lastResolverFallback: true,
|
||||
lastError: 'aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers.',
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
expect(snapshot.lastErrorKind).toBe('nameResolution');
|
||||
expect(snapshot.lastResolverFallback).toBe(true);
|
||||
expect(snapshot).not.toHaveProperty('aria2ResolverMode');
|
||||
});
|
||||
|
||||
it('redacts credentials from Properties errors at the renderer boundary', () => {
|
||||
const error = redactPropertiesError(new Error(
|
||||
'GET https://user:pa@ss@example.test/file?token=secret&x=1 Authorization: Bearer bearer-secret',
|
||||
));
|
||||
expect(error).not.toContain('pa@ss@example');
|
||||
expect(error).not.toContain('token=secret');
|
||||
expect(error).not.toContain('bearer-secret');
|
||||
expect(error).toContain('[redacted]');
|
||||
});
|
||||
|
||||
it('projects the latest live telemetry without exposing secrets', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'torrent-1',
|
||||
fileName: 'example',
|
||||
url: 'https://example.test/file',
|
||||
status: 'seeding',
|
||||
isTorrent: true,
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
speed: '-',
|
||||
eta: '-',
|
||||
fraction: 0,
|
||||
uploadedBytes: 1,
|
||||
password: 'secret',
|
||||
connections: 16,
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, {
|
||||
progress: {
|
||||
id: 'torrent-1',
|
||||
fraction: 0.75,
|
||||
speed: '2 MiB/s',
|
||||
eta: '10s',
|
||||
size: '4 MiB',
|
||||
size_is_final: true,
|
||||
downloaded_bytes: 3,
|
||||
total_bytes: 4,
|
||||
total_is_estimate: false,
|
||||
active_connections: 0,
|
||||
requested_connections: 8,
|
||||
uploaded_bytes: 9,
|
||||
upload_speed: '1 MiB/s',
|
||||
num_seeders: 0,
|
||||
torrent_seeded_seconds: 12,
|
||||
},
|
||||
moveProgress: 0.5,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toHaveProperty('password');
|
||||
expect(snapshot).toMatchObject({
|
||||
fraction: 0.75,
|
||||
speed: '1 MiB/s',
|
||||
eta: '-',
|
||||
downloadedBytes: 3,
|
||||
totalBytes: 4,
|
||||
totalIsEstimate: false,
|
||||
torrentUploadedBytes: 9,
|
||||
uploadSpeed: '1 MiB/s',
|
||||
torrentConnectedPeers: 0,
|
||||
torrentConnectedSeeders: 0,
|
||||
torrentSeededSeconds: 12,
|
||||
moveProgress: 0.5,
|
||||
});
|
||||
expect(snapshot).not.toHaveProperty('activeConnections');
|
||||
expect(snapshot).not.toHaveProperty('requestedConnections');
|
||||
expect(snapshot).not.toHaveProperty('connections');
|
||||
|
||||
const normalSnapshot = sanitizePropertiesSnapshot({
|
||||
id: 'http-1',
|
||||
fileName: 'example.bin',
|
||||
url: 'https://example.test/file',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
connections: 8,
|
||||
isTorrent: false,
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, {
|
||||
progress: {
|
||||
id: 'http-1',
|
||||
fraction: 0.5,
|
||||
speed: '1 MiB/s',
|
||||
eta: '5s',
|
||||
size: '4 MiB',
|
||||
size_is_final: true,
|
||||
active_connections: 3,
|
||||
requested_connections: 8,
|
||||
effective_connections: 1,
|
||||
},
|
||||
});
|
||||
expect(normalSnapshot).toMatchObject({
|
||||
activeConnections: 3,
|
||||
requestedConnections: 8,
|
||||
effectiveConnections: 1,
|
||||
});
|
||||
expect(normalSnapshot).not.toHaveProperty('connectedPeers');
|
||||
});
|
||||
|
||||
it('adds a user-facing queue name to the sanitized snapshot', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'queued-1',
|
||||
fileName: 'example.bin',
|
||||
url: 'https://example.test/file',
|
||||
status: 'queued',
|
||||
queueId: 'internal-queue-id',
|
||||
queuePosition: 2,
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, undefined, { queueName: 'Main Queue' });
|
||||
|
||||
expect(snapshot.queueName).toBe('Main Queue');
|
||||
expect(snapshot.queueId).toBe('internal-queue-id');
|
||||
});
|
||||
|
||||
it('projects the transient allocation phase without changing the persisted download status', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'allocating-1',
|
||||
fileName: 'large.bin',
|
||||
url: 'https://example.test/file',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, undefined, { allocationPending: true });
|
||||
|
||||
expect(snapshot.status).toBe('downloading');
|
||||
expect(snapshot.allocationPending).toBe(true);
|
||||
});
|
||||
|
||||
it('does not project Aria2 connection telemetry onto media snapshots', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'media-1',
|
||||
fileName: 'video.mp4',
|
||||
url: 'https://example.test/video',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
connections: 16,
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, {
|
||||
progress: {
|
||||
id: 'media-1',
|
||||
fraction: 0.5,
|
||||
speed: '1 MiB/s',
|
||||
eta: '5s',
|
||||
size: '4 MiB',
|
||||
size_is_final: false,
|
||||
active_connections: 8,
|
||||
requested_connections: 16,
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.connections).toBe(16);
|
||||
expect(snapshot).not.toHaveProperty('activeConnections');
|
||||
expect(snapshot).not.toHaveProperty('requestedConnections');
|
||||
});
|
||||
|
||||
it('preserves resolved Properties window chrome in the sanitized snapshot', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'chrome-1',
|
||||
fileName: 'example.bin',
|
||||
url: 'https://example.test/file',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
} as DownloadItem, {
|
||||
theme: 'dark',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, undefined, {
|
||||
windowChrome: { controlStyle: 'windows', side: 'right' },
|
||||
});
|
||||
|
||||
expect(snapshot.windowChrome).toEqual({ controlStyle: 'windows', side: 'right' });
|
||||
});
|
||||
|
||||
it('keeps diagnostic refreshes quiet when cached data exists', () => {
|
||||
expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial');
|
||||
expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing');
|
||||
expect(propertiesDiagnosticPhase(true, 'request-start')).toBe('refreshing');
|
||||
expect(propertiesDiagnosticPhase(true, 'success')).toBe('idle');
|
||||
expect(propertiesDiagnosticPhase(true, 'expected-unavailable')).toBe('stale');
|
||||
expect(propertiesDiagnosticPhase(false, 'expected-unavailable')).toBe('unavailable');
|
||||
expect(propertiesDiagnosticPhase(true, 'unexpected-error')).toBe('error');
|
||||
expect(propertiesDiagnosticRequestState(false, false, false)).toMatchObject({
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
resetMessage: true,
|
||||
phase: 'initial',
|
||||
});
|
||||
expect(propertiesDiagnosticRequestState(false, true, false)).toMatchObject({
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
resetMessage: false,
|
||||
phase: 'refreshing',
|
||||
});
|
||||
expect(propertiesDiagnosticRequestState(true, true, true)).toMatchObject({
|
||||
loading: false,
|
||||
refreshing: true,
|
||||
resetMessage: true,
|
||||
phase: 'refreshing',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats queue placement without ever using a raw queue id', () => {
|
||||
const formatPosition = (position: number) => `Position ${position}`;
|
||||
expect(formatPropertiesQueuePlacement('Main Queue', 2, formatPosition))
|
||||
.toBe('Main Queue · Position 3');
|
||||
expect(formatPropertiesQueuePlacement(undefined, 2, formatPosition))
|
||||
.toBe('Position 3');
|
||||
expect(formatPropertiesQueuePlacement('Main Queue', undefined, formatPosition))
|
||||
.toBe('Main Queue');
|
||||
expect(formatPropertiesQueuePlacement(' Main Queue ', 1.5, formatPosition))
|
||||
.toBe('Main Queue');
|
||||
expect(formatPropertiesQueuePlacement(undefined, Number.NaN, formatPosition))
|
||||
.toBe('—');
|
||||
});
|
||||
|
||||
it('applies explicit secret changes without conflating unchanged fields', () => {
|
||||
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
|
||||
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
|
||||
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
|
||||
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
|
||||
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
|
||||
expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch');
|
||||
});
|
||||
|
||||
it('derives truthful lifecycle commands from the current status', () => {
|
||||
expect(getPropertiesLifecycleAction('downloading')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('queued')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('retrying')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('paused')).toBe('resume');
|
||||
expect(getPropertiesLifecycleAction('ready')).toBe('start');
|
||||
expect(getPropertiesLifecycleAction('staged')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('failed')).toBe('retry');
|
||||
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves validated SFTP fingerprints and rejects identity edits after dispatch', () => {
|
||||
expect(copyEditablePropertiesPatch({ sftpHostKeyMd: 'MD5=0123456789abcdef0123456789abcdef' }, {
|
||||
isTorrent: false,
|
||||
status: 'ready',
|
||||
})).toMatchObject({ sftpHostKeyMd: 'md5=0123456789abcdef0123456789abcdef' });
|
||||
|
||||
expect(() => copyEditablePropertiesPatch({ fileName: 'renamed.bin' }, {
|
||||
isTorrent: false,
|
||||
status: 'completed',
|
||||
})).toThrow('read-only');
|
||||
expect(() => copyEditablePropertiesPatch({ destination: '/new/path' }, {
|
||||
isTorrent: true,
|
||||
status: 'paused',
|
||||
})).toThrow('read-only');
|
||||
expect(() => copyEditablePropertiesPatch({ fileName: 'queued.bin' }, {
|
||||
isTorrent: false,
|
||||
status: 'queued',
|
||||
})).toThrow('read-only');
|
||||
});
|
||||
|
||||
it('allows only native live controls for active Properties saves', () => {
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: false, status: 'downloading' },
|
||||
{ speedLimit: '2M' },
|
||||
)).toBe(true);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: true, status: 'seeding' },
|
||||
{ torrentUploadLimit: '1M', torrentMaxPeers: 120, torrentPeerSpeedLimit: '256K' },
|
||||
)).toBe(true);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: true, status: 'seeding' },
|
||||
{ speedLimit: '2M' },
|
||||
)).toBe(false);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: true, status: 'verifying' },
|
||||
{ torrentUploadLimit: '1M' },
|
||||
)).toBe(false);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: true, status: 'waitingToSeed' },
|
||||
{ torrentMaxPeers: 120 },
|
||||
)).toBe(false);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: true, status: 'downloading' },
|
||||
{ torrentTrackers: 'https://tracker.example/announce' },
|
||||
)).toBe(false);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: true, isTorrent: false, status: 'downloading' },
|
||||
{ speedLimit: '2M' },
|
||||
)).toBe(false);
|
||||
expect(isLivePropertiesPatch(
|
||||
{ isMedia: false, isTorrent: false, status: 'paused' },
|
||||
{ speedLimit: '2M' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
||||
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
||||
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
||||
expect(propertiesTorrentPeerLimit(0)).toBe(0);
|
||||
expect(propertiesTorrentPeerLimit(16.5)).toBe(55);
|
||||
});
|
||||
|
||||
it('recognizes expected diagnostics gaps without hiding real RPC failures', () => {
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('live Torrent file progress is unavailable'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has no current gid mapping'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer summary'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the first action locked when a duplicate request is rejected', () => {
|
||||
const inFlight = new Set<string>();
|
||||
const release = beginExclusivePropertiesAction(inFlight, 'window:download');
|
||||
|
||||
expect(() => beginExclusivePropertiesAction(inFlight, 'window:download'))
|
||||
.toThrow('Another Properties action is still in progress');
|
||||
expect(inFlight.has('window:download')).toBe(true);
|
||||
|
||||
release();
|
||||
release();
|
||||
expect(inFlight.has('window:download')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects actions from a superseded renderer session and older request IDs', () => {
|
||||
const registration = {
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-new',
|
||||
latestRequestId: 4,
|
||||
};
|
||||
|
||||
expect(shouldAcceptPropertiesActionRequest(registration, {
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-old',
|
||||
requestId: 99,
|
||||
})).toBe(false);
|
||||
expect(shouldAcceptPropertiesActionRequest(registration, {
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-new',
|
||||
requestId: 4,
|
||||
})).toBe(false);
|
||||
expect(shouldAcceptPropertiesActionRequest(registration, {
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-new',
|
||||
requestId: 5,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a request whose download binding does not match the window', () => {
|
||||
expect(shouldAcceptPropertiesActionRequest({
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-1',
|
||||
latestRequestId: 0,
|
||||
}, {
|
||||
downloadId: 'download-2',
|
||||
sessionId: 'session-1',
|
||||
requestId: 1,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('replays completed duplicate requests after a lost result and deduplicates retries', () => {
|
||||
const registration = {
|
||||
downloadId: 'download-1',
|
||||
sessionId: 'session-1',
|
||||
latestRequestId: 4,
|
||||
};
|
||||
const request = { downloadId: 'download-1', sessionId: 'session-1', requestId: 4 };
|
||||
|
||||
expect(classifyPropertiesActionRequest(registration, { ...request, requestId: 5 }, false, false)).toBe('accept');
|
||||
expect(classifyPropertiesActionRequest(registration, request, true, false)).toBe('replay');
|
||||
expect(classifyPropertiesActionRequest(registration, request, false, true)).toBe('pending');
|
||||
expect(classifyPropertiesActionRequest(registration, { ...request, requestId: 3 }, false, false)).toBe('ignore');
|
||||
expect(classifyPropertiesActionRequest(registration, { ...request, sessionId: 'session-old' }, false, false)).toBe('ignore');
|
||||
|
||||
const base = { windowLabel: 'properties-1', sessionId: 'session-1', requestId: 4 };
|
||||
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, requestId: 5 }));
|
||||
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, sessionId: 'session-2' }));
|
||||
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, windowLabel: 'properties-2' }));
|
||||
});
|
||||
|
||||
it('resets pending action state while advancing the bridge-generation request cursor', () => {
|
||||
expect(resetPropertiesActionState(9)).toEqual({
|
||||
requestId: 10,
|
||||
pendingAction: null,
|
||||
request: null,
|
||||
});
|
||||
expect(resetPropertiesActionState(Number.MAX_SAFE_INTEGER).requestId).toBe(1);
|
||||
});
|
||||
|
||||
it('serializes actions per window and continues after an earlier action fails', async () => {
|
||||
const chains = new Map<string, Promise<void>>();
|
||||
const events: string[] = [];
|
||||
let releaseFirst!: () => void;
|
||||
let markFirstStarted!: () => void;
|
||||
const firstGate = new Promise<void>(resolve => { releaseFirst = resolve; });
|
||||
const firstStarted = new Promise<void>(resolve => { markFirstStarted = resolve; });
|
||||
|
||||
const first = enqueuePropertiesAction(chains, 'window:download', async () => {
|
||||
events.push('first-start');
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
events.push('first-end');
|
||||
throw new Error('first action failed');
|
||||
});
|
||||
const second = enqueuePropertiesAction(chains, 'window:download', async () => {
|
||||
events.push('second');
|
||||
});
|
||||
|
||||
await firstStarted;
|
||||
expect(events).toEqual(['first-start']);
|
||||
releaseFirst();
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results[0].status).toBe('rejected');
|
||||
expect(results[1].status).toBe('fulfilled');
|
||||
expect(events).toEqual(['first-start', 'first-end', 'second']);
|
||||
expect(chains.size).toBe(0);
|
||||
});
|
||||
|
||||
it('coalesces repeated snapshot requests to one callback per animation frame', () => {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const delivered: string[] = [];
|
||||
let nextHandle = 0;
|
||||
const coalescer = createFrameCoalescer(
|
||||
key => delivered.push(key),
|
||||
callback => {
|
||||
const handle = ++nextHandle;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
handle => {
|
||||
frames.delete(handle);
|
||||
},
|
||||
);
|
||||
|
||||
coalescer.schedule('properties-1');
|
||||
coalescer.schedule('properties-1');
|
||||
coalescer.schedule('properties-2');
|
||||
expect(frames.size).toBe(2);
|
||||
for (const [handle, callback] of [...frames]) {
|
||||
frames.delete(handle);
|
||||
callback(0);
|
||||
}
|
||||
expect(delivered).toEqual(['properties-1', 'properties-2']);
|
||||
|
||||
coalescer.schedule('properties-1');
|
||||
coalescer.cancelAll();
|
||||
expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('unlistens a Tauri listener that resolves after bridge cleanup', async () => {
|
||||
let resolveListener!: (unlisten: () => void) => void;
|
||||
const listener = new Promise<() => void>(resolve => { resolveListener = resolve; });
|
||||
let disposed = true;
|
||||
let assigned = false;
|
||||
let unlistened = false;
|
||||
|
||||
attachAsyncPropertiesListener(
|
||||
listener,
|
||||
() => disposed,
|
||||
() => { assigned = true; },
|
||||
);
|
||||
resolveListener(() => { unlistened = true; });
|
||||
await listener;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(assigned).toBe(false);
|
||||
expect(unlistened).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns a live Tauri listener while the bridge is mounted', async () => {
|
||||
let resolveListener!: (unlisten: () => void) => void;
|
||||
const listener = new Promise<() => void>(resolve => { resolveListener = resolve; });
|
||||
const unlisten = vi.fn();
|
||||
let assigned: (() => void) | undefined;
|
||||
|
||||
attachAsyncPropertiesListener(listener, () => false, value => { assigned = value; });
|
||||
resolveListener(unlisten);
|
||||
await listener;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(assigned).toBe(unlisten);
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,564 +0,0 @@
|
||||
import { emitTo } from '@tauri-apps/api/event';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadErrorKind } from './bindings/DownloadErrorKind';
|
||||
import type { DownloadStatus } from './bindings/DownloadStatus';
|
||||
import type { DownloadItem } from './store/useDownloadStore';
|
||||
import { canPauseDownload } from './utils/downloadActions';
|
||||
import type { DocumentAppearance } from './utils/documentAppearance';
|
||||
import type { ResolvedWindowControlStyle } from './utils/windowControlStyle';
|
||||
import { invokeCommand as invoke } from './ipc';
|
||||
import { classifyDownloadError } from './utils/downloadErrors';
|
||||
|
||||
export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const;
|
||||
export const PROPERTIES_WINDOW_SNAPSHOT = 'properties-window-snapshot' as const;
|
||||
export const PROPERTIES_WINDOW_ACTION_REQUEST = 'properties-window-action-request' as const;
|
||||
export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result' as const;
|
||||
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
|
||||
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
||||
export const DEFAULT_PROPERTIES_TORRENT_MAX_PEERS = 55;
|
||||
|
||||
export type PropertiesWindowChrome = {
|
||||
controlStyle: ResolvedWindowControlStyle;
|
||||
side: 'left' | 'right';
|
||||
};
|
||||
|
||||
export const DEFAULT_PROPERTIES_WINDOW_CHROME: PropertiesWindowChrome = {
|
||||
controlStyle: 'macos',
|
||||
side: 'left',
|
||||
};
|
||||
|
||||
// Tauri's listen() default target is `Any`, which only receives events emitted
|
||||
// globally. Properties snapshots and child actions are emitted to a specific
|
||||
// WebviewWindow, so the child must register against that exact target. Keeping
|
||||
// the target construction here prevents a future listener from silently
|
||||
// falling back to the global target and waiting forever for its first
|
||||
// snapshot.
|
||||
export const propertiesWindowEventTarget = (windowLabel: string) => ({
|
||||
kind: 'WebviewWindow' as const,
|
||||
label: windowLabel,
|
||||
});
|
||||
|
||||
export const propertiesTorrentPeerLimit = (value: unknown): number =>
|
||||
typeof value === 'number'
|
||||
&& Number.isInteger(value)
|
||||
&& value >= 0
|
||||
&& value <= 1000
|
||||
? value
|
||||
: DEFAULT_PROPERTIES_TORRENT_MAX_PEERS;
|
||||
|
||||
const PROPERTIES_SNAPSHOT_KEYS = [
|
||||
'id',
|
||||
'url',
|
||||
'fileName',
|
||||
'status',
|
||||
'fraction',
|
||||
'speed',
|
||||
'eta',
|
||||
'size',
|
||||
'downloadedBytes',
|
||||
'totalBytes',
|
||||
'totalIsEstimate',
|
||||
'category',
|
||||
'dateAdded',
|
||||
'resumable',
|
||||
'connections',
|
||||
'speedLimit',
|
||||
'sftpHostKeyMd',
|
||||
'checksum',
|
||||
'destination',
|
||||
'isMedia',
|
||||
'mediaFormatSelector',
|
||||
'mediaQuality',
|
||||
'queueId',
|
||||
'queuePosition',
|
||||
'hasBeenDispatched',
|
||||
'lastError',
|
||||
'credentialsRequired',
|
||||
'lastErrorKind',
|
||||
'lastResolverFallback',
|
||||
'lastTry',
|
||||
'isTorrent',
|
||||
'torrentFileIndices',
|
||||
'torrentInfoHash',
|
||||
'torrentSeedTime',
|
||||
'torrentSeedRatio',
|
||||
'torrentSeedRemaining',
|
||||
'torrentUploadedBytes',
|
||||
'torrentSeededSeconds',
|
||||
'torrentRelocationCheckPending',
|
||||
'torrentMoveDestination',
|
||||
'torrentMoveRestoreStatus',
|
||||
'torrentWebSeeds',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
'torrentCheckIntegrity',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
'torrentTrackerConnectTimeout',
|
||||
'torrentTrackerTimeout',
|
||||
'torrentTrackerInterval',
|
||||
'torrentStopTimeout',
|
||||
'torrentPrioritizePiece',
|
||||
'torrentRemoveUnselectedFile',
|
||||
'torrentEncryptionPolicy',
|
||||
'torrentFileAllocation',
|
||||
'torrentVerifyOnly',
|
||||
'torrentVerifyRestoreStatus',
|
||||
] as const satisfies readonly (keyof DownloadItem)[];
|
||||
|
||||
export const isExpectedPropertiesDiagnosticUnavailable = (error: unknown): boolean => {
|
||||
const message = (error instanceof Error ? error.message : String(error)).trim().toLowerCase();
|
||||
if (message.startsWith('torrent lifecycle changed while reading ')) return true;
|
||||
return [
|
||||
'torrent peer diagnostics are unavailable for this lifecycle',
|
||||
'torrent availability is unavailable for this lifecycle',
|
||||
'live torrent file progress is unavailable',
|
||||
'live torrent piece progress is unavailable',
|
||||
'active torrent transfer has no gid',
|
||||
'active torrent transfer has no current gid mapping',
|
||||
'active torrent transfer has a stale control epoch',
|
||||
'active torrent has no gid',
|
||||
'active torrent has no current gid mapping',
|
||||
'active torrent has a stale control epoch',
|
||||
].includes(message);
|
||||
};
|
||||
|
||||
export type PropertiesDiagnosticPhase = 'idle' | 'initial' | 'refreshing' | 'stale' | 'unavailable' | 'error';
|
||||
export type PropertiesDiagnosticOutcome = 'request-start' | 'success' | 'expected-unavailable' | 'unexpected-error';
|
||||
|
||||
export const propertiesDiagnosticPhase = (
|
||||
hasCachedResult: boolean,
|
||||
outcome: PropertiesDiagnosticOutcome,
|
||||
hasPreviousAttempt = false,
|
||||
): PropertiesDiagnosticPhase => {
|
||||
if (outcome === 'request-start') return hasCachedResult || hasPreviousAttempt ? 'refreshing' : 'initial';
|
||||
if (outcome === 'success') return 'idle';
|
||||
if (outcome === 'expected-unavailable') return hasCachedResult ? 'stale' : 'unavailable';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
export const propertiesDiagnosticRequestState = (
|
||||
hasCachedResult: boolean,
|
||||
hasPreviousAttempt: boolean,
|
||||
manual: boolean,
|
||||
) => ({
|
||||
loading: !hasPreviousAttempt && !hasCachedResult,
|
||||
refreshing: manual && (hasPreviousAttempt || hasCachedResult),
|
||||
resetMessage: !hasPreviousAttempt || hasCachedResult,
|
||||
phase: propertiesDiagnosticPhase(hasCachedResult, 'request-start', hasPreviousAttempt),
|
||||
});
|
||||
|
||||
export const formatPropertiesQueuePlacement = (
|
||||
queueName: unknown,
|
||||
queuePosition: unknown,
|
||||
formatPosition: (position: number) => string,
|
||||
): string => {
|
||||
const name = typeof queueName === 'string' ? queueName.trim() : '';
|
||||
const hasPosition = typeof queuePosition === 'number'
|
||||
&& Number.isInteger(queuePosition)
|
||||
&& queuePosition >= 0;
|
||||
const position = hasPosition ? formatPosition(queuePosition + 1) : '';
|
||||
if (name && position) return `${name} · ${position}`;
|
||||
return name || position || '—';
|
||||
};
|
||||
|
||||
type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
|
||||
|
||||
export type PropertiesSnapshotContext = {
|
||||
queueName?: string;
|
||||
windowChrome?: PropertiesWindowChrome;
|
||||
allocationPending?: boolean;
|
||||
};
|
||||
|
||||
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||
appearance: DocumentAppearance;
|
||||
windowChrome: PropertiesWindowChrome;
|
||||
queueName?: string;
|
||||
allocationPending?: boolean;
|
||||
lastErrorKind?: DownloadErrorKind;
|
||||
lastResolverFallback?: boolean;
|
||||
activeConnections?: number;
|
||||
requestedConnections?: number;
|
||||
effectiveConnections?: number;
|
||||
uploadSpeed?: string;
|
||||
torrentConnectedPeers?: number;
|
||||
torrentConnectedSeeders?: number;
|
||||
moveProgress?: number;
|
||||
hasPassword: boolean;
|
||||
hasCookies: boolean;
|
||||
hasHeaders: boolean;
|
||||
hasUsername: boolean;
|
||||
hasMirrors: boolean;
|
||||
};
|
||||
|
||||
export const redactPropertiesError = (error: unknown): string => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
return text
|
||||
.replace(/(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+/gi, '$1[redacted]')
|
||||
.replace(/((?:cookie|set-cookie|proxy-authorization)\s*:\s*)[^\r\n]+/gi, '$1[redacted]')
|
||||
.replace(/((?:https?|sftp|ftp):\/\/)[^\s]*@/gi, '$1[redacted]@')
|
||||
.replace(/([?&](?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential|code)=)[^&#\s]*/gi, '$1[redacted]')
|
||||
.replace(/\b(?:token|access_token|refresh_token|api[_-]?key|secret|password|passwd|signature|sig|auth|credential)=\S+/gi, match => `${match.slice(0, match.indexOf('=') + 1)}[redacted]`);
|
||||
};
|
||||
|
||||
export type SecretPatch =
|
||||
| { kind: 'unchanged' }
|
||||
| { kind: 'replace'; value: string }
|
||||
| { kind: 'clear' };
|
||||
|
||||
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
|
||||
'destination',
|
||||
'sftpHostKeyMd',
|
||||
'speedLimit',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
'torrentSeedTime',
|
||||
'torrentSeedRatio',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
'torrentTrackerConnectTimeout',
|
||||
'torrentTrackerTimeout',
|
||||
'torrentTrackerInterval',
|
||||
'torrentStopTimeout',
|
||||
'torrentPrioritizePiece',
|
||||
'torrentEncryptionPolicy',
|
||||
'torrentFileAllocation',
|
||||
] as const;
|
||||
|
||||
type PropertiesPatchClearableKey = typeof PROPERTIES_PATCH_CLEARABLE_KEYS[number];
|
||||
|
||||
type PropertiesPatchClearableValues = {
|
||||
[Key in PropertiesPatchClearableKey]?: DownloadItem[Key] | null;
|
||||
};
|
||||
|
||||
export type PropertiesPatch = Partial<Omit<DownloadItem,
|
||||
'password' | 'cookies' | 'headers' | 'username' | PropertiesPatchClearableKey
|
||||
>> & PropertiesPatchClearableValues & {
|
||||
username?: SecretPatch;
|
||||
password?: SecretPatch;
|
||||
cookies?: SecretPatch;
|
||||
headers?: SecretPatch;
|
||||
};
|
||||
|
||||
// Tauri command arguments cross a JSON boundary. `undefined` object members
|
||||
// may be omitted before they reach Rust, so nullable fields are the explicit
|
||||
// wire-level sentinel for clearing an optional per-download override.
|
||||
export const encodePropertiesPatchValue = <T>(value: T | undefined): T | null => value ?? null;
|
||||
|
||||
export const decodePropertiesPatchValue = <T>(value: T | null | undefined): T | undefined =>
|
||||
value === null ? undefined : value;
|
||||
|
||||
export type PropertiesAction =
|
||||
| 'apply-properties'
|
||||
| 'set-torrent-file-selection'
|
||||
| 'pause-resume'
|
||||
| 'verify-torrent'
|
||||
| 'set-download-limit'
|
||||
| 'set-torrent-upload-limit'
|
||||
| 'set-torrent-peer-options';
|
||||
|
||||
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
||||
|
||||
export const getPropertiesLifecycleAction = (
|
||||
status: DownloadStatus,
|
||||
): PropertiesLifecycleAction | null => {
|
||||
if (status === 'ready') return 'start';
|
||||
if (canPauseDownload(status)) return 'pause';
|
||||
if (status === 'paused') return 'resume';
|
||||
if (status === 'failed') return 'retry';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const beginExclusivePropertiesAction = (
|
||||
inFlight: Set<string>,
|
||||
key: string,
|
||||
): (() => void) => {
|
||||
if (inFlight.has(key)) {
|
||||
throw new Error('Another Properties action is still in progress');
|
||||
}
|
||||
inFlight.add(key);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
inFlight.delete(key);
|
||||
};
|
||||
};
|
||||
|
||||
export type PropertiesWindowReady = {
|
||||
windowLabel: string;
|
||||
downloadId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type PropertiesActionRequest = {
|
||||
windowLabel: string;
|
||||
downloadId: string;
|
||||
sessionId: string;
|
||||
requestId: number;
|
||||
action: PropertiesAction;
|
||||
payload?: PropertiesPatch
|
||||
| { selectedIndices: number[] | null }
|
||||
| { limit: string | null }
|
||||
| { maxPeers: string | null; peerSpeedLimit: string | null }
|
||||
| { resumeWithoutCredentials: boolean };
|
||||
};
|
||||
|
||||
export type PropertiesActionResult = {
|
||||
windowLabel: string;
|
||||
downloadId: string;
|
||||
sessionId: string;
|
||||
requestId: number;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const nextPropertiesRequestId = (requestId: number): number =>
|
||||
requestId >= Number.MAX_SAFE_INTEGER ? 1 : requestId + 1;
|
||||
|
||||
export const resetPropertiesActionState = (requestId: number) => ({
|
||||
requestId: nextPropertiesRequestId(requestId),
|
||||
pendingAction: null as PropertiesAction | null,
|
||||
request: null as PropertiesActionRequest | null,
|
||||
});
|
||||
|
||||
export type PropertiesSnapshotEvent = {
|
||||
windowLabel: string;
|
||||
downloadId: string;
|
||||
sessionId: string;
|
||||
bridgeGeneration: number;
|
||||
revision: number;
|
||||
snapshot: PropertiesSnapshot;
|
||||
};
|
||||
|
||||
export type PropertiesWindowRegistration = {
|
||||
downloadId: string;
|
||||
sessionId: string;
|
||||
latestRequestId: number;
|
||||
};
|
||||
|
||||
export type PropertiesActionRequestDisposition = 'accept' | 'replay' | 'pending' | 'ignore';
|
||||
|
||||
export const propertiesActionRequestKey = (
|
||||
request: Pick<PropertiesActionRequest, 'windowLabel' | 'sessionId' | 'requestId'>,
|
||||
): string => `${request.windowLabel}\u0000${request.sessionId}\u0000${request.requestId}`;
|
||||
|
||||
export const classifyPropertiesActionRequest = (
|
||||
registration: PropertiesWindowRegistration | undefined,
|
||||
request: Pick<PropertiesActionRequest, 'downloadId' | 'sessionId' | 'requestId'>,
|
||||
hasCachedResult: boolean,
|
||||
isInFlight: boolean,
|
||||
): PropertiesActionRequestDisposition => {
|
||||
if (registration === undefined
|
||||
|| registration.downloadId !== request.downloadId
|
||||
|| registration.sessionId !== request.sessionId
|
||||
|| !Number.isSafeInteger(request.requestId)
|
||||
|| request.requestId <= 0) {
|
||||
return 'ignore';
|
||||
}
|
||||
if (hasCachedResult) return 'replay';
|
||||
if (isInFlight) return 'pending';
|
||||
return request.requestId > registration.latestRequestId ? 'accept' : 'ignore';
|
||||
};
|
||||
|
||||
export const shouldAcceptPropertiesActionRequest = (
|
||||
registration: PropertiesWindowRegistration | undefined,
|
||||
request: Pick<PropertiesActionRequest, 'downloadId' | 'sessionId' | 'requestId'>,
|
||||
): boolean => registration !== undefined
|
||||
&& registration.downloadId === request.downloadId
|
||||
&& registration.sessionId === request.sessionId
|
||||
&& Number.isSafeInteger(request.requestId)
|
||||
&& request.requestId > registration.latestRequestId;
|
||||
|
||||
export const enqueuePropertiesAction = (
|
||||
chains: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const previous = chains.get(key) ?? Promise.resolve();
|
||||
const operation = previous.catch(() => undefined).then(action);
|
||||
let tracked: Promise<void>;
|
||||
tracked = operation.finally(() => {
|
||||
if (chains.get(key) === tracked) chains.delete(key);
|
||||
});
|
||||
chains.set(key, tracked);
|
||||
return tracked;
|
||||
};
|
||||
|
||||
const copyWithoutSecrets = (
|
||||
item: DownloadItem,
|
||||
appearance: DocumentAppearance,
|
||||
live?: {
|
||||
progress?: DownloadProgressEvent;
|
||||
moveProgress?: number;
|
||||
},
|
||||
context?: PropertiesSnapshotContext,
|
||||
): PropertiesSnapshot => {
|
||||
const safeItem = Object.fromEntries(
|
||||
PROPERTIES_SNAPSHOT_KEYS.flatMap(key => (
|
||||
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
||||
)),
|
||||
) as SafePropertiesFields;
|
||||
if (typeof safeItem.lastError === 'string') {
|
||||
safeItem.lastError = redactPropertiesError(safeItem.lastError);
|
||||
}
|
||||
if (item.isTorrent === true) delete safeItem.connections;
|
||||
const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError);
|
||||
return {
|
||||
...safeItem,
|
||||
appearance,
|
||||
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||
...(lastErrorKind ? { lastErrorKind } : {}),
|
||||
...(context?.queueName ? { queueName: context.queueName } : {}),
|
||||
...(context?.allocationPending === true ? { allocationPending: true } : {}),
|
||||
...(live?.progress ? {
|
||||
fraction: live.progress.fraction,
|
||||
speed: item.status === 'seeding'
|
||||
? live.progress.upload_speed ?? live.progress.speed
|
||||
: live.progress.speed,
|
||||
eta: item.status === 'seeding' ? '-' : live.progress.eta,
|
||||
...(live.progress.size ? { size: live.progress.size } : {}),
|
||||
...(live.progress.downloaded_bytes !== undefined
|
||||
? { downloadedBytes: live.progress.downloaded_bytes }
|
||||
: {}),
|
||||
...(live.progress.total_bytes !== undefined
|
||||
? { totalBytes: live.progress.total_bytes }
|
||||
: {}),
|
||||
...(live.progress.total_is_estimate !== undefined
|
||||
? { totalIsEstimate: live.progress.total_is_estimate }
|
||||
: {}),
|
||||
...(live.progress.active_connections !== undefined
|
||||
? item.isTorrent === true
|
||||
? { torrentConnectedPeers: live.progress.active_connections }
|
||||
: item.isMedia !== true
|
||||
? { activeConnections: live.progress.active_connections }
|
||||
: {}
|
||||
: {}),
|
||||
...(item.isTorrent !== true
|
||||
&& item.isMedia !== true
|
||||
&& live.progress.requested_connections !== undefined
|
||||
? { requestedConnections: live.progress.requested_connections }
|
||||
: {}),
|
||||
...(item.isTorrent !== true
|
||||
&& item.isMedia !== true
|
||||
&& live.progress.effective_connections !== undefined
|
||||
? { effectiveConnections: live.progress.effective_connections }
|
||||
: {}),
|
||||
...(live.progress.uploaded_bytes !== undefined
|
||||
? { torrentUploadedBytes: live.progress.uploaded_bytes }
|
||||
: {}),
|
||||
...(live.progress.upload_speed !== undefined
|
||||
? { uploadSpeed: live.progress.upload_speed }
|
||||
: {}),
|
||||
...(live.progress.num_seeders !== undefined && item.isTorrent === true
|
||||
? { torrentConnectedSeeders: live.progress.num_seeders }
|
||||
: {}),
|
||||
...(live.progress.torrent_seeded_seconds !== undefined
|
||||
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
|
||||
: {}),
|
||||
} : {}),
|
||||
...(live?.moveProgress !== undefined ? { moveProgress: live.moveProgress } : {}),
|
||||
hasPassword: Boolean(item.password),
|
||||
hasCookies: Boolean(item.cookies),
|
||||
hasHeaders: Boolean(item.headers),
|
||||
hasUsername: Boolean(item.username),
|
||||
hasMirrors: Boolean(item.mirrors),
|
||||
};
|
||||
};
|
||||
|
||||
export const sanitizePropertiesSnapshot = copyWithoutSecrets;
|
||||
|
||||
export const createFrameCoalescer = (
|
||||
callback: (key: string) => void,
|
||||
requestFrame: (callback: FrameRequestCallback) => number,
|
||||
cancelFrame: (handle: number) => void,
|
||||
) => {
|
||||
const pending = new Map<string, number>();
|
||||
return {
|
||||
schedule(key: string) {
|
||||
if (pending.has(key)) return;
|
||||
const handle = requestFrame(() => {
|
||||
pending.delete(key);
|
||||
callback(key);
|
||||
});
|
||||
pending.set(key, handle);
|
||||
},
|
||||
cancel(key: string) {
|
||||
const handle = pending.get(key);
|
||||
if (handle === undefined) return;
|
||||
pending.delete(key);
|
||||
cancelFrame(handle);
|
||||
},
|
||||
cancelAll() {
|
||||
for (const handle of pending.values()) cancelFrame(handle);
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Tauri listener registration is asynchronous. React StrictMode can unmount
|
||||
// an effect before `listen()` resolves; in that case assigning the late
|
||||
// unlisten callback after cleanup leaks a second bridge listener. A leaked
|
||||
// Properties host can process one click twice, observe the queued state from
|
||||
// the first action, and turn the intended resume into an immediate pause.
|
||||
export const attachAsyncPropertiesListener = <T extends UnlistenFn>(
|
||||
listener: Promise<T>,
|
||||
isDisposed: () => boolean,
|
||||
assign: (unlisten: T) => void,
|
||||
): void => {
|
||||
void listener.then(unlisten => {
|
||||
if (isDisposed()) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
assign(unlisten);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
|
||||
invoke('open_download_properties_window', { id: downloadId });
|
||||
|
||||
export const sendPropertiesReady = (sessionId: string): Promise<void> =>
|
||||
invoke('properties_window_send_ready', { sessionId });
|
||||
|
||||
export const sendPropertiesActionRequest = (payload: PropertiesActionRequest): Promise<void> =>
|
||||
invoke('properties_window_send_action', {
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.requestId,
|
||||
action: payload.action,
|
||||
payload: payload.payload,
|
||||
});
|
||||
|
||||
export const sendPropertiesSnapshot = (windowLabel: string, payload: PropertiesSnapshotEvent): Promise<void> =>
|
||||
emitTo(propertiesWindowEventTarget(windowLabel), PROPERTIES_WINDOW_SNAPSHOT, payload);
|
||||
|
||||
export const sendPropertiesActionResult = (windowLabel: string, payload: PropertiesActionResult): Promise<void> =>
|
||||
emitTo(propertiesWindowEventTarget(windowLabel), PROPERTIES_WINDOW_ACTION_RESULT, payload);
|
||||
|
||||
export const sendPropertiesRemoved = (windowLabel: string, downloadId: string): Promise<void> =>
|
||||
emitTo(propertiesWindowEventTarget(windowLabel), PROPERTIES_WINDOW_REMOVED, { windowLabel, downloadId });
|
||||
|
||||
export const applySecretPatch = (
|
||||
patch: unknown,
|
||||
existing: string | undefined,
|
||||
): string | undefined => {
|
||||
if (patch === undefined) return existing;
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new Error('Invalid secret patch');
|
||||
}
|
||||
const candidate = patch as Record<string, unknown>;
|
||||
switch (candidate.kind) {
|
||||
case 'unchanged':
|
||||
return existing;
|
||||
case 'clear':
|
||||
return undefined;
|
||||
case 'replace':
|
||||
if (typeof candidate.value !== 'string') throw new Error('Invalid secret value');
|
||||
return candidate.value;
|
||||
default:
|
||||
throw new Error('Invalid secret patch');
|
||||
}
|
||||
};
|
||||
@@ -3,114 +3,24 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
retainedProgressMap: Record<string, DownloadProgressEvent>;
|
||||
moveProgressMap: Record<string, number>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
resetDownloadProgress: (id: string) => void;
|
||||
setMoveProgress: (id: string, fraction: number) => void;
|
||||
clearMoveProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
const finiteNonNegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const retainProgressSnapshot = (
|
||||
previous: DownloadProgressEvent | undefined,
|
||||
next: DownloadProgressEvent,
|
||||
): DownloadProgressEvent => {
|
||||
const previousDownloaded = finiteNonNegative(previous?.downloaded_bytes)
|
||||
? previous.downloaded_bytes
|
||||
: undefined;
|
||||
const nextDownloaded = finiteNonNegative(next.downloaded_bytes)
|
||||
? next.downloaded_bytes
|
||||
: undefined;
|
||||
const downloadedBytes = previousDownloaded === undefined
|
||||
? nextDownloaded
|
||||
: nextDownloaded === undefined
|
||||
? previousDownloaded
|
||||
: Math.max(previousDownloaded, nextDownloaded);
|
||||
|
||||
const exactTotal = [next, previous]
|
||||
.find(snapshot => snapshot?.total_is_estimate === false
|
||||
&& finiteNonNegative(snapshot.total_bytes))
|
||||
?.total_bytes;
|
||||
const totalBytes = exactTotal
|
||||
?? (finiteNonNegative(next.total_bytes)
|
||||
? next.total_bytes
|
||||
: finiteNonNegative(previous?.total_bytes)
|
||||
? previous.total_bytes
|
||||
: undefined);
|
||||
const totalIsEstimate = exactTotal !== undefined
|
||||
? false
|
||||
: next.total_is_estimate ?? previous?.total_is_estimate;
|
||||
const fractions = [previous?.fraction, next.fraction]
|
||||
.filter(finiteNonNegative);
|
||||
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
|
||||
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
fraction: fractions.length > 0
|
||||
? Math.min(1, Math.max(0, Math.max(...fractions)))
|
||||
: next.fraction,
|
||||
...(downloadedBytes !== undefined ? { downloaded_bytes: downloadedBytes } : {}),
|
||||
...(totalBytes !== undefined ? { total_bytes: totalBytes } : {}),
|
||||
...(totalIsEstimate !== undefined ? { total_is_estimate: totalIsEstimate } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
retainedProgressMap: {},
|
||||
moveProgressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
retainedProgressMap: {
|
||||
...state.retainedProgressMap,
|
||||
[id]: retainProgressSnapshot(state.retainedProgressMap[id], payload),
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap) && !(id in state.moveProgressMap)) return state;
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
const nextMove = { ...state.moveProgressMap };
|
||||
delete nextMove[id];
|
||||
return { progressMap: next, moveProgressMap: nextMove };
|
||||
}),
|
||||
resetDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)
|
||||
&& !(id in state.retainedProgressMap)
|
||||
&& !(id in state.moveProgressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
const nextRetained = { ...state.retainedProgressMap };
|
||||
delete nextRetained[id];
|
||||
const nextMove = { ...state.moveProgressMap };
|
||||
delete nextMove[id];
|
||||
return {
|
||||
progressMap: next,
|
||||
retainedProgressMap: nextRetained,
|
||||
moveProgressMap: nextMove
|
||||
};
|
||||
}),
|
||||
setMoveProgress: (id, fraction) =>
|
||||
set((state) => ({
|
||||
moveProgressMap: { ...state.moveProgressMap, [id]: fraction }
|
||||
})),
|
||||
clearMoveProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.moveProgressMap)) return state;
|
||||
const next = { ...state.moveProgressMap };
|
||||
delete next[id];
|
||||
return { moveProgressMap: next };
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
|
||||
import {
|
||||
clearDownloadControlIntents,
|
||||
initializeDownloadPersistence,
|
||||
downloadControlIntentFor,
|
||||
setDownloadControlIntent,
|
||||
useDownloadStore
|
||||
@@ -18,7 +17,7 @@ describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
@@ -44,7 +43,7 @@ describe('useDownloadProgressStore', () => {
|
||||
const first = initDownloadListener();
|
||||
const second = initDownloadListener();
|
||||
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(5);
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
|
||||
|
||||
const releaseFirst = await first;
|
||||
const releaseSecond = await second;
|
||||
@@ -52,7 +51,7 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(5);
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
@@ -92,353 +91,6 @@ describe('useDownloadProgressStore', () => {
|
||||
release();
|
||||
});
|
||||
|
||||
it('ignores malformed state events instead of projecting an unknown status', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'malformed-state',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'malformed-state',
|
||||
status: 'not-a-download-status',
|
||||
error: { secret: 'must not enter the store' }
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
id: 'malformed-state',
|
||||
status: 'downloading'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('lastError');
|
||||
release();
|
||||
});
|
||||
|
||||
it('removes a row from backend pending order when its lifecycle becomes active or retrying', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'pending-transition',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}],
|
||||
pendingOrder: ['pending-transition']
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'pending-transition',
|
||||
status: 'downloading'
|
||||
} });
|
||||
expect(useDownloadStore.getState().pendingOrder).toEqual([]);
|
||||
|
||||
useDownloadStore.getState().updateDownload('pending-transition', { status: 'downloading' });
|
||||
useDownloadStore.setState({ pendingOrder: ['pending-transition'] });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'pending-transition',
|
||||
status: 'retrying',
|
||||
error: 'network dropped'
|
||||
} });
|
||||
expect(useDownloadStore.getState().pendingOrder).toEqual([]);
|
||||
release();
|
||||
});
|
||||
|
||||
it('rejects malformed live progress values at the event boundary', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'malformed-progress',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'malformed-progress',
|
||||
fraction: 0.5,
|
||||
speed: '1 MB/s',
|
||||
eta: '1s',
|
||||
size: '1 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: -1,
|
||||
total_bytes: Number.NaN,
|
||||
active_connections: -2
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap['malformed-progress'])
|
||||
.not.toHaveProperty('downloaded_bytes');
|
||||
expect(useDownloadProgressStore.getState().progressMap['malformed-progress'])
|
||||
.not.toHaveProperty('total_bytes');
|
||||
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('downloadedBytes');
|
||||
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('totalBytes');
|
||||
release();
|
||||
});
|
||||
|
||||
it('accepts a progress frame that omits the optional size value', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'omitted-size',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'omitted-size',
|
||||
fraction: 0.25,
|
||||
speed: '1 MB/s',
|
||||
eta: '1s',
|
||||
size_is_final: false
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap['omitted-size'])
|
||||
.toMatchObject({ id: 'omitted-size', fraction: 0.25, size: null });
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps the last valid live frame when a malformed fraction arrives', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'invalid-fraction',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'invalid-fraction',
|
||||
fraction: 0.4,
|
||||
speed: '1 MB/s',
|
||||
eta: '1s',
|
||||
size: '1 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 400
|
||||
} });
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'invalid-fraction',
|
||||
fraction: 2,
|
||||
speed: '2 MB/s',
|
||||
eta: '0s',
|
||||
size: '1 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 2000
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap['invalid-fraction'])
|
||||
.toMatchObject({ fraction: 0.4, downloaded_bytes: 400 });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fraction: 0.4,
|
||||
downloadedBytes: 400
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('projects native allocation events after admission and ignores stale generations', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'native-allocation',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: true,
|
||||
lifecycleGeneration: 'not-a-generation'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: true,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '1'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false);
|
||||
release();
|
||||
});
|
||||
|
||||
it('retains a native allocation marker received before row hydration', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [],
|
||||
allocationPendingIds: new Set()
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'hydrating-allocation',
|
||||
pending: true,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
|
||||
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'hydrating-allocation',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'hydrating-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(false);
|
||||
release();
|
||||
});
|
||||
|
||||
it('applies the authoritative destination carried by Torrent move completion', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'moving-torrent',
|
||||
url: 'magnet:?xt=urn:btih:test',
|
||||
fileName: 'data',
|
||||
destination: '/old-root',
|
||||
status: 'moving',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
}],
|
||||
});
|
||||
useDownloadProgressStore.getState().setMoveProgress('moving-torrent', 0.8);
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'moving-torrent',
|
||||
status: 'paused',
|
||||
error: 'stale pause from the previous lifecycle',
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('moving');
|
||||
expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBe(0.8);
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'moving-torrent',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
destination: '/new-root',
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].destination).toBe('/new-root');
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
expect(useDownloadProgressStore.getState().moveProgressMap['moving-torrent']).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('invalidates replacement authorization when a native state event changes identity', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'native-identity-change',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'old.bin',
|
||||
status: 'staged',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
replaceExistingFingerprint: 'original-target-fingerprint',
|
||||
}],
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'native-identity-change',
|
||||
status: 'ready',
|
||||
error: null,
|
||||
fileName: 'new.bin',
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].fileName).toBe('new.bin');
|
||||
expect(useDownloadStore.getState().downloads[0].replaceExistingFingerprint).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps Aria2 connection telemetry from the live progress event', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
@@ -473,193 +125,6 @@ describe('useDownloadProgressStore', () => {
|
||||
release();
|
||||
});
|
||||
|
||||
it('projects resolver error metadata and clears it when the lifecycle resumes', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'resolver-error',
|
||||
url: 'https://example.test/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
}],
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resolver-error',
|
||||
status: 'retrying',
|
||||
error: 'aria2 error code 19: Name resolution failed',
|
||||
errorKind: 'nameResolution',
|
||||
resolverFallback: true,
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].lastErrorKind).toBe('nameResolution');
|
||||
expect(useDownloadStore.getState().downloads[0].lastResolverFallback).toBe(true);
|
||||
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'resolver-error',
|
||||
status: 'downloading',
|
||||
error: null,
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0].lastErrorKind).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0].lastResolverFallback).toBeUndefined();
|
||||
release();
|
||||
});
|
||||
|
||||
it('projects torrent seeding state and upload telemetry', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'torrent-seeding',
|
||||
url: 'magnet:?xt=urn:btih:test',
|
||||
fileName: 'ubuntu.iso',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'torrent-seeding',
|
||||
status: 'seeding'
|
||||
} });
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'torrent-seeding',
|
||||
fraction: 1,
|
||||
speed: '0 B/s',
|
||||
eta: '-',
|
||||
size: '2 GB',
|
||||
size_is_final: false,
|
||||
uploaded_bytes: 1048576,
|
||||
upload_speed: '512 KiB/s',
|
||||
num_seeders: 4,
|
||||
active_connections: 6,
|
||||
requested_connections: 8
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'seeding',
|
||||
fraction: 1,
|
||||
speed: '512 KiB/s',
|
||||
eta: '-'
|
||||
});
|
||||
expect(useDownloadProgressStore.getState().progressMap['torrent-seeding'])
|
||||
.toMatchObject({ uploaded_bytes: 1048576, upload_speed: '512 KiB/s', num_seeders: 4 });
|
||||
release();
|
||||
});
|
||||
|
||||
it('does not regress a seeding row from a delayed active state event', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'torrent-seeding-race',
|
||||
url: 'magnet:?xt=urn:btih:test',
|
||||
fileName: 'ubuntu.iso',
|
||||
status: 'seeding',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'torrent-seeding-race',
|
||||
status: 'downloading'
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'torrent-seeding-race',
|
||||
status: 'queued'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('seeding');
|
||||
release();
|
||||
});
|
||||
|
||||
it('accepts Torrent verification while a row is seeding', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'torrent-seeding-verification',
|
||||
url: 'magnet:?xt=urn:btih:test',
|
||||
fileName: 'ubuntu.iso',
|
||||
status: 'seeding',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'torrent-seeding-verification',
|
||||
status: 'verifying'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('verifying');
|
||||
release();
|
||||
});
|
||||
|
||||
it('durably acknowledges a completed Torrent verification before clearing its marker', async () => {
|
||||
const handlers: Record<string, (event: any) => unknown> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => unknown;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
const persistedMarkers: Array<boolean | undefined> = [];
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: any) => {
|
||||
if (command === 'db_commit_download_state') {
|
||||
const records = JSON.parse(args.downloadsData) as Array<{ torrentVerifyOnly?: boolean }>;
|
||||
persistedMarkers.push(records[0]?.torrentVerifyOnly);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'torrent-verification-ack',
|
||||
url: 'magnet:?xt=urn:btih:test',
|
||||
fileName: 'ubuntu.iso',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
torrentVerifyOnly: true,
|
||||
torrentVerifyRestoreStatus: 'paused'
|
||||
}] as any[]
|
||||
});
|
||||
const disposePersistence = initializeDownloadPersistence('main');
|
||||
|
||||
try {
|
||||
const release = await initDownloadListener();
|
||||
await handlers['download-state']({ payload: {
|
||||
id: 'torrent-verification-ack',
|
||||
status: 'paused'
|
||||
} });
|
||||
|
||||
expect(persistedMarkers).toEqual([true, undefined]);
|
||||
expect(useDownloadStore.getState().downloads[0].torrentVerifyOnly).toBeUndefined();
|
||||
release();
|
||||
} finally {
|
||||
disposePersistence();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears progress when events arrive after a download row was removed', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
@@ -774,182 +239,6 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(row.totalBytes).toBe(10240);
|
||||
expect(row.totalIsEstimate).toBe(true);
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadProgressStore.getState().retainedProgressMap.snapshot).toMatchObject({
|
||||
fraction: 0.8,
|
||||
downloaded_bytes: 8192,
|
||||
total_bytes: 10240
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('retains progress for failed and paused rows when the live entry is absent', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'terminal-progress',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
fraction: 0.7,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '10 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 7000,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
status: 'paused',
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
fraction: 0.7,
|
||||
downloadedBytes: 7000
|
||||
});
|
||||
|
||||
useDownloadStore.setState(state => ({
|
||||
downloads: state.downloads.map(download => ({ ...download, status: 'downloading' as const }))
|
||||
}));
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
status: 'failed',
|
||||
error: 'network stopped',
|
||||
progress: {
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
}
|
||||
} });
|
||||
expect(useDownloadProgressStore.getState().progressMap['terminal-progress']).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps retained bytes when a paused GID resumes through a queued state', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'same-gid-resume',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'same-gid-resume',
|
||||
fraction: 0.6,
|
||||
speed: '1 MB/s',
|
||||
eta: '4s',
|
||||
size: '10 KB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 6000,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
useDownloadStore.setState(state => ({
|
||||
downloads: state.downloads.map(download => ({
|
||||
...download,
|
||||
status: 'queued' as const
|
||||
}))
|
||||
}));
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'same-gid-resume',
|
||||
status: 'queued'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap['same-gid-resume']).toBeUndefined();
|
||||
expect(useDownloadProgressStore.getState().retainedProgressMap['same-gid-resume']).toMatchObject({
|
||||
downloaded_bytes: 6000,
|
||||
total_bytes: 10000
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps the greatest retained byte count across retry frames', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
handlers[event] = handler as (event: any) => void;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'retry-progress',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
const progress = (fraction: number, downloadedBytes: number) => handlers['download-progress']({ payload: {
|
||||
id: 'retry-progress',
|
||||
fraction,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '10 KB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: downloadedBytes,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
progress(0.8, 8000);
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'retry-progress',
|
||||
status: 'retrying',
|
||||
error: 'network dropped'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'retrying',
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
useDownloadStore.getState().updateDownload('retry-progress', { status: 'downloading' });
|
||||
progress(0.1, 1000);
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'retry-progress',
|
||||
status: 'failed',
|
||||
error: 'retry exhausted'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
|
||||
+48
-397
@@ -1,19 +1,12 @@
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { DownloadStateEvent } from '../bindings/DownloadStateEvent';
|
||||
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
||||
import { listenEvent as listen } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import { canStartDownload } from '../utils/downloadActions';
|
||||
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import i18n from '../i18n';
|
||||
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
commitDownloadState,
|
||||
currentDownloadLifecycleGeneration,
|
||||
downloadControlIntentFor,
|
||||
hasStaleTemporaryMediaEstimate,
|
||||
useDownloadStore
|
||||
@@ -22,178 +15,16 @@ import {
|
||||
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenAllocation: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
let unlistenMoveProgress: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
let listenerSetup: Promise<void> | null = null;
|
||||
let listenerConsumers = 0;
|
||||
|
||||
type ProgressFields = {
|
||||
fraction?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
totalIsEstimate?: boolean;
|
||||
};
|
||||
|
||||
const finiteNonNegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const isDownloadErrorKind = (value: unknown): value is DownloadErrorKind =>
|
||||
value === 'nameResolution' || value === 'destinationAccess';
|
||||
|
||||
const isLifecycleGeneration = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^\d+$/.test(value);
|
||||
|
||||
type SanitizedDownloadStateEvent = Omit<DownloadStateEvent, 'status'> & {
|
||||
status: DownloadStatus;
|
||||
};
|
||||
|
||||
const sanitizeStatePayload = (value: unknown): SanitizedDownloadStateEvent | null => {
|
||||
if (!isRecord(value) || typeof value.id !== 'string' || !isDownloadStatus(value.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
status: value.status,
|
||||
error: typeof value.error === 'string' ? value.error : null,
|
||||
...(isDownloadErrorKind(value.errorKind) ? { errorKind: value.errorKind } : {}),
|
||||
...(typeof value.resolverFallback === 'boolean'
|
||||
? { resolverFallback: value.resolverFallback }
|
||||
: {}),
|
||||
...(typeof value.fileName === 'string' ? { fileName: value.fileName } : {}),
|
||||
...(typeof value.destination === 'string' ? { destination: value.destination } : {}),
|
||||
...(finiteNonNegative(value.torrentSeedRemaining)
|
||||
? { torrentSeedRemaining: value.torrentSeedRemaining }
|
||||
: {}),
|
||||
...(value.progress !== undefined ? { progress: value.progress as DownloadStateEvent['progress'] } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeProgressPayload = (
|
||||
value: unknown,
|
||||
): DownloadProgressEvent | null => {
|
||||
if (!isRecord(value)
|
||||
|| typeof value.id !== 'string'
|
||||
|| typeof value.speed !== 'string'
|
||||
|| typeof value.eta !== 'string'
|
||||
|| (value.size !== undefined && value.size !== null && typeof value.size !== 'string')
|
||||
|| typeof value.size_is_final !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
const payload = { ...value, size: value.size ?? null } as unknown as DownloadProgressEvent;
|
||||
if (typeof payload.fraction !== 'number'
|
||||
|| !Number.isFinite(payload.fraction)
|
||||
|| payload.fraction < 0
|
||||
|| payload.fraction > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized = { ...payload };
|
||||
const numericFields: Array<keyof DownloadProgressEvent> = [
|
||||
'downloaded_bytes',
|
||||
'total_bytes',
|
||||
'active_connections',
|
||||
'requested_connections',
|
||||
'effective_connections',
|
||||
'uploaded_bytes',
|
||||
'num_seeders',
|
||||
'torrent_seeded_seconds',
|
||||
];
|
||||
for (const field of numericFields) {
|
||||
if (sanitized[field] !== undefined && !finiteNonNegative(sanitized[field])) {
|
||||
delete sanitized[field];
|
||||
}
|
||||
}
|
||||
if (sanitized.total_is_estimate !== undefined
|
||||
&& typeof sanitized.total_is_estimate !== 'boolean') {
|
||||
delete sanitized.total_is_estimate;
|
||||
}
|
||||
if (sanitized.upload_speed !== undefined && typeof sanitized.upload_speed !== 'string') {
|
||||
delete sanitized.upload_speed;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const progressFields = (source: unknown): ProgressFields => {
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
const value = source as Record<string, unknown>;
|
||||
const downloadedBytes = value.downloadedBytes ?? value.downloaded_bytes;
|
||||
const totalBytes = value.totalBytes ?? value.total_bytes;
|
||||
const totalIsEstimate = value.totalIsEstimate ?? value.total_is_estimate;
|
||||
return {
|
||||
...(finiteNonNegative(value.fraction) ? { fraction: value.fraction } : {}),
|
||||
...(finiteNonNegative(downloadedBytes) ? { downloadedBytes } : {}),
|
||||
...(finiteNonNegative(totalBytes) ? { totalBytes } : {}),
|
||||
...(typeof totalIsEstimate === 'boolean' ? { totalIsEstimate } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const mergeTerminalProgress = (
|
||||
current: DownloadItem,
|
||||
status: DownloadStatus,
|
||||
nativeSnapshot: unknown,
|
||||
retainedSnapshot: unknown,
|
||||
liveSnapshot: unknown
|
||||
): ProgressFields => {
|
||||
const ordered = [nativeSnapshot, retainedSnapshot, liveSnapshot]
|
||||
.map(progressFields);
|
||||
const row = progressFields({
|
||||
fraction: current.fraction,
|
||||
downloadedBytes: current.downloadedBytes,
|
||||
totalBytes: current.totalBytes,
|
||||
totalIsEstimate: current.totalIsEstimate
|
||||
});
|
||||
const all = [...ordered, row];
|
||||
const downloadedCandidates = all
|
||||
.map(snapshot => snapshot.downloadedBytes)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
const downloadedBytes = downloadedCandidates.length > 0
|
||||
? Math.max(...downloadedCandidates)
|
||||
: undefined;
|
||||
|
||||
const exactTotals = all
|
||||
.filter(snapshot => snapshot.totalIsEstimate === false && finiteNonNegative(snapshot.totalBytes))
|
||||
.map(snapshot => snapshot.totalBytes!);
|
||||
const anyTotals = all
|
||||
.map(snapshot => snapshot.totalBytes)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
const totalBytes = exactTotals[0] ?? anyTotals[0];
|
||||
const fractions = all
|
||||
.map(snapshot => snapshot.fraction)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
|
||||
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
|
||||
}
|
||||
if (status === 'completed') fractions.push(1);
|
||||
const fraction = fractions.length > 0
|
||||
? Math.min(1, Math.max(0, Math.max(...fractions)))
|
||||
: undefined;
|
||||
return {
|
||||
...(fraction !== undefined ? { fraction } : {}),
|
||||
...(downloadedBytes !== undefined ? { downloadedBytes } : {}),
|
||||
...(totalBytes !== undefined ? { totalBytes } : {}),
|
||||
...(exactTotals.length > 0
|
||||
? { totalIsEstimate: false }
|
||||
: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)?.totalIsEstimate !== undefined
|
||||
? { totalIsEstimate: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)!.totalIsEstimate }
|
||||
: {})
|
||||
};
|
||||
};
|
||||
|
||||
const disposeDownloadListeners = () => {
|
||||
unlistenProgress?.();
|
||||
unlistenProgress = null;
|
||||
unlistenAllocation?.();
|
||||
unlistenAllocation = null;
|
||||
unlistenState?.();
|
||||
unlistenState = null;
|
||||
unlistenMoveProgress?.();
|
||||
unlistenMoveProgress = null;
|
||||
unlistenTray?.();
|
||||
unlistenTray = null;
|
||||
listenerSetup = null;
|
||||
@@ -202,69 +33,51 @@ const disposeDownloadListeners = () => {
|
||||
const startDownloadListeners = async () => {
|
||||
const registrations = await Promise.allSettled([
|
||||
listen('download-progress', (event) => {
|
||||
const payload = sanitizeProgressPayload(event.payload);
|
||||
if (!payload) return;
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (!current) {
|
||||
// A removed row can still have one queued sidecar event in flight.
|
||||
// Do not let that event recreate an orphaned progress entry.
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||
// completion, or lifecycle reset. Do not let that stale chunk repopulate
|
||||
// the live progress map or overwrite a later lifecycle's first frame.
|
||||
if (!['downloading', 'processing', 'verifying', 'seeding'].includes(current.status)) {
|
||||
if (!['downloading', 'processing'].includes(current.status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
const sanitizedPayload = payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, sanitizedPayload);
|
||||
const shouldUpdateSize = Boolean(sanitizedPayload.size && (!current.isMedia || sanitizedPayload.size_is_final));
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') {
|
||||
updates.fraction = sanitizedPayload.fraction;
|
||||
updates.speed = current.status === 'seeding'
|
||||
? sanitizedPayload.upload_speed ?? '-'
|
||||
: sanitizedPayload.speed;
|
||||
updates.eta = current.status === 'seeding' ? '-' : sanitizedPayload.eta;
|
||||
if (current.status === 'downloading' || current.status === 'processing') {
|
||||
updates.fraction = payload.fraction;
|
||||
updates.speed = payload.speed;
|
||||
updates.eta = payload.eta;
|
||||
}
|
||||
if (shouldUpdateSize && current.size !== sanitizedPayload.size) {
|
||||
updates.size = sanitizedPayload.size!;
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
updates.size = payload.size!;
|
||||
}
|
||||
if (sanitizedPayload.downloaded_bytes !== null && sanitizedPayload.downloaded_bytes !== undefined) {
|
||||
updates.downloadedBytes = sanitizedPayload.downloaded_bytes;
|
||||
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
|
||||
updates.downloadedBytes = payload.downloaded_bytes;
|
||||
}
|
||||
if (sanitizedPayload.total_bytes !== null && sanitizedPayload.total_bytes !== undefined) {
|
||||
updates.totalBytes = sanitizedPayload.total_bytes;
|
||||
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
|
||||
updates.totalBytes = payload.total_bytes;
|
||||
}
|
||||
if (sanitizedPayload.total_is_estimate !== null && sanitizedPayload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = sanitizedPayload.total_is_estimate;
|
||||
}
|
||||
if (current.isTorrent) {
|
||||
if (sanitizedPayload.uploaded_bytes !== null
|
||||
&& sanitizedPayload.uploaded_bytes !== undefined
|
||||
&& Number.isSafeInteger(sanitizedPayload.uploaded_bytes)
|
||||
&& sanitizedPayload.uploaded_bytes >= 0) {
|
||||
updates.torrentUploadedBytes = sanitizedPayload.uploaded_bytes;
|
||||
}
|
||||
if (sanitizedPayload.torrent_seeded_seconds !== null
|
||||
&& sanitizedPayload.torrent_seeded_seconds !== undefined
|
||||
&& Number.isSafeInteger(sanitizedPayload.torrent_seeded_seconds)
|
||||
&& sanitizedPayload.torrent_seeded_seconds >= 0) {
|
||||
updates.torrentSeededSeconds = sanitizedPayload.torrent_seeded_seconds;
|
||||
}
|
||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = payload.total_is_estimate;
|
||||
}
|
||||
const observedDownloadedBytes = Math.max(
|
||||
current.downloadedBytes ?? 0,
|
||||
sanitizedPayload.downloaded_bytes ?? 0
|
||||
payload.downloaded_bytes ?? 0
|
||||
);
|
||||
// Older lifecycles may have persisted yt-dlp's temporary fragmented
|
||||
// estimate (often 1 KiB). Once actual bytes exceed it and the current
|
||||
// progress frame has no reliable total, discard that stale denominator
|
||||
// so it cannot survive a pause, queue transition, or app restart.
|
||||
if (sanitizedPayload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
||||
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
||||
isMedia: current.isMedia,
|
||||
downloadedBytes: observedDownloadedBytes,
|
||||
totalBytes: current.totalBytes,
|
||||
@@ -279,58 +92,15 @@ const startDownloadListeners = async () => {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}),
|
||||
listen('download-allocation', (event) => {
|
||||
listen('download-state', (event) => {
|
||||
const payload = event.payload;
|
||||
if (!isRecord(payload)
|
||||
|| typeof payload.id !== 'string'
|
||||
|| typeof payload.pending !== 'boolean'
|
||||
|| !isLifecycleGeneration(payload.lifecycleGeneration)) {
|
||||
return;
|
||||
}
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(download => download.id === payload.id);
|
||||
if (!current) {
|
||||
// Keep a validated native marker until persisted startup state or a
|
||||
// just-admitted row is projected. Dropping it here makes allocation
|
||||
// invisible when the event wins the hydration race.
|
||||
mainStore.setAllocationPending(
|
||||
payload.id,
|
||||
payload.pending,
|
||||
payload.lifecycleGeneration
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Allocation events are native lifecycle markers. A late marker from an
|
||||
// older GID/queue lifecycle must never hide the current lifecycle's
|
||||
// phase or clear its pending state.
|
||||
if (payload.lifecycleGeneration !== currentDownloadLifecycleGeneration(payload.id)) {
|
||||
return;
|
||||
}
|
||||
mainStore.setAllocationPending(
|
||||
payload.id,
|
||||
payload.pending,
|
||||
payload.lifecycleGeneration
|
||||
);
|
||||
}),
|
||||
listen('download-state', async (event) => {
|
||||
const payload = sanitizeStatePayload(event.payload);
|
||||
if (!payload) return;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (!current) {
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
const status = payload.status;
|
||||
// A move terminal event carries its authoritative destination. Older
|
||||
// lifecycle events do not, so they must not overwrite an active move or
|
||||
// clear its progress while the native relocation still owns the row.
|
||||
if (current.status === 'moving' && status !== 'moving' && payload.destination == null) {
|
||||
return;
|
||||
}
|
||||
if (status !== 'moving') {
|
||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||
}
|
||||
const status = payload.status as DownloadStatus;
|
||||
|
||||
// resume_download queues the row before the backend can emit its new
|
||||
// active state. Paused events already emitted by the old lifecycle may
|
||||
@@ -348,8 +118,7 @@ const startDownloadListeners = async () => {
|
||||
// applied while the transition is in flight.
|
||||
return;
|
||||
}
|
||||
if (status === 'downloading' || status === 'processing' || status === 'moving' ||
|
||||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
|
||||
if (status === 'downloading' || status === 'processing' ||
|
||||
status === 'completed' || status === 'failed') {
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
}
|
||||
@@ -364,188 +133,74 @@ const startDownloadListeners = async () => {
|
||||
// before asking the backend to resume, so an active event arriving while
|
||||
// the row is still paused cannot represent a new lifecycle.
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
status !== current.status && status !== 'moving') {
|
||||
status !== current.status) {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'paused' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed' &&
|
||||
status !== 'moving') {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'seeding' &&
|
||||
status !== 'seeding' &&
|
||||
status !== 'waitingToSeed' &&
|
||||
status !== 'verifying' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed' &&
|
||||
status !== 'moving') {
|
||||
status !== 'failed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressState = useDownloadProgressStore.getState();
|
||||
const liveProgress = progressState.progressMap[payload.id];
|
||||
const retainedProgress = progressState.retainedProgressMap[payload.id];
|
||||
const isTerminalOrPaused = ['completed', 'failed', 'paused', 'retrying', 'waitingToSeed'].includes(status);
|
||||
const terminalProgress = isTerminalOrPaused
|
||||
? mergeTerminalProgress(
|
||||
current,
|
||||
status,
|
||||
payload.progress,
|
||||
retainedProgress,
|
||||
liveProgress
|
||||
)
|
||||
: undefined;
|
||||
if (status === 'queued') {
|
||||
// A queued event can represent either a genuinely new admission or a
|
||||
// same-GID resume of a paused Aria2 transfer. Lifecycle-changing
|
||||
// callers reset the retained snapshot before admission; this event
|
||||
// only ends the old live frame so a same-GID resume keeps its bytes.
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
} else if (['retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const moveRestoreStatus = status === 'moving'
|
||||
? current.status === 'paused' || current.status === 'completed' || current.status === 'failed'
|
||||
? current.status
|
||||
: current.torrentMoveRestoreStatus
|
||||
: undefined;
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
torrentMoveRestoreStatus: moveRestoreStatus,
|
||||
...(terminalProgress ? {
|
||||
...(terminalProgress.fraction !== undefined
|
||||
? { fraction: terminalProgress.fraction }
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
? { downloadedBytes: progress.downloaded_bytes }
|
||||
: {}),
|
||||
...(terminalProgress.downloadedBytes !== undefined
|
||||
? { downloadedBytes: terminalProgress.downloadedBytes }
|
||||
...(progress.total_bytes != null
|
||||
? { totalBytes: progress.total_bytes }
|
||||
: {}),
|
||||
...(terminalProgress.totalBytes !== undefined
|
||||
? { totalBytes: terminalProgress.totalBytes }
|
||||
: {}),
|
||||
...(terminalProgress.totalIsEstimate !== undefined
|
||||
? { totalIsEstimate: terminalProgress.totalIsEstimate }
|
||||
...(progress.total_is_estimate != null
|
||||
? { totalIsEstimate: progress.total_is_estimate }
|
||||
: {})
|
||||
} : {}),
|
||||
...(payload.error ? {
|
||||
lastError: payload.error,
|
||||
lastErrorKind: payload.errorKind,
|
||||
lastResolverFallback: payload.resolverFallback,
|
||||
} : {}),
|
||||
...((status === 'downloading' || status === 'verifying' || status === 'retrying')
|
||||
...(payload.error ? { lastError: payload.error } : {}),
|
||||
...((status === 'downloading' || status === 'retrying')
|
||||
? { lastTry: new Date().toISOString() }
|
||||
: {})
|
||||
};
|
||||
if (payload.torrentSeedRemaining != null) {
|
||||
updates.torrentSeedRemaining = payload.torrentSeedRemaining;
|
||||
} else if (status === 'seeding' || status === 'completed' || status === 'failed') {
|
||||
updates.torrentSeedRemaining = undefined;
|
||||
}
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
updates.lastErrorKind = undefined;
|
||||
updates.lastResolverFallback = undefined;
|
||||
}
|
||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||
updates.fileName = payload.fileName;
|
||||
updates.category = categoryForDownload(
|
||||
payload.fileName,
|
||||
current.isTorrent === true,
|
||||
current.category
|
||||
);
|
||||
updates.replaceExistingFingerprint = undefined;
|
||||
updates.category = categoryForFileName(payload.fileName);
|
||||
}
|
||||
if (payload.destination && payload.destination !== current.destination) {
|
||||
updates.destination = payload.destination;
|
||||
updates.replaceExistingFingerprint = undefined;
|
||||
}
|
||||
if (status !== 'downloading' && status !== 'verifying') {
|
||||
if (status !== 'downloading') {
|
||||
updates.speed = '-';
|
||||
updates.eta = '-';
|
||||
}
|
||||
const verificationRestoreStatus = current.torrentVerifyRestoreStatus;
|
||||
const verificationNeedsAcknowledgement = current.torrentVerifyOnly === true &&
|
||||
typeof verificationRestoreStatus === 'string' &&
|
||||
['ready', 'staged', 'paused', 'completed', 'failed'].includes(status);
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
|
||||
if (status === 'queued') {
|
||||
useDownloadStore.setState(state => state.pendingOrder.includes(payload.id)
|
||||
? {}
|
||||
: { pendingOrder: [...state.pendingOrder, payload.id] });
|
||||
} else {
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||
useDownloadStore.setState(state => ({
|
||||
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
|
||||
}));
|
||||
} else if (status === 'queued') {
|
||||
useDownloadStore.setState(state => state.pendingOrder.includes(payload.id)
|
||||
? {}
|
||||
: { pendingOrder: [...state.pendingOrder, payload.id] });
|
||||
}
|
||||
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
|
||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||
mainStore.registerBackendIds([payload.id]);
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
|
||||
if (verificationNeedsAcknowledgement) {
|
||||
try {
|
||||
// The native persistence marker is intentionally acknowledged in a
|
||||
// separate durable snapshot before the renderer clears its copy.
|
||||
// Coalescing both updates into one snapshot would let the native
|
||||
// marker protect an already-finished verification forever.
|
||||
await commitDownloadState();
|
||||
const acknowledged = useDownloadStore.getState().downloads.find(
|
||||
download => download.id === payload.id
|
||||
);
|
||||
if (
|
||||
!acknowledged ||
|
||||
acknowledged.status !== status ||
|
||||
acknowledged.torrentVerifyOnly !== true ||
|
||||
acknowledged.torrentVerifyRestoreStatus !== verificationRestoreStatus
|
||||
) {
|
||||
return;
|
||||
}
|
||||
mainStore.updateDownload(payload.id, {
|
||||
torrentVerifyOnly: undefined,
|
||||
torrentVerifyRestoreStatus: undefined
|
||||
});
|
||||
await commitDownloadState();
|
||||
} catch (error) {
|
||||
// Keep the marker in the durable/native path when the acknowledgement
|
||||
// cannot be committed. Restarting verification is safer than losing
|
||||
// the integrity-maintenance lifecycle.
|
||||
console.error('Failed to acknowledge Torrent verification:', error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
listen('torrent-move-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
if (!isRecord(payload) || typeof payload.id !== 'string') return;
|
||||
const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id);
|
||||
if (!current || current.status !== 'moving') {
|
||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(payload.fraction) && payload.fraction >= 0 && payload.fraction <= 1) {
|
||||
useDownloadProgressStore.getState().setMoveProgress(payload.id, payload.fraction);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
void mainStore.pauseAll();
|
||||
} else if (event.payload === 'resume-all') {
|
||||
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 : []
|
||||
});
|
||||
void mainStore.startAll();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
@@ -560,17 +215,13 @@ const startDownloadListeners = async () => {
|
||||
throw failedRegistration.reason;
|
||||
}
|
||||
|
||||
const [progress, allocation, state, moveProgress, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
const [progress, state, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
];
|
||||
unlistenProgress = progress.value;
|
||||
unlistenAllocation = allocation.value;
|
||||
unlistenState = state.value;
|
||||
unlistenMoveProgress = moveProgress.value;
|
||||
unlistenTray = tray.value;
|
||||
};
|
||||
|
||||
|
||||
+11
-1760
File diff suppressed because it is too large
Load Diff
+171
-1255
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,6 @@ import {
|
||||
} from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||
import {
|
||||
DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
MAX_TORRENT_MAX_OPEN_FILES
|
||||
} from '../utils/downloads';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn()
|
||||
@@ -25,252 +21,6 @@ describe('last used download directory preference', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable main-window and sidebar preferences', () => {
|
||||
it('uses safe defaults and persists the current values', async () => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ isFoldersCollapsed: false, mainWindowSize: null });
|
||||
|
||||
expect(useSettingsStore.getState()).toMatchObject({
|
||||
isFoldersCollapsed: false,
|
||||
mainWindowSize: null
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setFoldersCollapsed(true);
|
||||
useSettingsStore.getState().setMainWindowSize({ width: 1280, height: 800 });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'db_save_settings')
|
||||
.slice(-1)[0];
|
||||
expect(save).toBeDefined();
|
||||
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
|
||||
isFoldersCollapsed: true,
|
||||
mainWindowSize: { width: 1280, height: 800 }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed, undersized, and oversized geometry during hydration', () => {
|
||||
const merge = useSettingsStore.persist.getOptions().merge;
|
||||
expect(merge).toBeTypeOf('function');
|
||||
const current = useSettingsStore.getState();
|
||||
|
||||
expect(merge?.({ mainWindowSize: { width: 959, height: 800 } }, current).mainWindowSize)
|
||||
.toBe(current.mainWindowSize);
|
||||
expect(merge?.({ mainWindowSize: { width: 1280, height: 16_385 } }, current).mainWindowSize)
|
||||
.toBe(current.mainWindowSize);
|
||||
expect(merge?.({ mainWindowSize: { width: '1280', height: 800 } }, current).mainWindowSize)
|
||||
.toBe(current.mainWindowSize);
|
||||
expect(merge?.({ mainWindowSize: { width: 1440, height: 900 } }, current).mainWindowSize)
|
||||
.toEqual({ width: 1440, height: 900 });
|
||||
});
|
||||
|
||||
it('rejects malformed consumer values during hydration', () => {
|
||||
const merge = useSettingsStore.persist.getOptions().merge;
|
||||
expect(merge).toBeTypeOf('function');
|
||||
const current = useSettingsStore.getState();
|
||||
|
||||
expect(merge?.({
|
||||
proxyMode: 'custom',
|
||||
proxyHost: 123,
|
||||
proxyPort: 70000,
|
||||
customUserAgent: ['not-a-string'],
|
||||
isSidebarVisible: 'yes',
|
||||
lastCustomSpeedLimitKiB: Number.POSITIVE_INFINITY,
|
||||
approvedDownloadRoots: ['/safe', 42],
|
||||
speedLimitPresetValues: [1, '5', Number.NaN]
|
||||
}, current)).toMatchObject({
|
||||
proxyMode: 'custom',
|
||||
proxyHost: current.proxyHost,
|
||||
proxyPort: current.proxyPort,
|
||||
customUserAgent: current.customUserAgent,
|
||||
isSidebarVisible: current.isSidebarVisible,
|
||||
lastCustomSpeedLimitKiB: current.lastCustomSpeedLimitKiB,
|
||||
approvedDownloadRoots: ['/safe'],
|
||||
speedLimitPresetValues: [1]
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the legacy localStorage value only when durable state is absent', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { localStorage: { getItem: () => 'true' } }
|
||||
});
|
||||
try {
|
||||
const merge = useSettingsStore.persist.getOptions().merge;
|
||||
const current = { ...useSettingsStore.getState(), isFoldersCollapsed: false };
|
||||
expect(merge?.({}, current).isFoldersCollapsed).toBe(true);
|
||||
expect(merge?.({ isFoldersCollapsed: false }, current).isFoldersCollapsed).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('normal download reliability preferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses migration-safe defaults and persists bounded changes', async () => {
|
||||
expect(useSettingsStore.getState()).toMatchObject({
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(64);
|
||||
useSettingsStore.getState().setRetryNotFoundErrors(true);
|
||||
useSettingsStore.getState().setAdaptiveMirrorSelection(false);
|
||||
await vi.waitFor(() => {
|
||||
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'db_save_settings')
|
||||
.slice(-1)[0];
|
||||
expect(save).toBeDefined();
|
||||
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
|
||||
minimumNormalDownloadSpeedKiB: 64,
|
||||
retryNotFoundErrors: true,
|
||||
adaptiveMirrorSelection: false,
|
||||
});
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(2_000_000);
|
||||
expect(useSettingsStore.getState().minimumNormalDownloadSpeedKiB).toBe(1_048_576);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent peer discovery preferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
torrentEnableDht: true,
|
||||
torrentEnableDht6: false,
|
||||
torrentEnablePex: true,
|
||||
torrentEnableLpd: false
|
||||
});
|
||||
});
|
||||
|
||||
it('clears an IPv6 bind address when IPv6 transport is disabled', () => {
|
||||
useSettingsStore.setState({
|
||||
torrentIpv6Enabled: true,
|
||||
torrentBindAddress: '2001:db8::10'
|
||||
});
|
||||
|
||||
useSettingsStore.getState().setTorrentIpv6Enabled(false);
|
||||
|
||||
expect(useSettingsStore.getState()).toMatchObject({
|
||||
torrentIpv6Enabled: false,
|
||||
torrentBindAddress: ''
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an IPv6 bind address entered after IPv6 transport is disabled', () => {
|
||||
useSettingsStore.setState({
|
||||
torrentIpv6Enabled: false,
|
||||
torrentBindAddress: ''
|
||||
});
|
||||
|
||||
expect(useSettingsStore.getState().setTorrentBindAddress('2001:db8::10')).toBe(false);
|
||||
expect(useSettingsStore.getState().torrentBindAddress).toBe('');
|
||||
|
||||
expect(useSettingsStore.getState().setTorrentBindAddress('192.0.2.10')).toBe(true);
|
||||
expect(useSettingsStore.getState().torrentBindAddress).toBe('192.0.2.10');
|
||||
});
|
||||
|
||||
it('matches Aria2 defaults and persists explicit changes', async () => {
|
||||
expect(useSettingsStore.getState().torrentEnableDht).toBe(true);
|
||||
expect(useSettingsStore.getState().torrentEnableDht6).toBe(false);
|
||||
expect(useSettingsStore.getState().torrentEnablePex).toBe(true);
|
||||
expect(useSettingsStore.getState().torrentEnableLpd).toBe(false);
|
||||
|
||||
useSettingsStore.getState().setTorrentEnableDht(false);
|
||||
useSettingsStore.getState().setTorrentEnableDht6(true);
|
||||
useSettingsStore.getState().setTorrentEnablePex(false);
|
||||
useSettingsStore.getState().setTorrentEnableLpd(true);
|
||||
await vi.waitFor(() => {
|
||||
const save = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'db_save_settings')
|
||||
.slice(-1)[0];
|
||||
expect(save).toBeDefined();
|
||||
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
|
||||
torrentEnableDht: false,
|
||||
torrentEnableDht6: true,
|
||||
torrentEnablePex: false,
|
||||
torrentEnableLpd: true
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent open-file limit preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES });
|
||||
});
|
||||
|
||||
it('applies a bounded global limit before persisting it', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
|
||||
await useSettingsStore.getState().setTorrentMaxOpenFiles(256);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_max_open_files', {
|
||||
max_open_files: 256
|
||||
});
|
||||
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(256);
|
||||
});
|
||||
|
||||
it('rejects unsafe values without changing the saved limit', async () => {
|
||||
await expect(useSettingsStore.getState().setTorrentMaxOpenFiles(0)).rejects.toThrow();
|
||||
await expect(
|
||||
useSettingsStore.getState().setTorrentMaxOpenFiles(MAX_TORRENT_MAX_OPEN_FILES + 1)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'set_torrent_max_open_files',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useSettingsStore.getState().torrentMaxOpenFiles)
|
||||
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
});
|
||||
|
||||
it('serializes rapid updates so the native global option cannot reorder', async () => {
|
||||
let releaseFirst!: () => void;
|
||||
const firstUpdate = new Promise<void>(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
|
||||
if (command !== 'set_torrent_max_open_files') return undefined;
|
||||
const value = (args as { max_open_files: number }).max_open_files;
|
||||
events.push(`start:${value}`);
|
||||
if (value === 256) await firstUpdate;
|
||||
events.push(`finish:${value}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const first = useSettingsStore.getState().setTorrentMaxOpenFiles(256);
|
||||
const second = useSettingsStore.getState().setTorrentMaxOpenFiles(512);
|
||||
await vi.waitFor(() => expect(events).toEqual(['start:256']));
|
||||
expect(useSettingsStore.getState().torrentMaxOpenFiles)
|
||||
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(events).toEqual(['start:256', 'finish:256', 'start:512', 'finish:512']);
|
||||
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar preference', () => {
|
||||
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -303,64 +53,6 @@ describe('useSettingsStore global speed limit persistence', () => {
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
|
||||
});
|
||||
|
||||
it('rejects malformed limits before changing native or local state', async () => {
|
||||
await expect(useSettingsStore.getState().setGlobalSpeedLimit('not-a-rate'))
|
||||
.rejects.toThrow('Global speed limit is invalid');
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'set_global_speed_limit',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore Torrent overall upload limit persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ torrentOverallUploadLimit: '2M' });
|
||||
});
|
||||
|
||||
it('applies a normalized limit before updating local state', async () => {
|
||||
await useSettingsStore.getState().setTorrentOverallUploadLimit('1.5 MB/s');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', {
|
||||
limit: '1.5M'
|
||||
});
|
||||
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('1.5M');
|
||||
});
|
||||
|
||||
it('keeps the saved value when the native global option rejects an update', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
|
||||
|
||||
await expect(
|
||||
useSettingsStore.getState().setTorrentOverallUploadLimit('3M')
|
||||
).rejects.toThrow('aria2 unavailable');
|
||||
|
||||
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M');
|
||||
});
|
||||
|
||||
it('uses null to restore Aria2 unlimited upload', async () => {
|
||||
await useSettingsStore.getState().setTorrentOverallUploadLimit('');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', {
|
||||
limit: null
|
||||
});
|
||||
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('');
|
||||
});
|
||||
|
||||
it('rejects malformed limits without clearing the saved value', async () => {
|
||||
await expect(
|
||||
useSettingsStore.getState().setTorrentOverallUploadLimit('not-a-rate')
|
||||
).rejects.toThrow('Torrent overall upload limit is invalid');
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'set_torrent_overall_upload_limit',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore dock badge synchronization', () => {
|
||||
|
||||
@@ -20,16 +20,7 @@ import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeDownloadLocationSettings
|
||||
} from '../utils/downloadLocations';
|
||||
import {
|
||||
DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
MAX_TORRENT_MAX_OPEN_FILES,
|
||||
MIN_TORRENT_MAX_OPEN_FILES,
|
||||
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
normalizeSpeedLimitForBackend,
|
||||
normalizeTorrentDhtMessageTimeout,
|
||||
normalizeTorrentMaxOpenFiles
|
||||
} from '../utils/downloads';
|
||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
import i18n from '../i18n';
|
||||
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
||||
import {
|
||||
@@ -37,32 +28,14 @@ import {
|
||||
isCalendarPreference,
|
||||
type CalendarPreference
|
||||
} from '../utils/dateTime';
|
||||
import type { MainWindowSize } from '../bindings/MainWindowSize';
|
||||
import { normalizeMainWindowSize } from '../utils/mainWindowState';
|
||||
|
||||
let settingsQueue: Promise<void> = Promise.resolve();
|
||||
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
|
||||
let torrentOverallUploadLimitQueue: Promise<void> = Promise.resolve();
|
||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||
let shouldPersistLegacyFoldersFallback = false;
|
||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||
let settingsPersistenceFailed = false;
|
||||
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const LEGACY_FOLDERS_COLLAPSED_KEY = 'firelink-folders-collapsed';
|
||||
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
|
||||
|
||||
const readLegacyFoldersCollapsed = (): boolean | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
try {
|
||||
const value = window.localStorage.getItem(LEGACY_FOLDERS_COLLAPSED_KEY);
|
||||
return value === null ? undefined : value === 'true';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const initialFoldersCollapsed = readLegacyFoldersCollapsed() ?? false;
|
||||
|
||||
export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => {
|
||||
settingsPersistenceErrorListeners.add(listener);
|
||||
if (settingsPersistenceFailed) listener();
|
||||
@@ -89,8 +62,6 @@ export const runSettingsPersistenceTransaction = <T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => enqueueSettingsTask(operation);
|
||||
|
||||
export const waitForSettingsPersistence = (): Promise<void> => settingsQueue;
|
||||
|
||||
const notifySettingsPersistenceError = () => {
|
||||
if (settingsPersistenceFailed) return;
|
||||
settingsPersistenceFailed = true;
|
||||
@@ -162,21 +133,6 @@ const sanitizeSiteLogins = (value: unknown): SiteLogin[] => {
|
||||
const persistedBoolean = (value: unknown, fallback: boolean) =>
|
||||
typeof value === 'boolean' ? value : fallback;
|
||||
|
||||
const persistedString = (value: unknown, fallback: string): string =>
|
||||
typeof value === 'string' ? value : fallback;
|
||||
|
||||
const persistedFiniteInteger = (
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
fallback: number
|
||||
): number => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return value >= minimum && value <= maximum ? value : fallback;
|
||||
};
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
@@ -249,13 +205,10 @@ export interface SettingsState {
|
||||
approvedDownloadRoots: string[];
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
torrentOverallUploadLimit: string;
|
||||
speedLimitPresetValues: number[];
|
||||
logsEnabled: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
isFoldersCollapsed: boolean;
|
||||
sidebarPosition: SidebarPosition;
|
||||
mainWindowSize: MainWindowSize | null;
|
||||
activeView: ActiveView;
|
||||
activeSettingsTab: SettingsTab;
|
||||
scheduler: SchedulerSettings;
|
||||
@@ -269,9 +222,6 @@ export interface SettingsState {
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
maxAutomaticRetries: number;
|
||||
minimumNormalDownloadSpeedKiB: number;
|
||||
retryNotFoundErrors: boolean;
|
||||
adaptiveMirrorSelection: boolean;
|
||||
showNotifications: boolean;
|
||||
playCompletionSound: boolean;
|
||||
autoAddClipboardLinks: boolean;
|
||||
@@ -284,26 +234,6 @@ export interface SettingsState {
|
||||
proxyMode: ProxyMode;
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
torrentEnableDht: boolean;
|
||||
torrentEnableDht6: boolean;
|
||||
torrentEnablePex: boolean;
|
||||
torrentEnableLpd: boolean;
|
||||
torrentMaxOpenFiles: number;
|
||||
torrentDhtMessageTimeout: number;
|
||||
torrentSeparateSeedSlots: boolean;
|
||||
torrentMaxConcurrentSeeds: number;
|
||||
torrentIpv6Enabled: boolean;
|
||||
torrentListenPort: string;
|
||||
torrentDhtListenPort: string;
|
||||
torrentExternalIp: string;
|
||||
torrentDhtEntryPoint: string;
|
||||
torrentDhtEntryPoint6: string;
|
||||
torrentDhtListenAddr6: string;
|
||||
torrentLpdInterface: string;
|
||||
torrentPeerIdPrefix: string;
|
||||
torrentPeerAgent: string;
|
||||
torrentBindAddress: string;
|
||||
aria2DiskCache: string;
|
||||
customUserAgent: string;
|
||||
askWhereToSaveEachFile: boolean;
|
||||
preventsSleepWhileDownloading: boolean;
|
||||
@@ -328,13 +258,9 @@ export interface SettingsState {
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => Promise<void>;
|
||||
setTorrentOverallUploadLimit: (limit: string) => Promise<void>;
|
||||
setSpeedLimitPresetValues: (values: number[]) => void;
|
||||
setLogsEnabled: (enabled: boolean) => void;
|
||||
setSidebarPosition: (position: SidebarPosition) => void;
|
||||
setFoldersCollapsed: (collapsed: boolean) => void;
|
||||
toggleFoldersCollapsed: () => void;
|
||||
setMainWindowSize: (size: MainWindowSize) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
setScheduler: (settings: SchedulerSettings) => void;
|
||||
@@ -348,9 +274,6 @@ export interface SettingsState {
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setMinimumNormalDownloadSpeedKiB: (speed: number) => void;
|
||||
setRetryNotFoundErrors: (enabled: boolean) => void;
|
||||
setAdaptiveMirrorSelection: (enabled: boolean) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAutoAddClipboardLinks: (enabled: boolean) => void;
|
||||
@@ -361,26 +284,6 @@ export interface SettingsState {
|
||||
setProxyMode: (mode: ProxyMode) => void;
|
||||
setProxyHost: (host: string) => void;
|
||||
setProxyPort: (port: number) => void;
|
||||
setTorrentEnableDht: (enabled: boolean) => void;
|
||||
setTorrentEnableDht6: (enabled: boolean) => void;
|
||||
setTorrentEnablePex: (enabled: boolean) => void;
|
||||
setTorrentEnableLpd: (enabled: boolean) => void;
|
||||
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
|
||||
setTorrentDhtMessageTimeout: (value: number) => void;
|
||||
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
|
||||
setTorrentMaxConcurrentSeeds: (value: number) => void;
|
||||
setTorrentIpv6Enabled: (enabled: boolean) => void;
|
||||
setTorrentListenPort: (value: string) => void;
|
||||
setTorrentDhtListenPort: (value: string) => void;
|
||||
setTorrentExternalIp: (value: string) => void;
|
||||
setTorrentDhtEntryPoint: (value: string) => void;
|
||||
setTorrentDhtEntryPoint6: (value: string) => void;
|
||||
setTorrentDhtListenAddr6: (value: string) => void;
|
||||
setTorrentLpdInterface: (value: string) => void;
|
||||
setTorrentPeerIdPrefix: (value: string) => void;
|
||||
setTorrentPeerAgent: (value: string) => void;
|
||||
setTorrentBindAddress: (value: string) => boolean;
|
||||
setAria2DiskCache: (value: string) => void;
|
||||
setCustomUserAgent: (userAgent: string) => void;
|
||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||
@@ -420,14 +323,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
approvedDownloadRoots: [],
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
torrentOverallUploadLimit: '',
|
||||
speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
|
||||
logsEnabled: false,
|
||||
activeView: 'downloads',
|
||||
activeSettingsTab: 'downloads',
|
||||
isSidebarVisible: true,
|
||||
isFoldersCollapsed: initialFoldersCollapsed,
|
||||
mainWindowSize: null,
|
||||
sidebarPosition: 'auto',
|
||||
scheduler: {
|
||||
enabled: false,
|
||||
@@ -449,9 +349,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
minimumNormalDownloadSpeedKiB: 0,
|
||||
retryNotFoundErrors: false,
|
||||
adaptiveMirrorSelection: true,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
@@ -463,26 +360,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
proxyMode: 'none',
|
||||
proxyHost: '',
|
||||
proxyPort: 8080,
|
||||
torrentEnableDht: true,
|
||||
torrentEnableDht6: false,
|
||||
torrentEnablePex: true,
|
||||
torrentEnableLpd: false,
|
||||
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
|
||||
torrentSeparateSeedSlots: false,
|
||||
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
|
||||
torrentIpv6Enabled: true,
|
||||
torrentListenPort: '',
|
||||
torrentDhtListenPort: '',
|
||||
torrentExternalIp: '',
|
||||
torrentDhtEntryPoint: '',
|
||||
torrentDhtEntryPoint6: '',
|
||||
torrentDhtListenAddr6: '',
|
||||
torrentLpdInterface: '',
|
||||
torrentPeerIdPrefix: '',
|
||||
torrentPeerAgent: '',
|
||||
torrentBindAddress: '',
|
||||
aria2DiskCache: '16M',
|
||||
customUserAgent: '',
|
||||
askWhereToSaveEachFile: false,
|
||||
preventsSleepWhileDownloading: true,
|
||||
@@ -532,42 +409,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
},
|
||||
setGlobalSpeedLimit: async (limit) => {
|
||||
const normalized = normalizeSpeedLimitForBackend(limit);
|
||||
if (limit.trim() && !normalized) {
|
||||
return Promise.reject(new Error('Global speed limit is invalid'));
|
||||
}
|
||||
await invoke('set_global_speed_limit', {
|
||||
limit: normalized
|
||||
limit: normalizeSpeedLimitForBackend(limit)
|
||||
});
|
||||
info('Settings updated: globalSpeedLimit');
|
||||
set({ globalSpeedLimit: normalized ?? '' });
|
||||
},
|
||||
setTorrentOverallUploadLimit: (limit) => {
|
||||
const normalizedLimit = normalizeSpeedLimitForBackend(limit);
|
||||
if (limit.trim() && !normalizedLimit) {
|
||||
return Promise.reject(new Error('Torrent overall upload limit is invalid'));
|
||||
}
|
||||
const normalized = normalizedLimit ?? '';
|
||||
const apply = async () => {
|
||||
await invoke('set_torrent_overall_upload_limit', {
|
||||
limit: normalized || null
|
||||
});
|
||||
info('Settings updated: torrentOverallUploadLimit');
|
||||
set({ torrentOverallUploadLimit: normalized });
|
||||
};
|
||||
const result = torrentOverallUploadLimitQueue.then(apply, apply);
|
||||
torrentOverallUploadLimitQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
set({ globalSpeedLimit: limit });
|
||||
},
|
||||
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
|
||||
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
||||
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
|
||||
setFoldersCollapsed: (isFoldersCollapsed) => set({ isFoldersCollapsed }),
|
||||
toggleFoldersCollapsed: () => set(state => ({ isFoldersCollapsed: !state.isFoldersCollapsed })),
|
||||
setMainWindowSize: (size) => {
|
||||
const normalized = normalizeMainWindowSize(size);
|
||||
if (normalized) set({ mainWindowSize: normalized });
|
||||
},
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||
setScheduler: (scheduler) => set({ scheduler }),
|
||||
@@ -585,16 +435,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({
|
||||
maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3)
|
||||
}),
|
||||
setMinimumNormalDownloadSpeedKiB: (minimumNormalDownloadSpeedKiB) => set({
|
||||
minimumNormalDownloadSpeedKiB: clampSettingInteger(
|
||||
minimumNormalDownloadSpeedKiB,
|
||||
0,
|
||||
1_048_576,
|
||||
0
|
||||
)
|
||||
}),
|
||||
setRetryNotFoundErrors: (retryNotFoundErrors) => set({ retryNotFoundErrors }),
|
||||
setAdaptiveMirrorSelection: (adaptiveMirrorSelection) => set({ adaptiveMirrorSelection }),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||
@@ -614,69 +454,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
? Math.min(65535, Math.max(1, Math.trunc(proxyPort)))
|
||||
: 8080
|
||||
}),
|
||||
setTorrentEnableDht: (torrentEnableDht) => set({ torrentEnableDht }),
|
||||
setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }),
|
||||
setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }),
|
||||
setTorrentEnableLpd: (torrentEnableLpd) => set({ torrentEnableLpd }),
|
||||
setTorrentListenPort: (torrentListenPort) => set({ torrentListenPort }),
|
||||
setTorrentDhtListenPort: (torrentDhtListenPort) => set({ torrentDhtListenPort }),
|
||||
setTorrentExternalIp: (torrentExternalIp) => set({ torrentExternalIp }),
|
||||
setTorrentDhtEntryPoint: (torrentDhtEntryPoint) => set({ torrentDhtEntryPoint }),
|
||||
setTorrentDhtEntryPoint6: (torrentDhtEntryPoint6) => set({ torrentDhtEntryPoint6 }),
|
||||
setTorrentDhtListenAddr6: (torrentDhtListenAddr6) => set({ torrentDhtListenAddr6 }),
|
||||
setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }),
|
||||
setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }),
|
||||
setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }),
|
||||
setTorrentBindAddress: (torrentBindAddress) => {
|
||||
let accepted = true;
|
||||
set(state => {
|
||||
if (!state.torrentIpv6Enabled && torrentBindAddress.includes(':')) {
|
||||
accepted = false;
|
||||
return state;
|
||||
}
|
||||
return { torrentBindAddress };
|
||||
});
|
||||
return accepted;
|
||||
},
|
||||
setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }),
|
||||
setTorrentMaxOpenFiles: (value) => {
|
||||
const normalized = normalizeTorrentMaxOpenFiles(value);
|
||||
if (normalized === undefined) {
|
||||
return Promise.reject(new Error(
|
||||
`Torrent maximum open files must be between ${MIN_TORRENT_MAX_OPEN_FILES} and ${MAX_TORRENT_MAX_OPEN_FILES}`
|
||||
));
|
||||
}
|
||||
const apply = async () => {
|
||||
await invoke('set_torrent_max_open_files', { max_open_files: normalized });
|
||||
info('Settings updated: torrentMaxOpenFiles');
|
||||
set({ torrentMaxOpenFiles: normalized });
|
||||
};
|
||||
const result = torrentMaxOpenFilesQueue.then(apply, apply);
|
||||
torrentMaxOpenFilesQueue = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
},
|
||||
setTorrentDhtMessageTimeout: (value) => {
|
||||
const normalized = normalizeTorrentDhtMessageTimeout(value);
|
||||
set({
|
||||
torrentDhtMessageTimeout: normalized
|
||||
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
});
|
||||
},
|
||||
setTorrentSeparateSeedSlots: (torrentSeparateSeedSlots) => set({ torrentSeparateSeedSlots }),
|
||||
setTorrentMaxConcurrentSeeds: (value) => set({
|
||||
torrentMaxConcurrentSeeds: Number.isInteger(value) && value >= 1 && value <= 64
|
||||
? value
|
||||
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}),
|
||||
setTorrentIpv6Enabled: (torrentIpv6Enabled) => set(state => ({
|
||||
torrentIpv6Enabled,
|
||||
// An IPv6 bind address is invalid once IPv6 transport is disabled.
|
||||
// Clear it as part of the same state transition so the next durable
|
||||
// settings save cannot fail on a cross-field contradiction.
|
||||
...(torrentIpv6Enabled || !state.torrentBindAddress.includes(':')
|
||||
? {}
|
||||
: { torrentBindAddress: '' })
|
||||
})),
|
||||
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
||||
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||
@@ -819,15 +596,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
logsEnabled: persisted.logsEnabled === true
|
||||
} as SettingsState;
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) {
|
||||
shouldPersistLegacyFoldersFallback = false;
|
||||
return;
|
||||
}
|
||||
if (!shouldPersistLegacyFoldersFallback) return;
|
||||
shouldPersistLegacyFoldersFallback = false;
|
||||
state.setFoldersCollapsed(state.isFoldersCollapsed);
|
||||
},
|
||||
partialize: (state): PersistedSettingsSnapshot => ({
|
||||
theme: state.theme,
|
||||
fontFamily: state.fontFamily,
|
||||
@@ -842,12 +610,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
approvedDownloadRoots: state.approvedDownloadRoots,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
torrentOverallUploadLimit: state.torrentOverallUploadLimit,
|
||||
speedLimitPresetValues: state.speedLimitPresetValues,
|
||||
logsEnabled: state.logsEnabled,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
isFoldersCollapsed: state.isFoldersCollapsed,
|
||||
mainWindowSize: state.mainWindowSize ?? undefined,
|
||||
sidebarPosition: state.sidebarPosition,
|
||||
activeSettingsTab: state.activeSettingsTab,
|
||||
scheduler: state.scheduler,
|
||||
@@ -860,9 +625,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
minimumNormalDownloadSpeedKiB: state.minimumNormalDownloadSpeedKiB,
|
||||
retryNotFoundErrors: state.retryNotFoundErrors,
|
||||
adaptiveMirrorSelection: state.adaptiveMirrorSelection,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||
@@ -873,26 +635,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
proxyMode: state.proxyMode,
|
||||
proxyHost: state.proxyHost,
|
||||
proxyPort: state.proxyPort,
|
||||
torrentEnableDht: state.torrentEnableDht,
|
||||
torrentEnableDht6: state.torrentEnableDht6,
|
||||
torrentEnablePex: state.torrentEnablePex,
|
||||
torrentEnableLpd: state.torrentEnableLpd,
|
||||
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
|
||||
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
|
||||
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
|
||||
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
|
||||
torrentIpv6Enabled: state.torrentIpv6Enabled,
|
||||
torrentListenPort: state.torrentListenPort,
|
||||
torrentDhtListenPort: state.torrentDhtListenPort,
|
||||
torrentExternalIp: state.torrentExternalIp,
|
||||
torrentDhtEntryPoint: state.torrentDhtEntryPoint,
|
||||
torrentDhtEntryPoint6: state.torrentDhtEntryPoint6,
|
||||
torrentDhtListenAddr6: state.torrentDhtListenAddr6,
|
||||
torrentLpdInterface: state.torrentLpdInterface,
|
||||
torrentPeerIdPrefix: state.torrentPeerIdPrefix,
|
||||
torrentPeerAgent: state.torrentPeerAgent,
|
||||
torrentBindAddress: state.torrentBindAddress,
|
||||
aria2DiskCache: state.aria2DiskCache,
|
||||
customUserAgent: state.customUserAgent,
|
||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
@@ -908,13 +650,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<SettingsState>
|
||||
: {};
|
||||
shouldPersistLegacyFoldersFallback = false;
|
||||
const legacyFoldersCollapsed = readLegacyFoldersCollapsed();
|
||||
if (typeof persisted.isFoldersCollapsed !== 'boolean' && legacyFoldersCollapsed !== undefined) {
|
||||
shouldPersistLegacyFoldersFallback = true;
|
||||
}
|
||||
const foldersCollapsedFallback = legacyFoldersCollapsed
|
||||
?? currentState.isFoldersCollapsed;
|
||||
const locations = normalizeDownloadLocationSettings(persisted);
|
||||
return ({
|
||||
...currentState,
|
||||
@@ -939,84 +674,18 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
language: isAppLocalePreference(persisted.language)
|
||||
? persisted.language
|
||||
: currentState.language,
|
||||
isFoldersCollapsed: persistedBoolean(
|
||||
persisted.isFoldersCollapsed,
|
||||
foldersCollapsedFallback
|
||||
),
|
||||
isSidebarVisible: persistedBoolean(
|
||||
persisted.isSidebarVisible,
|
||||
currentState.isSidebarVisible
|
||||
),
|
||||
mainWindowSize: normalizeMainWindowSize(persisted.mainWindowSize)
|
||||
?? currentState.mainWindowSize,
|
||||
appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize)
|
||||
? persisted.appFontSize
|
||||
: currentState.appFontSize,
|
||||
listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity)
|
||||
? persisted.listRowDensity
|
||||
: currentState.listRowDensity,
|
||||
torrentEnableDht: persistedBoolean(persisted.torrentEnableDht, currentState.torrentEnableDht),
|
||||
torrentEnableDht6: persistedBoolean(persisted.torrentEnableDht6, currentState.torrentEnableDht6),
|
||||
torrentEnablePex: persistedBoolean(persisted.torrentEnablePex, currentState.torrentEnablePex),
|
||||
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
|
||||
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
|
||||
?? currentState.torrentMaxOpenFiles,
|
||||
torrentDhtMessageTimeout: normalizeTorrentDhtMessageTimeout(persisted.torrentDhtMessageTimeout)
|
||||
?? currentState.torrentDhtMessageTimeout,
|
||||
torrentSeparateSeedSlots: persistedBoolean(
|
||||
persisted.torrentSeparateSeedSlots,
|
||||
currentState.torrentSeparateSeedSlots
|
||||
),
|
||||
torrentMaxConcurrentSeeds: typeof persisted.torrentMaxConcurrentSeeds === 'number'
|
||||
&& Number.isInteger(persisted.torrentMaxConcurrentSeeds)
|
||||
&& persisted.torrentMaxConcurrentSeeds >= 1
|
||||
&& persisted.torrentMaxConcurrentSeeds <= 64
|
||||
? persisted.torrentMaxConcurrentSeeds
|
||||
: currentState.torrentMaxConcurrentSeeds,
|
||||
torrentIpv6Enabled: persistedBoolean(
|
||||
persisted.torrentIpv6Enabled,
|
||||
currentState.torrentIpv6Enabled
|
||||
),
|
||||
torrentListenPort: typeof persisted.torrentListenPort === 'string'
|
||||
? persisted.torrentListenPort
|
||||
: currentState.torrentListenPort,
|
||||
torrentDhtListenPort: typeof persisted.torrentDhtListenPort === 'string'
|
||||
? persisted.torrentDhtListenPort
|
||||
: currentState.torrentDhtListenPort,
|
||||
torrentExternalIp: typeof persisted.torrentExternalIp === 'string'
|
||||
? persisted.torrentExternalIp
|
||||
: currentState.torrentExternalIp,
|
||||
torrentDhtEntryPoint: typeof persisted.torrentDhtEntryPoint === 'string'
|
||||
? persisted.torrentDhtEntryPoint
|
||||
: currentState.torrentDhtEntryPoint,
|
||||
torrentDhtEntryPoint6: typeof persisted.torrentDhtEntryPoint6 === 'string'
|
||||
? persisted.torrentDhtEntryPoint6
|
||||
: currentState.torrentDhtEntryPoint6,
|
||||
torrentDhtListenAddr6: typeof persisted.torrentDhtListenAddr6 === 'string'
|
||||
? persisted.torrentDhtListenAddr6
|
||||
: currentState.torrentDhtListenAddr6,
|
||||
torrentLpdInterface: typeof persisted.torrentLpdInterface === 'string'
|
||||
? persisted.torrentLpdInterface
|
||||
: currentState.torrentLpdInterface,
|
||||
torrentPeerIdPrefix: typeof persisted.torrentPeerIdPrefix === 'string'
|
||||
? persisted.torrentPeerIdPrefix
|
||||
: currentState.torrentPeerIdPrefix,
|
||||
torrentPeerAgent: typeof persisted.torrentPeerAgent === 'string'
|
||||
? persisted.torrentPeerAgent
|
||||
: currentState.torrentPeerAgent,
|
||||
torrentBindAddress: typeof persisted.torrentBindAddress === 'string'
|
||||
? persisted.torrentBindAddress
|
||||
: currentState.torrentBindAddress,
|
||||
aria2DiskCache: persistedString(persisted.aria2DiskCache, currentState.aria2DiskCache),
|
||||
customUserAgent: persistedString(persisted.customUserAgent, currentState.customUserAgent),
|
||||
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
|
||||
? persisted.sidebarPosition
|
||||
: currentState.sidebarPosition,
|
||||
proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode)
|
||||
? persisted.proxyMode
|
||||
: currentState.proxyMode,
|
||||
proxyHost: persistedString(persisted.proxyHost, currentState.proxyHost),
|
||||
proxyPort: persistedFiniteInteger(persisted.proxyPort, 1, 65_535, currentState.proxyPort),
|
||||
mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource)
|
||||
? persisted.mediaCookieSource
|
||||
: 'none',
|
||||
@@ -1065,12 +734,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
12,
|
||||
currentState.maxConcurrentDownloads
|
||||
),
|
||||
torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? ''
|
||||
: currentState.torrentOverallUploadLimit,
|
||||
globalSpeedLimit: typeof persisted.globalSpeedLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(persisted.globalSpeedLimit) ?? ''
|
||||
: currentState.globalSpeedLimit,
|
||||
perServerConnections: clampSettingInteger(
|
||||
persisted.perServerConnections,
|
||||
1,
|
||||
@@ -1083,38 +746,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
10,
|
||||
currentState.maxAutomaticRetries
|
||||
),
|
||||
minimumNormalDownloadSpeedKiB: clampSettingInteger(
|
||||
persisted.minimumNormalDownloadSpeedKiB,
|
||||
0,
|
||||
1_048_576,
|
||||
currentState.minimumNormalDownloadSpeedKiB
|
||||
),
|
||||
retryNotFoundErrors: persistedBoolean(
|
||||
persisted.retryNotFoundErrors,
|
||||
currentState.retryNotFoundErrors
|
||||
),
|
||||
adaptiveMirrorSelection: persistedBoolean(
|
||||
persisted.adaptiveMirrorSelection,
|
||||
currentState.adaptiveMirrorSelection
|
||||
),
|
||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||
? persisted.speedLimitPresetValues.filter(
|
||||
(value): value is number => typeof value === 'number' && Number.isFinite(value)
|
||||
)
|
||||
? persisted.speedLimitPresetValues
|
||||
: currentState.speedLimitPresetValues,
|
||||
lastCustomSpeedLimitKiB: persistedFiniteInteger(
|
||||
persisted.lastCustomSpeedLimitKiB,
|
||||
1,
|
||||
10_485_760,
|
||||
currentState.lastCustomSpeedLimitKiB
|
||||
),
|
||||
lastCustomSpeedLimitUnit: persisted.lastCustomSpeedLimitUnit === 'KB/s'
|
||||
|| persisted.lastCustomSpeedLimitUnit === 'MB/s'
|
||||
? persisted.lastCustomSpeedLimitUnit
|
||||
: currentState.lastCustomSpeedLimitUnit,
|
||||
logsEnabled: persisted.logsEnabled === true,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots.filter((root): root is string => typeof root === 'string')
|
||||
? persisted.approvedDownloadRoots
|
||||
: currentState.approvedDownloadRoots,
|
||||
scheduler: {
|
||||
...currentState.scheduler,
|
||||
|
||||
@@ -12,11 +12,6 @@ import {
|
||||
mediaTypeForFormat,
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
isMagnetUrl,
|
||||
isAddDownloadMetadataLoading,
|
||||
isAddDownloadMetadataError,
|
||||
isMetadataRefreshableRow,
|
||||
isRemoteTorrentUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
@@ -89,99 +84,6 @@ describe('add download metadata workflow', () => {
|
||||
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits magnets and local torrent files through the Add window metadata path', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example\nfile:///tmp/Example.torrent',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
status: 'ready',
|
||||
file: 'Example',
|
||||
torrentMetadataStatus: 'loading'
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
sourceUrl: 'file:///tmp/Example.torrent',
|
||||
status: 'loading'
|
||||
});
|
||||
expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`);
|
||||
expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`);
|
||||
expect(isMagnetUrl(rows[0].sourceUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('admits remote .torrent URLs through the Torrent metadata path', () => {
|
||||
expect(isRemoteTorrentUrl('https://example.com/files/sample.torrent?download=1')).toBe(true);
|
||||
expect(isRemoteTorrentUrl('https://example.com/files/sample.zip')).toBe(false);
|
||||
|
||||
const rows = reconcileDownloadRows('https://example.com/files/sample.torrent?download=1', []);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
status: 'loading'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an explicit torrent handoff for an opaque remote URL', () => {
|
||||
const sourceUrl = 'https://example.com/download?id=opaque';
|
||||
const rows = reconcileDownloadRows(
|
||||
sourceUrl,
|
||||
[],
|
||||
'example.torrent',
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
new Set([sourceUrl])
|
||||
);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
sourceUrl,
|
||||
isTorrent: true,
|
||||
torrentCacheId: `${rows[0].id}-1`,
|
||||
file: 'example.torrent'
|
||||
});
|
||||
});
|
||||
|
||||
it('gives refreshed torrent metadata a new cache identity', () => {
|
||||
const existing = row({
|
||||
id: 'torrent-row',
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
downloadUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
isTorrent: true,
|
||||
torrentCacheId: 'torrent-row-1',
|
||||
torrentPath: '/managed/torrent-row-1.torrent',
|
||||
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
generation: 1,
|
||||
requestContextVersion: 1
|
||||
});
|
||||
|
||||
const refreshed = reconcileDownloadRows(
|
||||
existing.sourceUrl,
|
||||
[existing],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{ [existing.sourceUrl]: 2 }
|
||||
);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({
|
||||
generation: 2,
|
||||
torrentCacheId: 'torrent-row-2',
|
||||
torrentPath: undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://www.youtube.com/playlist?list=PL123',
|
||||
@@ -540,128 +442,6 @@ describe('add download metadata workflow', () => {
|
||||
expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 });
|
||||
});
|
||||
|
||||
it('does not duplicate an in-flight magnet probe and refreshes it after failure', () => {
|
||||
const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
||||
const admitted = reconcileDownloadRows(magnet, [])[0];
|
||||
|
||||
expect(admitted.status).toBe('ready');
|
||||
expect(canSubmitMetadataRows([admitted])).toBe(true);
|
||||
expect(isMetadataRefreshableRow(admitted)).toBe(false);
|
||||
expect(isMetadataRefreshableRow({ ...admitted, torrentMetadataStatus: 'ready' })).toBe(true);
|
||||
|
||||
const failed = { ...admitted, torrentMetadataStatus: 'error' as const };
|
||||
const refreshed = refreshFailedMetadataRows([failed])[0];
|
||||
expect(refreshed).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 2,
|
||||
torrentCacheId: `${admitted.id}-2`,
|
||||
});
|
||||
expect(isMetadataRefreshableRow(failed)).toBe(true);
|
||||
expect(isMetadataRefreshableRow({ ...admitted, status: 'loading' })).toBe(false);
|
||||
expect(isMetadataRefreshableRow(row())).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes optional magnet metadata as loading while keeping transfer readiness', () => {
|
||||
const magnet = row({
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
isTorrent: true,
|
||||
torrentMetadataStatus: 'loading'
|
||||
});
|
||||
|
||||
expect(magnet.status).toBe('ready');
|
||||
expect(isAddDownloadMetadataLoading(magnet)).toBe(true);
|
||||
expect(isAddDownloadMetadataLoading({
|
||||
...magnet,
|
||||
torrentMetadataStatus: 'ready'
|
||||
})).toBe(false);
|
||||
expect(isAddDownloadMetadataLoading({
|
||||
...magnet,
|
||||
status: 'loading',
|
||||
torrentMetadataStatus: undefined
|
||||
})).toBe(true);
|
||||
expect(isAddDownloadMetadataError({
|
||||
...magnet,
|
||||
torrentMetadataStatus: 'error'
|
||||
})).toBe(true);
|
||||
expect(isAddDownloadMetadataError({
|
||||
...magnet,
|
||||
torrentMetadataStatus: 'ready'
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes only selected metadata rows when requested by the preview action', () => {
|
||||
const selected = row({
|
||||
id: 'selected-magnet',
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
isTorrent: true,
|
||||
selected: true
|
||||
});
|
||||
const unselected = row({
|
||||
id: 'unselected-magnet',
|
||||
sourceUrl: 'magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
isTorrent: true,
|
||||
selected: false
|
||||
});
|
||||
|
||||
const refreshed = refreshFailedMetadataRows([selected, unselected], true);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({ status: 'loading', generation: 2 });
|
||||
expect(refreshed[1]).toBe(unselected);
|
||||
});
|
||||
|
||||
it('invalidates stale torrent metadata before an optional refresh', () => {
|
||||
const refreshed = refreshFailedMetadataRows([row({
|
||||
isTorrent: true,
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
status: 'ready',
|
||||
torrentPath: '/managed/old.torrent',
|
||||
torrentCacheId: 'old-cache',
|
||||
torrentInfoHash: 'old-hash',
|
||||
torrentFiles: [{ index: 1, path: 'old.bin', length: 10 }],
|
||||
selectedTorrentFileIndices: [1]
|
||||
})])[0];
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 2,
|
||||
torrentCacheId: 'row-1-2',
|
||||
torrentMetadataStatus: 'loading'
|
||||
});
|
||||
expect(refreshed.torrentPath).toBeUndefined();
|
||||
expect(refreshed.torrentInfoHash).toBeUndefined();
|
||||
expect(refreshed.torrentFiles).toBeUndefined();
|
||||
expect(refreshed.selectedTorrentFileIndices).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy magnet fallback rows to transfer-ready state', () => {
|
||||
const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
||||
const legacy = row({
|
||||
sourceUrl: magnet,
|
||||
downloadUrl: 'torrent:stale-metadata-hash',
|
||||
status: 'fallback',
|
||||
isTorrent: true,
|
||||
torrentPath: '/managed/stale.torrent',
|
||||
torrentCacheId: 'legacy-cache',
|
||||
torrentInfoHash: 'legacy-hash',
|
||||
torrentFiles: [{ index: 1, path: 'stale.bin', length: 10 }],
|
||||
selectedTorrentFileIndices: [1]
|
||||
});
|
||||
|
||||
const migrated = reconcileDownloadRows(magnet, [legacy])[0];
|
||||
|
||||
expect(migrated).toMatchObject({
|
||||
status: 'ready',
|
||||
downloadUrl: magnet,
|
||||
isTorrent: true,
|
||||
torrentMetadataStatus: 'loading'
|
||||
});
|
||||
expect(migrated.torrentPath).toBeUndefined();
|
||||
expect(migrated.torrentCacheId).toBeUndefined();
|
||||
expect(migrated.torrentInfoHash).toBeUndefined();
|
||||
expect(migrated.torrentFiles).toBeUndefined();
|
||||
expect(migrated.selectedTorrentFileIndices).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale metadata results after generation changes', () => {
|
||||
const current = row({ generation: 2, status: 'loading' });
|
||||
const updated = updateRowIfCurrent(
|
||||
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
isMediaUrl
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import type { TorrentWebSeedDraft } from './downloads';
|
||||
import i18n from '../i18n';
|
||||
import { localePluralVariant } from '../i18n/locales';
|
||||
|
||||
export type MetadataStatus = 'loading' | 'ready' | 'fallback' | 'metadata-error' | 'invalid';
|
||||
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
||||
|
||||
export interface AddMediaFormat {
|
||||
name: string;
|
||||
@@ -54,25 +52,6 @@ export interface AddDownloadDraftRow {
|
||||
playlistError?: string;
|
||||
metadataBlockedReason?: 'unsafe-url';
|
||||
selected?: boolean;
|
||||
/** Opaque native fingerprint captured for an exact unmanaged-file replace. */
|
||||
replaceExistingFingerprint?: string;
|
||||
isTorrent?: boolean;
|
||||
torrentPath?: string;
|
||||
torrentCacheId?: string;
|
||||
torrentInfoHash?: string;
|
||||
torrentFiles?: TorrentFile[];
|
||||
/** Best-effort metadata enrichment state for directly admitted magnets. */
|
||||
torrentMetadataStatus?: 'loading' | 'ready' | 'error';
|
||||
selectedTorrentFileIndices?: number[];
|
||||
torrentSeedTime?: number;
|
||||
torrentSeedRatio?: number;
|
||||
torrentUploadLimit?: string;
|
||||
torrentMaxPeers?: number;
|
||||
torrentPeerSpeedLimit?: string;
|
||||
torrentCheckIntegrity?: boolean;
|
||||
torrentTrackers?: string;
|
||||
torrentExcludeTrackers?: string;
|
||||
torrentWebSeedRows?: TorrentWebSeedDraft[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,64 +61,12 @@ export interface AddDownloadDraftRow {
|
||||
*/
|
||||
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:', 'magnet:']);
|
||||
|
||||
const isLocalTorrentPath = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol === 'file:') {
|
||||
return parsed.pathname.toLowerCase().endsWith('.torrent');
|
||||
}
|
||||
} catch {
|
||||
// A native Windows path is not a URL, even though URL parsing may treat
|
||||
// its drive letter as a scheme.
|
||||
}
|
||||
return value.toLowerCase().endsWith('.torrent')
|
||||
&& (value.startsWith('/') || /^[a-z]:[\\/]/i.test(value));
|
||||
};
|
||||
|
||||
export const isRemoteTorrentUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.pathname.toLowerCase().endsWith('.torrent');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isMagnetUrl = (value: string): boolean => {
|
||||
try {
|
||||
return new URL(value).protocol === 'magnet:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isMagnetTorrentRow = (
|
||||
row: Pick<AddDownloadDraftRow, 'isTorrent' | 'sourceUrl'>
|
||||
): boolean => row.isTorrent === true && isMagnetUrl(row.sourceUrl);
|
||||
|
||||
export const isAddDownloadMetadataLoading = (
|
||||
row: Pick<AddDownloadDraftRow, 'status' | 'isTorrent' | 'sourceUrl' | 'torrentMetadataStatus'>
|
||||
): boolean => row.status === 'loading'
|
||||
|| (isMagnetTorrentRow(row) && row.torrentMetadataStatus === 'loading');
|
||||
|
||||
export const isAddDownloadMetadataError = (
|
||||
row: Pick<AddDownloadDraftRow, 'status' | 'isTorrent' | 'sourceUrl' | 'torrentMetadataStatus'>
|
||||
): boolean => row.status === 'metadata-error'
|
||||
|| (isMagnetTorrentRow(row) && row.torrentMetadataStatus === 'error');
|
||||
|
||||
export const isMetadataRefreshableRow = (row: AddDownloadDraftRow): boolean =>
|
||||
row.status !== 'loading'
|
||||
&& (row.status === 'metadata-error'
|
||||
|| (isMagnetTorrentRow(row) && row.torrentMetadataStatus !== 'loading'));
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
|
||||
type ParsedInput = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
valid: boolean;
|
||||
isTorrent?: boolean;
|
||||
isPlaylist?: boolean;
|
||||
playlistSourceUrl?: string;
|
||||
playlistTitle?: string;
|
||||
@@ -188,18 +115,10 @@ const parseInputLines = (
|
||||
|
||||
let sourceUrl = line;
|
||||
let valid = false;
|
||||
let isTorrent = false;
|
||||
if (isLocalTorrentPath(line)) {
|
||||
valid = true;
|
||||
isTorrent = true;
|
||||
}
|
||||
try {
|
||||
if (!isTorrent) {
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
isTorrent = valid && (url.protocol === 'magnet:' || isRemoteTorrentUrl(sourceUrl));
|
||||
if (valid) sourceUrl = url.href;
|
||||
}
|
||||
const url = new URL(line);
|
||||
valid = ALLOWED_SCHEMES.has(url.protocol);
|
||||
if (valid) sourceUrl = url.href;
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
@@ -247,7 +166,6 @@ const parseInputLines = (
|
||||
identity,
|
||||
sourceUrl,
|
||||
valid,
|
||||
isTorrent,
|
||||
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
|
||||
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
|
||||
selected: selectedBySourceUrl[sourceUrl] !== false
|
||||
@@ -266,8 +184,7 @@ export const reconcileDownloadRows = (
|
||||
requestFilenames: Readonly<Record<string, string>> = {},
|
||||
requestContextVersions: Readonly<Record<string, number>> = {},
|
||||
playlistExpansions: PlaylistExpansions = {},
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {},
|
||||
forceTorrentUrls: ReadonlySet<string> = new Set()
|
||||
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
|
||||
): AddDownloadDraftRow[] => {
|
||||
const inputs = parseInputLines(
|
||||
rawText,
|
||||
@@ -281,7 +198,6 @@ export const reconcileDownloadRows = (
|
||||
const preserved = existing.get(input.sourceUrl);
|
||||
if (preserved) {
|
||||
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
|
||||
const forcedTorrent = input.valid && forceTorrentUrls.has(input.sourceUrl);
|
||||
const requestContextVersion = input.requestContextVersion;
|
||||
const contextChanged = requestContextVersion !== undefined
|
||||
&& requestContextVersion !== preserved.requestContextVersion;
|
||||
@@ -290,11 +206,7 @@ export const reconcileDownloadRows = (
|
||||
|| preserved.playlistIndex !== input.playlistIndex
|
||||
|| preserved.playlistCount !== input.playlistCount
|
||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||
if ((forcedMedia && !preserved.isMedia)
|
||||
|| (forcedTorrent && !preserved.isTorrent)
|
||||
|| contextChanged
|
||||
|| playlistContextChanged) {
|
||||
const nextGeneration = preserved.generation + 1;
|
||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||
const requestedFilename = input.playlistSourceUrl
|
||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||
: requestFilenames[input.sourceUrl];
|
||||
@@ -303,12 +215,10 @@ export const reconcileDownloadRows = (
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading',
|
||||
torrentMetadataStatus: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'loading' : undefined,
|
||||
generation: nextGeneration,
|
||||
status: 'loading',
|
||||
generation: preserved.generation + 1,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
isTorrent: input.isTorrent || forcedTorrent,
|
||||
size: undefined,
|
||||
sizeBytes: undefined,
|
||||
resumable: undefined,
|
||||
@@ -325,33 +235,7 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
playlistError: undefined,
|
||||
metadataBlockedReason: undefined,
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
};
|
||||
}
|
||||
// Direct magnets are admission-ready even when their optional metadata
|
||||
// preview was never requested. Migrate drafts created by the old
|
||||
// fallback classification so the Add window cannot imply that the
|
||||
// transfer itself is a fallback download.
|
||||
if (preserved.status === 'fallback'
|
||||
&& input.valid
|
||||
&& input.isTorrent
|
||||
&& isMagnetUrl(input.sourceUrl)) {
|
||||
return {
|
||||
...preserved,
|
||||
downloadUrl: input.sourceUrl,
|
||||
status: 'ready',
|
||||
isTorrent: true,
|
||||
torrentMetadataStatus: 'loading',
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
metadataBlockedReason: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
@@ -365,21 +249,13 @@ export const reconcileDownloadRows = (
|
||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||
);
|
||||
|
||||
const id = createId();
|
||||
const generation = input.valid ? 1 : 0;
|
||||
return {
|
||||
id,
|
||||
id: createId(),
|
||||
sourceUrl: input.sourceUrl,
|
||||
downloadUrl: input.sourceUrl,
|
||||
file: fallback,
|
||||
// A magnet already contains the transfer identity. Metadata is useful
|
||||
// for the preview, but it is not required to admit the transfer. Keep
|
||||
// the probe best-effort so the Add window never blocks a valid magnet
|
||||
// on the bounded native probe timeout.
|
||||
status: input.valid
|
||||
? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading'
|
||||
: 'invalid',
|
||||
generation,
|
||||
status: input.valid ? 'loading' : 'invalid',
|
||||
generation: input.valid ? 1 : 0,
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
isMedia: input.valid && (
|
||||
Boolean(input.isPlaylist)
|
||||
@@ -387,7 +263,6 @@ export const reconcileDownloadRows = (
|
||||
|| forceMediaUrls.has(input.sourceUrl)
|
||||
|| isMediaUrl(input.sourceUrl)
|
||||
),
|
||||
isTorrent: input.valid && (Boolean(input.isTorrent) || forceTorrentUrls.has(input.sourceUrl)),
|
||||
isPlaylist: input.isPlaylist,
|
||||
playlistSourceUrl: input.playlistSourceUrl,
|
||||
playlistTitle: input.playlistTitle,
|
||||
@@ -395,12 +270,6 @@ export const reconcileDownloadRows = (
|
||||
playlistCount: input.playlistCount,
|
||||
playlistEntryTitle: input.playlistEntryTitle,
|
||||
metadataBlockedReason: undefined,
|
||||
torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
|
||||
? `${id}-${generation}`
|
||||
: undefined,
|
||||
torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl)
|
||||
? 'loading'
|
||||
: undefined,
|
||||
selected: input.selected !== false
|
||||
};
|
||||
});
|
||||
@@ -448,35 +317,23 @@ export const updateRowIfCurrent = (
|
||||
);
|
||||
|
||||
export const refreshFailedMetadataRows = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
selectedOnly = false
|
||||
): AddDownloadDraftRow[] => rows.map(row => {
|
||||
if ((selectedOnly && row.selected === false) || !isMetadataRefreshableRow(row)) return row;
|
||||
const generation = row.generation + 1;
|
||||
return {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation,
|
||||
metadataBlockedReason: undefined,
|
||||
...(row.isTorrent
|
||||
? {
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: `${row.id}-${generation}`,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
torrentMetadataStatus: isMagnetTorrentRow(row) ? 'loading' : undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
rows: AddDownloadDraftRow[]
|
||||
): AddDownloadDraftRow[] => rows.map(row =>
|
||||
row.status === 'metadata-error'
|
||||
? {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation: row.generation + 1,
|
||||
metadataBlockedReason: undefined
|
||||
}
|
||||
: {})
|
||||
};
|
||||
});
|
||||
: row
|
||||
);
|
||||
|
||||
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
|
||||
const selectedRows = rows.filter(row => row.selected !== false);
|
||||
return selectedRows.length > 0
|
||||
&& selectedRows.every(row =>
|
||||
row.status === 'ready'
|
||||
|| (row.isTorrent === true && row.status === 'fallback')
|
||||
|| (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason)
|
||||
);
|
||||
};
|
||||
@@ -676,13 +533,13 @@ export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSumma
|
||||
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||
if (loading > 0) return { type: 'loading', count: loading };
|
||||
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error' || row.status === 'fallback').length;
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||
if (blocked > 0) return { type: 'unsafe', count: blocked };
|
||||
if (failedMedia > 0) return { type: 'media-error', count: failedMedia };
|
||||
if (failed === selectedRows.length && !selectedRows.some(row => row.status === 'fallback')) return { type: 'all-error' };
|
||||
if (failed === selectedRows.length) return { type: 'all-error' };
|
||||
if (failed > 0) return { type: 'fallback', ready, failed };
|
||||
return { type: 'ready', count: ready };
|
||||
};
|
||||
@@ -726,7 +583,7 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
);
|
||||
}
|
||||
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error' || row.status === 'fallback').length;
|
||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||
@@ -746,7 +603,7 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||
() => i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: failedMedia })
|
||||
);
|
||||
}
|
||||
if (failed === selectedRows.length && !selectedRows.some(row => row.status === 'fallback')) {
|
||||
if (failed === selectedRows.length) {
|
||||
return i18n.t($ => $.addDownloads.metadataUnavailableFallback);
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -13,23 +13,16 @@ describe('clipboard URL extraction', () => {
|
||||
|
||||
it('reads only supported, unique download URLs from clipboard text', async () => {
|
||||
vi.mocked(readText).mockResolvedValue(
|
||||
'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567 mailto:user@example.com'
|
||||
'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso mailto:user@example.com'
|
||||
);
|
||||
|
||||
await expect(readClipboardDownloadUrls()).resolves.toEqual([
|
||||
'https://example.com/file.zip',
|
||||
'ftp://example.com/file.bin',
|
||||
'sftp://example.com/file.iso',
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores malformed magnet URLs at the clipboard boundary', async () => {
|
||||
vi.mocked(readText).mockResolvedValue('magnet: magnet:?invalid magnet://tracker/?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
await expect(readClipboardDownloadUrls()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves clipboard read failures for the caller to handle', async () => {
|
||||
const error = new Error('clipboard unavailable');
|
||||
vi.mocked(readText).mockRejectedValue(error);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createColumnResizeSession } from './columnResize';
|
||||
|
||||
const createEventTarget = () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
const target = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
const current = listeners.get(type) ?? new Set<EventListener>();
|
||||
current.add(listener);
|
||||
listeners.set(type, current);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
target,
|
||||
dispatch: (type: string, event: Partial<PointerEvent> = {}) => {
|
||||
listeners.get(type)?.forEach(listener => listener(event as Event));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('column resize session', () => {
|
||||
it('ignores other pointers, clamps the active pointer, and persists on completion', () => {
|
||||
const windowTarget = createEventTarget();
|
||||
const documentTarget = createEventTarget();
|
||||
const classes = new Set<string>();
|
||||
const onWidth = vi.fn();
|
||||
const onEnd = vi.fn();
|
||||
const classList = {
|
||||
add: (value: string) => classes.add(value),
|
||||
remove: (value: string) => classes.delete(value),
|
||||
} as unknown as DOMTokenList;
|
||||
createColumnResizeSession({
|
||||
windowTarget: windowTarget.target,
|
||||
documentTarget: documentTarget.target,
|
||||
body: { classList },
|
||||
pointerId: 7,
|
||||
startX: 100,
|
||||
startWidth: 220,
|
||||
minWidth: 92,
|
||||
onWidth,
|
||||
onEnd,
|
||||
});
|
||||
|
||||
expect(classes.has('is-column-resizing')).toBe(true);
|
||||
windowTarget.dispatch('pointermove', { pointerId: 8, clientX: 1 });
|
||||
expect(onWidth).not.toHaveBeenCalled();
|
||||
windowTarget.dispatch('pointermove', { pointerId: 7, clientX: 1 });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(121);
|
||||
windowTarget.dispatch('pointermove', { pointerId: 7, clientX: 1000 });
|
||||
expect(onWidth).toHaveBeenLastCalledWith(1120);
|
||||
|
||||
windowTarget.dispatch('pointerup', { pointerId: 8 });
|
||||
expect(classes.has('is-column-resizing')).toBe(true);
|
||||
windowTarget.dispatch('pointerup', { pointerId: 7 });
|
||||
expect(classes.has('is-column-resizing')).toBe(false);
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up on visibility interruption and makes cleanup idempotent', () => {
|
||||
const windowTarget = createEventTarget();
|
||||
const documentTarget = createEventTarget();
|
||||
const classList = {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
} as unknown as DOMTokenList;
|
||||
const cleanup = createColumnResizeSession({
|
||||
windowTarget: windowTarget.target,
|
||||
documentTarget: documentTarget.target,
|
||||
body: { classList },
|
||||
pointerId: 3,
|
||||
startX: 100,
|
||||
startWidth: 220,
|
||||
minWidth: 92,
|
||||
onWidth: vi.fn(),
|
||||
});
|
||||
|
||||
documentTarget.dispatch('visibilitychange');
|
||||
expect(classList.remove).toHaveBeenCalledWith('is-column-resizing');
|
||||
cleanup();
|
||||
expect(classList.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
type ResizeEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
||||
type ResizeDocumentTarget = Pick<Document, 'addEventListener' | 'removeEventListener'>;
|
||||
type ResizeBody = Pick<HTMLElement, 'classList'>;
|
||||
|
||||
/** Own the global listeners for one table-column resize gesture. */
|
||||
export const createColumnResizeSession = ({
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
body,
|
||||
pointerId,
|
||||
startX,
|
||||
startWidth,
|
||||
minWidth,
|
||||
onWidth,
|
||||
onEnd,
|
||||
}: {
|
||||
windowTarget: ResizeEventTarget;
|
||||
documentTarget: ResizeDocumentTarget;
|
||||
body: ResizeBody;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
minWidth: number;
|
||||
onWidth: (width: number) => void;
|
||||
onEnd?: () => void;
|
||||
}): (() => void) => {
|
||||
let active = true;
|
||||
|
||||
const handlePointerMove = (event: Event) => {
|
||||
const pointerEvent = event as PointerEvent;
|
||||
if (!active || pointerEvent.pointerId !== pointerId) return;
|
||||
onWidth(Math.max(minWidth, startWidth + pointerEvent.clientX - startX));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
windowTarget.removeEventListener('pointermove', handlePointerMove);
|
||||
windowTarget.removeEventListener('pointerup', handlePointerEnd);
|
||||
windowTarget.removeEventListener('pointercancel', handlePointerEnd);
|
||||
windowTarget.removeEventListener('blur', handleInterrupted);
|
||||
documentTarget.removeEventListener('visibilitychange', handleInterrupted);
|
||||
body.classList.remove('is-column-resizing');
|
||||
onEnd?.();
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: Event) => {
|
||||
if ((event as PointerEvent).pointerId === pointerId) cleanup();
|
||||
};
|
||||
|
||||
const handleInterrupted = () => cleanup();
|
||||
|
||||
body.classList.add('is-column-resizing');
|
||||
windowTarget.addEventListener('pointermove', handlePointerMove);
|
||||
windowTarget.addEventListener('pointerup', handlePointerEnd);
|
||||
windowTarget.addEventListener('pointercancel', handlePointerEnd);
|
||||
windowTarget.addEventListener('blur', handleInterrupted);
|
||||
documentTarget.addEventListener('visibilitychange', handleInterrupted);
|
||||
|
||||
return cleanup;
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyDocumentAppearance } from './documentAppearance';
|
||||
|
||||
const fakeDocument = () => {
|
||||
const classes = new Set<string>(['theme-light', 'dark']);
|
||||
const root = {
|
||||
classList: {
|
||||
add: (...values: string[]) => values.forEach(value => classes.add(value)),
|
||||
remove: (...values: string[]) => values.forEach(value => classes.delete(value)),
|
||||
},
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
lang: '',
|
||||
dir: '',
|
||||
};
|
||||
return {
|
||||
classes,
|
||||
root,
|
||||
document: { documentElement: root } as unknown as Document,
|
||||
};
|
||||
};
|
||||
|
||||
describe('document appearance synchronization', () => {
|
||||
it('applies a complete dark RTL projection without retaining stale theme classes', () => {
|
||||
const target = fakeDocument();
|
||||
applyDocumentAppearance(target.document, {
|
||||
theme: 'nord',
|
||||
fontFamily: 'vazirmatn',
|
||||
appFontSize: 'large',
|
||||
listRowDensity: 'compact',
|
||||
locale: 'fa',
|
||||
}, false);
|
||||
|
||||
expect([...target.classes].sort()).toEqual(['dark', 'theme-nord']);
|
||||
expect(target.root.dataset).toEqual({
|
||||
resolvedTheme: 'dark',
|
||||
fontFamily: 'vazirmatn',
|
||||
fontSize: 'large',
|
||||
listDensity: 'compact',
|
||||
});
|
||||
expect(target.root.style.colorScheme).toBe('dark');
|
||||
expect(target.root.lang).toBe('fa');
|
||||
expect(target.root.dir).toBe('rtl');
|
||||
});
|
||||
|
||||
it('resolves system appearance while preserving an LTR locale', () => {
|
||||
const target = fakeDocument();
|
||||
applyDocumentAppearance(target.document, {
|
||||
theme: 'system',
|
||||
fontFamily: 'system',
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
locale: 'en',
|
||||
}, false);
|
||||
|
||||
expect([...target.classes]).toEqual(['theme-light']);
|
||||
expect(target.root.dataset.resolvedTheme).toBe('light');
|
||||
expect(target.root.style.colorScheme).toBe('light');
|
||||
expect(target.root.lang).toBe('en');
|
||||
expect(target.root.dir).toBe('ltr');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { AppFontSize } from '../bindings/AppFontSize';
|
||||
import type { FontFamily } from '../bindings/FontFamily';
|
||||
import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
import { localeDirection, resolveAppLocale, type AppLocale } from '../i18n/locales';
|
||||
|
||||
export type DocumentAppearance = {
|
||||
theme: Theme;
|
||||
fontFamily: FontFamily;
|
||||
appFontSize: AppFontSize;
|
||||
listRowDensity: ListRowDensity;
|
||||
locale: AppLocale;
|
||||
};
|
||||
|
||||
const DARK_THEMES: ReadonlySet<Theme> = new Set(['dark', 'dracula', 'nord']);
|
||||
const THEME_CLASSES = ['theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'] as const;
|
||||
|
||||
export const applyDocumentAppearance = (
|
||||
document: Document,
|
||||
appearance: DocumentAppearance,
|
||||
systemDark: boolean,
|
||||
): void => {
|
||||
const root = document.documentElement;
|
||||
const resolvedTheme = appearance.theme === 'system'
|
||||
? (systemDark ? 'dark' : 'light')
|
||||
: (DARK_THEMES.has(appearance.theme) ? 'dark' : 'light');
|
||||
const themeClass = appearance.theme === 'system'
|
||||
? `theme-${resolvedTheme}`
|
||||
: `theme-${appearance.theme}`;
|
||||
|
||||
root.classList.remove(...THEME_CLASSES);
|
||||
root.classList.add(themeClass);
|
||||
if (resolvedTheme === 'dark') root.classList.add('dark');
|
||||
root.dataset.resolvedTheme = resolvedTheme;
|
||||
root.style.colorScheme = resolvedTheme;
|
||||
root.dataset.fontFamily = appearance.fontFamily;
|
||||
root.dataset.fontSize = appearance.appFontSize;
|
||||
root.dataset.listDensity = appearance.listRowDensity;
|
||||
|
||||
const locale = resolveAppLocale(appearance.locale);
|
||||
root.lang = locale;
|
||||
root.dir = localeDirection(locale);
|
||||
};
|
||||
|
||||
export const synchronizeDocumentAppearance = (
|
||||
window: Window,
|
||||
appearance: DocumentAppearance,
|
||||
): (() => void) => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const apply = () => applyDocumentAppearance(window.document, appearance, media.matches);
|
||||
apply();
|
||||
if (appearance.theme !== 'system') return () => undefined;
|
||||
media.addEventListener('change', apply);
|
||||
return () => media.removeEventListener('change', apply);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ describe('download action policy', () => {
|
||||
expect(canStartDownload(status)).toBe(true);
|
||||
expect(canPauseDownload(status)).toBe(false);
|
||||
}
|
||||
for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'verifying', 'retrying'] as const) {
|
||||
for (const status of ['staged', 'queued', 'downloading', 'processing', 'retrying'] as const) {
|
||||
expect(canPauseDownload(status)).toBe(true);
|
||||
}
|
||||
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
|
||||
@@ -33,15 +33,12 @@ describe('download action policy', () => {
|
||||
expect(canRedownload('failed')).toBe(true);
|
||||
expect(canRedownload('paused')).toBe(true);
|
||||
expect(canRedownload('downloading')).toBe(false);
|
||||
expect(canRedownload('seeding')).toBe(false);
|
||||
});
|
||||
|
||||
it('only exposes pause or resume for the details-view toggle', () => {
|
||||
expect(getPauseResumeAction('queued')).toBe('pause');
|
||||
expect(getPauseResumeAction('downloading')).toBe('pause');
|
||||
expect(getPauseResumeAction('processing')).toBe('pause');
|
||||
expect(getPauseResumeAction('verifying')).toBe('pause');
|
||||
expect(getPauseResumeAction('seeding')).toBe('pause');
|
||||
expect(getPauseResumeAction('retrying')).toBe('pause');
|
||||
expect(getPauseResumeAction('paused')).toBe('resume');
|
||||
|
||||
@@ -56,7 +53,6 @@ describe('download action policy', () => {
|
||||
expect(startActionLabel('failed')).toBe('Start');
|
||||
expect(startActionLabel('paused')).toBe('Resume');
|
||||
expect(isTransferLocked('processing')).toBe(true);
|
||||
expect(isTransferLocked('seeding')).toBe(true);
|
||||
expect(isIdentityLocked('completed')).toBe(true);
|
||||
expect(isTransferLocked('completed')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'ready',
|
||||
'staged',
|
||||
'paused',
|
||||
'waitingToSeed',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
@@ -12,10 +11,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'staged',
|
||||
'queued',
|
||||
'downloading',
|
||||
'seeding',
|
||||
'waitingToSeed',
|
||||
'processing',
|
||||
'verifying',
|
||||
'retrying',
|
||||
]);
|
||||
|
||||
@@ -45,9 +41,7 @@ export const countDownloadActions = (
|
||||
downloads: ReadonlyArray<{ status: DownloadStatus }>
|
||||
): DownloadActionCounts => downloads.reduce<DownloadActionCounts>((counts, download) => {
|
||||
if (canPauseDownload(download.status)) counts.pause += 1;
|
||||
if (canStartDownload(download.status)) {
|
||||
counts.resume += 1;
|
||||
}
|
||||
if (canStartDownload(download.status)) counts.resume += 1;
|
||||
return counts;
|
||||
}, { pause: 0, resume: 0 });
|
||||
|
||||
@@ -69,7 +63,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
|
||||
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
|
||||
|
||||
export const isTransferLocked = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying' || status === 'moving';
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const isIdentityLocked = (status: DownloadStatus): boolean =>
|
||||
isTransferLocked(status) || status === 'completed';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user