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:
NimBold
2026-08-27 19:54:55 +03:30
parent a5cecb3777
commit 21c8fc26aa
148 changed files with 2403 additions and 47020 deletions
+237 -372
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -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"] }
+1 -2
View File
@@ -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"
]
}
-19
View File
@@ -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 -28
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+30 -292
View File
@@ -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);
}
}
}
+5 -42
View File
@@ -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());
}
}
}
+29 -142
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -27
View File
@@ -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
View File
@@ -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(&current).await?;
if metadata.file_type().is_symlink() {
if let Some(canonical_alias) = resolve_atomic_system_alias(&current)? {
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")
));
}
}
-786
View File
@@ -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, &registry)
}
#[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, &registry)?;
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, &registry)?;
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, &registry)?;
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
View File
File diff suppressed because it is too large Load Diff
+5 -193
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-97
View File
@@ -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);
}
}
+3 -17
View File
@@ -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"]
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 800,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": false,
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 800,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": true,
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 800,
"height": 760,
"minWidth": 960,
"minHeight": 640,
"transparent": true,
+119 -236
View File
@@ -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 />
-3
View File
@@ -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 -1
View File
@@ -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";
-3
View File
@@ -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 -3
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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, };
-3
View File
@@ -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 -1
View File
@@ -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";
-4
View File
@@ -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, };
-3
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { 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, };
-3
View File
@@ -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, };
+1 -2
View File
@@ -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>, };
-3
View File
@@ -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>, };
-3
View File
@@ -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, };
-3
View File
@@ -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>, };
-4
View File
@@ -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, };
-3
View File
@@ -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, };
-3
View File
@@ -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, };
-4
View File
@@ -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>, };
-3
View File
@@ -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, };
File diff suppressed because it is too large Load Diff
+2 -17
View File
@@ -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
View File
@@ -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" />
+54 -137
View File
@@ -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);
});
});
+6 -21
View File
@@ -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 -2
View File
@@ -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();
+743
View File
@@ -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;
};
+6 -50
View File
@@ -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({
+5 -613
View File
@@ -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
View File
@@ -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');
});
});
-135
View File
@@ -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>
);
};
+1 -4
View File
@@ -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
View File
@@ -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: '01000; 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 Aria2s 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 Aria2s 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 Aria2s 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 Aria2s 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 Aria2s 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 Aria2s 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 Aria2s 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 Aria2s 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
View File
@@ -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
View File
@@ -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: '01000; ‏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
View File
@@ -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: '01000; 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
View File
@@ -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: '01000; 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
View File
@@ -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: '010000 表示不限',
inputFormatSeedTime: '分钟,例如 60',
inputFormatSeedRatio: '小数,例如 1.50 表示仅按时间',
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: '强制加密 payloadARC4',
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、FTPSFTP 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: '强制加密 payloadARC4',
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;
-12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3 -88
View File
@@ -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
View File
@@ -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()
-644
View File
@@ -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();
});
});
-564
View File
@@ -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');
}
};
+2 -92
View File
@@ -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 };
}),
}));
+3 -714
View File
@@ -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
View File
@@ -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;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-308
View File
@@ -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', () => {
+5 -364
View File
@@ -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,
-220
View File
@@ -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(
+27 -170
View File
@@ -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) {
+1 -8
View File
@@ -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);
-85
View File
@@ -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);
});
});
-61
View File
@@ -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;
};
-62
View File
@@ -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');
});
});
-55
View File
@@ -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);
};
+1 -5
View File
@@ -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);
});
+2 -8
View File
@@ -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