fix(properties): harden torrent diagnostics lifecycle

This commit is contained in:
NimBold
2026-08-04 18:09:07 +03:30
parent 2ab292dd5d
commit c86eb1838c
13 changed files with 176 additions and 56 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type 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, };
+67 -15
View File
@@ -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<TorrentAvailabilitySnapshot | null>(null);
const [details, setDetails] = useState<TorrentDetails | null>(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<number[] | null>(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 && <div className="space-y-3">
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('files', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} onChange={() => { 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}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticsLoading && <p className="text-xs text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsLoading)}</p>}
{!diagnosticsLoading && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
</div>}
@@ -517,9 +568,10 @@ export const PropertiesWindowApp = () => {
</div>}
{activeTab === 'peers' && isTorrent && <div className="space-y-4">
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('peers', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('peers', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentAvailability)}</span><p className="mt-1">{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p></div><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsHint)}</span><p className="mt-1">{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}</p></div></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[520px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={index} className="border-t border-border-modal/60"><td className="p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[820px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerAddress)}</th><th className="p-2">{t($ => $.properties.torrentPeerId)}</th><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={`${peer.ip ?? 'peer'}-${peer.port ?? 'unknown'}-${index}`} className="border-t border-border-modal/60"><td className="p-2 font-mono">{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}</td><td className="max-w-[220px] truncate p-2 font-mono" title={peer.peerId ?? undefined}>{peer.peerId || '—'}</td><td className="p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
</div>}
{(activeTab === 'transfer' || activeTab === 'options') && <div className="grid max-w-2xl gap-4 sm:grid-cols-2">
@@ -529,7 +581,7 @@ export const PropertiesWindowApp = () => {
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab(activeTab); }} placeholder="50K" disabled={!isEditableStatus(snapshot.status)} /></label>}
</div>}
{activeTab === 'advanced' && <div className="space-y-4"><p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p><p className="text-xs">{snapshot.hasCookies ? t($ => $.properties.cookies) : '—'} · {snapshot.hasHeaders ? t($ => $.properties.headers) : '—'}</p><p className="text-xs text-text-muted">{t($ => $.properties.liveSpeedLimitHint)}</p></div>}
{activeTab === 'advanced' && <div className="space-y-4"><p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p><div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2"><div><span className="text-text-muted">{t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'}</p></div><div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div><div><span className="text-text-muted">{t($ => $.properties.cookies)}</span><p className="mt-1">{snapshot.hasCookies ? '✓' : '—'}</p></div><div><span className="text-text-muted">{t($ => $.properties.headers)}</span><p className="mt-1">{snapshot.hasHeaders ? '✓' : '—'}</p></div></div><p className="text-xs text-text-muted">{t($ => $.properties.liveSpeedLimitHint)}</p></div>}
</section>
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
@@ -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;
+4 -2
View File
@@ -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.',
+4 -2
View File
@@ -264,9 +264,11 @@ const fa = {
torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت',
torrentPeerDiagnosticsRefresh: 'تازه‌سازی',
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.',
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentPeerDiagnosticsHint: 'نشانی و شناسهٔ همتا فقط در همین پنجره نمایش داده می‌شود؛ آریا۲ اطلاعات کشور ارائه نمی‌کند و بیت‌فیلد خام ذخیره نمی‌شود.',
torrentPeerAddress: 'نشانی همتا',
torrentPeerId: 'شناسهٔ همتا',
torrentFileProgress: 'پیشرفت فایل‌های تورنت',
torrentFileSelection: 'انتخاب فایل‌های تورنت',
torrentFileSelectionHint: 'فایل‌های موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایل‌ها فیلتر را حذف می‌کند؛ حداقل یک فایل باید انتخاب شود.',
+4 -2
View File
@@ -264,9 +264,11 @@ const he = {
torrentPeerDiagnostics: 'אבחון עמיתי טורנט',
torrentPeerDiagnosticsRefresh: 'רענון',
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.',
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentPeerDiagnosticsHint: 'כתובת ומזהה העמית מוצגים בחלון זה בלבד; Aria2 אינה מספקת נתוני מדינה. שדות ביטים גולמיים אינם נשמרים.',
torrentPeerAddress: 'כתובת עמית',
torrentPeerId: 'מזהה עמית',
torrentFileProgress: 'התקדמות קובצי הטורנט',
torrentFileSelection: 'בחירת קובצי טורנט',
torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.',
+4 -2
View File
@@ -264,9 +264,11 @@ const ru = {
torrentPeerDiagnostics: 'Диагностика пиров торрента',
torrentPeerDiagnosticsRefresh: 'Обновить',
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.',
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentPeerDiagnosticsHint: 'Адреса и идентификаторы пиров показываются только в этом окне; Aria2 не предоставляет данные о стране. Исходные битовые поля не сохраняются.',
torrentPeerAddress: 'Адрес пира',
torrentPeerId: 'ID пира',
torrentFileProgress: 'Прогресс файлов торрента',
torrentFileSelection: 'Выбор файлов торрента',
torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.',
+4 -2
View File
@@ -264,9 +264,11 @@ const uk = {
torrentPeerDiagnostics: 'Діагностика пірів торрента',
torrentPeerDiagnosticsRefresh: 'Оновити',
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.',
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentPeerDiagnosticsHint: 'Адреси та ідентифікатори пірів показуються лише в цьому вікні; Aria2 не надає даних про країну. Сирі бітові поля не зберігаються.',
torrentPeerAddress: 'Адреса піра',
torrentPeerId: 'ID піра',
torrentFileProgress: 'Прогрес файлів торрента',
torrentFileSelection: 'Вибір файлів торрента',
torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.',
+4 -2
View File
@@ -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: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。',
+9
View File
@@ -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<string>();
const release = beginExclusivePropertiesAction(inFlight, 'window:download');
+17
View File
@@ -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<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
export type PropertiesSnapshot = SafePropertiesFields & {