feat(torrents): harden lifecycle and web-seed management

- Enforce generation-safe seed admission and budget tracking.
- Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe.
- Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered.
- Ignore local TORRENT_FEATURES.md roadmap notes.
This commit is contained in:
NimBold
2026-08-03 16:56:01 +03:30
parent 79c0e48c43
commit c4d3a2be51
31 changed files with 3398 additions and 258 deletions
+399
View File
@@ -4,6 +4,9 @@ import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow';
import { useSettingsStore } from '../store/useSettingsStore';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
@@ -42,6 +45,9 @@ const formatLastTry = (
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -99,9 +105,18 @@ export const PropertiesModal = () => {
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
@@ -119,6 +134,9 @@ export const PropertiesModal = () => {
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const torrentFileProgressRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
useEffect(() => {
@@ -132,6 +150,17 @@ export const PropertiesModal = () => {
setTorrentPeerDiagnostics(null);
setTorrentPeerDiagnosticsError(false);
setIsTorrentPeerDiagnosticsPending(false);
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -205,6 +234,9 @@ export const PropertiesModal = () => {
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
.map(seed => `${seed.fileIndex}|${seed.uri}`)
.join('\n'));
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -229,6 +261,120 @@ export const PropertiesModal = () => {
setIsTorrentPeerDiagnosticsPending(false);
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
useEffect(() => {
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
void invoke('get_torrent_file_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgressError(true);
}
})
.finally(() => {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return;
const requestId = torrentWebSeedsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentWebSeedsPending(true);
void invoke('get_torrent_web_seeds', { id: propertiesDownloadId })
.then(seeds => {
if (
requestId === torrentWebSeedsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
}
})
.catch(() => {
if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true);
})
.finally(() => {
if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false);
});
}, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
void invoke('get_torrent_piece_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgressError(true);
}
})
.finally(() => {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
setLiveTorrentMaxPeersValue(
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
@@ -321,6 +467,108 @@ export const PropertiesModal = () => {
}
};
const handleRefreshTorrentFileProgress = async () => {
if (
isTorrentFileProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
setTorrentFileProgressError(false);
try {
const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
} catch {
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentFileProgressError(true);
setTorrentFileProgress(null);
}
} finally {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
}
};
const handleRefreshTorrentPieceProgress = async () => {
if (
isTorrentPieceProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
setTorrentPieceProgressError(false);
try {
const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
} catch {
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentPieceProgressError(true);
setTorrentPieceProgress(null);
}
} finally {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
}
};
const handleTorrentWebSeedsSave = async () => {
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
const seeds: TorrentWebSeed[] = [];
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf('|');
const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : '');
const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : '';
if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) {
setTorrentWebSeedsError(true);
return;
}
seeds.push({ fileIndex, uri });
}
setIsTorrentWebSeedsPending(true);
setTorrentWebSeedsError(false);
try {
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
} catch {
setTorrentWebSeedsError(true);
} finally {
setIsTorrentWebSeedsPending(false);
}
};
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -857,6 +1105,127 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPieceProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPieceProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentPieceProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPieceProgressPending
? t($ => $.properties.torrentPieceProgressLoading)
: t($ => $.properties.torrentPieceProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressUnavailable)}
</p>
)}
{torrentPieceProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPieceProgressFailed)}
</p>
)}
{torrentPieceProgress && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentPieceProgressSummary, {
completed: torrentPieceProgress.completedPieces,
total: torrentPieceProgress.numPieces,
size: formatDownloadBytes(torrentPieceProgress.pieceLength),
})}
</div>
<div
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentPieceProgress.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentPieceProgressMap)}
>
{torrentPieceProgress.buckets.map((percentage, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-blue-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.max(0.15, percentage / 100) }}
title={`${percentage}%`}
/>
))}
</div>
</>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentFileProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentFileProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentFileProgressPending
? t($ => $.properties.torrentFileProgressLoading)
: t($ => $.properties.torrentFileProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressUnavailable)}
</p>
)}
{torrentFileProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentFileProgressFailed)}
</p>
)}
{torrentFileProgress && (
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressPath)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressCompleted)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressSelected)}</th>
</tr>
</thead>
<tbody>
{torrentFileProgress.files.map(file => {
const percentage = file.length === 0
? 100
: Math.round((file.completedLength / file.length) * 100);
return (
<tr key={file.index} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{file.index}</td>
<td className="px-2 py-1 max-w-[220px] truncate" title={file.relativePath} dir="auto">{file.relativePath}</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%)
</td>
<td className="px-2 py-1">
{file.selected
? t($ => $.properties.torrentFileProgressSelected)
: t($ => $.properties.torrentFileProgressUnselected)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
@@ -1313,6 +1682,36 @@ export const PropertiesModal = () => {
</div>
</section>
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentWebSeeds)}
</h3>
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
<textarea
value={torrentWebSeedsText}
onChange={event => setTorrentWebSeedsText(event.target.value)}
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
disabled={isTorrentWebSeedsPending}
aria-label={t($ => $.properties.torrentWebSeeds)}
className="w-full h-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
{torrentWebSeedsError ? t($ => $.properties.torrentWebSeedsFailed) : ''}
</span>
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
</button>
</div>
</section>
)}
{/* Advanced Transfer Section */}
<section>
<button