diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index add486d..625d525 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -296,6 +296,15 @@ pub struct DownloadItem { #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub peer_id: Option, #[ts(type = "number")] pub download_speed: u64, #[ts(type = "number")] diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 7a57a12..0c0ea92 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2511,7 +2511,8 @@ impl QueueManager { Ok(()) } - /// Return redacted, bounded peer diagnostics for the current Torrent GID. + /// Return bounded peer diagnostics for the current Torrent GID. Endpoint + /// and peer-id fields live only in this response; they are not persisted. /// The control lock and post-RPC mapping check prevent a late response from /// being attributed to a replaced or terminal lifecycle. pub async fn get_aria2_torrent_peers( @@ -2519,10 +2520,8 @@ impl QueueManager { id: &str, ) -> Result { let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { - return Err("download is not an active aria2 transfer".to_string()); + if !self.is_registered(id).await { + return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string()); } let is_torrent = self .aria2_payloads @@ -2564,7 +2563,6 @@ impl QueueManager { let diagnostics = parse_torrent_peer_diagnostics(result)?; let still_current = self.is_registered(id).await - && matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) && self .is_aria2_control_epoch_current(id, expected_mapping.epoch) .await @@ -2585,9 +2583,7 @@ impl QueueManager { id: &str, ) -> Result { let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { + if !self.is_registered(id).await { return Err("Torrent availability is unavailable for this lifecycle".to_string()); } if !self @@ -2666,9 +2662,7 @@ impl QueueManager { id: &str, ) -> Result { let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { + if !self.is_registered(id).await { return Err("live Torrent file progress is unavailable".to_string()); } let payload = self @@ -2725,7 +2719,6 @@ impl QueueManager { let snapshot = parse_torrent_file_progress(result, &metadata.files)?; let still_current = self.is_registered(id).await - && matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) && self .is_aria2_control_epoch_current(id, expected_mapping.epoch) .await @@ -2746,9 +2739,7 @@ impl QueueManager { id: &str, ) -> Result { let _control_guard = self.acquire_aria2_control(id).await; - if !self.is_registered(id).await - || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) - { + if !self.is_registered(id).await { return Err("live Torrent piece progress is unavailable".to_string()); } let is_torrent = self @@ -2791,7 +2782,6 @@ impl QueueManager { let snapshot = parse_torrent_piece_progress(result)?; let still_current = self.is_registered(id).await - && matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) && self .is_aria2_control_epoch_current(id, expected_mapping.epoch) .await @@ -5268,6 +5258,30 @@ fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 { } } +fn aria2_peer_ip(value: Option<&serde_json::Value>) -> Option { + let value = value?.as_str()?.trim(); + if value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) { + return None; + } + value.parse::().ok().map(|ip| ip.to_string()) +} + +fn aria2_peer_port(value: Option<&serde_json::Value>) -> Option { + match value { + Some(serde_json::Value::String(value)) => value.parse().ok(), + Some(serde_json::Value::Number(value)) => value.as_u64()?.try_into().ok(), + _ => None, + } +} + +fn aria2_peer_id(value: Option<&serde_json::Value>) -> Option { + let value = value?.as_str()?.trim(); + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return None; + } + Some(value.to_string()) +} + fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool { match value { Some(serde_json::Value::Bool(value)) => *value, @@ -5437,6 +5451,9 @@ pub(crate) fn parse_torrent_peer_diagnostics( total_seeders = total_seeders.saturating_add(1); } sanitized.push(crate::ipc::TorrentPeer { + ip: aria2_peer_ip(peer.get("ip")), + port: aria2_peer_port(peer.get("port")), + peer_id: aria2_peer_id(peer.get("peerId")), download_speed: aria2_peer_number(peer.get("downloadSpeed")), upload_speed: aria2_peer_number(peer.get("uploadSpeed")), seeder, @@ -7618,7 +7635,7 @@ mod tests { } #[test] - fn torrent_peer_diagnostics_are_redacted_and_bounded() { + fn torrent_peer_diagnostics_are_bounded_and_omit_bitfields() { let mut result = vec![serde_json::json!({ "peerId": "secret-peer-id", "ip": "192.0.2.10", @@ -7647,9 +7664,13 @@ mod tests { assert_eq!(diagnostics.total_seeders, 2); assert_eq!(diagnostics.peers.len(), MAX_TORRENT_PEER_DIAGNOSTICS); assert!(diagnostics.truncated); + assert_eq!(diagnostics.peers[0].ip.as_deref(), Some("192.0.2.10")); + assert_eq!(diagnostics.peers[0].port, Some(6881)); + assert_eq!(diagnostics.peers[0].peer_id.as_deref(), Some("secret-peer-id")); let serialized = serde_json::to_string(&diagnostics).unwrap(); - assert!(!serialized.contains("peerId")); - assert!(!serialized.contains("192.0.2.")); + assert!(serialized.contains("peerId")); + assert!(serialized.contains("192.0.2.")); + assert!(!serialized.contains("\"port\":null")); assert!(!serialized.contains("bitfield")); } diff --git a/src/bindings/TorrentPeer.ts b/src/bindings/TorrentPeer.ts index c275ab0..115008b 100644 --- a/src/bindings/TorrentPeer.ts +++ b/src/bindings/TorrentPeer.ts @@ -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 TorrentPeer = { downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, }; +export type TorrentPeer = { ip?: string, port?: number, peerId?: string, downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, }; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 7399665..93c6349 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -17,6 +17,7 @@ import { getPropertiesLifecycleAction, sendPropertiesActionRequest, sendPropertiesReady, + isExpectedPropertiesDiagnosticUnavailable, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, @@ -30,9 +31,12 @@ import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced'; -const isTorrentStatus = (status: string) => +const isTorrentDiagnosticsStatus = (status: string) => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); +const isTorrentPollingStatus = (status: string) => + ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); + const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'retrying', 'moving'].includes(status); const safeTitle = (name: string) => { @@ -59,6 +63,7 @@ export const PropertiesWindowApp = () => { const [availability, setAvailability] = useState(null); const [details, setDetails] = useState(null); const [diagnosticError, setDiagnosticError] = useState(''); + const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); // null means the Files tab has no local selection draft yet; [] is an // explicit user choice to clear every file and must remain visually empty. const [selectedFiles, setSelectedFiles] = useState(null); @@ -113,14 +118,17 @@ export const PropertiesWindowApp = () => { }, []); const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => { - if (!isTorrentStatus(snapshotRef.current?.status ?? '')) return; + if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return; const requestKey = `${id}:${tab}`; if (diagnosticsInFlightRef.current.has(requestKey)) return; diagnosticsInFlightRef.current.add(requestKey); const isCurrent = () => downloadIdRef.current === id && activeTabRef.current === tab - && isTorrentStatus(snapshotRef.current?.status ?? ''); - if (isCurrent()) setDiagnosticError(''); + && isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? ''); + if (isCurrent()) { + setDiagnosticError(''); + setDiagnosticsLoading(true); + } try { if (tab === 'overview') { const nextDetails = await invoke('get_torrent_details', { id }); @@ -129,19 +137,44 @@ export const PropertiesWindowApp = () => { const nextProgress = await invoke('get_torrent_file_progress', { id }); if (isCurrent()) setFileProgress(nextProgress); } else if (tab === 'peers') { - const [nextPeers, nextAvailability] = await Promise.all([ + const [peerResult, availabilityResult] = await Promise.allSettled([ invoke('get_torrent_peers', { id }), invoke('get_torrent_availability', { id }), ]); if (isCurrent()) { - setPeers(nextPeers); - setAvailability(nextAvailability); + if (peerResult.status === 'fulfilled') setPeers(peerResult.value); + else setPeers(null); + if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); + else setAvailability(null); + const unexpectedErrors = [peerResult, availabilityResult] + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason) + .filter(error => !isExpectedPropertiesDiagnosticUnavailable(error)); + setDiagnosticError(unexpectedErrors.length > 0 ? errorText(unexpectedErrors[0]) : ''); } } } catch (error) { - if (isCurrent()) setDiagnosticError(errorText(error)); + if (isCurrent()) { + const message = errorText(error); + // A paused row may not have a retained Aria2 GID (for example when it + // was paused before its first dispatch). That is an expected absence, + // not a diagnostic failure, and must not flash a raw backend error. + if (isExpectedPropertiesDiagnosticUnavailable(error)) { + setDiagnosticError(''); + if (tab === 'files') setFileProgress(null); + if (tab === 'peers') { + setPeers(null); + setAvailability(null); + } + } else { + setDiagnosticError(message); + } + } } finally { diagnosticsInFlightRef.current.delete(requestKey); + if (downloadIdRef.current === id && activeTabRef.current === tab) { + setDiagnosticsLoading(false); + } } }, []); @@ -252,12 +285,28 @@ export const PropertiesWindowApp = () => { }, [draftTab, hydrateDraft, snapshot]); useEffect(() => { - if (!downloadId || !snapshot || !isTorrent) return; + if (!downloadId || !snapshot || !isTorrent || !isTorrentDiagnosticsStatus(snapshot.status)) { + setDetails(null); + setFileProgress(null); + setPeers(null); + setAvailability(null); + setDiagnosticError(''); + setDiagnosticsLoading(false); + return; + } + if (!isTorrentPollingStatus(snapshot.status)) { + setFileProgress(null); + setPeers(null); + setAvailability(null); + } void refreshDiagnostics(activeTab, downloadId); - if (!['files', 'peers'].includes(activeTab)) return; - const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), activeTab === 'peers' ? 3000 : 2000); + if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return; + // Match the 1-second cadence of the normal Aria2 progress poll. The + // diagnostics request itself is still single-flight, so a slow RPC cannot + // create overlapping refreshes. + const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), 1000); return () => window.clearInterval(interval); - }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot]); + }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); useEffect(() => { if (!isDirty) return; @@ -506,6 +555,8 @@ export const PropertiesWindowApp = () => { {activeTab === 'files' && isTorrent &&
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} />{file.index + 1}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
+ {diagnosticsLoading &&

{t($ => $.properties.torrentPeerDiagnosticsLoading)}

} + {!diagnosticsLoading && !fileProgress && !diagnosticError &&

{t($ => $.properties.torrentFileProgressUnavailable)}

} {diagnosticError &&

{diagnosticError}

}
} @@ -517,9 +568,10 @@ export const PropertiesWindowApp = () => { } {activeTab === 'peers' && isTorrent &&
-

{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}

+

{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}

{t($ => $.properties.torrentAvailability)}

{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}

{t($ => $.properties.torrentPeerDiagnosticsHint)}

{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}

-
{peers?.peers.map((peer, index) => )}
{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerChoking)}
{formatDownloadBytes(peer.downloadSpeed)}/s{formatDownloadBytes(peer.uploadSpeed)}/s{peer.seeder ? '✓' : '—'}{peer.peerChoking ? '✓' : '—'}
+
{peers?.peers.map((peer, index) => )}
{t($ => $.properties.torrentPeerAddress)}{t($ => $.properties.torrentPeerId)}{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerChoking)}
{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}{peer.peerId || '—'}{formatDownloadBytes(peer.downloadSpeed)}/s{formatDownloadBytes(peer.uploadSpeed)}/s{peer.seeder ? '✓' : '—'}{peer.peerChoking ? '✓' : '—'}
+ {diagnosticError &&

{diagnosticError}

}
} {(activeTab === 'transfer' || activeTab === 'options') &&
@@ -529,7 +581,7 @@ export const PropertiesWindowApp = () => { {isTorrent && }
} - {activeTab === 'advanced' &&

{t($ => $.properties.advancedTransfer)}

{snapshot.hasCookies ? t($ => $.properties.cookies) : '—'} · {snapshot.hasHeaders ? t($ => $.properties.headers) : '—'}

{t($ => $.properties.liveSpeedLimitHint)}

} + {activeTab === 'advanced' &&

{t($ => $.properties.advancedTransfer)}

{t($ => $.properties.connections)}

{snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'}

{t($ => $.properties.speedCap)}

{snapshot.speedLimit || '—'}

{t($ => $.properties.cookies)}

{snapshot.hasCookies ? '✓' : '—'}

{t($ => $.properties.headers)}

{snapshot.hasHeaders ? '✓' : '—'}

{t($ => $.properties.liveSpeedLimitHint)}

} {(isDirty || errorMessage || notice || pendingTab || closePrompt) &&
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index d8c0de0..85da7a2 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -217,14 +217,14 @@ export const PropertiesWindowBridgeHost = () => { if (!resumed) { throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart)); } - const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId); - if (!current) throw new Error('Download was removed while starting'); - // A fast completion is a valid outcome of a successful resume; - // only a status that proves the request never left its - // pre-action state is a rejected start. Preserve failed as an - // error so a real backend failure is not reported as success. - if (['paused', 'ready', 'staged', 'failed'].includes(current.status)) { - 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; diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index a588a99..efc5703 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -264,9 +264,11 @@ const common = { torrentPeerDiagnostics: 'Torrent peer diagnostics', torrentPeerDiagnosticsRefresh: 'Refresh', torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…', - torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active.', + torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active or paused.', torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.', - torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.', + torrentPeerDiagnosticsHint: 'Peer addresses and IDs are shown in this window only; Aria2 does not provide country metadata. Raw bitfields are not retained.', + 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.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 50fa687..fc13801 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -264,9 +264,11 @@ const fa = { torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت', torrentPeerDiagnosticsRefresh: 'تازه‌سازی', torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…', - torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.', + torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.', torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.', - torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.', + torrentPeerDiagnosticsHint: 'نشانی و شناسهٔ همتا فقط در همین پنجره نمایش داده می‌شود؛ آریا۲ اطلاعات کشور ارائه نمی‌کند و بیت‌فیلد خام ذخیره نمی‌شود.', + torrentPeerAddress: 'نشانی همتا', + torrentPeerId: 'شناسهٔ همتا', torrentFileProgress: 'پیشرفت فایل‌های تورنت', torrentFileSelection: 'انتخاب فایل‌های تورنت', torrentFileSelectionHint: 'فایل‌های موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایل‌ها فیلتر را حذف می‌کند؛ حداقل یک فایل باید انتخاب شود.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index ebf7622..d19c893 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -264,9 +264,11 @@ const he = { torrentPeerDiagnostics: 'אבחון עמיתי טורנט', torrentPeerDiagnosticsRefresh: 'רענון', torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…', - torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.', + torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.', torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.', - torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.', + torrentPeerDiagnosticsHint: 'כתובת ומזהה העמית מוצגים בחלון זה בלבד; Aria2 אינה מספקת נתוני מדינה. שדות ביטים גולמיים אינם נשמרים.', + torrentPeerAddress: 'כתובת עמית', + torrentPeerId: 'מזהה עמית', torrentFileProgress: 'התקדמות קובצי הטורנט', torrentFileSelection: 'בחירת קובצי טורנט', torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 4a6fc6f..6c63729 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -264,9 +264,11 @@ const ru = { torrentPeerDiagnostics: 'Диагностика пиров торрента', torrentPeerDiagnosticsRefresh: 'Обновить', torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…', - torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.', + torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.', torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.', - torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.', + torrentPeerDiagnosticsHint: 'Адреса и идентификаторы пиров показываются только в этом окне; Aria2 не предоставляет данные о стране. Исходные битовые поля не сохраняются.', + torrentPeerAddress: 'Адрес пира', + torrentPeerId: 'ID пира', torrentFileProgress: 'Прогресс файлов торрента', torrentFileSelection: 'Выбор файлов торрента', torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index a516987..eef6aa4 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -264,9 +264,11 @@ const uk = { torrentPeerDiagnostics: 'Діагностика пірів торрента', torrentPeerDiagnosticsRefresh: 'Оновити', torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…', - torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.', + torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.', torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.', - torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.', + torrentPeerDiagnosticsHint: 'Адреси та ідентифікатори пірів показуються лише в цьому вікні; Aria2 не надає даних про країну. Сирі бітові поля не зберігаються.', + torrentPeerAddress: 'Адреса піра', + torrentPeerId: 'ID піра', torrentFileProgress: 'Прогрес файлів торрента', torrentFileSelection: 'Вибір файлів торрента', torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 9dc0728..afed73e 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -264,9 +264,11 @@ const zhCN = { torrentPeerDiagnostics: 'Torrent 对等节点诊断', torrentPeerDiagnosticsRefresh: '刷新', torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…', - torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。', + torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。', torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。', - torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。', + torrentPeerDiagnosticsHint: '节点地址和 ID 仅在此窗口显示;Aria2 不提供国家信息。不会保留原始位域。', + torrentPeerAddress: '节点地址', + torrentPeerId: '节点 ID', torrentFileProgress: 'Torrent 文件进度', torrentFileSelection: 'Torrent 文件选择', torrentFileSelectionHint: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。', diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index aee21b0..0b94515 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -15,6 +15,7 @@ import { beginExclusivePropertiesAction, createFrameCoalescer, getPropertiesLifecycleAction, + isExpectedPropertiesDiagnosticUnavailable, sanitizePropertiesSnapshot, } from './propertiesBridge'; @@ -135,6 +136,14 @@ describe('Properties window bridge', () => { expect(getPropertiesLifecycleAction('completed')).toBeNull(); }); + 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('Torrent lifecycle changed while reading peer diagnostics'))).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(); const release = beginExclusivePropertiesAction(inFlight, 'window:download'); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 94e2a49..c3045e8 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -70,6 +70,23 @@ const PROPERTIES_SNAPSHOT_KEYS = [ '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); +}; + type SafePropertiesFields = Pick; export type PropertiesSnapshot = SafePropertiesFields & {