mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 21:16:52 +00:00
fix(properties): harden protocol-aware resume recovery
This commit is contained in:
@@ -16,6 +16,8 @@ import {
|
||||
PROPERTIES_WINDOW_SNAPSHOT,
|
||||
attachAsyncPropertiesListener,
|
||||
getPropertiesLifecycleAction,
|
||||
propertiesLifecycleReachedPostcondition,
|
||||
propertiesTorrentPeerLimit,
|
||||
sendPropertiesActionRequest,
|
||||
sendPropertiesReady,
|
||||
isExpectedPropertiesDiagnosticUnavailable,
|
||||
@@ -29,8 +31,20 @@ import {
|
||||
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);
|
||||
@@ -38,7 +52,10 @@ const isTorrentDiagnosticsStatus = (status: string) =>
|
||||
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 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);
|
||||
@@ -59,7 +76,8 @@ export const PropertiesWindowApp = () => {
|
||||
const [closePrompt, setClosePrompt] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<PropertiesAction | null>(null);
|
||||
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | null>(null);
|
||||
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||
@@ -78,13 +96,32 @@ export const PropertiesWindowApp = () => {
|
||||
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<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
|
||||
const [fileAllocation, setFileAllocation] = useState<TorrentFileAllocation>('prealloc');
|
||||
const [trackerConnectTimeout, setTrackerConnectTimeout] = useState('');
|
||||
const [trackerTimeout, setTrackerTimeout] = useState('');
|
||||
const [trackerInterval, setTrackerInterval] = useState('');
|
||||
const [secretDrafts, setSecretDrafts] = useState<Record<SecretName, SecretDraft>>({
|
||||
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<PropertiesTab | null>(null);
|
||||
const draftTabRef = useRef<PropertiesTab | null>(null);
|
||||
const closeAfterSaveRef = useRef(false);
|
||||
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
||||
const pendingLifecycleIntentRef = useRef<ReturnType<typeof getPropertiesLifecycleAction>>(null);
|
||||
const latestSnapshotRevisionRef = useRef(0);
|
||||
const latestBridgeGenerationRef = useRef<number | null>(null);
|
||||
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
||||
const hasRevealedWindowRef = useRef(false);
|
||||
const revealInFlightRef = useRef(false);
|
||||
@@ -117,6 +154,23 @@ export const PropertiesWindowApp = () => {
|
||||
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) => {
|
||||
@@ -183,6 +237,7 @@ export const PropertiesWindowApp = () => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readyRetryTimer: number | undefined;
|
||||
let readyHeartbeatTimer: number | undefined;
|
||||
let unlistenSnapshot: UnlistenFn | undefined;
|
||||
let unlistenResult: UnlistenFn | undefined;
|
||||
let unlistenRemoved: UnlistenFn | undefined;
|
||||
@@ -195,6 +250,37 @@ export const PropertiesWindowApp = () => {
|
||||
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;
|
||||
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;
|
||||
await changeAppLocale(event.payload.snapshot.appearance.locale);
|
||||
@@ -205,6 +291,16 @@ export const PropertiesWindowApp = () => {
|
||||
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) {
|
||||
@@ -231,9 +327,10 @@ export const PropertiesWindowApp = () => {
|
||||
|| 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;
|
||||
pendingLifecycleIntentRef.current = null;
|
||||
setPendingAction(null);
|
||||
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
||||
else {
|
||||
const nextTab = switchAfterSaveRef.current;
|
||||
@@ -265,6 +362,10 @@ export const PropertiesWindowApp = () => {
|
||||
window.clearInterval(readyRetryTimer);
|
||||
readyRetryTimer = undefined;
|
||||
}
|
||||
if (readyHeartbeatTimer !== undefined) {
|
||||
window.clearInterval(readyHeartbeatTimer);
|
||||
readyHeartbeatTimer = undefined;
|
||||
}
|
||||
setSnapshot(null);
|
||||
setNotice(t($ => $.downloadTable.noDownloads));
|
||||
}
|
||||
@@ -284,6 +385,13 @@ export const PropertiesWindowApp = () => {
|
||||
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));
|
||||
}
|
||||
@@ -292,6 +400,7 @@ export const PropertiesWindowApp = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer);
|
||||
if (readyHeartbeatTimer !== undefined) window.clearInterval(readyHeartbeatTimer);
|
||||
unlistenSnapshot?.();
|
||||
unlistenResult?.();
|
||||
unlistenRemoved?.();
|
||||
@@ -347,11 +456,16 @@ export const PropertiesWindowApp = () => {
|
||||
action: PropertiesAction,
|
||||
payload?: PropertiesActionRequest['payload'],
|
||||
) => {
|
||||
if (!downloadId) return;
|
||||
if (pendingActionRef.current !== null) return;
|
||||
const requestId = ++requestIdRef.current;
|
||||
if (!downloadId || pendingActionRef.current !== null) return;
|
||||
const requestId = nextPropertiesRequestId(requestIdRef.current);
|
||||
requestIdRef.current = requestId;
|
||||
pendingActionRef.current = action;
|
||||
setIsSaving(true);
|
||||
if (action === 'pause-resume') {
|
||||
pendingLifecycleIntentRef.current = getPropertiesLifecycleAction(snapshot?.status ?? 'completed');
|
||||
} else {
|
||||
pendingLifecycleIntentRef.current = null;
|
||||
}
|
||||
setPendingAction(action);
|
||||
try {
|
||||
await sendPropertiesActionRequest({
|
||||
windowLabel,
|
||||
@@ -362,13 +476,30 @@ export const PropertiesWindowApp = () => {
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsSaving(false);
|
||||
setPendingAction(null);
|
||||
pendingActionRef.current = null;
|
||||
pendingLifecycleIntentRef.current = null;
|
||||
closeAfterSaveRef.current = false;
|
||||
switchAfterSaveRef.current = null;
|
||||
setErrorMessage(errorText(error));
|
||||
}
|
||||
}, [downloadId, sessionId, windowLabel]);
|
||||
}, [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)) {
|
||||
@@ -379,33 +510,86 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
const patch: PropertiesPatch = {};
|
||||
if (activeTab === 'overview') {
|
||||
patch.fileName = fileName;
|
||||
patch.destination = destination || undefined;
|
||||
if (connections.trim()) patch.connections = Number(connections);
|
||||
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;
|
||||
}
|
||||
patch.torrentFileIndices = nextSelectedFiles;
|
||||
await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles });
|
||||
return;
|
||||
} else if (activeTab === 'trackers') {
|
||||
patch.torrentTrackers = trackers;
|
||||
patch.torrentExcludeTrackers = excludedTrackers;
|
||||
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 (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) {
|
||||
patch.torrentUploadLimit = uploadLimit;
|
||||
patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined;
|
||||
patch.torrentPeerSpeedLimit = peerSpeedLimit;
|
||||
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, connections, destination, downloadLimit, excludedTrackers, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, requestAction, selectedFiles, snapshot, t, trackers, uploadLimit]);
|
||||
}, [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;
|
||||
@@ -435,6 +619,12 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
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 }));
|
||||
@@ -451,11 +641,11 @@ export const PropertiesWindowApp = () => {
|
||||
await invoke('move_torrent_data', { id: downloadId, destination: selected });
|
||||
setNotice(t($ => $.properties.torrentMoveCompleted));
|
||||
}
|
||||
} else {
|
||||
await requestAction('verify-torrent');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(errorText(error));
|
||||
} finally {
|
||||
setPendingTorrentCommand(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -468,17 +658,29 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
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 torrentMaxPeersLabel = snapshot.torrentMaxPeers === undefined
|
||||
? `${propertiesTorrentPeerLimit(snapshot.torrentMaxPeers)}${t($ => $.properties.defaultValue)}`
|
||||
: snapshot.torrentMaxPeers === 0
|
||||
? t($ => $.speedLimiter.unlimited)
|
||||
: String(snapshot.torrentMaxPeers);
|
||||
const connectionMetric = isTorrent
|
||||
? `${snapshot.connectedPeers ?? '—'} ${t($ => $.properties.torrentConnectedPeers)} · ${torrentMaxPeersLabel} ${t($ => $.properties.torrentMaxPeers)}`
|
||||
: snapshot.isMedia === true
|
||||
? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}`
|
||||
: `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'} ${t($ => $.properties.connections)}`;
|
||||
const tabLabel = (tab: PropertiesTab) => {
|
||||
switch (tab) {
|
||||
case 'overview': return t($ => $.properties.torrentDetails);
|
||||
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($ => $.properties.advancedTransfer);
|
||||
case 'options': return t($ => $.downloads.actions.options);
|
||||
case 'transfer': return t($ => $.properties.connections);
|
||||
case 'advanced': return t($ => $.properties.advancedTransfer);
|
||||
}
|
||||
@@ -496,7 +698,7 @@ export const PropertiesWindowApp = () => {
|
||||
{lifecycleAction && <button
|
||||
type="button"
|
||||
className="app-button px-3 text-xs"
|
||||
disabled={isSaving}
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => {
|
||||
if (lifecycleAction === 'pause'
|
||||
&& snapshot.resumable === false
|
||||
@@ -516,9 +718,9 @@ export const PropertiesWindowApp = () => {
|
||||
: t($ => $.downloads.actions.start)}
|
||||
</button>}
|
||||
{isTorrent && <>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'magnet'} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'export'} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
|
||||
<button type="button" className="app-button px-3 text-xs" disabled={pendingTorrentCommand === 'move'} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,7 +731,7 @@ export const PropertiesWindowApp = () => {
|
||||
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
|
||||
<span>{snapshot.speed || '—'}</span>
|
||||
<span>{snapshot.eta || '—'}</span>
|
||||
<span>{snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'} {t($ => $.properties.connections)}</span>
|
||||
<span>{connectionMetric}</span>
|
||||
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
|
||||
</div>
|
||||
</header>
|
||||
@@ -563,32 +765,55 @@ export const PropertiesWindowApp = () => {
|
||||
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="min-h-0 flex-1 overflow-auto p-5" tabIndex={0}>
|
||||
{activeTab === 'overview' && <div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!isEditableStatus(snapshot.status)} /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!isEditableStatus(snapshot.status)} /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
||||
</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.url)}</span><p className="mt-1 break-all" dir="ltr">{snapshot.url}</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.category)}</span><p className="mt-1">{snapshot.category}</p></div>
|
||||
</div>
|
||||
<div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<span className="text-text-muted">{t($ => $.properties.dateAdded)}</span><span dir="ltr">{snapshot.dateAdded || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.lastTry)}</span><span dir="ltr">{snapshot.lastTry || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.queueId)}</span><span>{snapshot.queueId || '—'}{snapshot.queuePosition === undefined ? '' : ` · ${snapshot.queuePosition + 1}`}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.resumable)}</span><span>{snapshot.resumable === false ? '—' : '✓'}</span>
|
||||
{snapshot.lastError && <><span className="text-text-muted">{t($ => $.properties.lastError)}</span><span className="break-words text-red-300">{snapshot.lastError}</span></>}
|
||||
</div>
|
||||
{snapshot.isMedia === true && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<span className="text-text-muted">{t($ => $.addDownloads.format)}</span><span className="break-all font-mono">{snapshot.mediaFormatSelector || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.addDownloads.quality)}</span><span>{snapshot.mediaQuality || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.configuredConcurrency)}</span><span>{snapshot.connections ?? '—'}</span>
|
||||
</div>}
|
||||
{isTorrent && details && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsDisplayName)}</span><span>{details.displayName || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsInfoHash)}</span><span className="font-mono break-all">{details.infoHash}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsSize)}</span><span>{formatDownloadBytes(details.totalBytes)}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsFiles)}</span><span>{details.fileCount}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPieces)}</span><span>{details.pieceCount} × {formatDownloadBytes(details.pieceLength)}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPrivate)}</span><span>{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsCreated)}</span><span>{details.creationDate || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsCreator)}</span><span>{details.creator || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsComment)}</span><span className="break-words">{details.comment || '—'}</span>
|
||||
</div>}
|
||||
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={isSaving || !['paused', 'completed', 'failed'].includes(snapshot.status)} onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
|
||||
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null || !['paused', 'completed', 'failed'].includes(snapshot.status)} onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
|
||||
</div>}
|
||||
|
||||
{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>
|
||||
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} 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" disabled={!fileSelectionEditingEnabled} 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} disabled={!fileSelectionEditingEnabled} 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>}
|
||||
|
||||
{activeTab === 'trackers' && isTorrent && <div className="space-y-4">
|
||||
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={trackers} onChange={event => { setTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
|
||||
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentExcludeTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={excludedTrackers} onChange={event => { setExcludedTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
|
||||
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={trackers} disabled={!editingEnabled} onChange={event => { setTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
|
||||
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentExcludeTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={excludedTrackers} disabled={!editingEnabled} onChange={event => { setExcludedTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.torrentTrackerConnectTimeout)}<input className="app-control mt-1 w-full" value={trackerConnectTimeout} disabled={!editingEnabled} onChange={event => { setTrackerConnectTimeout(event.target.value); setDraftTab('trackers'); }} inputMode="numeric" placeholder="60" /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.torrentTrackerTimeout)}<input className="app-control mt-1 w-full" value={trackerTimeout} disabled={!editingEnabled} onChange={event => { setTrackerTimeout(event.target.value); setDraftTab('trackers'); }} inputMode="numeric" placeholder="60" /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.torrentTrackerInterval)}<input className="app-control mt-1 w-full" value={trackerInterval} disabled={!editingEnabled} onChange={event => { setTrackerInterval(event.target.value); setDraftTab('trackers'); }} inputMode="numeric" placeholder="0" /></label>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.torrentTrackersHint)}</p>
|
||||
{details && <div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><strong>{t($ => $.properties.torrentDetailsTrackers)}</strong><p className="mt-1 break-words" dir="auto">{details.trackers.join(', ') || '—'}</p></div>}
|
||||
</div>}
|
||||
@@ -596,22 +821,59 @@ export const PropertiesWindowApp = () => {
|
||||
{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 }) : 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-[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>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] 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.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="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">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab(activeTab); }} placeholder="1024K" disabled={!isEditableStatus(snapshot.status)} /></label>
|
||||
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.liveTorrentUploadLimit)}<input className="app-control mt-1 w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab(activeTab); }} placeholder="1024K" disabled={!isEditableStatus(snapshot.status)} /></label>}
|
||||
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.torrentMaxPeers)}<input className="app-control mt-1 w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab(activeTab); }} inputMode="numeric" disabled={!isEditableStatus(snapshot.status)} /></label>}
|
||||
{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>}
|
||||
{activeTab === 'transfer' && <div className="space-y-4">
|
||||
<div className="grid max-w-2xl gap-4 sm:grid-cols-2">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? snapshot.connections ?? '—' : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
||||
</div>
|
||||
<label className="block max-w-2xl text-xs text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}<div className="mt-2 flex items-center gap-3"><input type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={t($ => $.properties.connections)} /><span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span></div></label>
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.transferSettings)}</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>}
|
||||
{activeTab === 'options' && isTorrent && <div className="space-y-5 text-xs">
|
||||
<div className="grid max-w-3xl gap-4 sm:grid-cols-2">
|
||||
<label className="text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.liveTorrentUploadLimit)}<input className="app-control mt-1 w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentMaxPeers)}<input className="app-control mt-1 w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={String(propertiesTorrentPeerLimit(undefined))} disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder="50K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.addDownloads.seedTime)}<input className="app-control mt-1 w-full" value={seedTime} onChange={event => { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.defaultValue)} disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.addDownloads.seedRatio)}<input className="app-control mt-1 w-full" value={seedRatio} onChange={event => { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder="0" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentStopTimeout)}<input className="app-control mt-1 w-full" value={stopTimeout} onChange={event => { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder="0" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}<input className="app-control mt-1 w-full" value={prioritizePiece} onChange={event => { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder="head=1M,tail=1M" disabled={!editingEnabled} /></label>
|
||||
</div>
|
||||
<div className="grid max-w-3xl gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={checkIntegrity} onChange={event => { setCheckIntegrity(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled} />{t($ => $.properties.torrentVerifyIntegrity)}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={removeUnselectedFile} onChange={event => { setRemoveUnselectedFile(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))} />{t($ => $.properties.torrentRemoveUnselectedFile)}</label>
|
||||
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentFileAllocation)}<select className="app-control" value={fileAllocation} onChange={event => { setFileAllocation(event.target.value as TorrentFileAllocation); setDraftTab('options'); }} disabled={!editingEnabled}><option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option><option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option></select></label>
|
||||
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentEncryptionPolicy)}<select className="app-control" value={encryptionPolicy} onChange={event => { setEncryptionPolicy(event.target.value as TorrentEncryptionPolicy); setDraftTab('options'); }} disabled={!editingEnabled}><option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>{t($ => $.properties.torrentEncryptionDisabled)}</option><option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>{t($ => $.properties.torrentEncryptionRequireCrypto)}</option><option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>{t($ => $.properties.torrentEncryptionForceEncryption)}</option></select></label>
|
||||
</div>
|
||||
<p className="max-w-3xl text-text-muted">{t($ => $.properties.torrentPeerOptionsSavedHint)}</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.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${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.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</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>
|
||||
<div className="grid max-w-2xl gap-3 text-xs">
|
||||
{(['username', 'password'] as const).map(name => <label key={name} className="text-text-muted">{t($ => $.properties[name])}<div className="mt-1 flex gap-2"><input className="app-control min-w-0 flex-1" type={name === 'password' ? 'password' : 'text'} value={secretDrafts[name].value} placeholder={snapshot[name === 'username' ? 'hasUsername' : 'hasPassword'] ? '••••••' : ''} onChange={event => updateSecretDraft(name, event.target.value)} disabled={!editingEnabled} /><button type="button" className="app-button shrink-0 px-2 text-xs" onClick={() => clearSecretDraft(name)} disabled={!editingEnabled}>{t($ => $.properties.clear)}</button></div></label>)}
|
||||
{(['cookies', 'headers'] as const).map(name => <label key={name} className="text-text-muted">{t($ => $.properties[name])}<div className="mt-1 flex gap-2"><textarea className="app-control min-w-0 flex-1" value={secretDrafts[name].value} placeholder={snapshot[name === 'cookies' ? 'hasCookies' : 'hasHeaders'] ? '••••••' : ''} onChange={event => updateSecretDraft(name, event.target.value)} disabled={!editingEnabled} /><button type="button" className="app-button h-fit shrink-0 px-2 text-xs" onClick={() => clearSecretDraft(name)} disabled={!editingEnabled}>{t($ => $.properties.clear)}</button></div></label>)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.transferSettings)}</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">
|
||||
{pendingTab || closePrompt ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={isSaving} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.cancel)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{isDirty && <><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={isSaving} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.properties.cancel)}</button></div></div>}
|
||||
{pendingTab || closePrompt ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.cancel)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{isDirty && <><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.properties.cancel)}</button></div></div>}
|
||||
</div>}
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user