diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 04e76f6..37f8954 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -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 response/progress-driven scheduling. Timing is persisted and reapplied when 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 selected-file subset is configured. Firelink requires explicit confirmation, 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 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 if the resulting child-GID ownership model can be represented safely; the 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; follow-up implementations add stall-timeout control, bounded peer diagnostics, 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. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 67c04ef..55fd252 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -704,6 +704,14 @@ async function main() { trackerlessTorrent.delete('announce'); trackerlessTorrent.delete('announce-list'); 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); if (probeRemoved) await waitForRemoved(client, probeGid); 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')); 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'); - 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'); fs.mkdirSync(path.dirname(integrityPath), { recursive: true }); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 7243267..f06a604 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -30,6 +30,10 @@ fn default_torrent_enable_lpd() -> bool { false } +fn default_torrent_max_open_files() -> u32 { + crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -493,6 +497,8 @@ pub struct PersistedSettings { 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, pub custom_user_agent: String, pub ask_where_to_save_each_file: bool, pub remember_last_used_download_directory: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa462a8..eb88a5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6626,6 +6626,32 @@ fn apply_aria2_torrent_peer_discovery_options( .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] async fn set_global_speed_limit( state: tauri::State<'_, AppState>, @@ -7718,6 +7744,7 @@ mod tests { cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, normalize_speed_limit_for_aria2, + apply_aria2_torrent_global_options, apply_aria2_torrent_peer_discovery_options, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, @@ -7739,6 +7766,7 @@ mod tests { #[cfg(target_os = "macos")] use super::should_apply_dock_badge_update; use serde_json::json; + use crate::queue; use std::time::{Duration, Instant}; #[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!["--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!["--bt-max-open-files=100"] + ); + } + #[test] fn retained_torrent_metadata_survives_unrelated_persisted_field_corruption() { let record = json!({ @@ -10419,6 +10472,21 @@ pub fn run() { ) }) .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 app_handle_bg = app.handle().clone(); @@ -10453,6 +10521,8 @@ pub fn run() { .arg("--check-certificate=true") .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( &mut cmd, torrent_peer_discovery.0, @@ -11017,7 +11087,7 @@ pub fn run() { authorize_keychain_access, acknowledge_pairing_token_change, 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, 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, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 8b66023..7d8487e 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -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_TRACKER_TIMEOUT: 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 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) } +pub fn normalize_torrent_max_open_files(value: u32) -> Result { + 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( queue_tasks: &[QueuedTask], ids: &[String], @@ -4831,6 +4843,17 @@ mod tests { 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] fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() { let mut options = serde_json::Map::new(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2c24f54..dbadb56 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, "perServerConnections", |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 [ "torrentEnableDht", "torrentEnableDht6", @@ -308,6 +317,10 @@ fn validate_settings(settings: &mut PersistedSettings) { 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.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!( settings.last_custom_speed_limit_unit.as_str(), "KB/s" | "MB/s" @@ -500,6 +513,7 @@ fn default_settings() -> PersistedSettings { torrent_enable_dht6: false, torrent_enable_pex: true, torrent_enable_lpd: false, + torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES, custom_user_agent: String::new(), ask_where_to_save_each_file: false, remember_last_used_download_directory: false, @@ -792,6 +806,7 @@ mod tests { "torrentEnableDht6": 1, "torrentEnablePex": null, "torrentEnableLpd": [], + "torrentMaxOpenFiles": 0, "theme": "not-a-theme", "calendarPreference": "lunar", "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_pex); 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.calendar_preference, diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 28accb3..3becb8c 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -11,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, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, 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, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 855804b..507d205 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -34,6 +34,11 @@ import { import { usePlatformInfo } from '../utils/platform'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; 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 { localeDirection, resolveAppLocale } from '../i18n'; @@ -318,6 +323,10 @@ const engineRunId = useRef(0); () => String(settings.maxConcurrentDownloads) ); const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort)); + const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState( + () => String(settings.torrentMaxOpenFiles) + ); + const torrentMaxOpenFilesCommitRef = useRef(0); useEffect(() => { setPerServerConnectionsInput(String(settings.perServerConnections)); @@ -331,6 +340,10 @@ const engineRunId = useRef(0); setProxyPortInput(String(settings.proxyPort)); }, [settings.proxyPort]); + useEffect(() => { + setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles)); + }, [settings.torrentMaxOpenFiles]); + // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); const [loginUser, setLoginUser] = useState(''); @@ -346,6 +359,22 @@ const engineRunId = useRef(0); // Toast notifications 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 [manualUpdateStatus, setManualUpdateStatus] = useState({ type: 'idle' }); @@ -1212,6 +1241,27 @@ runEngineChecks(false);

+

{t($ => $.settings.network.torrentResourceLimits)}

+
+
+
+ {t($ => $.settings.network.torrentMaxOpenFiles)} + {t($ => $.settings.network.torrentMaxOpenFilesDescription)} +
+ setTorrentMaxOpenFilesInput(event.target.value)} + onBlur={(event) => commitTorrentMaxOpenFiles(event.target.value)} + className="app-control settings-port-input text-center" + aria-label={t($ => $.settings.network.torrentMaxOpenFiles)} + /> +
+
+

{t($ => $.settings.network.identity)}

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 04347f4..5aeb929 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -799,6 +799,10 @@ const common = { 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.', + 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', customUserAgent: 'Custom User-Agent', userAgentDescription: 'Applied to metadata fetches and download engines.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 5b8edea..6708116 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -799,6 +799,10 @@ const fa = { torrentLpd: 'کشف همتای محلی (LPD)', torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا می‌کند و دیده‌شدن ترافیک در شبکه محلی را افزایش می‌دهد.', torrentPeerDiscoveryRestartNote: 'این گزینه‌ها سراسری و مربوط به Aria2 هستند و پس از راه‌اندازی مجدد Firelink اعمال می‌شوند. Aria2 همچنان کشف همتا را برای تورنت‌های خصوصی خاموش می‌کند.', + torrentResourceLimits: 'محدودیت منابع بیت‌تورنت', + torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت', + torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.', + torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}', identity: 'هویت', customUserAgent: 'User-Agent سفارشی', userAgentDescription: 'در دریافت‌های متادیتا و موتورهای دانلود اعمال می‌شود.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 8b43531..2e5396c 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -799,6 +799,10 @@ const he = { torrentLpd: 'גילוי עמיתים מקומיים (LPD)', torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.', torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.', + torrentResourceLimits: 'מגבלות משאבי BitTorrent', + torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי', + torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.', + torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}', identity: 'זהות', customUserAgent: 'User-Agent מותאם אישית', userAgentDescription: 'מוחל על משיכות מטא נתונים ומנועי הורדה.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 9df0dc5..55d5410 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -799,6 +799,10 @@ const ru = { torrentLpd: 'Локальное обнаружение пиров (LPD)', torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.', torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.', + torrentResourceLimits: 'Ограничения ресурсов BitTorrent', + torrentMaxOpenFiles: 'Максимум открытых файлов Torrent', + torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.', + torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}', identity: 'Идентификация', customUserAgent: 'Собственный User-Agent', userAgentDescription: 'Применяется при получении метаданных и работе движков загрузки.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index fce4942..fa664e7 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -799,6 +799,10 @@ const uk = { torrentLpd: 'Локальний пошук пірів (LPD)', torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.', torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.', + torrentResourceLimits: 'Обмеження ресурсів BitTorrent', + torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent', + torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.', + torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}', identity: 'Ідентифікація', customUserAgent: 'Власний User-Agent', userAgentDescription: 'Застосовується до запитів метаданих та рушіїв завантаження.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index b5e503e..b0489d2 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -799,6 +799,10 @@ const zhCN = { torrentLpd: '本地节点发现(LPD)', torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。', torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。', + torrentResourceLimits: 'BitTorrent 资源限制', + torrentMaxOpenFiles: 'Torrent 最大打开文件数', + torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。', + torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}', identity: '身份', customUserAgent: '自定义 User-Agent', userAgentDescription: '应用于元数据获取和下载引擎。', diff --git a/src/ipc.ts b/src/ipc.ts index 1b709c9..deb6c1a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -77,6 +77,7 @@ type CommandMap = { result: void; }; 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 }; request_automation_permission: { args: undefined; result: void }; check_automation_permission: { args: undefined; result: void }; diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index ee78dcb..bcd583c 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -6,6 +6,10 @@ 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() @@ -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(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(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 3774b5b..beb1980 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -20,7 +20,13 @@ import { DEFAULT_CATEGORY_SUBFOLDERS, normalizeDownloadLocationSettings } 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 { isAppLocalePreference, type AppLocalePreference } from '../i18n/locales'; import { @@ -30,6 +36,7 @@ import { } from '../utils/dateTime'; let settingsQueue: Promise = Promise.resolve(); +let torrentMaxOpenFilesQueue: Promise = Promise.resolve(); let pairingTokenHydrationRequest: Promise | null = null; const settingsPersistenceErrorListeners = new Set<() => void>(); let settingsPersistenceFailed = false; @@ -238,6 +245,7 @@ export interface SettingsState { torrentEnableDht6: boolean; torrentEnablePex: boolean; torrentEnableLpd: boolean; + torrentMaxOpenFiles: number; customUserAgent: string; askWhereToSaveEachFile: boolean; preventsSleepWhileDownloading: boolean; @@ -292,6 +300,7 @@ export interface SettingsState { setTorrentEnableDht6: (enabled: boolean) => void; setTorrentEnablePex: (enabled: boolean) => void; setTorrentEnableLpd: (enabled: boolean) => void; + setTorrentMaxOpenFiles: (value: number) => Promise; setCustomUserAgent: (userAgent: string) => void; setAskWhereToSaveEachFile: (ask: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void; @@ -372,6 +381,7 @@ export const useSettingsStore = create()( torrentEnableDht6: false, torrentEnablePex: true, torrentEnableLpd: false, + torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES, customUserAgent: '', askWhereToSaveEachFile: false, preventsSleepWhileDownloading: true, @@ -470,6 +480,22 @@ export const useSettingsStore = create()( setTorrentEnableDht6: (torrentEnableDht6) => set({ torrentEnableDht6 }), setTorrentEnablePex: (torrentEnablePex) => set({ torrentEnablePex }), 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 }), setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }), setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => { @@ -655,6 +681,7 @@ export const useSettingsStore = create()( torrentEnableDht6: state.torrentEnableDht6, torrentEnablePex: state.torrentEnablePex, torrentEnableLpd: state.torrentEnableLpd, + torrentMaxOpenFiles: state.torrentMaxOpenFiles, customUserAgent: state.customUserAgent, askWhereToSaveEachFile: state.askWhereToSaveEachFile, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, @@ -704,6 +731,8 @@ export const useSettingsStore = create()( torrentEnableDht6: persistedBoolean(persisted.torrentEnableDht6, currentState.torrentEnableDht6), torrentEnablePex: persistedBoolean(persisted.torrentEnablePex, currentState.torrentEnablePex), torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd), + torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles) + ?? currentState.torrentMaxOpenFiles, sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition) ? persisted.sidebarPosition : currentState.sidebarPosition, diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index b012276..9261d11 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -9,6 +9,7 @@ import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, normalizeTorrentEncryptionPolicy, + normalizeTorrentMaxOpenFiles, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, 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', () => { it('uses a clamped fallback for legacy rows without a saved value', () => { expect(resolveDownloadConnections(undefined, 8)).toBe(8); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 365cdb2..76e4f43 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -67,6 +67,9 @@ export const normalizeTorrentEncryptionPolicy = ( export const MAX_TORRENT_TRACKER_TIMEOUT = 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 => { if (typeof value === 'number') { @@ -93,6 +96,15 @@ export const normalizeTorrentTrackerInterval = (value: unknown): number | undefi : 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 // limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this // bound is also conservative for Windows filename components.