import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; import { open, save } from '@tauri-apps/plugin-dialog'; import { Copy, FileDown, FolderOpen, Pause, Play, RefreshCw, Save, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; import type { TorrentDetails } from '../bindings/TorrentDetails'; import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; import { invokeCommand as invoke } from '../ipc'; import { PROPERTIES_WINDOW_ACTION_RESULT, PROPERTIES_WINDOW_REMOVED, PROPERTIES_WINDOW_SNAPSHOT, getPropertiesLifecycleAction, sendPropertiesActionRequest, sendPropertiesReady, isExpectedPropertiesDiagnosticUnavailable, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, type PropertiesPatch, type PropertiesSnapshot, type PropertiesSnapshotEvent, } from '../propertiesBridge'; import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced'; 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) => { const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160); return `${bounded || 'Download'} - Properties - Firelink`; }; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); export const PropertiesWindowApp = () => { const { t } = useTranslation(); const currentWindow = useMemo(() => getCurrentWindow(), []); const windowLabel = currentWindow.label; const sessionId = useMemo(() => crypto.randomUUID(), []); const [downloadId, setDownloadId] = useState(null); const [snapshot, setSnapshot] = useState(null); const [activeTab, setActiveTab] = useState('overview'); const [pendingTab, setPendingTab] = useState(null); const [closePrompt, setClosePrompt] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [notice, setNotice] = useState(''); const [isSaving, setIsSaving] = useState(false); const [fileProgress, setFileProgress] = useState(null); const [peers, setPeers] = useState(null); 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); const [fileName, setFileName] = useState(''); const [destination, setDestination] = useState(''); const [connections, setConnections] = useState(''); const [trackers, setTrackers] = useState(''); const [excludedTrackers, setExcludedTrackers] = useState(''); const [downloadLimit, setDownloadLimit] = useState(''); const [uploadLimit, setUploadLimit] = useState(''); const [maxPeers, setMaxPeers] = useState(''); const [peerSpeedLimit, setPeerSpeedLimit] = useState(''); const [draftTab, setDraftTab] = useState(null); const draftTabRef = useRef(null); const closeAfterSaveRef = useRef(false); const switchAfterSaveRef = useRef(null); const requestIdRef = useRef(0); const pendingActionRef = useRef(null); const latestSnapshotRevisionRef = useRef(0); const appearanceCleanupRef = useRef<(() => void) | null>(null); const hasRevealedWindowRef = useRef(false); const revealInFlightRef = useRef(false); const diagnosticsInFlightRef = useRef(new Set()); const snapshotRef = useRef(snapshot); const activeTabRef = useRef(activeTab); const downloadIdRef = useRef(downloadId); snapshotRef.current = snapshot; activeTabRef.current = activeTab; downloadIdRef.current = downloadId; const isTorrent = snapshot?.isTorrent === true; const tabs = useMemo(() => isTorrent ? ['overview', 'files', 'trackers', 'peers', 'options'] : ['overview', 'transfer', 'advanced'], [isTorrent]); const isDirty = draftTab !== null; useEffect(() => { draftTabRef.current = draftTab; }, [draftTab]); const hydrateDraft = useCallback((next: PropertiesSnapshot) => { setFileName(next.fileName); setDestination(next.destination ?? ''); setConnections(next.connections === undefined ? '' : String(next.connections)); setTrackers(next.torrentTrackers ?? ''); setExcludedTrackers(next.torrentExcludeTrackers ?? ''); setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null); setDownloadLimit(next.speedLimit ?? ''); setUploadLimit(next.torrentUploadLimit ?? ''); setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers)); setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? ''); }, []); const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => { 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 && isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? ''); if (isCurrent()) { setDiagnosticError(''); setDiagnosticsLoading(true); } try { if (tab === 'overview') { const nextDetails = await invoke('get_torrent_details', { id }); if (isCurrent()) setDetails(nextDetails); } else if (tab === 'files') { const nextProgress = await invoke('get_torrent_file_progress', { id }); if (isCurrent()) setFileProgress(nextProgress); } else if (tab === 'peers') { const [peerResult, availabilityResult] = await Promise.allSettled([ invoke('get_torrent_peers', { id }), invoke('get_torrent_availability', { id }), ]); if (isCurrent()) { 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()) { 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); } } }, []); useEffect(() => { let cancelled = false; let readyRetryTimer: number | undefined; let unlistenSnapshot: UnlistenFn | undefined; let unlistenResult: UnlistenFn | undefined; let unlistenRemoved: UnlistenFn | undefined; const start = async () => { try { const id = await invoke('get_properties_window_download_id'); if (cancelled) return; setDownloadId(id); unlistenSnapshot = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (event.payload.revision <= latestSnapshotRevisionRef.current) return; latestSnapshotRevisionRef.current = event.payload.revision; await changeAppLocale(event.payload.snapshot.appearance.locale); if (event.payload.revision !== latestSnapshotRevisionRef.current) return; appearanceCleanupRef.current?.(); appearanceCleanupRef.current = synchronizeDocumentAppearance( window, event.payload.snapshot.appearance, ); setSnapshot(event.payload.snapshot); if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot); void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); if (!hasRevealedWindowRef.current && !revealInFlightRef.current) { revealInFlightRef.current = true; try { await invoke('properties_window_reveal'); hasRevealedWindowRef.current = true; if (readyRetryTimer !== undefined) { window.clearInterval(readyRetryTimer); readyRetryTimer = undefined; } } finally { revealInFlightRef.current = false; } } }); unlistenResult = await listen(PROPERTIES_WINDOW_ACTION_RESULT, event => { if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (event.payload.requestId !== requestIdRef.current) return; setIsSaving(false); const completedAction = pendingActionRef.current; pendingActionRef.current = null; if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed'); else { const nextTab = switchAfterSaveRef.current; const shouldClose = closeAfterSaveRef.current; switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setErrorMessage(''); setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : ''); draftTabRef.current = null; setDraftTab(null); if (nextTab) { setActiveTab(nextTab); setPendingTab(null); } if (shouldClose) { setClosePrompt(false); void currentWindow.close().catch(error => setErrorMessage(errorText(error))); } } }); unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => { if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) { if (readyRetryTimer !== undefined) { window.clearInterval(readyRetryTimer); readyRetryTimer = undefined; } setSnapshot(null); setNotice(t($ => $.downloadTable.noDownloads)); } }); await sendPropertiesReady(sessionId); if (cancelled) return; // Tauri event listeners are registered asynchronously. If the main // bridge was still installing its listener, the first ready event can // legitimately be missed; retry until the first snapshot confirms // the handshake rather than leaving a permanently hidden window. readyRetryTimer = window.setInterval(() => { if (cancelled || hasRevealedWindowRef.current) return; void sendPropertiesReady(sessionId).catch(() => undefined); }, 500); } catch (error) { if (!cancelled) setErrorMessage(errorText(error)); } }; void start(); return () => { cancelled = true; if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer); unlistenSnapshot?.(); unlistenResult?.(); unlistenRemoved?.(); appearanceCleanupRef.current?.(); appearanceCleanupRef.current = null; }; }, [currentWindow, hydrateDraft, sessionId, t, windowLabel]); useEffect(() => { if (!snapshot || draftTab !== null) return; hydrateDraft(snapshot); }, [draftTab, hydrateDraft, snapshot]); useEffect(() => { 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 (!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?.status]); useEffect(() => { if (!isDirty) return; let unlisten: UnlistenFn | undefined; void currentWindow.onCloseRequested(event => { event.preventDefault(); setClosePrompt(true); }).then(value => { unlisten = value; }); return () => unlisten?.(); }, [currentWindow, isDirty]); const requestAction = useCallback(async ( action: PropertiesAction, payload?: PropertiesActionRequest['payload'], ) => { if (!downloadId) return; if (pendingActionRef.current !== null) return; const requestId = ++requestIdRef.current; pendingActionRef.current = action; setIsSaving(true); try { await sendPropertiesActionRequest({ windowLabel, downloadId, sessionId, requestId, action, payload, }); } catch (error) { setIsSaving(false); pendingActionRef.current = null; closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(errorText(error)); } }, [downloadId, sessionId, windowLabel]); const applyActiveTab = useCallback(async () => { if (!snapshot || !isEditableStatus(snapshot.status)) { closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(t($ => $.properties.editingUnavailable)); return; } const patch: PropertiesPatch = {}; if (activeTab === 'overview') { patch.fileName = fileName; patch.destination = destination || undefined; if (connections.trim()) patch.connections = Number(connections); } else if (activeTab === 'files' && isTorrent) { const nextSelectedFiles = selectedFiles ?? fileProgress?.files.filter(file => file.selected).map(file => file.index) ?? []; if (nextSelectedFiles.length === 0) { setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } patch.torrentFileIndices = nextSelectedFiles; } else if (activeTab === 'trackers') { patch.torrentTrackers = trackers; patch.torrentExcludeTrackers = excludedTrackers; } else if (activeTab === 'options' || activeTab === 'transfer') { if (downloadLimit !== snapshot.speedLimit) patch.speedLimit = downloadLimit; if (isTorrent) { patch.torrentUploadLimit = uploadLimit; patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined; patch.torrentPeerSpeedLimit = peerSpeedLimit; } } await requestAction('apply-properties', patch); }, [activeTab, connections, destination, downloadLimit, excludedTrackers, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, requestAction, selectedFiles, snapshot, t, trackers, uploadLimit]); const chooseTab = (tab: PropertiesTab) => { if (tab === activeTab) return; if (isDirty) setPendingTab(tab); else setActiveTab(tab); }; const discardDraft = () => { const shouldClose = closePrompt; if (snapshot) hydrateDraft(snapshot); draftTabRef.current = null; setDraftTab(null); if (pendingTab) setActiveTab(pendingTab); setPendingTab(null); setClosePrompt(false); if (shouldClose) void currentWindow.close().catch(error => setErrorMessage(errorText(error))); }; const closeWindow = async () => { if (!downloadId) return; try { await invoke('close_download_properties_window', { id: downloadId }); } catch (error) { setErrorMessage(errorText(error)); } }; const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => { if (!downloadId) return; try { if (action === 'magnet') { await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId })); setNotice(t($ => $.properties.torrentMagnetCopied)); } else if (action === 'export') { const destinationPath = await save({ defaultPath: `${snapshot?.fileName || 'download'}.torrent` }); if (destinationPath) { await invoke('export_torrent_metadata', { id: downloadId, destination: destinationPath }); setNotice(t($ => $.properties.torrentMetadataExported)); } } else if (action === 'move') { const selected = await open({ directory: true, multiple: false }); if (selected && typeof selected === 'string') { await invoke('move_torrent_data', { id: downloadId, destination: selected }); setNotice(t($ => $.properties.torrentMoveCompleted)); } } else { await requestAction('verify-torrent'); } } catch (error) { setErrorMessage(errorText(error)); } }; if (!downloadId) { return
{errorMessage || t($ => $.app.loading)}
; } if (!snapshot) { return
{errorMessage || t($ => $.app.loading)}
; } const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0)); const lifecycleAction = getPropertiesLifecycleAction(snapshot.status); const total = snapshot.size || (snapshot.totalBytes === undefined ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); const tabLabel = (tab: PropertiesTab) => { switch (tab) { case 'overview': return t($ => $.properties.torrentDetails); case 'files': return t($ => $.properties.torrentFileProgress); case 'trackers': return t($ => $.properties.torrentTrackers); case 'peers': return t($ => $.properties.torrentPeerDiagnostics); case 'options': return t($ => $.properties.advancedTransfer); case 'transfer': return t($ => $.properties.connections); case 'advanced': return t($ => $.properties.advancedTransfer); } }; return (

{snapshot.fileName}

{statusLabel} · {Math.round(progress * 100)}% · {total}

$.actions.continue)}> {lifecycleAction && } {isTorrent && <> }
$.properties.progress)}>
{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total} {snapshot.speed || '—'} {snapshot.eta || '—'} {snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'} {t($ => $.properties.connections)} {isTorrent && {formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}}
{activeTab === 'overview' &&
{t($ => $.properties.url)}

{snapshot.url}

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

{snapshot.category}

{isTorrent && details &&
{t($ => $.properties.torrentDetailsInfoHash)}{details.infoHash} {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}
} {isTorrent &&
}
} {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}

}
} {activeTab === 'trackers' && isTorrent &&