mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-31 04:58:11 +00:00
fix(properties): harden protocol-aware resume recovery
This commit is contained in:
@@ -302,9 +302,6 @@ pub struct TorrentPeer {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
#[ts(optional)]
|
|
||||||
pub peer_id: Option<String>,
|
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
pub download_speed: u64,
|
pub download_speed: u64,
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
|
|||||||
@@ -14253,7 +14253,7 @@ pub fn run() {
|
|||||||
total_bytes: (total > 0).then_some(total as f64),
|
total_bytes: (total > 0).then_some(total as f64),
|
||||||
total_is_estimate: Some(false),
|
total_is_estimate: Some(false),
|
||||||
active_connections: Some(active_connections),
|
active_connections: Some(active_connections),
|
||||||
requested_connections: Some(requested_connections),
|
requested_connections: (!is_torrent).then_some(requested_connections),
|
||||||
uploaded_bytes: torrent_telemetry
|
uploaded_bytes: torrent_telemetry
|
||||||
.map(|value| value.uploaded_bytes as f64)
|
.map(|value| value.uploaded_bytes as f64)
|
||||||
.or_else(|| uploaded_bytes.map(|value| value as f64)),
|
.or_else(|| uploaded_bytes.map(|value| value as f64)),
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ fn is_properties_action(action: &str) -> bool {
|
|||||||
matches!(
|
matches!(
|
||||||
action,
|
action,
|
||||||
"apply-properties"
|
"apply-properties"
|
||||||
|
| "set-torrent-file-selection"
|
||||||
| "pause-resume"
|
| "pause-resume"
|
||||||
| "verify-torrent"
|
| "verify-torrent"
|
||||||
| "set-download-limit"
|
| "set-download-limit"
|
||||||
|
|||||||
+45
-15
@@ -1773,7 +1773,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(id)
|
.get(id)
|
||||||
.and_then(|payload| payload.connections)
|
.and_then(|payload| {
|
||||||
|
if payload.is_torrent {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
payload.connections
|
||||||
|
}
|
||||||
|
})
|
||||||
.map(clamp_download_connections)
|
.map(clamp_download_connections)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5114,6 +5120,10 @@ fn apply_aria2_connection_options(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_apply_aria2_connection_options(payload: &SpawnPayload) -> bool {
|
||||||
|
!payload.is_torrent
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_aria2_follow_options(
|
fn apply_aria2_follow_options(
|
||||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
payload: &SpawnPayload,
|
payload: &SpawnPayload,
|
||||||
@@ -5286,14 +5296,6 @@ fn aria2_peer_port(value: Option<&serde_json::Value>) -> Option<u16> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_peer_id(value: Option<&serde_json::Value>) -> Option<String> {
|
|
||||||
let value = value?.as_str()?.trim();
|
|
||||||
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(value.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool {
|
fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool {
|
||||||
match value {
|
match value {
|
||||||
Some(serde_json::Value::Bool(value)) => *value,
|
Some(serde_json::Value::Bool(value)) => *value,
|
||||||
@@ -5465,7 +5467,6 @@ pub(crate) fn parse_torrent_peer_diagnostics(
|
|||||||
sanitized.push(crate::ipc::TorrentPeer {
|
sanitized.push(crate::ipc::TorrentPeer {
|
||||||
ip: aria2_peer_ip(peer.get("ip")),
|
ip: aria2_peer_ip(peer.get("ip")),
|
||||||
port: aria2_peer_port(peer.get("port")),
|
port: aria2_peer_port(peer.get("port")),
|
||||||
peer_id: aria2_peer_id(peer.get("peerId")),
|
|
||||||
download_speed: aria2_peer_number(peer.get("downloadSpeed")),
|
download_speed: aria2_peer_number(peer.get("downloadSpeed")),
|
||||||
upload_speed: aria2_peer_number(peer.get("uploadSpeed")),
|
upload_speed: aria2_peer_number(peer.get("uploadSpeed")),
|
||||||
seeder,
|
seeder,
|
||||||
@@ -6118,8 +6119,10 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if !payload.is_torrent {
|
if !payload.is_torrent {
|
||||||
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
options.insert("out".to_string(), serde_json::json!(safe_filename));
|
||||||
}
|
}
|
||||||
let conn = effective_aria2_connections(id, payload).await;
|
if should_apply_aria2_connection_options(payload) {
|
||||||
apply_aria2_connection_options(&mut options, conn);
|
let conn = effective_aria2_connections(id, payload).await;
|
||||||
|
apply_aria2_connection_options(&mut options, conn);
|
||||||
|
}
|
||||||
apply_aria2_follow_options(&mut options, payload);
|
apply_aria2_follow_options(&mut options, payload);
|
||||||
apply_aria2_torrent_options(&mut options, payload)?;
|
apply_aria2_torrent_options(&mut options, payload)?;
|
||||||
let mt = aria2_attempt_limit(payload.max_tries);
|
let mt = aria2_attempt_limit(payload.max_tries);
|
||||||
@@ -6897,6 +6900,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_payloads_do_not_use_generic_connection_options() {
|
||||||
|
let torrent = SpawnPayload {
|
||||||
|
is_torrent: true,
|
||||||
|
connections: Some(16),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let normal = SpawnPayload {
|
||||||
|
is_torrent: false,
|
||||||
|
connections: Some(16),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut torrent_options = serde_json::Map::new();
|
||||||
|
if should_apply_aria2_connection_options(&torrent) {
|
||||||
|
apply_aria2_connection_options(&mut torrent_options, 16);
|
||||||
|
}
|
||||||
|
assert!(!torrent_options.contains_key("split"));
|
||||||
|
assert!(!torrent_options.contains_key("max-connection-per-server"));
|
||||||
|
|
||||||
|
let mut normal_options = serde_json::Map::new();
|
||||||
|
if should_apply_aria2_connection_options(&normal) {
|
||||||
|
apply_aria2_connection_options(&mut normal_options, 16);
|
||||||
|
}
|
||||||
|
assert_eq!(normal_options.get("split"), Some(&serde_json::json!("16")));
|
||||||
|
assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
|
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -7647,7 +7677,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn torrent_peer_diagnostics_are_bounded_and_omit_bitfields() {
|
fn torrent_peer_diagnostics_are_bounded_and_omit_identity_and_bitfields() {
|
||||||
let mut result = vec![serde_json::json!({
|
let mut result = vec![serde_json::json!({
|
||||||
"peerId": "secret-peer-id",
|
"peerId": "secret-peer-id",
|
||||||
"ip": "192.0.2.10",
|
"ip": "192.0.2.10",
|
||||||
@@ -7678,11 +7708,11 @@ mod tests {
|
|||||||
assert!(diagnostics.truncated);
|
assert!(diagnostics.truncated);
|
||||||
assert_eq!(diagnostics.peers[0].ip.as_deref(), Some("192.0.2.10"));
|
assert_eq!(diagnostics.peers[0].ip.as_deref(), Some("192.0.2.10"));
|
||||||
assert_eq!(diagnostics.peers[0].port, Some(6881));
|
assert_eq!(diagnostics.peers[0].port, Some(6881));
|
||||||
assert_eq!(diagnostics.peers[0].peer_id.as_deref(), Some("secret-peer-id"));
|
|
||||||
let serialized = serde_json::to_string(&diagnostics).unwrap();
|
let serialized = serde_json::to_string(&diagnostics).unwrap();
|
||||||
assert!(serialized.contains("peerId"));
|
|
||||||
assert!(serialized.contains("192.0.2."));
|
assert!(serialized.contains("192.0.2."));
|
||||||
assert!(!serialized.contains("\"port\":null"));
|
assert!(!serialized.contains("\"port\":null"));
|
||||||
|
assert!(!serialized.contains("peerId"));
|
||||||
|
assert!(!serialized.contains("secret-peer-id"));
|
||||||
assert!(!serialized.contains("bitfield"));
|
assert!(!serialized.contains("bitfield"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type TorrentPeer = { ip?: string, port?: number, peerId?: string, downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
|
export type TorrentPeer = { ip?: string, port?: number, downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
|
||||||
|
|||||||
@@ -1501,7 +1501,10 @@ export const AddDownloadsModal = () => {
|
|||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
category,
|
category,
|
||||||
dateAdded: new Date().toISOString(),
|
dateAdded: new Date().toISOString(),
|
||||||
connections: Number(connections),
|
// HTTP connections and yt-dlp fragment concurrency are separate
|
||||||
|
// from BitTorrent peer limits. Torrent rows use bt-max-peers below
|
||||||
|
// and must not inherit the generic 1–16 HTTP setting.
|
||||||
|
connections: item.isTorrent ? undefined : Number(connections),
|
||||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||||
username: useAuth ? username.trim() : undefined,
|
username: useAuth ? username.trim() : undefined,
|
||||||
password: useAuth ? password.trim() : undefined,
|
password: useAuth ? password.trim() : undefined,
|
||||||
@@ -2774,13 +2777,15 @@ export const AddDownloadsModal = () => {
|
|||||||
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.transferSettings)}
|
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.transferSettings)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
{!(selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent) && (
|
||||||
<label className="text-xs text-text-secondary font-medium">{t($ => $.addDownloads.connectionsPerFile)}</label>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<label className="text-xs text-text-secondary font-medium">{t($ => $.addDownloads.connectionsPerFile)}</label>
|
||||||
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label={t($ => $.addDownloads.connectionsPerFileAria)} />
|
<div className="flex items-center gap-2">
|
||||||
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
|
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label={t($ => $.addDownloads.connectionsPerFileAria)} />
|
||||||
</div>
|
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
PROPERTIES_WINDOW_SNAPSHOT,
|
PROPERTIES_WINDOW_SNAPSHOT,
|
||||||
attachAsyncPropertiesListener,
|
attachAsyncPropertiesListener,
|
||||||
getPropertiesLifecycleAction,
|
getPropertiesLifecycleAction,
|
||||||
|
propertiesLifecycleReachedPostcondition,
|
||||||
|
propertiesTorrentPeerLimit,
|
||||||
sendPropertiesActionRequest,
|
sendPropertiesActionRequest,
|
||||||
sendPropertiesReady,
|
sendPropertiesReady,
|
||||||
isExpectedPropertiesDiagnosticUnavailable,
|
isExpectedPropertiesDiagnosticUnavailable,
|
||||||
@@ -29,8 +31,20 @@ import {
|
|||||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||||
import { changeAppLocale } from '../i18n';
|
import { changeAppLocale } from '../i18n';
|
||||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
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 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) =>
|
const isTorrentDiagnosticsStatus = (status: string) =>
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
||||||
@@ -38,7 +52,10 @@ const isTorrentDiagnosticsStatus = (status: string) =>
|
|||||||
const isTorrentPollingStatus = (status: string) =>
|
const isTorrentPollingStatus = (status: string) =>
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
['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 safeTitle = (name: string) => {
|
||||||
const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160);
|
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 [closePrompt, setClosePrompt] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [notice, setNotice] = 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 [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||||
@@ -78,13 +96,32 @@ export const PropertiesWindowApp = () => {
|
|||||||
const [uploadLimit, setUploadLimit] = useState('');
|
const [uploadLimit, setUploadLimit] = useState('');
|
||||||
const [maxPeers, setMaxPeers] = useState('');
|
const [maxPeers, setMaxPeers] = useState('');
|
||||||
const [peerSpeedLimit, setPeerSpeedLimit] = 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 [draftTab, setDraftTab] = useState<PropertiesTab | null>(null);
|
||||||
const draftTabRef = useRef<PropertiesTab | null>(null);
|
const draftTabRef = useRef<PropertiesTab | null>(null);
|
||||||
const closeAfterSaveRef = useRef(false);
|
const closeAfterSaveRef = useRef(false);
|
||||||
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
||||||
|
const pendingLifecycleIntentRef = useRef<ReturnType<typeof getPropertiesLifecycleAction>>(null);
|
||||||
const latestSnapshotRevisionRef = useRef(0);
|
const latestSnapshotRevisionRef = useRef(0);
|
||||||
|
const latestBridgeGenerationRef = useRef<number | null>(null);
|
||||||
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const hasRevealedWindowRef = useRef(false);
|
const hasRevealedWindowRef = useRef(false);
|
||||||
const revealInFlightRef = useRef(false);
|
const revealInFlightRef = useRef(false);
|
||||||
@@ -117,6 +154,23 @@ export const PropertiesWindowApp = () => {
|
|||||||
setUploadLimit(next.torrentUploadLimit ?? '');
|
setUploadLimit(next.torrentUploadLimit ?? '');
|
||||||
setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers));
|
setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers));
|
||||||
setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? '');
|
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) => {
|
const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => {
|
||||||
@@ -183,6 +237,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let readyRetryTimer: number | undefined;
|
let readyRetryTimer: number | undefined;
|
||||||
|
let readyHeartbeatTimer: number | undefined;
|
||||||
let unlistenSnapshot: UnlistenFn | undefined;
|
let unlistenSnapshot: UnlistenFn | undefined;
|
||||||
let unlistenResult: UnlistenFn | undefined;
|
let unlistenResult: UnlistenFn | undefined;
|
||||||
let unlistenRemoved: UnlistenFn | undefined;
|
let unlistenRemoved: UnlistenFn | undefined;
|
||||||
@@ -195,6 +250,37 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (event.payload.windowLabel !== windowLabel
|
if (event.payload.windowLabel !== windowLabel
|
||||||
|| event.payload.downloadId !== id
|
|| event.payload.downloadId !== id
|
||||||
|| event.payload.sessionId !== sessionId) return;
|
|| 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;
|
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
|
||||||
latestSnapshotRevisionRef.current = event.payload.revision;
|
latestSnapshotRevisionRef.current = event.payload.revision;
|
||||||
await changeAppLocale(event.payload.snapshot.appearance.locale);
|
await changeAppLocale(event.payload.snapshot.appearance.locale);
|
||||||
@@ -205,6 +291,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
event.payload.snapshot.appearance,
|
event.payload.snapshot.appearance,
|
||||||
);
|
);
|
||||||
setSnapshot(event.payload.snapshot);
|
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);
|
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
|
||||||
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
|
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
|
||||||
if (!hasRevealedWindowRef.current && !revealInFlightRef.current) {
|
if (!hasRevealedWindowRef.current && !revealInFlightRef.current) {
|
||||||
@@ -231,9 +327,10 @@ export const PropertiesWindowApp = () => {
|
|||||||
|| event.payload.downloadId !== id
|
|| event.payload.downloadId !== id
|
||||||
|| event.payload.sessionId !== sessionId) return;
|
|| event.payload.sessionId !== sessionId) return;
|
||||||
if (event.payload.requestId !== requestIdRef.current) return;
|
if (event.payload.requestId !== requestIdRef.current) return;
|
||||||
setIsSaving(false);
|
|
||||||
const completedAction = pendingActionRef.current;
|
const completedAction = pendingActionRef.current;
|
||||||
pendingActionRef.current = null;
|
pendingActionRef.current = null;
|
||||||
|
pendingLifecycleIntentRef.current = null;
|
||||||
|
setPendingAction(null);
|
||||||
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
||||||
else {
|
else {
|
||||||
const nextTab = switchAfterSaveRef.current;
|
const nextTab = switchAfterSaveRef.current;
|
||||||
@@ -265,6 +362,10 @@ export const PropertiesWindowApp = () => {
|
|||||||
window.clearInterval(readyRetryTimer);
|
window.clearInterval(readyRetryTimer);
|
||||||
readyRetryTimer = undefined;
|
readyRetryTimer = undefined;
|
||||||
}
|
}
|
||||||
|
if (readyHeartbeatTimer !== undefined) {
|
||||||
|
window.clearInterval(readyHeartbeatTimer);
|
||||||
|
readyHeartbeatTimer = undefined;
|
||||||
|
}
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
setNotice(t($ => $.downloadTable.noDownloads));
|
setNotice(t($ => $.downloadTable.noDownloads));
|
||||||
}
|
}
|
||||||
@@ -284,6 +385,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (cancelled || hasRevealedWindowRef.current) return;
|
if (cancelled || hasRevealedWindowRef.current) return;
|
||||||
void sendPropertiesReady(sessionId).catch(() => undefined);
|
void sendPropertiesReady(sessionId).catch(() => undefined);
|
||||||
}, 500);
|
}, 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) {
|
} catch (error) {
|
||||||
if (!cancelled) setErrorMessage(errorText(error));
|
if (!cancelled) setErrorMessage(errorText(error));
|
||||||
}
|
}
|
||||||
@@ -292,6 +400,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer);
|
if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer);
|
||||||
|
if (readyHeartbeatTimer !== undefined) window.clearInterval(readyHeartbeatTimer);
|
||||||
unlistenSnapshot?.();
|
unlistenSnapshot?.();
|
||||||
unlistenResult?.();
|
unlistenResult?.();
|
||||||
unlistenRemoved?.();
|
unlistenRemoved?.();
|
||||||
@@ -347,11 +456,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
action: PropertiesAction,
|
action: PropertiesAction,
|
||||||
payload?: PropertiesActionRequest['payload'],
|
payload?: PropertiesActionRequest['payload'],
|
||||||
) => {
|
) => {
|
||||||
if (!downloadId) return;
|
if (!downloadId || pendingActionRef.current !== null) return;
|
||||||
if (pendingActionRef.current !== null) return;
|
const requestId = nextPropertiesRequestId(requestIdRef.current);
|
||||||
const requestId = ++requestIdRef.current;
|
requestIdRef.current = requestId;
|
||||||
pendingActionRef.current = action;
|
pendingActionRef.current = action;
|
||||||
setIsSaving(true);
|
if (action === 'pause-resume') {
|
||||||
|
pendingLifecycleIntentRef.current = getPropertiesLifecycleAction(snapshot?.status ?? 'completed');
|
||||||
|
} else {
|
||||||
|
pendingLifecycleIntentRef.current = null;
|
||||||
|
}
|
||||||
|
setPendingAction(action);
|
||||||
try {
|
try {
|
||||||
await sendPropertiesActionRequest({
|
await sendPropertiesActionRequest({
|
||||||
windowLabel,
|
windowLabel,
|
||||||
@@ -362,13 +476,30 @@ export const PropertiesWindowApp = () => {
|
|||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsSaving(false);
|
setPendingAction(null);
|
||||||
pendingActionRef.current = null;
|
pendingActionRef.current = null;
|
||||||
|
pendingLifecycleIntentRef.current = null;
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
setErrorMessage(errorText(error));
|
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 () => {
|
const applyActiveTab = useCallback(async () => {
|
||||||
if (!snapshot || !isEditableStatus(snapshot.status)) {
|
if (!snapshot || !isEditableStatus(snapshot.status)) {
|
||||||
@@ -379,33 +510,86 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
const patch: PropertiesPatch = {};
|
const patch: PropertiesPatch = {};
|
||||||
if (activeTab === 'overview') {
|
if (activeTab === 'overview') {
|
||||||
patch.fileName = fileName;
|
if (fileName !== snapshot.fileName) patch.fileName = fileName;
|
||||||
patch.destination = destination || undefined;
|
if (destination !== (snapshot.destination ?? '')) patch.destination = destination || undefined;
|
||||||
if (connections.trim()) patch.connections = Number(connections);
|
if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) {
|
||||||
|
patch.connections = Number(connections);
|
||||||
|
}
|
||||||
} else if (activeTab === 'files' && isTorrent) {
|
} else if (activeTab === 'files' && isTorrent) {
|
||||||
const nextSelectedFiles = selectedFiles
|
const nextSelectedFiles = selectedFiles
|
||||||
?? fileProgress?.files.filter(file => file.selected).map(file => file.index)
|
?? 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) {
|
if (nextSelectedFiles.length === 0) {
|
||||||
setErrorMessage(t($ => $.properties.torrentFileSelectionRequired));
|
setErrorMessage(t($ => $.properties.torrentFileSelectionRequired));
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
patch.torrentFileIndices = nextSelectedFiles;
|
await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles });
|
||||||
|
return;
|
||||||
} else if (activeTab === 'trackers') {
|
} else if (activeTab === 'trackers') {
|
||||||
patch.torrentTrackers = trackers;
|
if (trackers !== (snapshot.torrentTrackers ?? '')) patch.torrentTrackers = trackers;
|
||||||
patch.torrentExcludeTrackers = excludedTrackers;
|
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') {
|
} 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) {
|
if (isTorrent) {
|
||||||
patch.torrentUploadLimit = uploadLimit;
|
if (removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) {
|
||||||
patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined;
|
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
|
||||||
patch.torrentPeerSpeedLimit = peerSpeedLimit;
|
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);
|
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) => {
|
const chooseTab = (tab: PropertiesTab) => {
|
||||||
if (tab === activeTab) return;
|
if (tab === activeTab) return;
|
||||||
@@ -435,6 +619,12 @@ export const PropertiesWindowApp = () => {
|
|||||||
|
|
||||||
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
||||||
if (!downloadId) return;
|
if (!downloadId) return;
|
||||||
|
if (action === 'verify') {
|
||||||
|
await requestAction('verify-torrent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingTorrentCommand !== null) return;
|
||||||
|
setPendingTorrentCommand(action);
|
||||||
try {
|
try {
|
||||||
if (action === 'magnet') {
|
if (action === 'magnet') {
|
||||||
await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId }));
|
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 });
|
await invoke('move_torrent_data', { id: downloadId, destination: selected });
|
||||||
setNotice(t($ => $.properties.torrentMoveCompleted));
|
setNotice(t($ => $.properties.torrentMoveCompleted));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await requestAction('verify-torrent');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(errorText(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 progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
||||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
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
|
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||||
? t($ => $.addDownloads.unknownSize)
|
? t($ => $.addDownloads.unknownSize)
|
||||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||||
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
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) => {
|
const tabLabel = (tab: PropertiesTab) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'overview': return t($ => $.properties.torrentDetails);
|
case 'overview': return t($ => $.properties.details);
|
||||||
case 'files': return t($ => $.properties.torrentFileProgress);
|
case 'files': return t($ => $.properties.torrentFileProgress);
|
||||||
case 'trackers': return t($ => $.properties.torrentTrackers);
|
case 'trackers': return t($ => $.properties.torrentTrackers);
|
||||||
case 'peers': return t($ => $.properties.torrentPeerDiagnostics);
|
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 'transfer': return t($ => $.properties.connections);
|
||||||
case 'advanced': return t($ => $.properties.advancedTransfer);
|
case 'advanced': return t($ => $.properties.advancedTransfer);
|
||||||
}
|
}
|
||||||
@@ -496,7 +698,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
{lifecycleAction && <button
|
{lifecycleAction && <button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-3 text-xs"
|
className="app-button px-3 text-xs"
|
||||||
disabled={isSaving}
|
disabled={pendingAction !== null}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (lifecycleAction === 'pause'
|
if (lifecycleAction === 'pause'
|
||||||
&& snapshot.resumable === false
|
&& snapshot.resumable === false
|
||||||
@@ -516,9 +718,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
: t($ => $.downloads.actions.start)}
|
: t($ => $.downloads.actions.start)}
|
||||||
</button>}
|
</button>}
|
||||||
{isTorrent && <>
|
{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={pendingTorrentCommand === 'magnet'} 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={pendingTorrentCommand === 'export'} 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 === 'move'} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,7 +731,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
|
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
|
||||||
<span>{snapshot.speed || '—'}</span>
|
<span>{snapshot.speed || '—'}</span>
|
||||||
<span>{snapshot.eta || '—'}</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>}
|
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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}>
|
<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">
|
{activeTab === 'overview' && <div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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.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={!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={!editingEnabled} /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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.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 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>
|
||||||
|
<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">
|
{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.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.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.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>}
|
</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>}
|
</div>}
|
||||||
|
|
||||||
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
{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="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} 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="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 && <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>}
|
{!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>}
|
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{activeTab === 'trackers' && isTorrent && <div className="space-y-4">
|
{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.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} onChange={event => { setExcludedTrackers(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>
|
<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>}
|
{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>}
|
</div>}
|
||||||
@@ -596,22 +821,59 @@ export const PropertiesWindowApp = () => {
|
|||||||
{activeTab === 'peers' && isTorrent && <div className="space-y-4">
|
{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="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="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>}
|
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{(activeTab === 'transfer' || activeTab === 'options') && <div className="grid max-w-2xl gap-4 sm:grid-cols-2">
|
{activeTab === 'transfer' && <div className="space-y-4">
|
||||||
<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>
|
<div className="grid max-w-2xl gap-4 sm:grid-cols-2">
|
||||||
{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>}
|
<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>
|
||||||
{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>}
|
<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>
|
||||||
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab(activeTab); }} placeholder="50K" disabled={!isEditableStatus(snapshot.status)} /></label>}
|
</div>
|
||||||
|
<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>}
|
</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>
|
</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">
|
{(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>}
|
</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import type { DownloadItem } from '../store/useDownloadStore';
|
|||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import {
|
import {
|
||||||
|
MAX_TORRENT_STOP_TIMEOUT,
|
||||||
isValidTorrentExcludeTrackerList,
|
isValidTorrentExcludeTrackerList,
|
||||||
isValidTorrentTrackerList,
|
isValidTorrentTrackerList,
|
||||||
normalizeSpeedLimitForBackend,
|
normalizeSpeedLimitForBackend,
|
||||||
|
normalizeTorrentEncryptionPolicy,
|
||||||
|
normalizeTorrentFileAllocation,
|
||||||
|
normalizeTorrentPrioritizePiece,
|
||||||
|
normalizeTorrentTrackerInterval,
|
||||||
|
normalizeTorrentTrackerTimeout,
|
||||||
} from '../utils/downloads';
|
} from '../utils/downloads';
|
||||||
import {
|
import {
|
||||||
PROPERTIES_WINDOW_ACTION_REQUEST,
|
PROPERTIES_WINDOW_ACTION_REQUEST,
|
||||||
@@ -33,6 +39,7 @@ import { invokeCommand as invoke } from '../ipc';
|
|||||||
import i18n, { resolveAppLocale } from '../i18n';
|
import i18n, { resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||||
|
let lastPropertiesBridgeGeneration = 0;
|
||||||
|
|
||||||
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
||||||
if (typeof value !== 'string') throw new Error(`Invalid ${label}`);
|
if (typeof value !== 'string') throw new Error(`Invalid ${label}`);
|
||||||
@@ -55,12 +62,22 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
|||||||
'destination',
|
'destination',
|
||||||
'connections',
|
'connections',
|
||||||
'speedLimit',
|
'speedLimit',
|
||||||
'torrentFileIndices',
|
|
||||||
'torrentTrackers',
|
'torrentTrackers',
|
||||||
'torrentExcludeTrackers',
|
'torrentExcludeTrackers',
|
||||||
|
'torrentSeedTime',
|
||||||
|
'torrentSeedRatio',
|
||||||
|
'torrentCheckIntegrity',
|
||||||
|
'torrentRemoveUnselectedFile',
|
||||||
'torrentUploadLimit',
|
'torrentUploadLimit',
|
||||||
'torrentMaxPeers',
|
'torrentMaxPeers',
|
||||||
'torrentPeerSpeedLimit',
|
'torrentPeerSpeedLimit',
|
||||||
|
'torrentTrackerConnectTimeout',
|
||||||
|
'torrentTrackerTimeout',
|
||||||
|
'torrentTrackerInterval',
|
||||||
|
'torrentStopTimeout',
|
||||||
|
'torrentPrioritizePiece',
|
||||||
|
'torrentEncryptionPolicy',
|
||||||
|
'torrentFileAllocation',
|
||||||
] as const) copy(key);
|
] as const) copy(key);
|
||||||
|
|
||||||
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
|
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
|
||||||
@@ -87,6 +104,48 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
|||||||
&& (!Number.isInteger(safePatch.torrentMaxPeers) || safePatch.torrentMaxPeers < 0 || safePatch.torrentMaxPeers > 1000)) {
|
&& (!Number.isInteger(safePatch.torrentMaxPeers) || safePatch.torrentMaxPeers < 0 || safePatch.torrentMaxPeers > 1000)) {
|
||||||
throw new Error('Torrent maximum peers must be a whole number from 0 to 1000');
|
throw new Error('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||||
}
|
}
|
||||||
|
for (const [key, minimum] of [
|
||||||
|
['torrentSeedTime', 0],
|
||||||
|
['torrentSeedRatio', 0],
|
||||||
|
] as const) {
|
||||||
|
const value = safePatch[key];
|
||||||
|
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < minimum)) {
|
||||||
|
throw new Error(`Invalid ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['torrentTrackerConnectTimeout', 'torrentTrackerTimeout'] as const) {
|
||||||
|
const value = safePatch[key];
|
||||||
|
if (value !== undefined && normalizeTorrentTrackerTimeout(value) === undefined) {
|
||||||
|
throw new Error(`Invalid ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (safePatch.torrentTrackerInterval !== undefined
|
||||||
|
&& normalizeTorrentTrackerInterval(safePatch.torrentTrackerInterval) === undefined) {
|
||||||
|
throw new Error('Invalid torrentTrackerInterval');
|
||||||
|
}
|
||||||
|
if (safePatch.torrentStopTimeout !== undefined
|
||||||
|
&& (!Number.isInteger(safePatch.torrentStopTimeout)
|
||||||
|
|| safePatch.torrentStopTimeout < 0
|
||||||
|
|| safePatch.torrentStopTimeout > MAX_TORRENT_STOP_TIMEOUT)) {
|
||||||
|
throw new Error('Invalid torrentStopTimeout');
|
||||||
|
}
|
||||||
|
if (safePatch.torrentPrioritizePiece !== undefined
|
||||||
|
&& normalizeTorrentPrioritizePiece(safePatch.torrentPrioritizePiece) == null) {
|
||||||
|
throw new Error('Invalid torrentPrioritizePiece');
|
||||||
|
}
|
||||||
|
if (safePatch.torrentEncryptionPolicy !== undefined
|
||||||
|
&& normalizeTorrentEncryptionPolicy(safePatch.torrentEncryptionPolicy) === undefined) {
|
||||||
|
throw new Error('Invalid torrentEncryptionPolicy');
|
||||||
|
}
|
||||||
|
if (safePatch.torrentFileAllocation !== undefined
|
||||||
|
&& normalizeTorrentFileAllocation(safePatch.torrentFileAllocation) === undefined) {
|
||||||
|
throw new Error('Invalid torrentFileAllocation');
|
||||||
|
}
|
||||||
|
for (const key of ['torrentCheckIntegrity', 'torrentRemoveUnselectedFile'] as const) {
|
||||||
|
if (safePatch[key] !== undefined && typeof safePatch[key] !== 'boolean') {
|
||||||
|
throw new Error(`Invalid ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (safePatch.torrentTrackers !== undefined
|
if (safePatch.torrentTrackers !== undefined
|
||||||
&& (typeof safePatch.torrentTrackers !== 'string' || !isValidTorrentTrackerList(safePatch.torrentTrackers))) {
|
&& (typeof safePatch.torrentTrackers !== 'string' || !isValidTorrentTrackerList(safePatch.torrentTrackers))) {
|
||||||
throw new Error('Invalid Torrent tracker list');
|
throw new Error('Invalid Torrent tracker list');
|
||||||
@@ -116,6 +175,8 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const snapshotRevisions = new Map<string, number>();
|
const snapshotRevisions = new Map<string, number>();
|
||||||
const actionsInFlight = new Set<string>();
|
const actionsInFlight = new Set<string>();
|
||||||
const actionChains = new Map<string, Promise<void>>();
|
const actionChains = new Map<string, Promise<void>>();
|
||||||
|
const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1);
|
||||||
|
lastPropertiesBridgeGeneration = bridgeGeneration;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let unlistenReady: UnlistenFn | undefined;
|
let unlistenReady: UnlistenFn | undefined;
|
||||||
let unlistenAction: UnlistenFn | undefined;
|
let unlistenAction: UnlistenFn | undefined;
|
||||||
@@ -142,6 +203,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
windowLabel,
|
windowLabel,
|
||||||
downloadId,
|
downloadId,
|
||||||
sessionId: registration.sessionId,
|
sessionId: registration.sessionId,
|
||||||
|
bridgeGeneration,
|
||||||
revision,
|
revision,
|
||||||
snapshot: sanitizePropertiesSnapshot(item, {
|
snapshot: sanitizePropertiesSnapshot(item, {
|
||||||
theme: settings.theme,
|
theme: settings.theme,
|
||||||
@@ -176,6 +238,23 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assertCurrentAction = async (request: PropertiesActionRequest) => {
|
||||||
|
await invoke('validate_properties_window_request', {
|
||||||
|
windowLabel: request.windowLabel,
|
||||||
|
downloadId: request.downloadId,
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
requestId: request.requestId,
|
||||||
|
});
|
||||||
|
if (disposed) throw new Error('Properties bridge is no longer active');
|
||||||
|
const registration = windows.get(request.windowLabel);
|
||||||
|
if (!registration
|
||||||
|
|| registration.downloadId !== request.downloadId
|
||||||
|
|| registration.sessionId !== request.sessionId
|
||||||
|
|| registration.latestRequestId !== request.requestId) {
|
||||||
|
throw new Error('Properties action is stale');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleReady = async (payload: PropertiesWindowReady) => {
|
const handleReady = async (payload: PropertiesWindowReady) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
try {
|
try {
|
||||||
@@ -201,14 +280,10 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||||
let releaseAction: (() => void) | undefined;
|
let releaseAction: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
await invoke('validate_properties_window_request', request);
|
// The request may have waited behind another action. Revalidate the
|
||||||
if (disposed) return;
|
// native session and request ordering at dequeue time so a closed,
|
||||||
const registration = windows.get(request.windowLabel);
|
// reopened, or reloaded Properties window cannot apply stale work.
|
||||||
if (!registration
|
await assertCurrentAction(request);
|
||||||
|| registration.downloadId !== request.downloadId
|
|
||||||
|| registration.sessionId !== request.sessionId) {
|
|
||||||
throw new Error('Properties window is no longer registered');
|
|
||||||
}
|
|
||||||
releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey);
|
releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey);
|
||||||
const store = useDownloadStore.getState();
|
const store = useDownloadStore.getState();
|
||||||
const item = store.downloads.find(download => download.id === request.downloadId);
|
const item = store.downloads.find(download => download.id === request.downloadId);
|
||||||
@@ -216,7 +291,11 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
|
|
||||||
switch (request.action) {
|
switch (request.action) {
|
||||||
case 'apply-properties': {
|
case 'apply-properties': {
|
||||||
|
await assertCurrentAction(request);
|
||||||
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
|
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) {
|
||||||
|
throw new Error('Torrent file selection requires the dedicated selection action');
|
||||||
|
}
|
||||||
const safePatch = copyEditablePatch(rawPatch);
|
const safePatch = copyEditablePatch(rawPatch);
|
||||||
if ('password' in rawPatch) {
|
if ('password' in rawPatch) {
|
||||||
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
||||||
@@ -230,9 +309,58 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
if ('username' in rawPatch) {
|
if ('username' in rawPatch) {
|
||||||
safePatch.username = applySecretPatch(rawPatch.username, item.username);
|
safePatch.username = applySecretPatch(rawPatch.username, item.username);
|
||||||
}
|
}
|
||||||
|
const torrentOptionKeys = [
|
||||||
|
'torrentFileIndices',
|
||||||
|
'torrentTrackers',
|
||||||
|
'torrentExcludeTrackers',
|
||||||
|
'torrentSeedTime',
|
||||||
|
'torrentSeedRatio',
|
||||||
|
'torrentCheckIntegrity',
|
||||||
|
'torrentRemoveUnselectedFile',
|
||||||
|
'torrentUploadLimit',
|
||||||
|
'torrentMaxPeers',
|
||||||
|
'torrentPeerSpeedLimit',
|
||||||
|
'torrentTrackerConnectTimeout',
|
||||||
|
'torrentTrackerTimeout',
|
||||||
|
'torrentTrackerInterval',
|
||||||
|
'torrentStopTimeout',
|
||||||
|
'torrentPrioritizePiece',
|
||||||
|
'torrentEncryptionPolicy',
|
||||||
|
'torrentFileAllocation',
|
||||||
|
] as const;
|
||||||
|
if (item.isTorrent !== true && torrentOptionKeys.some(key => Object.prototype.hasOwnProperty.call(rawPatch, key))) {
|
||||||
|
throw new Error('Torrent properties are only available for Torrent downloads');
|
||||||
|
}
|
||||||
|
if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) {
|
||||||
|
throw new Error('Generic connection settings are not available for Torrent downloads');
|
||||||
|
}
|
||||||
await store.applyProperties(request.downloadId, safePatch);
|
await store.applyProperties(request.downloadId, safePatch);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'set-torrent-file-selection': {
|
||||||
|
if (item.isTorrent !== true
|
||||||
|
|| !request.payload
|
||||||
|
|| !('selectedIndices' in request.payload)) {
|
||||||
|
throw new Error('Torrent file selection is unavailable for this download');
|
||||||
|
}
|
||||||
|
const selectedIndices = request.payload.selectedIndices;
|
||||||
|
if (selectedIndices !== null
|
||||||
|
&& (!Array.isArray(selectedIndices)
|
||||||
|
|| selectedIndices.length === 0
|
||||||
|
|| selectedIndices.some(index => !Number.isInteger(index) || index < 1))) {
|
||||||
|
throw new Error('Torrent file selection must contain at least one valid file');
|
||||||
|
}
|
||||||
|
const selection = await invoke('set_torrent_file_selection', {
|
||||||
|
id: request.downloadId,
|
||||||
|
selected_indices: selectedIndices,
|
||||||
|
});
|
||||||
|
const selected = selection.files.filter(file => file.selected).map(file => file.index);
|
||||||
|
const allSelected = selection.files.length > 0 && selected.length === selection.files.length;
|
||||||
|
store.updateDownload(request.downloadId, {
|
||||||
|
torrentFileIndices: allSelected ? undefined : selected,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'pause-resume': {
|
case 'pause-resume': {
|
||||||
const lifecycleAction = getPropertiesLifecycleAction(item.status);
|
const lifecycleAction = getPropertiesLifecycleAction(item.status);
|
||||||
if (!lifecycleAction) {
|
if (!lifecycleAction) {
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const common = {
|
|||||||
speed: 'Speed',
|
speed: 'Speed',
|
||||||
eta: 'ETA',
|
eta: 'ETA',
|
||||||
connections: 'Connections',
|
connections: 'Connections',
|
||||||
|
configuredConcurrency: 'Configured concurrency',
|
||||||
|
connectedPeers: 'connected peers',
|
||||||
|
details: 'Details',
|
||||||
|
queueId: 'Queue',
|
||||||
|
resumable: 'Resumable',
|
||||||
connectionCount: '{{active}}/{{total}}',
|
connectionCount: '{{active}}/{{total}}',
|
||||||
connectionCountUnknown: '—/{{total}}',
|
connectionCountUnknown: '—/{{total}}',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const common = {
|
|||||||
torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…',
|
torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active or paused.',
|
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active or paused.',
|
||||||
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
|
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
|
||||||
torrentPeerDiagnosticsHint: 'Peer addresses and IDs are shown in this window only; Aria2 does not provide country metadata. Raw bitfields are not retained.',
|
torrentPeerDiagnosticsHint: 'Validated peer addresses and ports are shown ephemerally; peer IDs and raw bitfields are never retained.',
|
||||||
torrentPeerAddress: 'Peer address',
|
torrentPeerAddress: 'Peer address',
|
||||||
torrentPeerId: 'Peer ID',
|
torrentPeerId: 'Peer ID',
|
||||||
torrentFileProgress: 'Torrent file progress',
|
torrentFileProgress: 'Torrent file progress',
|
||||||
@@ -419,6 +424,7 @@ const common = {
|
|||||||
mirrors: 'Mirrors',
|
mirrors: 'Mirrors',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
clear: 'Clear',
|
||||||
enterValidUrl: 'Enter a valid URL.',
|
enterValidUrl: 'Enter a valid URL.',
|
||||||
fileNameEmpty: 'File name cannot be empty.',
|
fileNameEmpty: 'File name cannot be empty.',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const fa = {
|
|||||||
speed: 'سرعت',
|
speed: 'سرعت',
|
||||||
eta: 'زمان باقیمانده',
|
eta: 'زمان باقیمانده',
|
||||||
connections: 'اتصالات',
|
connections: 'اتصالات',
|
||||||
|
configuredConcurrency: 'همزمانی پیکربندیشده',
|
||||||
|
connectedPeers: 'همتای متصل',
|
||||||
|
details: 'جزئیات',
|
||||||
|
queueId: 'صف',
|
||||||
|
resumable: 'قابل ادامه',
|
||||||
connectionCount: '{{active}}/{{total}} فعال',
|
connectionCount: '{{active}}/{{total}} فعال',
|
||||||
connectionCountUnknown: '—/{{total}} فعال',
|
connectionCountUnknown: '—/{{total}} فعال',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const fa = {
|
|||||||
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
|
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.',
|
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.',
|
||||||
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
|
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
|
||||||
torrentPeerDiagnosticsHint: 'نشانی و شناسهٔ همتا فقط در همین پنجره نمایش داده میشود؛ آریا۲ اطلاعات کشور ارائه نمیکند و بیتفیلد خام ذخیره نمیشود.',
|
torrentPeerDiagnosticsHint: 'آدرس و پورت معتبر همتاها فقط بهصورت موقت نمایش داده میشوند؛ شناسه همتا و بیتفیلد خام هرگز نگهداری نمیشود.',
|
||||||
torrentPeerAddress: 'نشانی همتا',
|
torrentPeerAddress: 'نشانی همتا',
|
||||||
torrentPeerId: 'شناسهٔ همتا',
|
torrentPeerId: 'شناسهٔ همتا',
|
||||||
torrentFileProgress: 'پیشرفت فایلهای تورنت',
|
torrentFileProgress: 'پیشرفت فایلهای تورنت',
|
||||||
@@ -419,6 +424,7 @@ const fa = {
|
|||||||
mirrors: 'آینهها',
|
mirrors: 'آینهها',
|
||||||
username: 'نام کاربری',
|
username: 'نام کاربری',
|
||||||
password: 'رمز عبور',
|
password: 'رمز عبور',
|
||||||
|
clear: 'پاک کردن',
|
||||||
enterValidUrl: 'یک URL معتبر وارد کنید.',
|
enterValidUrl: 'یک URL معتبر وارد کنید.',
|
||||||
fileNameEmpty: 'نام فایل نمیتواند خالی باشد.',
|
fileNameEmpty: 'نام فایل نمیتواند خالی باشد.',
|
||||||
cancel: 'لغو',
|
cancel: 'لغو',
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const he = {
|
|||||||
speed: 'מהירות',
|
speed: 'מהירות',
|
||||||
eta: 'זמן נותר',
|
eta: 'זמן נותר',
|
||||||
connections: 'חיבורים',
|
connections: 'חיבורים',
|
||||||
|
configuredConcurrency: 'מקביליות מוגדרת',
|
||||||
|
connectedPeers: 'עמיתים מחוברים',
|
||||||
|
details: 'פרטים',
|
||||||
|
queueId: 'תור',
|
||||||
|
resumable: 'ניתן להמשך',
|
||||||
connectionCount: '{{active}}/{{total}} פעילות',
|
connectionCount: '{{active}}/{{total}} פעילות',
|
||||||
connectionCountUnknown: '—/{{total}} פעילות',
|
connectionCountUnknown: '—/{{total}} פעילות',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const he = {
|
|||||||
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
|
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.',
|
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.',
|
||||||
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
|
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
|
||||||
torrentPeerDiagnosticsHint: 'כתובת ומזהה העמית מוצגים בחלון זה בלבד; Aria2 אינה מספקת נתוני מדינה. שדות ביטים גולמיים אינם נשמרים.',
|
torrentPeerDiagnosticsHint: 'כתובות ויציאות מאומתות של עמיתים מוצגות באופן זמני בלבד; מזהי עמיתים ושדות סיביות גולמיים לעולם אינם נשמרים.',
|
||||||
torrentPeerAddress: 'כתובת עמית',
|
torrentPeerAddress: 'כתובת עמית',
|
||||||
torrentPeerId: 'מזהה עמית',
|
torrentPeerId: 'מזהה עמית',
|
||||||
torrentFileProgress: 'התקדמות קובצי הטורנט',
|
torrentFileProgress: 'התקדמות קובצי הטורנט',
|
||||||
@@ -419,6 +424,7 @@ const he = {
|
|||||||
mirrors: 'מראות',
|
mirrors: 'מראות',
|
||||||
username: 'שם משתמש',
|
username: 'שם משתמש',
|
||||||
password: 'סיסמה',
|
password: 'סיסמה',
|
||||||
|
clear: 'ניקוי',
|
||||||
enterValidUrl: 'נא להזין כתובת URL תקינה.',
|
enterValidUrl: 'נא להזין כתובת URL תקינה.',
|
||||||
fileNameEmpty: 'שם הקובץ אינו יכול להיות ריק.',
|
fileNameEmpty: 'שם הקובץ אינו יכול להיות ריק.',
|
||||||
cancel: 'ביטול',
|
cancel: 'ביטול',
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const ru = {
|
|||||||
speed: 'Скорость',
|
speed: 'Скорость',
|
||||||
eta: 'Осталось',
|
eta: 'Осталось',
|
||||||
connections: 'Соединения',
|
connections: 'Соединения',
|
||||||
|
configuredConcurrency: 'Настроенная параллельность',
|
||||||
|
connectedPeers: 'подключённых пиров',
|
||||||
|
details: 'Подробности',
|
||||||
|
queueId: 'Очередь',
|
||||||
|
resumable: 'Возобновляемая',
|
||||||
connectionCount: '{{active}}/{{total}} активных',
|
connectionCount: '{{active}}/{{total}} активных',
|
||||||
connectionCountUnknown: '—/{{total}} активных',
|
connectionCountUnknown: '—/{{total}} активных',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const ru = {
|
|||||||
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
|
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.',
|
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.',
|
||||||
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
|
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
|
||||||
torrentPeerDiagnosticsHint: 'Адреса и идентификаторы пиров показываются только в этом окне; Aria2 не предоставляет данные о стране. Исходные битовые поля не сохраняются.',
|
torrentPeerDiagnosticsHint: 'Проверенные адреса и порты пиров показываются только временно; идентификаторы пиров и исходные битовые поля никогда не сохраняются.',
|
||||||
torrentPeerAddress: 'Адрес пира',
|
torrentPeerAddress: 'Адрес пира',
|
||||||
torrentPeerId: 'ID пира',
|
torrentPeerId: 'ID пира',
|
||||||
torrentFileProgress: 'Прогресс файлов торрента',
|
torrentFileProgress: 'Прогресс файлов торрента',
|
||||||
@@ -419,6 +424,7 @@ const ru = {
|
|||||||
mirrors: 'Зеркала',
|
mirrors: 'Зеркала',
|
||||||
username: 'Имя пользователя',
|
username: 'Имя пользователя',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
|
clear: 'Очистить',
|
||||||
enterValidUrl: 'Введите корректный URL.',
|
enterValidUrl: 'Введите корректный URL.',
|
||||||
fileNameEmpty: 'Имя файла не может быть пустым.',
|
fileNameEmpty: 'Имя файла не может быть пустым.',
|
||||||
cancel: 'Отмена',
|
cancel: 'Отмена',
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const uk = {
|
|||||||
speed: 'Швидкість',
|
speed: 'Швидкість',
|
||||||
eta: 'Залишилось',
|
eta: 'Залишилось',
|
||||||
connections: 'З\'єднання',
|
connections: 'З\'єднання',
|
||||||
|
configuredConcurrency: 'Налаштована паралельність',
|
||||||
|
connectedPeers: 'підключених пірів',
|
||||||
|
details: 'Деталі',
|
||||||
|
queueId: 'Черга',
|
||||||
|
resumable: 'Можна продовжити',
|
||||||
connectionCount: '{{active}}/{{total}} активних',
|
connectionCount: '{{active}}/{{total}} активних',
|
||||||
connectionCountUnknown: '—/{{total}} активних',
|
connectionCountUnknown: '—/{{total}} активних',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const uk = {
|
|||||||
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
|
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.',
|
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.',
|
||||||
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
|
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
|
||||||
torrentPeerDiagnosticsHint: 'Адреси та ідентифікатори пірів показуються лише в цьому вікні; Aria2 не надає даних про країну. Сирі бітові поля не зберігаються.',
|
torrentPeerDiagnosticsHint: 'Перевірені адреси й порти пірів показуються лише тимчасово; ідентифікатори пірів і сирі бітові поля ніколи не зберігаються.',
|
||||||
torrentPeerAddress: 'Адреса піра',
|
torrentPeerAddress: 'Адреса піра',
|
||||||
torrentPeerId: 'ID піра',
|
torrentPeerId: 'ID піра',
|
||||||
torrentFileProgress: 'Прогрес файлів торрента',
|
torrentFileProgress: 'Прогрес файлів торрента',
|
||||||
@@ -419,6 +424,7 @@ const uk = {
|
|||||||
mirrors: 'Дзеркала',
|
mirrors: 'Дзеркала',
|
||||||
username: 'Ім\'я користувача',
|
username: 'Ім\'я користувача',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
|
clear: 'Очистити',
|
||||||
enterValidUrl: 'Введіть дійсну URL-адресу.',
|
enterValidUrl: 'Введіть дійсну URL-адресу.',
|
||||||
fileNameEmpty: 'Ім\'я файлу не може бути порожнім.',
|
fileNameEmpty: 'Ім\'я файлу не може бути порожнім.',
|
||||||
cancel: 'Скасувати',
|
cancel: 'Скасувати',
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ const zhCN = {
|
|||||||
speed: '速度',
|
speed: '速度',
|
||||||
eta: '剩余时间',
|
eta: '剩余时间',
|
||||||
connections: '连接数',
|
connections: '连接数',
|
||||||
|
configuredConcurrency: '已配置并发数',
|
||||||
|
connectedPeers: '已连接对等端',
|
||||||
|
details: '详细信息',
|
||||||
|
queueId: '队列',
|
||||||
|
resumable: '可续传',
|
||||||
connectionCount: '{{active}}/{{total}} 个连接',
|
connectionCount: '{{active}}/{{total}} 个连接',
|
||||||
connectionCountUnknown: '—/{{total}} 个连接',
|
connectionCountUnknown: '—/{{total}} 个连接',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
@@ -266,7 +271,7 @@ const zhCN = {
|
|||||||
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
|
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
|
||||||
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。',
|
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。',
|
||||||
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
|
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
|
||||||
torrentPeerDiagnosticsHint: '节点地址和 ID 仅在此窗口显示;Aria2 不提供国家信息。不会保留原始位域。',
|
torrentPeerDiagnosticsHint: '仅临时显示经过验证的对等端地址和端口;永不保留对等端 ID 或原始位域。',
|
||||||
torrentPeerAddress: '节点地址',
|
torrentPeerAddress: '节点地址',
|
||||||
torrentPeerId: '节点 ID',
|
torrentPeerId: '节点 ID',
|
||||||
torrentFileProgress: 'Torrent 文件进度',
|
torrentFileProgress: 'Torrent 文件进度',
|
||||||
@@ -419,6 +424,7 @@ const zhCN = {
|
|||||||
mirrors: '镜像源',
|
mirrors: '镜像源',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
|
clear: '清除',
|
||||||
enterValidUrl: '请输入有效的 URL。',
|
enterValidUrl: '请输入有效的 URL。',
|
||||||
fileNameEmpty: '文件名不能为空。',
|
fileNameEmpty: '文件名不能为空。',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
enqueuePropertiesAction,
|
enqueuePropertiesAction,
|
||||||
getPropertiesLifecycleAction,
|
getPropertiesLifecycleAction,
|
||||||
isExpectedPropertiesDiagnosticUnavailable,
|
isExpectedPropertiesDiagnosticUnavailable,
|
||||||
|
propertiesLifecycleReachedPostcondition,
|
||||||
|
propertiesTorrentPeerLimit,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
shouldAcceptPropertiesActionRequest,
|
shouldAcceptPropertiesActionRequest,
|
||||||
} from './propertiesBridge';
|
} from './propertiesBridge';
|
||||||
@@ -68,6 +70,7 @@ describe('Properties window bridge', () => {
|
|||||||
fileName: 'example',
|
fileName: 'example',
|
||||||
url: 'https://example.test/file',
|
url: 'https://example.test/file',
|
||||||
status: 'seeding',
|
status: 'seeding',
|
||||||
|
isTorrent: true,
|
||||||
category: 'Other',
|
category: 'Other',
|
||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
speed: '-',
|
speed: '-',
|
||||||
@@ -75,6 +78,7 @@ describe('Properties window bridge', () => {
|
|||||||
fraction: 0,
|
fraction: 0,
|
||||||
uploadedBytes: 1,
|
uploadedBytes: 1,
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
|
connections: 16,
|
||||||
} as DownloadItem, {
|
} as DownloadItem, {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
fontFamily: 'system',
|
fontFamily: 'system',
|
||||||
@@ -110,14 +114,46 @@ describe('Properties window bridge', () => {
|
|||||||
downloadedBytes: 3,
|
downloadedBytes: 3,
|
||||||
totalBytes: 4,
|
totalBytes: 4,
|
||||||
totalIsEstimate: false,
|
totalIsEstimate: false,
|
||||||
activeConnections: 4,
|
connectedPeers: 4,
|
||||||
requestedConnections: 8,
|
|
||||||
torrentUploadedBytes: 9,
|
torrentUploadedBytes: 9,
|
||||||
uploadSpeed: '1 MiB/s',
|
uploadSpeed: '1 MiB/s',
|
||||||
torrentSeeders: 6,
|
torrentSeeders: 6,
|
||||||
torrentSeededSeconds: 12,
|
torrentSeededSeconds: 12,
|
||||||
moveProgress: 0.5,
|
moveProgress: 0.5,
|
||||||
});
|
});
|
||||||
|
expect(snapshot).not.toHaveProperty('activeConnections');
|
||||||
|
expect(snapshot).not.toHaveProperty('requestedConnections');
|
||||||
|
expect(snapshot).not.toHaveProperty('connections');
|
||||||
|
|
||||||
|
const normalSnapshot = sanitizePropertiesSnapshot({
|
||||||
|
id: 'http-1',
|
||||||
|
fileName: 'example.bin',
|
||||||
|
url: 'https://example.test/file',
|
||||||
|
status: 'downloading',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
connections: 8,
|
||||||
|
isTorrent: false,
|
||||||
|
} as DownloadItem, {
|
||||||
|
theme: 'dark',
|
||||||
|
fontFamily: 'system',
|
||||||
|
appFontSize: 'standard',
|
||||||
|
listRowDensity: 'standard',
|
||||||
|
locale: 'en',
|
||||||
|
}, {
|
||||||
|
progress: {
|
||||||
|
id: 'http-1',
|
||||||
|
fraction: 0.5,
|
||||||
|
speed: '1 MiB/s',
|
||||||
|
eta: '5s',
|
||||||
|
size: '4 MiB',
|
||||||
|
size_is_final: true,
|
||||||
|
active_connections: 3,
|
||||||
|
requested_connections: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(normalSnapshot).toMatchObject({ activeConnections: 3, requestedConnections: 8 });
|
||||||
|
expect(normalSnapshot).not.toHaveProperty('connectedPeers');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies explicit secret changes without conflating unchanged fields', () => {
|
it('applies explicit secret changes without conflating unchanged fields', () => {
|
||||||
@@ -140,6 +176,22 @@ describe('Properties window bridge', () => {
|
|||||||
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears a lost lifecycle action from an authoritative postcondition', () => {
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('resume', 'downloading')).toBe(true);
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('resume', 'seeding')).toBe(true);
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('resume', 'paused')).toBe(false);
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('pause', 'paused')).toBe(true);
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('pause', 'completed')).toBe(true);
|
||||||
|
expect(propertiesLifecycleReachedPostcondition('pause', 'downloading')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
||||||
|
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
||||||
|
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
||||||
|
expect(propertiesTorrentPeerLimit(0)).toBe(0);
|
||||||
|
expect(propertiesTorrentPeerLimit(16.5)).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
it('recognizes expected diagnostics gaps without hiding real RPC failures', () => {
|
it('recognizes expected diagnostics gaps without hiding real RPC failures', () => {
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('live Torrent file progress is unavailable'))).toBe(true);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('live Torrent file progress is unavailable'))).toBe(true);
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has no current gid mapping'))).toBe(true);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has no current gid mapping'))).toBe(true);
|
||||||
|
|||||||
+31
-3
@@ -13,6 +13,15 @@ export const PROPERTIES_WINDOW_ACTION_REQUEST = 'properties-window-action-reques
|
|||||||
export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result' as const;
|
export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result' as const;
|
||||||
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
|
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
|
||||||
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
||||||
|
export const DEFAULT_PROPERTIES_TORRENT_MAX_PEERS = 55;
|
||||||
|
|
||||||
|
export const propertiesTorrentPeerLimit = (value: unknown): number =>
|
||||||
|
typeof value === 'number'
|
||||||
|
&& Number.isInteger(value)
|
||||||
|
&& value >= 0
|
||||||
|
&& value <= 1000
|
||||||
|
? value
|
||||||
|
: DEFAULT_PROPERTIES_TORRENT_MAX_PEERS;
|
||||||
|
|
||||||
const PROPERTIES_SNAPSHOT_KEYS = [
|
const PROPERTIES_SNAPSHOT_KEYS = [
|
||||||
'id',
|
'id',
|
||||||
@@ -94,6 +103,7 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
appearance: DocumentAppearance;
|
appearance: DocumentAppearance;
|
||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
requestedConnections?: number;
|
requestedConnections?: number;
|
||||||
|
connectedPeers?: number;
|
||||||
uploadSpeed?: string;
|
uploadSpeed?: string;
|
||||||
torrentSeeders?: number;
|
torrentSeeders?: number;
|
||||||
moveProgress?: number;
|
moveProgress?: number;
|
||||||
@@ -118,6 +128,7 @@ export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies'
|
|||||||
|
|
||||||
export type PropertiesAction =
|
export type PropertiesAction =
|
||||||
| 'apply-properties'
|
| 'apply-properties'
|
||||||
|
| 'set-torrent-file-selection'
|
||||||
| 'pause-resume'
|
| 'pause-resume'
|
||||||
| 'verify-torrent'
|
| 'verify-torrent'
|
||||||
| 'set-download-limit'
|
| 'set-download-limit'
|
||||||
@@ -126,6 +137,16 @@ export type PropertiesAction =
|
|||||||
|
|
||||||
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
||||||
|
|
||||||
|
export const propertiesLifecycleReachedPostcondition = (
|
||||||
|
action: PropertiesLifecycleAction,
|
||||||
|
status: DownloadStatus,
|
||||||
|
): boolean => {
|
||||||
|
if (action === 'pause') {
|
||||||
|
return ['paused', 'completed', 'failed'].includes(status);
|
||||||
|
}
|
||||||
|
return ['queued', 'downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying'].includes(status);
|
||||||
|
};
|
||||||
|
|
||||||
export const getPropertiesLifecycleAction = (
|
export const getPropertiesLifecycleAction = (
|
||||||
status: DownloadStatus,
|
status: DownloadStatus,
|
||||||
): PropertiesLifecycleAction | null => {
|
): PropertiesLifecycleAction | null => {
|
||||||
@@ -164,7 +185,10 @@ export type PropertiesActionRequest = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
requestId: number;
|
requestId: number;
|
||||||
action: PropertiesAction;
|
action: PropertiesAction;
|
||||||
payload?: PropertiesPatch | { limit: string | null } | { maxPeers: string | null; peerSpeedLimit: string | null };
|
payload?: PropertiesPatch
|
||||||
|
| { selectedIndices: number[] | null }
|
||||||
|
| { limit: string | null }
|
||||||
|
| { maxPeers: string | null; peerSpeedLimit: string | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropertiesActionResult = {
|
export type PropertiesActionResult = {
|
||||||
@@ -180,6 +204,7 @@ export type PropertiesSnapshotEvent = {
|
|||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
downloadId: string;
|
downloadId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
bridgeGeneration: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
snapshot: PropertiesSnapshot;
|
snapshot: PropertiesSnapshot;
|
||||||
};
|
};
|
||||||
@@ -227,6 +252,7 @@ const copyWithoutSecrets = (
|
|||||||
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
|
||||||
)),
|
)),
|
||||||
) as SafePropertiesFields;
|
) as SafePropertiesFields;
|
||||||
|
if (item.isTorrent === true) delete safeItem.connections;
|
||||||
return {
|
return {
|
||||||
...safeItem,
|
...safeItem,
|
||||||
appearance,
|
appearance,
|
||||||
@@ -247,9 +273,11 @@ const copyWithoutSecrets = (
|
|||||||
? { totalIsEstimate: live.progress.total_is_estimate }
|
? { totalIsEstimate: live.progress.total_is_estimate }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.active_connections !== undefined
|
...(live.progress.active_connections !== undefined
|
||||||
? { activeConnections: live.progress.active_connections }
|
? item.isTorrent === true
|
||||||
|
? { connectedPeers: live.progress.active_connections }
|
||||||
|
: { activeConnections: live.progress.active_connections }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.requested_connections !== undefined
|
...(item.isTorrent !== true && live.progress.requested_connections !== undefined
|
||||||
? { requestedConnections: live.progress.requested_connections }
|
? { requestedConnections: live.progress.requested_connections }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.uploaded_bytes !== undefined
|
...(live.progress.uploaded_bytes !== undefined
|
||||||
|
|||||||
@@ -910,6 +910,7 @@ describe('useDownloadStore', () => {
|
|||||||
category: 'Other',
|
category: 'Other',
|
||||||
dateAdded: '',
|
dateAdded: '',
|
||||||
isTorrent: true,
|
isTorrent: true,
|
||||||
|
connections: 16,
|
||||||
torrentMaxPeers: 'not-a-number' as unknown as number,
|
torrentMaxPeers: 'not-a-number' as unknown as number,
|
||||||
torrentPeerSpeedLimit: 0 as unknown as string,
|
torrentPeerSpeedLimit: 0 as unknown as string,
|
||||||
torrentCheckIntegrity: 'yes' as unknown as boolean,
|
torrentCheckIntegrity: 'yes' as unknown as boolean,
|
||||||
@@ -925,6 +926,7 @@ describe('useDownloadStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||||
|
expect(normalized.connections).toBeUndefined();
|
||||||
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
||||||
expect(normalized.torrentCheckIntegrity).toBeUndefined();
|
expect(normalized.torrentCheckIntegrity).toBeUndefined();
|
||||||
expect(normalized.torrentTrackers).toBeUndefined();
|
expect(normalized.torrentTrackers).toBeUndefined();
|
||||||
|
|||||||
@@ -327,7 +327,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
destination,
|
destination,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
connections: item.isTorrent === true
|
||||||
|
? null
|
||||||
|
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
@@ -631,6 +633,7 @@ export const hasStaleTemporaryMediaEstimate = (
|
|||||||
|
|
||||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
|
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
|
||||||
const rawSeedRemaining = download.torrentSeedRemaining as unknown;
|
const rawSeedRemaining = download.torrentSeedRemaining as unknown;
|
||||||
|
const normalizedConnections = download.isTorrent === true ? undefined : download.connections;
|
||||||
const normalizedSeedRemaining = typeof rawSeedRemaining === 'number' &&
|
const normalizedSeedRemaining = typeof rawSeedRemaining === 'number' &&
|
||||||
Number.isFinite(rawSeedRemaining) &&
|
Number.isFinite(rawSeedRemaining) &&
|
||||||
rawSeedRemaining >= 0
|
rawSeedRemaining >= 0
|
||||||
@@ -745,6 +748,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
? rawVerifyRestoreStatus
|
? rawVerifyRestoreStatus
|
||||||
: undefined;
|
: undefined;
|
||||||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
||||||
|
download.connections !== normalizedConnections ||
|
||||||
rawUploadedBytes !== normalizedUploadedBytes ||
|
rawUploadedBytes !== normalizedUploadedBytes ||
|
||||||
rawSeededSeconds !== normalizedSeededSeconds ||
|
rawSeededSeconds !== normalizedSeededSeconds ||
|
||||||
rawWebSeeds !== normalizedWebSeeds ||
|
rawWebSeeds !== normalizedWebSeeds ||
|
||||||
@@ -770,6 +774,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
|
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
|
||||||
? {
|
? {
|
||||||
...download,
|
...download,
|
||||||
|
connections: normalizedConnections,
|
||||||
status: recoveredMoveStatus,
|
status: recoveredMoveStatus,
|
||||||
torrentSeedRemaining: normalizedSeedRemaining,
|
torrentSeedRemaining: normalizedSeedRemaining,
|
||||||
torrentUploadedBytes: normalizedUploadedBytes,
|
torrentUploadedBytes: normalizedUploadedBytes,
|
||||||
@@ -1542,7 +1547,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
totalIsEstimate: normalizedItem.totalIsEstimate ?? (
|
totalIsEstimate: normalizedItem.totalIsEstimate ?? (
|
||||||
normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
|
normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
|
||||||
),
|
),
|
||||||
connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
|
connections: normalizedItem.isTorrent === true
|
||||||
|
? undefined
|
||||||
|
: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
|
||||||
destination: destPath,
|
destination: destPath,
|
||||||
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
||||||
queueId,
|
queueId,
|
||||||
@@ -2332,7 +2339,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
destination: destPath,
|
destination: destPath,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
connections: item.isTorrent === true
|
||||||
|
? null
|
||||||
|
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||||
username: item.username || (login ? login.username : null),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
|
|||||||
Reference in New Issue
Block a user