mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 16:36:50 +00:00
feat(torrents): add global open-file limit
This commit is contained in:
+8
-2
@@ -46,6 +46,10 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
|
|||||||
timeouts are bounded to 1–604800 seconds; interval 0 restores Aria2's
|
timeouts are bounded to 1–604800 seconds; interval 0 restores Aria2's
|
||||||
response/progress-driven scheduling. Timing is persisted and reapplied when
|
response/progress-driven scheduling. Timing is persisted and reapplied when
|
||||||
a Torrent starts or retries.
|
a Torrent starts or retries.
|
||||||
|
- Global `bt-max-open-files` control for multi-file Torrents, bounded to
|
||||||
|
1–4096 with Aria2's default of 100. The setting is persisted, applied at
|
||||||
|
daemon startup, and updateable through Aria2's global-option RPC; changes
|
||||||
|
affect newly added Torrents without restarting Aria2.
|
||||||
- Optional `bt-remove-unselected-file` cleanup after completion when a
|
- Optional `bt-remove-unselected-file` cleanup after completion when a
|
||||||
selected-file subset is configured. Firelink requires explicit confirmation,
|
selected-file subset is configured. Firelink requires explicit confirmation,
|
||||||
reserves the unselected paths against competing downloads, keeps those
|
reserves the unselected paths against competing downloads, keeps those
|
||||||
@@ -75,7 +79,8 @@ No remaining Tier 0 items.
|
|||||||
|
|
||||||
1. Configurable TCP/UDP listen ports, external IP, DHT entry points, IPv6 DHT
|
1. Configurable TCP/UDP listen ports, external IP, DHT entry points, IPv6 DHT
|
||||||
listen address, and LPD interface, with platform/firewall warnings.
|
listen address, and LPD interface, with platform/firewall warnings.
|
||||||
2. Global BitTorrent open-file limits and peer identity/agent controls.
|
2. Peer identity/agent controls, with explicit privacy and protocol-identity
|
||||||
|
warnings.
|
||||||
3. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only
|
3. Aria2 `follow-torrent`/in-memory follow behavior for generic downloads only
|
||||||
if the resulting child-GID ownership model can be represented safely; the
|
if the resulting child-GID ownership model can be represented safely; the
|
||||||
current explicit metadata path intentionally avoids unmapped child jobs.
|
current explicit metadata path intentionally avoids unmapped child jobs.
|
||||||
@@ -83,4 +88,5 @@ No remaining Tier 0 items.
|
|||||||
The first implementation in this task was remote `.torrent` metadata intake;
|
The first implementation in this task was remote `.torrent` metadata intake;
|
||||||
follow-up implementations add stall-timeout control, bounded peer diagnostics,
|
follow-up implementations add stall-timeout control, bounded peer diagnostics,
|
||||||
persisted tracker exclusion, piece-preview priority, safe unselected-file
|
persisted tracker exclusion, piece-preview priority, safe unselected-file
|
||||||
removal, the validated encryption policy, and tracker timing controls.
|
removal, the validated encryption policy, tracker timing controls, and the
|
||||||
|
global Torrent open-file limit.
|
||||||
|
|||||||
@@ -704,6 +704,14 @@ async function main() {
|
|||||||
trackerlessTorrent.delete('announce');
|
trackerlessTorrent.delete('announce');
|
||||||
trackerlessTorrent.delete('announce-list');
|
trackerlessTorrent.delete('announce-list');
|
||||||
const trackerlessTorrentBytes = bencode(trackerlessTorrent);
|
const trackerlessTorrentBytes = bencode(trackerlessTorrent);
|
||||||
|
await rpc(client.rpcPort, client.secret, 'aria2.changeGlobalOption', [
|
||||||
|
{ 'bt-max-open-files': '256' },
|
||||||
|
]);
|
||||||
|
const globalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getGlobalOption', []);
|
||||||
|
assert(
|
||||||
|
globalOptions['bt-max-open-files'] === '256',
|
||||||
|
`Aria2 did not retain the global Torrent open-file limit: ${JSON.stringify(globalOptions['bt-max-open-files'])}`,
|
||||||
|
);
|
||||||
const probeRemoved = await forceRemoveIfPresent(client, probeGid);
|
const probeRemoved = await forceRemoveIfPresent(client, probeGid);
|
||||||
if (probeRemoved) await waitForRemoved(client, probeGid);
|
if (probeRemoved) await waitForRemoved(client, probeGid);
|
||||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||||
@@ -749,7 +757,7 @@ async function main() {
|
|||||||
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
|
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
|
||||||
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
|
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
|
||||||
assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output');
|
assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output');
|
||||||
console.log('[OK] additional tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed');
|
console.log('[OK] global open-file limit, tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed');
|
||||||
|
|
||||||
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
|
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
|
||||||
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
|
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ fn default_torrent_enable_lpd() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_torrent_max_open_files() -> u32 {
|
||||||
|
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -493,6 +497,8 @@ pub struct PersistedSettings {
|
|||||||
pub torrent_enable_pex: bool,
|
pub torrent_enable_pex: bool,
|
||||||
#[serde(default = "default_torrent_enable_lpd")]
|
#[serde(default = "default_torrent_enable_lpd")]
|
||||||
pub torrent_enable_lpd: bool,
|
pub torrent_enable_lpd: bool,
|
||||||
|
#[serde(default = "default_torrent_max_open_files")]
|
||||||
|
pub torrent_max_open_files: u32,
|
||||||
pub custom_user_agent: String,
|
pub custom_user_agent: String,
|
||||||
pub ask_where_to_save_each_file: bool,
|
pub ask_where_to_save_each_file: bool,
|
||||||
pub remember_last_used_download_directory: bool,
|
pub remember_last_used_download_directory: bool,
|
||||||
|
|||||||
+71
-1
@@ -6626,6 +6626,32 @@ fn apply_aria2_torrent_peer_discovery_options(
|
|||||||
.arg(format!("--bt-enable-lpd={enable_lpd}"));
|
.arg(format!("--bt-enable-lpd={enable_lpd}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_aria2_torrent_global_options(
|
||||||
|
command: &mut std::process::Command,
|
||||||
|
max_open_files: u32,
|
||||||
|
) {
|
||||||
|
let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)
|
||||||
|
.unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||||
|
command.arg(format!("--bt-max-open-files={max_open_files}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "snake_case")]
|
||||||
|
async fn set_torrent_max_open_files(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
max_open_files: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let max_open_files = queue::normalize_torrent_max_open_files(max_open_files)?;
|
||||||
|
rpc_call(
|
||||||
|
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
&state.aria2_secret,
|
||||||
|
"aria2.changeGlobalOption",
|
||||||
|
serde_json::json!([{"bt-max-open-files": max_open_files.to_string()}]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| format!("Failed to set Torrent maximum open files: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn set_global_speed_limit(
|
async fn set_global_speed_limit(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -7718,6 +7744,7 @@ mod tests {
|
|||||||
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
|
||||||
metadata_headers, metadata_response_error,
|
metadata_headers, metadata_response_error,
|
||||||
normalize_speed_limit_for_aria2,
|
normalize_speed_limit_for_aria2,
|
||||||
|
apply_aria2_torrent_global_options,
|
||||||
apply_aria2_torrent_peer_discovery_options,
|
apply_aria2_torrent_peer_discovery_options,
|
||||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||||
@@ -7739,6 +7766,7 @@ mod tests {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use super::should_apply_dock_badge_update;
|
use super::should_apply_dock_badge_update;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use crate::queue;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -7768,6 +7796,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
||||||
|
let mut command = std::process::Command::new("aria2c");
|
||||||
|
apply_aria2_torrent_global_options(&mut command, 256);
|
||||||
|
assert_eq!(
|
||||||
|
command
|
||||||
|
.get_args()
|
||||||
|
.map(|arg| arg.to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["--bt-max-open-files=256"]
|
||||||
|
);
|
||||||
|
let mut fallback_command = std::process::Command::new("aria2c");
|
||||||
|
apply_aria2_torrent_global_options(
|
||||||
|
&mut fallback_command,
|
||||||
|
queue::MAX_TORRENT_MAX_OPEN_FILES + 1,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_command
|
||||||
|
.get_args()
|
||||||
|
.map(|arg| arg.to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["--bt-max-open-files=100"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() {
|
fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() {
|
||||||
let record = json!({
|
let record = json!({
|
||||||
@@ -10419,6 +10472,21 @@ pub fn run() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or((true, false, true, false));
|
.unwrap_or((true, false, true, false));
|
||||||
|
let torrent_max_open_files = persisted_settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|settings| settings.torrent_max_open_files)
|
||||||
|
.unwrap_or(queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||||
|
let torrent_max_open_files = match queue::normalize_torrent_max_open_files(
|
||||||
|
torrent_max_open_files,
|
||||||
|
) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!(
|
||||||
|
"invalid persisted Torrent open-file limit; using Aria2 default: {error}"
|
||||||
|
);
|
||||||
|
queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let aria2_secret_clone = aria2_secret.clone();
|
let aria2_secret_clone = aria2_secret.clone();
|
||||||
let app_handle_bg = app.handle().clone();
|
let app_handle_bg = app.handle().clone();
|
||||||
@@ -10453,6 +10521,8 @@ pub fn run() {
|
|||||||
.arg("--check-certificate=true")
|
.arg("--check-certificate=true")
|
||||||
.arg(format!("--stop-with-process={}", std::process::id()));
|
.arg(format!("--stop-with-process={}", std::process::id()));
|
||||||
|
|
||||||
|
apply_aria2_torrent_global_options(&mut cmd, torrent_max_open_files);
|
||||||
|
|
||||||
apply_aria2_torrent_peer_discovery_options(
|
apply_aria2_torrent_peer_discovery_options(
|
||||||
&mut cmd,
|
&mut cmd,
|
||||||
torrent_peer_discovery.0,
|
torrent_peer_discovery.0,
|
||||||
@@ -11017,7 +11087,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_global_speed_limit, remove_download, get_download_primary_path,
|
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_torrent_max_open_files, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
@@ -24,11 +24,23 @@ pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
|
|||||||
pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024;
|
pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024;
|
||||||
pub const MAX_TORRENT_TRACKER_TIMEOUT: u32 = 604_800;
|
pub const MAX_TORRENT_TRACKER_TIMEOUT: u32 = 604_800;
|
||||||
pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800;
|
pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800;
|
||||||
|
pub const DEFAULT_TORRENT_MAX_OPEN_FILES: u32 = 100;
|
||||||
|
pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1;
|
||||||
|
pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096;
|
||||||
|
|
||||||
pub fn clamp_download_connections(connections: i32) -> i32 {
|
pub fn clamp_download_connections(connections: i32) -> i32 {
|
||||||
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn normalize_torrent_max_open_files(value: u32) -> Result<u32, String> {
|
||||||
|
if !(MIN_TORRENT_MAX_OPEN_FILES..=MAX_TORRENT_MAX_OPEN_FILES).contains(&value) {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent maximum open files must be between {MIN_TORRENT_MAX_OPEN_FILES} and {MAX_TORRENT_MAX_OPEN_FILES}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn reorder_selected_queue_tasks(
|
fn reorder_selected_queue_tasks(
|
||||||
queue_tasks: &[QueuedTask],
|
queue_tasks: &[QueuedTask],
|
||||||
ids: &[String],
|
ids: &[String],
|
||||||
@@ -4831,6 +4843,17 @@ mod tests {
|
|||||||
assert!(!options.contains_key("bt-tracker-interval"));
|
assert!(!options.contains_key("bt-tracker-interval"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_max_open_files_is_bounded() {
|
||||||
|
assert_eq!(normalize_torrent_max_open_files(1).unwrap(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES).unwrap(),
|
||||||
|
MAX_TORRENT_MAX_OPEN_FILES
|
||||||
|
);
|
||||||
|
assert!(normalize_torrent_max_open_files(0).is_err());
|
||||||
|
assert!(normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES + 1).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() {
|
fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() {
|
||||||
let mut options = serde_json::Map::new();
|
let mut options = serde_json::Map::new();
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
|||||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
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, "perServerConnections", |value| value.as_i64().is_some());
|
||||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
});
|
||||||
for key in [
|
for key in [
|
||||||
"torrentEnableDht",
|
"torrentEnableDht",
|
||||||
"torrentEnableDht6",
|
"torrentEnableDht6",
|
||||||
@@ -308,6 +317,10 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
|||||||
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
|
||||||
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
|
||||||
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
|
||||||
|
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);
|
||||||
if !matches!(
|
if !matches!(
|
||||||
settings.last_custom_speed_limit_unit.as_str(),
|
settings.last_custom_speed_limit_unit.as_str(),
|
||||||
"KB/s" | "MB/s"
|
"KB/s" | "MB/s"
|
||||||
@@ -500,6 +513,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
torrent_enable_dht6: false,
|
torrent_enable_dht6: false,
|
||||||
torrent_enable_pex: true,
|
torrent_enable_pex: true,
|
||||||
torrent_enable_lpd: false,
|
torrent_enable_lpd: false,
|
||||||
|
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||||
custom_user_agent: String::new(),
|
custom_user_agent: String::new(),
|
||||||
ask_where_to_save_each_file: false,
|
ask_where_to_save_each_file: false,
|
||||||
remember_last_used_download_directory: false,
|
remember_last_used_download_directory: false,
|
||||||
@@ -792,6 +806,7 @@ mod tests {
|
|||||||
"torrentEnableDht6": 1,
|
"torrentEnableDht6": 1,
|
||||||
"torrentEnablePex": null,
|
"torrentEnablePex": null,
|
||||||
"torrentEnableLpd": [],
|
"torrentEnableLpd": [],
|
||||||
|
"torrentMaxOpenFiles": 0,
|
||||||
"theme": "not-a-theme",
|
"theme": "not-a-theme",
|
||||||
"calendarPreference": "lunar",
|
"calendarPreference": "lunar",
|
||||||
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
"siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}]
|
||||||
@@ -809,6 +824,10 @@ mod tests {
|
|||||||
assert!(!settings.torrent_enable_dht6);
|
assert!(!settings.torrent_enable_dht6);
|
||||||
assert!(settings.torrent_enable_pex);
|
assert!(settings.torrent_enable_pex);
|
||||||
assert!(!settings.torrent_enable_lpd);
|
assert!(!settings.torrent_enable_lpd);
|
||||||
|
assert_eq!(
|
||||||
|
settings.torrent_max_open_files,
|
||||||
|
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||||
|
);
|
||||||
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
assert!(matches!(settings.theme, crate::ipc::Theme::System));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
settings.calendar_preference,
|
settings.calendar_preference,
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
|
|||||||
import type { Theme } from "./Theme";
|
import type { Theme } from "./Theme";
|
||||||
import type { WindowControlStyle } from "./WindowControlStyle";
|
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, 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, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, 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, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ import {
|
|||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||||
|
import {
|
||||||
|
MAX_TORRENT_MAX_OPEN_FILES,
|
||||||
|
MIN_TORRENT_MAX_OPEN_FILES,
|
||||||
|
normalizeTorrentMaxOpenFiles
|
||||||
|
} from '../utils/downloads';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { localeDirection, resolveAppLocale } from '../i18n';
|
import { localeDirection, resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
@@ -318,6 +323,10 @@ const engineRunId = useRef(0);
|
|||||||
() => String(settings.maxConcurrentDownloads)
|
() => String(settings.maxConcurrentDownloads)
|
||||||
);
|
);
|
||||||
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
||||||
|
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||||
|
() => String(settings.torrentMaxOpenFiles)
|
||||||
|
);
|
||||||
|
const torrentMaxOpenFilesCommitRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPerServerConnectionsInput(String(settings.perServerConnections));
|
setPerServerConnectionsInput(String(settings.perServerConnections));
|
||||||
@@ -331,6 +340,10 @@ const engineRunId = useRef(0);
|
|||||||
setProxyPortInput(String(settings.proxyPort));
|
setProxyPortInput(String(settings.proxyPort));
|
||||||
}, [settings.proxyPort]);
|
}, [settings.proxyPort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
|
||||||
|
}, [settings.torrentMaxOpenFiles]);
|
||||||
|
|
||||||
// Local state for adding site login
|
// Local state for adding site login
|
||||||
const [loginPattern, setLoginPattern] = useState('');
|
const [loginPattern, setLoginPattern] = useState('');
|
||||||
const [loginUser, setLoginUser] = useState('');
|
const [loginUser, setLoginUser] = useState('');
|
||||||
@@ -346,6 +359,22 @@ const engineRunId = useRef(0);
|
|||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
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 [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
||||||
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
|
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
|
||||||
|
|
||||||
@@ -1212,6 +1241,27 @@ runEngineChecks(false);
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 className="settings-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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="settings-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-group settings-popup-group">
|
||||||
<div className="mac-settings-row settings-network-row">
|
<div className="mac-settings-row settings-network-row">
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const common = {
|
|||||||
torrentLpd: 'Local Peer Discovery (LPD)',
|
torrentLpd: 'Local Peer Discovery (LPD)',
|
||||||
torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.',
|
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.',
|
torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.',
|
||||||
|
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.',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
|
||||||
identity: 'Identity',
|
identity: 'Identity',
|
||||||
customUserAgent: 'Custom User-Agent',
|
customUserAgent: 'Custom User-Agent',
|
||||||
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const fa = {
|
|||||||
torrentLpd: 'کشف همتای محلی (LPD)',
|
torrentLpd: 'کشف همتای محلی (LPD)',
|
||||||
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا میکند و دیدهشدن ترافیک در شبکه محلی را افزایش میدهد.',
|
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا میکند و دیدهشدن ترافیک در شبکه محلی را افزایش میدهد.',
|
||||||
torrentPeerDiscoveryRestartNote: 'این گزینهها سراسری و مربوط به Aria2 هستند و پس از راهاندازی مجدد Firelink اعمال میشوند. Aria2 همچنان کشف همتا را برای تورنتهای خصوصی خاموش میکند.',
|
torrentPeerDiscoveryRestartNote: 'این گزینهها سراسری و مربوط به Aria2 هستند و پس از راهاندازی مجدد Firelink اعمال میشوند. Aria2 همچنان کشف همتا را برای تورنتهای خصوصی خاموش میکند.',
|
||||||
|
torrentResourceLimits: 'محدودیت منابع بیتتورنت',
|
||||||
|
torrentMaxOpenFiles: 'حداکثر فایلهای باز تورنت',
|
||||||
|
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایلهای همزمان باز در تورنتهای چندفایلی. مقدار کمتر مصرف file descriptor را کم میکند؛ پیشفرض ۱۰۰ است. تغییرات برای تورنتهای جدید و بدون راهاندازی مجدد Aria2 اعمال میشوند و محدودیت سیستمعامل را افزایش نمیدهند.',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایلهای باز تورنت ممکن نشد: {{detail}}',
|
||||||
identity: 'هویت',
|
identity: 'هویت',
|
||||||
customUserAgent: 'User-Agent سفارشی',
|
customUserAgent: 'User-Agent سفارشی',
|
||||||
userAgentDescription: 'در دریافتهای متادیتا و موتورهای دانلود اعمال میشود.',
|
userAgentDescription: 'در دریافتهای متادیتا و موتورهای دانلود اعمال میشود.',
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const he = {
|
|||||||
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
|
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
|
||||||
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
|
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
|
||||||
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
|
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
|
||||||
|
torrentResourceLimits: 'מגבלות משאבי BitTorrent',
|
||||||
|
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
|
||||||
|
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
|
||||||
identity: 'זהות',
|
identity: 'זהות',
|
||||||
customUserAgent: 'User-Agent מותאם אישית',
|
customUserAgent: 'User-Agent מותאם אישית',
|
||||||
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
|
userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.',
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const ru = {
|
|||||||
torrentLpd: 'Локальное обнаружение пиров (LPD)',
|
torrentLpd: 'Локальное обнаружение пиров (LPD)',
|
||||||
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
|
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
|
||||||
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
|
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
|
||||||
|
torrentResourceLimits: 'Ограничения ресурсов BitTorrent',
|
||||||
|
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
|
||||||
|
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
|
||||||
identity: 'Идентификация',
|
identity: 'Идентификация',
|
||||||
customUserAgent: 'Собственный User-Agent',
|
customUserAgent: 'Собственный User-Agent',
|
||||||
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
|
userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.',
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const uk = {
|
|||||||
torrentLpd: 'Локальний пошук пірів (LPD)',
|
torrentLpd: 'Локальний пошук пірів (LPD)',
|
||||||
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
|
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
|
||||||
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
|
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
|
||||||
|
torrentResourceLimits: 'Обмеження ресурсів BitTorrent',
|
||||||
|
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
|
||||||
|
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
|
||||||
identity: 'Ідентифікація',
|
identity: 'Ідентифікація',
|
||||||
customUserAgent: 'Власний User-Agent',
|
customUserAgent: 'Власний User-Agent',
|
||||||
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
|
userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.',
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ const zhCN = {
|
|||||||
torrentLpd: '本地节点发现(LPD)',
|
torrentLpd: '本地节点发现(LPD)',
|
||||||
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
|
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
|
||||||
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
|
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
|
||||||
|
torrentResourceLimits: 'BitTorrent 资源限制',
|
||||||
|
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
|
||||||
|
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
|
||||||
|
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
|
||||||
identity: '身份',
|
identity: '身份',
|
||||||
customUserAgent: '自定义 User-Agent',
|
customUserAgent: '自定义 User-Agent',
|
||||||
userAgentDescription: '应用于元数据获取和下载引擎。',
|
userAgentDescription: '应用于元数据获取和下载引擎。',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type CommandMap = {
|
|||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||||
|
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
|
||||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||||
request_automation_permission: { args: undefined; result: void };
|
request_automation_permission: { args: undefined; result: void };
|
||||||
check_automation_permission: { args: undefined; result: void };
|
check_automation_permission: { args: undefined; result: void };
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
} from './useSettingsStore';
|
} from './useSettingsStore';
|
||||||
import * as ipc from '../ipc';
|
import * as ipc from '../ipc';
|
||||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||||
|
import {
|
||||||
|
DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||||
|
MAX_TORRENT_MAX_OPEN_FILES
|
||||||
|
} from '../utils/downloads';
|
||||||
|
|
||||||
vi.mock('../ipc', () => ({
|
vi.mock('../ipc', () => ({
|
||||||
invokeCommand: vi.fn()
|
invokeCommand: vi.fn()
|
||||||
@@ -57,6 +61,66 @@ describe('Torrent peer discovery preferences', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('calendar preference', () => {
|
||||||
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
|
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import {
|
|||||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||||
normalizeDownloadLocationSettings
|
normalizeDownloadLocationSettings
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
import {
|
||||||
|
DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||||
|
MAX_TORRENT_MAX_OPEN_FILES,
|
||||||
|
MIN_TORRENT_MAX_OPEN_FILES,
|
||||||
|
normalizeSpeedLimitForBackend,
|
||||||
|
normalizeTorrentMaxOpenFiles
|
||||||
|
} from '../utils/downloads';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
import { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +36,7 @@ import {
|
|||||||
} from '../utils/dateTime';
|
} from '../utils/dateTime';
|
||||||
|
|
||||||
let settingsQueue: Promise<void> = Promise.resolve();
|
let settingsQueue: Promise<void> = Promise.resolve();
|
||||||
|
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
|
||||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||||
const settingsPersistenceErrorListeners = new Set<() => void>();
|
const settingsPersistenceErrorListeners = new Set<() => void>();
|
||||||
let settingsPersistenceFailed = false;
|
let settingsPersistenceFailed = false;
|
||||||
@@ -238,6 +245,7 @@ export interface SettingsState {
|
|||||||
torrentEnableDht6: boolean;
|
torrentEnableDht6: boolean;
|
||||||
torrentEnablePex: boolean;
|
torrentEnablePex: boolean;
|
||||||
torrentEnableLpd: boolean;
|
torrentEnableLpd: boolean;
|
||||||
|
torrentMaxOpenFiles: number;
|
||||||
customUserAgent: string;
|
customUserAgent: string;
|
||||||
askWhereToSaveEachFile: boolean;
|
askWhereToSaveEachFile: boolean;
|
||||||
preventsSleepWhileDownloading: boolean;
|
preventsSleepWhileDownloading: boolean;
|
||||||
@@ -292,6 +300,7 @@ export interface SettingsState {
|
|||||||
setTorrentEnableDht6: (enabled: boolean) => void;
|
setTorrentEnableDht6: (enabled: boolean) => void;
|
||||||
setTorrentEnablePex: (enabled: boolean) => void;
|
setTorrentEnablePex: (enabled: boolean) => void;
|
||||||
setTorrentEnableLpd: (enabled: boolean) => void;
|
setTorrentEnableLpd: (enabled: boolean) => void;
|
||||||
|
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
|
||||||
setCustomUserAgent: (userAgent: string) => void;
|
setCustomUserAgent: (userAgent: string) => void;
|
||||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||||
@@ -372,6 +381,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
torrentEnableDht6: false,
|
torrentEnableDht6: false,
|
||||||
torrentEnablePex: true,
|
torrentEnablePex: true,
|
||||||
torrentEnableLpd: false,
|
torrentEnableLpd: false,
|
||||||
|
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||||
customUserAgent: '',
|
customUserAgent: '',
|
||||||
askWhereToSaveEachFile: false,
|
askWhereToSaveEachFile: false,
|
||||||
preventsSleepWhileDownloading: true,
|
preventsSleepWhileDownloading: true,
|
||||||
@@ -470,6 +480,22 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }),
|
setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }),
|
||||||
setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }),
|
setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }),
|
||||||
setTorrentEnableLpd: (torrentEnableLpd) => set({ torrentEnableLpd }),
|
setTorrentEnableLpd: (torrentEnableLpd) => set({ torrentEnableLpd }),
|
||||||
|
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;
|
||||||
|
},
|
||||||
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
||||||
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
||||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||||
@@ -655,6 +681,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
torrentEnableDht6: state.torrentEnableDht6,
|
torrentEnableDht6: state.torrentEnableDht6,
|
||||||
torrentEnablePex: state.torrentEnablePex,
|
torrentEnablePex: state.torrentEnablePex,
|
||||||
torrentEnableLpd: state.torrentEnableLpd,
|
torrentEnableLpd: state.torrentEnableLpd,
|
||||||
|
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
|
||||||
customUserAgent: state.customUserAgent,
|
customUserAgent: state.customUserAgent,
|
||||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||||
@@ -704,6 +731,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
torrentEnableDht6: persistedBoolean(persisted.torrentEnableDht6, currentState.torrentEnableDht6),
|
torrentEnableDht6: persistedBoolean(persisted.torrentEnableDht6, currentState.torrentEnableDht6),
|
||||||
torrentEnablePex: persistedBoolean(persisted.torrentEnablePex, currentState.torrentEnablePex),
|
torrentEnablePex: persistedBoolean(persisted.torrentEnablePex, currentState.torrentEnablePex),
|
||||||
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
|
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
|
||||||
|
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
|
||||||
|
?? currentState.torrentMaxOpenFiles,
|
||||||
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
|
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
|
||||||
? persisted.sidebarPosition
|
? persisted.sidebarPosition
|
||||||
: currentState.sidebarPosition,
|
: currentState.sidebarPosition,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isValidTorrentExcludeTrackerList,
|
isValidTorrentExcludeTrackerList,
|
||||||
isValidTorrentTrackerList,
|
isValidTorrentTrackerList,
|
||||||
normalizeTorrentEncryptionPolicy,
|
normalizeTorrentEncryptionPolicy,
|
||||||
|
normalizeTorrentMaxOpenFiles,
|
||||||
normalizeTorrentPrioritizePiece,
|
normalizeTorrentPrioritizePiece,
|
||||||
normalizeTorrentTrackerInterval,
|
normalizeTorrentTrackerInterval,
|
||||||
normalizeTorrentTrackerTimeout,
|
normalizeTorrentTrackerTimeout,
|
||||||
@@ -126,6 +127,19 @@ describe('Torrent tracker timing validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Torrent open-file limit validation', () => {
|
||||||
|
it('accepts bounded integer limits', () => {
|
||||||
|
expect(normalizeTorrentMaxOpenFiles(1)).toBe(1);
|
||||||
|
expect(normalizeTorrentMaxOpenFiles(4096)).toBe(4096);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero, fractional, and oversized limits', () => {
|
||||||
|
expect(normalizeTorrentMaxOpenFiles(0)).toBeUndefined();
|
||||||
|
expect(normalizeTorrentMaxOpenFiles('1.5')).toBeUndefined();
|
||||||
|
expect(normalizeTorrentMaxOpenFiles(4097)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('download connection resolution', () => {
|
describe('download connection resolution', () => {
|
||||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const normalizeTorrentEncryptionPolicy = (
|
|||||||
|
|
||||||
export const MAX_TORRENT_TRACKER_TIMEOUT = 604800;
|
export const MAX_TORRENT_TRACKER_TIMEOUT = 604800;
|
||||||
export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
|
export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
|
||||||
|
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
|
||||||
|
export const MIN_TORRENT_MAX_OPEN_FILES = 1;
|
||||||
|
export const MAX_TORRENT_MAX_OPEN_FILES = 4096;
|
||||||
|
|
||||||
const parseIntegerOption = (value: unknown): number | undefined => {
|
const parseIntegerOption = (value: unknown): number | undefined => {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -93,6 +96,15 @@ export const normalizeTorrentTrackerInterval = (value: unknown): number | undefi
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeTorrentMaxOpenFiles = (value: unknown): number | undefined => {
|
||||||
|
const parsed = parseIntegerOption(value);
|
||||||
|
return parsed !== undefined
|
||||||
|
&& parsed >= MIN_TORRENT_MAX_OPEN_FILES
|
||||||
|
&& parsed <= MAX_TORRENT_MAX_OPEN_FILES
|
||||||
|
? parsed
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// Keep every filename component within the common cross-platform filesystem
|
// Keep every filename component within the common cross-platform filesystem
|
||||||
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
|
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
|
||||||
// bound is also conservative for Windows filename components.
|
// bound is also conservative for Windows filename components.
|
||||||
|
|||||||
Reference in New Issue
Block a user