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 { Activity, Copy, Download, FileDown, FolderOpen, Gauge, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, Timer, Upload, Users, 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, attachAsyncPropertiesListener, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, propertiesLifecycleReachedPostcondition, propertiesTorrentPeerLimit, propertiesDiagnosticRequestState, sendPropertiesActionRequest, sendPropertiesReady, isExpectedPropertiesDiagnosticUnavailable, propertiesDiagnosticPhase, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, type PropertiesPatch, type PropertiesDiagnosticPhase, type PropertiesSnapshot, type PropertiesSnapshotEvent, } from '../propertiesBridge'; import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation, } from '../utils/downloads'; type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced'; type SecretName = 'username' | 'password' | 'cookies' | 'headers'; type SecretDraft = { value: string; touched: boolean; clear: boolean }; const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers']; const nextPropertiesRequestId = (current: number): number => current >= Number.MAX_SAFE_INTEGER ? 1 : current + 1; 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', 'waitingToSeed', 'retrying', 'moving'].includes(status); const isTorrentFileSelectionEditable = (status: string) => ['ready', 'staged', 'queued', 'paused', 'failed'].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); const propertiesStatusTone = (status: string) => { if (status === 'paused') return 'paused'; if (status === 'seeding') return 'seeding'; if (status === 'failed') return 'failed'; if (status === 'processing' || status === 'verifying' || status === 'moving') return 'processing'; if (status === 'queued' || status === 'staged') return 'queued'; if (status === 'retrying') return 'retrying'; if (status === 'completed') return 'completed'; return 'downloading'; }; const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [ snapshot.id, snapshot.status, snapshot.lastTry ?? '', snapshot.hasBeenDispatched === true, snapshot.destination ?? '', snapshot.torrentInfoHash ?? '', snapshot.torrentFileIndices?.join(',') ?? '', snapshot.torrentMoveDestination ?? '', snapshot.torrentMoveRestoreStatus ?? '', snapshot.torrentVerifyOnly === true, snapshot.torrentRelocationCheckPending === true, ].join('\u0000'); 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 [pendingAction, setPendingAction] = useState(null); const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | null>(null); 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); const [diagnosticsRefreshing, setDiagnosticsRefreshing] = useState(false); const [diagnosticPhase, setDiagnosticPhase] = useState('idle'); // 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 [seedTime, setSeedTime] = useState(''); const [seedRatio, setSeedRatio] = useState(''); const [checkIntegrity, setCheckIntegrity] = useState(false); const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false); const [stopTimeout, setStopTimeout] = useState(''); const [prioritizePiece, setPrioritizePiece] = useState(''); const [encryptionPolicy, setEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); const [fileAllocation, setFileAllocation] = useState('prealloc'); const [trackerConnectTimeout, setTrackerConnectTimeout] = useState(''); const [trackerTimeout, setTrackerTimeout] = useState(''); const [trackerInterval, setTrackerInterval] = useState(''); const [secretDrafts, setSecretDrafts] = useState>({ username: { value: '', touched: false, clear: false }, password: { value: '', touched: false, clear: false }, cookies: { value: '', touched: false, clear: false }, headers: { value: '', touched: false, clear: false }, }); 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 pendingLifecycleIntentRef = useRef>(null); const latestSnapshotRevisionRef = useRef(0); const latestBridgeGenerationRef = useRef(null); 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); const fileProgressRef = useRef(fileProgress); const peersRef = useRef(peers); const availabilityRef = useRef(availability); const detailsRef = useRef(details); const diagnosticAttemptsRef = useRef(new Set()); const diagnosticLifecycleKeyRef = useRef(''); const diagnosticLifecycleEpochRef = useRef(0); snapshotRef.current = snapshot; activeTabRef.current = activeTab; downloadIdRef.current = downloadId; fileProgressRef.current = fileProgress; peersRef.current = peers; availabilityRef.current = availability; detailsRef.current = details; 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 ?? ''); setSeedTime(next.torrentSeedTime === undefined ? '' : String(next.torrentSeedTime)); setSeedRatio(next.torrentSeedRatio === undefined ? '' : String(next.torrentSeedRatio)); setCheckIntegrity(next.torrentCheckIntegrity === true); setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true); setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout)); setPrioritizePiece(next.torrentPrioritizePiece ?? ''); setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED); setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc'); setTrackerConnectTimeout(next.torrentTrackerConnectTimeout === undefined ? '' : String(next.torrentTrackerConnectTimeout)); setTrackerTimeout(next.torrentTrackerTimeout === undefined ? '' : String(next.torrentTrackerTimeout)); setTrackerInterval(next.torrentTrackerInterval === undefined ? '' : String(next.torrentTrackerInterval)); setSecretDrafts({ username: { value: '', touched: false, clear: false }, password: { value: '', touched: false, clear: false }, cookies: { value: '', touched: false, clear: false }, headers: { value: '', touched: false, clear: false }, }); }, []); const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string, manual = false) => { if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return; const diagnosticTabKey = `${id}:${tab}`; const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current; const requestKey = `${diagnosticTabKey}:${requestLifecycleEpoch}`; if (diagnosticsInFlightRef.current.has(requestKey)) return; diagnosticsInFlightRef.current.add(requestKey); const isCurrent = () => downloadIdRef.current === id && activeTabRef.current === tab && isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '') && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch; const hasCachedResult = () => tab === 'files' ? fileProgressRef.current !== null : tab === 'peers' ? peersRef.current !== null || availabilityRef.current !== null : detailsRef.current !== null; const hasPreviousAttempt = diagnosticAttemptsRef.current.has(diagnosticTabKey); diagnosticAttemptsRef.current.add(diagnosticTabKey); if (isCurrent()) { const cached = hasCachedResult(); const requestState = propertiesDiagnosticRequestState(cached, hasPreviousAttempt, manual); setDiagnosticsLoading(requestState.loading); setDiagnosticsRefreshing(requestState.refreshing); // A silent refresh with no cached result must not replace a stable // unavailable/error message between polling requests. Cached results // remain visible while their request is refreshed in the background. if (requestState.resetMessage) { setDiagnosticError(''); setDiagnosticPhase(requestState.phase); } } try { if (tab === 'overview') { const nextDetails = await invoke('get_torrent_details', { id }); if (isCurrent()) { setDetails(nextDetails); setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } else if (tab === 'files') { const nextProgress = await invoke('get_torrent_file_progress', { id }); if (isCurrent()) { setFileProgress(nextProgress); setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } 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); if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); const rejectedResults = [peerResult, availabilityResult] .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map(result => result.reason); const unexpectedErrors = rejectedResults .filter(error => !isExpectedPropertiesDiagnosticUnavailable(error)); setDiagnosticError(unexpectedErrors.length > 0 ? errorText(unexpectedErrors[0]) : ''); if (rejectedResults.length > 0) { const hasPeerResult = hasCachedResult() || peerResult.status === 'fulfilled' || availabilityResult.status === 'fulfilled'; setDiagnosticPhase(propertiesDiagnosticPhase( hasPeerResult, unexpectedErrors.length > 0 ? 'unexpected-error' : 'expected-unavailable', )); } else setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } } 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(''); setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'expected-unavailable')); } else { setDiagnosticError(message); setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'unexpected-error')); } } } finally { diagnosticsInFlightRef.current.delete(requestKey); if (downloadIdRef.current === id && activeTabRef.current === tab && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch) { setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); } } }, []); useEffect(() => { let cancelled = false; let readyRetryTimer: number | undefined; let readyHeartbeatTimer: 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); const snapshotListener = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (latestBridgeGenerationRef.current !== null && event.payload.bridgeGeneration < latestBridgeGenerationRef.current) return; if (latestBridgeGenerationRef.current !== event.payload.bridgeGeneration) { latestBridgeGenerationRef.current = event.payload.bridgeGeneration; latestSnapshotRevisionRef.current = 0; diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); const lostAction = pendingActionRef.current; const lostApply = lostAction === 'apply-properties'; // A main-webview restart can lose both the action-result event and // the store transition event while the child Properties window // remains alive. No result from the dead bridge can be correlated // safely after this point, so release the UI lock after adopting // the next snapshot and require any retry to be an explicit user // action. This never replays a possibly completed lifecycle // request, and also recovers when the native request failed while // its failure event was lost. requestIdRef.current = nextPropertiesRequestId(requestIdRef.current); pendingActionRef.current = null; pendingLifecycleIntentRef.current = null; setPendingAction(null); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; if (lostApply) { // A completed property action is represented by the fresh // snapshot, not by the stale draft that produced the request. draftTabRef.current = null; setDraftTab(null); setPendingTab(null); setClosePrompt(false); } setPendingTorrentCommand(null); } if (event.payload.revision <= latestSnapshotRevisionRef.current) return; latestSnapshotRevisionRef.current = event.payload.revision; const nextDiagnosticLifecycleKey = propertiesDiagnosticLifecycleKey(event.payload.snapshot); if (diagnosticLifecycleKeyRef.current !== nextDiagnosticLifecycleKey) { diagnosticLifecycleKeyRef.current = nextDiagnosticLifecycleKey; diagnosticLifecycleEpochRef.current += 1; diagnosticAttemptsRef.current.clear(); } 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 (pendingActionRef.current === 'pause-resume' && pendingLifecycleIntentRef.current && propertiesLifecycleReachedPostcondition( pendingLifecycleIntentRef.current, event.payload.snapshot.status, )) { pendingActionRef.current = null; pendingLifecycleIntentRef.current = null; setPendingAction(null); } 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; } } }); if (cancelled) { snapshotListener(); return; } unlistenSnapshot = snapshotListener; const resultListener = 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; const completedAction = pendingActionRef.current; pendingActionRef.current = null; pendingLifecycleIntentRef.current = null; setPendingAction(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))); } } }); if (cancelled) { resultListener(); return; } unlistenResult = resultListener; const removedListener = 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; } if (readyHeartbeatTimer !== undefined) { window.clearInterval(readyHeartbeatTimer); readyHeartbeatTimer = undefined; } diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); setSnapshot(null); setNotice(t($ => $.downloadTable.noDownloads)); } }); if (cancelled) { removedListener(); return; } unlistenRemoved = removedListener; 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); // The main webview can restart independently of this child window. // Keep the registration alive so a fresh Properties bridge can send a // new snapshot and recover action state without replaying a request. readyHeartbeatTimer = window.setInterval(() => { if (cancelled) return; void sendPropertiesReady(sessionId).catch(() => undefined); }, 2000); } catch (error) { if (!cancelled) setErrorMessage(errorText(error)); } }; void start(); return () => { cancelled = true; if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer); if (readyHeartbeatTimer !== undefined) window.clearInterval(readyHeartbeatTimer); 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); setDiagnosticsRefreshing(false); diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); return; } if (!isTorrentPollingStatus(snapshot.status)) { setFileProgress(null); setPeers(null); setAvailability(null); diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); } 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 disposed = false; let unlisten: UnlistenFn | undefined; attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => { event.preventDefault(); setClosePrompt(true); }), () => disposed, value => { unlisten = value; }); return () => { disposed = true; unlisten?.(); }; }, [currentWindow, isDirty]); const requestAction = useCallback(async ( action: PropertiesAction, payload?: PropertiesActionRequest['payload'], ) => { if (!downloadId || pendingActionRef.current !== null) return; const requestId = nextPropertiesRequestId(requestIdRef.current); requestIdRef.current = requestId; pendingActionRef.current = action; if (action === 'pause-resume') { pendingLifecycleIntentRef.current = getPropertiesLifecycleAction(snapshot?.status ?? 'completed'); } else { pendingLifecycleIntentRef.current = null; } setPendingAction(action); try { await sendPropertiesActionRequest({ windowLabel, downloadId, sessionId, requestId, action, payload, }); } catch (error) { setPendingAction(null); pendingActionRef.current = null; pendingLifecycleIntentRef.current = null; closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(errorText(error)); } }, [downloadId, sessionId, snapshot?.status, windowLabel]); const updateSecretDraft = (name: SecretName, value: string) => { setSecretDrafts(current => ({ ...current, [name]: { value, touched: true, clear: false }, })); setDraftTab('advanced'); }; const clearSecretDraft = (name: SecretName) => { setSecretDrafts(current => ({ ...current, [name]: { value: '', touched: true, clear: true }, })); setDraftTab('advanced'); }; 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') { if (fileName !== snapshot.fileName) patch.fileName = fileName; if (destination !== (snapshot.destination ?? '')) patch.destination = destination || undefined; if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) { patch.connections = Number(connections); } } else if (activeTab === 'files' && isTorrent) { const nextSelectedFiles = selectedFiles ?? fileProgress?.files.filter(file => file.selected).map(file => file.index) ?? []; if (!isTorrentFileSelectionEditable(snapshot.status)) { setErrorMessage(t($ => $.properties.editingUnavailable)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } if (nextSelectedFiles.length === 0) { setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles }); return; } else if (activeTab === 'trackers') { if (trackers !== (snapshot.torrentTrackers ?? '')) patch.torrentTrackers = trackers; if (excludedTrackers !== (snapshot.torrentExcludeTrackers ?? '')) patch.torrentExcludeTrackers = excludedTrackers; if (trackerConnectTimeout !== String(snapshot.torrentTrackerConnectTimeout ?? '')) { patch.torrentTrackerConnectTimeout = trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined; } if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) { patch.torrentTrackerTimeout = trackerTimeout.trim() ? Number(trackerTimeout) : undefined; } if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) { patch.torrentTrackerInterval = trackerInterval.trim() ? Number(trackerInterval) : undefined; } } else if (activeTab === 'options' || activeTab === 'transfer') { if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = downloadLimit; if (activeTab === 'transfer' && !isTorrent && connections.trim()) { const nextConnections = Number(connections); if (nextConnections !== snapshot.connections) patch.connections = nextConnections; } if (isTorrent) { if (removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) { setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = uploadLimit; if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) { patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined; } if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = peerSpeedLimit; if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { patch.torrentSeedTime = seedTime.trim() ? Number(seedTime) : undefined; } if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { patch.torrentSeedRatio = seedRatio.trim() ? Number(seedRatio) : undefined; } if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity; if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile; if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) { patch.torrentStopTimeout = stopTimeout.trim() ? Number(stopTimeout) : undefined; } if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = prioritizePiece.trim() || undefined; const snapshotEncryptionPolicy = (snapshot.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED; if (encryptionPolicy !== snapshotEncryptionPolicy) { patch.torrentEncryptionPolicy = encryptionPolicy === TORRENT_ENCRYPTION_POLICY_DISABLED ? undefined : encryptionPolicy; } if (fileAllocation !== ((snapshot.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc')) patch.torrentFileAllocation = fileAllocation; } } else if (activeTab === 'advanced') { for (const name of SECRET_NAMES) { const draft = secretDrafts[name]; if (!draft.touched) continue; patch[name] = draft.clear ? { kind: 'clear' } : { kind: 'replace', value: draft.value }; } } await requestAction('apply-properties', patch); }, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, 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; if (action === 'verify') { await requestAction('verify-torrent'); return; } if (pendingTorrentCommand !== null) return; setPendingTorrentCommand(action); 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)); } } } catch (error) { setErrorMessage(errorText(error)); } finally { setPendingTorrentCommand(null); } }; 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 editingEnabled = pendingAction === null && isEditableStatus(snapshot.status); const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(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 connectionMetric = isTorrent ? String(snapshot.connectedPeers ?? '—') : snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'} ${t($ => $.properties.connections)}`; const queuePlacement = formatPropertiesQueuePlacement( snapshot.queueName, snapshot.queuePosition, position => t($ => $.properties.queuePosition, { position }), ); const progressPercent = `${Math.round(progress * 100)}%`; const statusTone = propertiesStatusTone(snapshot.status); const lifecycleLabel = lifecycleAction === 'pause' ? t($ => $.downloads.actions.pause) : lifecycleAction === 'resume' ? t($ => $.downloads.actions.resume) : lifecycleAction === 'retry' ? t($ => $.downloads.actions.retry) : t($ => $.downloads.actions.start); const tabLabel = (tab: PropertiesTab) => { switch (tab) { case 'overview': return t($ => $.properties.details); case 'files': return t($ => $.properties.torrentFileProgress); case 'trackers': return t($ => $.properties.torrentTrackers); case 'peers': return t($ => $.properties.torrentPeerDiagnostics); case 'options': return t($ => $.downloads.actions.options); case 'transfer': return t($ => $.properties.connections); case 'advanced': return t($ => $.properties.advancedTransfer); } }; return (

{snapshot.fileName}

{statusLabel}

{queuePlacement}

$.actions.continue)}> {lifecycleAction && } {isTorrent && <>
$.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}>
}
$.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}>
{progressPercent}
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
{t($ => $.properties.speed)}{snapshot.speed || '—'}
{t($ => $.properties.eta)}{snapshot.eta || '—'}
{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}{connectionMetric}
{isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
}
{snapshot.destination || '—'}
{activeTab === 'overview' &&
{t($ => $.properties.url)}

{snapshot.url}

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

{snapshot.category}

{t($ => $.properties.dateAdded)}{snapshot.dateAdded || '—'} {t($ => $.properties.lastTry)}{snapshot.lastTry || '—'} {t($ => $.properties.queueId)}{queuePlacement} {t($ => $.properties.resumable)}{snapshot.resumable === false ? '—' : '✓'} {snapshot.lastError && <> {t($ => $.properties.lastError)}
{snapshot.lastErrorKind === 'nameResolution' && (

{snapshot.status === 'retrying' ? snapshot.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) : t($ => $.downloads.status.retrying) : t($ => $.downloads.errors.nameResolutionFailed)}

)} {snapshot.lastError}
}
{snapshot.isMedia === true &&
{t($ => $.addDownloads.format)}{snapshot.mediaFormatSelector || '—'} {t($ => $.addDownloads.quality)}{snapshot.mediaQuality || '—'} {t($ => $.properties.configuredConcurrency)}{snapshot.connections ?? '—'}
} {isTorrent && details &&
{t($ => $.properties.torrentDetailsDisplayName)}{details.displayName || '—'} {t($ => $.properties.torrentDetailsInfoHash)}{details.infoHash} {t($ => $.properties.torrentDetailsSize)}{formatDownloadBytes(details.totalBytes)} {t($ => $.properties.torrentDetailsFiles)}{details.fileCount} {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)} {t($ => $.properties.torrentDetailsCreated)}{details.creationDate || '—'} {t($ => $.properties.torrentDetailsCreator)}{details.creator || '—'} {t($ => $.properties.torrentDetailsComment)}{details.comment || '—'}
} {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}%)
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

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

} {diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError &&

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

} {diagnosticError &&

{diagnosticError}

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