mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-25 18:17:21 +00:00
fix(torrent): harden metadata reuse and live peer telemetry
- reuse validated tracker-bearing metainfo without restoring direct web seeds - preserve Torrent file selection while making long paths scrollable and copyable - add lifecycle-fenced peer and seeder summaries to Properties telemetry - validate cache tracker metadata and cover malformed, stale, and path-copy cases
This commit is contained in:
@@ -346,6 +346,16 @@ pub struct TorrentPeerDiagnostics {
|
|||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct TorrentPeerSummary {
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub total_peers: u32,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub total_seeders: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, TS)]
|
#[derive(Clone, Debug, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
|||||||
+93
-2
@@ -6783,12 +6783,57 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MagnetMetadataProbeTelemetry {
|
||||||
|
info_hash: String,
|
||||||
|
started_at: Instant,
|
||||||
|
outcome: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagnetMetadataProbeTelemetry {
|
||||||
|
fn new(info_hash: &str, tracker_bearing: bool, proxy_configured: bool) -> Self {
|
||||||
|
log::debug!(
|
||||||
|
"magnet metadata probe started: info_hash={info_hash}, tracker_bearing={tracker_bearing}, proxy_configured={proxy_configured}"
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
info_hash: info_hash.to_string(),
|
||||||
|
started_at: Instant::now(),
|
||||||
|
outcome: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, outcome: &'static str) {
|
||||||
|
if self.outcome.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.outcome = Some(outcome);
|
||||||
|
log::debug!(
|
||||||
|
"magnet metadata probe finished: info_hash={}, outcome={outcome}, elapsed_ms={}",
|
||||||
|
self.info_hash,
|
||||||
|
self.started_at.elapsed().as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MagnetMetadataProbeTelemetry {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.outcome.is_none() {
|
||||||
|
log::debug!(
|
||||||
|
"magnet metadata probe finished: info_hash={}, outcome=canceled, elapsed_ms={}",
|
||||||
|
self.info_hash,
|
||||||
|
self.started_at.elapsed().as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_magnet_metadata(
|
async fn resolve_magnet_metadata(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
source: &str,
|
source: &str,
|
||||||
id: &str,
|
id: &str,
|
||||||
proxy: Option<&str>,
|
proxy: Option<&str>,
|
||||||
|
headers: Option<&str>,
|
||||||
|
cookies: Option<&str>,
|
||||||
cache: bool,
|
cache: bool,
|
||||||
) -> Result<crate::ipc::TorrentMetadata, String> {
|
) -> Result<crate::ipc::TorrentMetadata, String> {
|
||||||
let expected = crate::torrent::inspect_source(source)?;
|
let expected = crate::torrent::inspect_source(source)?;
|
||||||
@@ -6798,6 +6843,7 @@ async fn resolve_magnet_metadata(
|
|||||||
.transpose()?
|
.transpose()?
|
||||||
.flatten();
|
.flatten();
|
||||||
if cache && crate::torrent::magnet_allows_cached_metadata(source) {
|
if cache && crate::torrent::magnet_allows_cached_metadata(source) {
|
||||||
|
let cache_started_at = Instant::now();
|
||||||
match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash)
|
match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -6806,6 +6852,11 @@ async fn resolve_magnet_metadata(
|
|||||||
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
||||||
let torrent_path =
|
let torrent_path =
|
||||||
crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?;
|
crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||||
|
log::debug!(
|
||||||
|
"magnet metadata cache hit: info_hash={}, elapsed_ms={}",
|
||||||
|
expected.info_hash,
|
||||||
|
cache_started_at.elapsed().as_millis()
|
||||||
|
);
|
||||||
return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path)));
|
return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path)));
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
@@ -6839,12 +6890,33 @@ async fn resolve_magnet_metadata(
|
|||||||
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
|
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
|
||||||
options.insert("timeout".to_string(), serde_json::json!("60"));
|
options.insert("timeout".to_string(), serde_json::json!("60"));
|
||||||
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
|
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
|
||||||
if let Some(proxy) = proxy_value {
|
if let Some(proxy) = proxy_value.as_deref() {
|
||||||
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
|
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
|
||||||
}
|
}
|
||||||
|
let mut header_list = Vec::new();
|
||||||
|
if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
header_list.push(format!("Cookie: {cookies}"));
|
||||||
|
}
|
||||||
|
if let Some(headers) = headers {
|
||||||
|
header_list.extend(
|
||||||
|
headers
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !header_list.is_empty() {
|
||||||
|
options.insert("header".to_string(), serde_json::json!(header_list));
|
||||||
|
}
|
||||||
|
|
||||||
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
|
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
|
||||||
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
|
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
|
||||||
|
let mut telemetry = MagnetMetadataProbeTelemetry::new(
|
||||||
|
&expected.info_hash,
|
||||||
|
sanitized_source.contains("tr="),
|
||||||
|
proxy_value.is_some(),
|
||||||
|
);
|
||||||
let metadata_result = crate::torrent_probe::run_metadata_probe(
|
let metadata_result = crate::torrent_probe::run_metadata_probe(
|
||||||
client,
|
client,
|
||||||
&sanitized_source,
|
&sanitized_source,
|
||||||
@@ -6855,6 +6927,12 @@ async fn resolve_magnet_metadata(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
match &metadata_result {
|
||||||
|
Ok(_) => telemetry.finish("probe-success"),
|
||||||
|
Err(crate::torrent_probe::ProbeFailure::Metadata(_)) => telemetry.finish("metadata-failure"),
|
||||||
|
Err(crate::torrent_probe::ProbeFailure::Cleanup(_)) => telemetry.finish("cleanup-failure"),
|
||||||
|
}
|
||||||
|
|
||||||
let bytes = match metadata_result {
|
let bytes = match metadata_result {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => {
|
Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => {
|
||||||
@@ -6917,6 +6995,8 @@ async fn inspect_torrent(
|
|||||||
&source,
|
&source,
|
||||||
&id,
|
&id,
|
||||||
proxy.as_deref(),
|
proxy.as_deref(),
|
||||||
|
headers.as_deref(),
|
||||||
|
cookies.as_deref(),
|
||||||
cache == Some(true),
|
cache == Some(true),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -7390,6 +7470,17 @@ async fn get_torrent_peers(
|
|||||||
state.queue_manager.get_aria2_torrent_peers(&id).await
|
state.queue_manager.get_aria2_torrent_peers(&id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_torrent_peer_summary(
|
||||||
|
caller: tauri::WebviewWindow,
|
||||||
|
properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
||||||
|
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
||||||
|
state.queue_manager.get_aria2_torrent_peer_summary(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_torrent_availability(
|
async fn get_torrent_availability(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
@@ -15467,7 +15558,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_peer_summary, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
+90
-34
@@ -2661,14 +2661,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return bounded peer diagnostics for the current Torrent GID. Endpoint
|
async fn get_aria2_torrent_peer_result(
|
||||||
/// and peer-id fields live only in this response; they are not persisted.
|
|
||||||
/// The control lock and post-RPC mapping check prevent a late response from
|
|
||||||
/// being attributed to a replaced or terminal lifecycle.
|
|
||||||
pub async fn get_aria2_torrent_peers(
|
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
result_kind: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
let _control_guard = self.acquire_aria2_control(id).await;
|
let _control_guard = self.acquire_aria2_control(id).await;
|
||||||
if !self.is_registered(id).await {
|
if !self.is_registered(id).await {
|
||||||
return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string());
|
return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string());
|
||||||
@@ -2710,8 +2707,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
crate::redact_sensitive_text(&error)
|
crate::redact_sensitive_text(&error)
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let diagnostics = parse_torrent_peer_diagnostics(result)?;
|
|
||||||
|
|
||||||
let still_current = self.is_registered(id).await
|
let still_current = self.is_registered(id).await
|
||||||
&& self
|
&& self
|
||||||
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
|
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
|
||||||
@@ -2719,10 +2714,37 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|
||||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
|
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
|
||||||
if !still_current {
|
if !still_current {
|
||||||
return Err("Torrent lifecycle changed while reading peer diagnostics".to_string());
|
return Err(format!("Torrent lifecycle changed while reading {result_kind}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(diagnostics)
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return bounded peer diagnostics for the current Torrent GID. Endpoint
|
||||||
|
/// and peer-id fields live only in this response; they are not persisted.
|
||||||
|
/// The control lock and post-RPC mapping check prevent a late response from
|
||||||
|
/// being attributed to a replaced or terminal lifecycle.
|
||||||
|
pub async fn get_aria2_torrent_peers(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
||||||
|
let result = self
|
||||||
|
.get_aria2_torrent_peer_result(id, "peer diagnostics")
|
||||||
|
.await?;
|
||||||
|
parse_torrent_peer_diagnostics(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return only aggregate peer/seeder counts for the current Torrent GID.
|
||||||
|
/// No peer addresses, IDs, bitfields, or transfer rates cross the IPC
|
||||||
|
/// boundary.
|
||||||
|
pub async fn get_aria2_torrent_peer_summary(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
||||||
|
let result = self
|
||||||
|
.get_aria2_torrent_peer_result(id, "peer summary")
|
||||||
|
.await?;
|
||||||
|
parse_torrent_peer_summary(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute bounded, anonymized swarm availability for the current
|
/// Compute bounded, anonymized swarm availability for the current
|
||||||
@@ -5933,20 +5955,46 @@ pub(crate) fn parse_torrent_availability(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_torrent_peer_diagnostics(
|
fn torrent_peer_summary_from_array(
|
||||||
result: serde_json::Value,
|
peers: &[serde_json::Value],
|
||||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
||||||
let peers = result
|
|
||||||
.as_array()
|
|
||||||
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
|
|
||||||
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
|
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
|
||||||
return Err("aria2.getPeers returned too many peers".to_string());
|
return Err("aria2.getPeers returned too many peers".to_string());
|
||||||
}
|
}
|
||||||
if peers.iter().any(|peer| !peer.is_object()) {
|
if peers.iter().any(|peer| !peer.is_object()) {
|
||||||
return Err("aria2.getPeers returned malformed peer data".to_string());
|
return Err("aria2.getPeers returned malformed peer data".to_string());
|
||||||
}
|
}
|
||||||
let total_peers = u32::try_from(peers.len()).unwrap_or(u32::MAX);
|
|
||||||
let mut total_seeders = 0u32;
|
let mut total_seeders = 0u32;
|
||||||
|
for peer in peers {
|
||||||
|
let peer = peer
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
||||||
|
if aria2_peer_bool(peer.get("seeder")) {
|
||||||
|
total_seeders = total_seeders.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(crate::ipc::TorrentPeerSummary {
|
||||||
|
total_peers: u32::try_from(peers.len()).unwrap_or(u32::MAX),
|
||||||
|
total_seeders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_torrent_peer_summary(
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
||||||
|
let peers = result
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
|
||||||
|
torrent_peer_summary_from_array(peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_torrent_peer_diagnostics(
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
||||||
|
let peers = result
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
|
||||||
|
let summary = torrent_peer_summary_from_array(peers)?;
|
||||||
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
|
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
|
||||||
|
|
||||||
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
||||||
@@ -5954,9 +6002,6 @@ pub(crate) fn parse_torrent_peer_diagnostics(
|
|||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
||||||
let seeder = aria2_peer_bool(peer.get("seeder"));
|
let seeder = aria2_peer_bool(peer.get("seeder"));
|
||||||
if seeder {
|
|
||||||
total_seeders = total_seeders.saturating_add(1);
|
|
||||||
}
|
|
||||||
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")),
|
||||||
@@ -5968,21 +6013,9 @@ pub(crate) fn parse_torrent_peer_diagnostics(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count seeders beyond the display cap without retaining any identifying
|
|
||||||
// peer data. The response is bounded by Aria2's per-Torrent peer limit,
|
|
||||||
// while the UI receives at most MAX_TORRENT_PEER_DIAGNOSTICS rows.
|
|
||||||
for peer in peers.iter().skip(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
|
||||||
let peer = peer
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
|
||||||
if aria2_peer_bool(peer.get("seeder")) {
|
|
||||||
total_seeders = total_seeders.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(crate::ipc::TorrentPeerDiagnostics {
|
Ok(crate::ipc::TorrentPeerDiagnostics {
|
||||||
total_peers,
|
total_peers: summary.total_peers,
|
||||||
total_seeders,
|
total_seeders: summary.total_seeders,
|
||||||
peers: sanitized,
|
peers: sanitized,
|
||||||
truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS,
|
truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS,
|
||||||
})
|
})
|
||||||
@@ -8311,10 +8344,28 @@ mod tests {
|
|||||||
assert!(!serialized.contains("bitfield"));
|
assert!(!serialized.contains("bitfield"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_peer_summary_counts_all_seeders_without_returning_peer_data() {
|
||||||
|
let result = serde_json::json!([
|
||||||
|
{"ip": "192.0.2.10", "seeder": "true", "bitfield": "secret"},
|
||||||
|
{"ip": "192.0.2.11", "seeder": false},
|
||||||
|
{"ip": "192.0.2.12", "seeder": true}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let summary = parse_torrent_peer_summary(result).unwrap();
|
||||||
|
assert_eq!(summary.total_peers, 3);
|
||||||
|
assert_eq!(summary.total_seeders, 2);
|
||||||
|
let serialized = serde_json::to_string(&summary).unwrap();
|
||||||
|
assert!(!serialized.contains("192.0.2."));
|
||||||
|
assert!(!serialized.contains("bitfield"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn torrent_peer_diagnostics_reject_non_array_results() {
|
fn torrent_peer_diagnostics_reject_non_array_results() {
|
||||||
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
|
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
|
||||||
assert!(error.contains("non-array"));
|
assert!(error.contains("non-array"));
|
||||||
|
let summary_error = parse_torrent_peer_summary(serde_json::json!({"peers": []})).unwrap_err();
|
||||||
|
assert!(summary_error.contains("non-array"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8324,6 +8375,11 @@ mod tests {
|
|||||||
}, "not-a-peer"]))
|
}, "not-a-peer"]))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.contains("malformed"));
|
assert!(error.contains("malformed"));
|
||||||
|
let summary_error = parse_torrent_peer_summary(serde_json::json!([{
|
||||||
|
"seeder": true
|
||||||
|
}, "not-a-peer"]))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(summary_error.contains("malformed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_torrent_progress_metadata() -> Vec<crate::ipc::TorrentFile> {
|
fn test_torrent_progress_metadata() -> Vec<crate::ipc::TorrentFile> {
|
||||||
|
|||||||
+99
-13
@@ -443,6 +443,48 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<S
|
|||||||
values
|
values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn torrent_tracker_uri_is_safe(value: &BencodeValue) -> bool {
|
||||||
|
let BencodeValue::Bytes(bytes) = value else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(value) = String::from_utf8(bytes.clone()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
bounded_uri(value.trim(), &["http", "https", "udp"]).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn torrent_tracker_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> bool {
|
||||||
|
let mut tracker_count = 0usize;
|
||||||
|
let mut count_tracker = |value: &BencodeValue| {
|
||||||
|
tracker_count = tracker_count.saturating_add(1);
|
||||||
|
tracker_count <= 256 && torrent_tracker_uri_is_safe(value)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(announce) = root.get(b"announce".as_slice()) {
|
||||||
|
if !count_tracker(announce) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(announce_list) = root.get(b"announce-list".as_slice()) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let BencodeValue::List(tiers) = announce_list else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for tier in tiers {
|
||||||
|
let BencodeValue::List(trackers) = tier else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for tracker in trackers {
|
||||||
|
if !count_tracker(tracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
|
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -546,7 +588,11 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDet
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
|
/// Return whether validated metainfo may be reused for a Magnet identified by
|
||||||
|
/// its info hash. Tracker lists are safe to retain because they are peer
|
||||||
|
/// discovery metadata, while direct web-seed/source fields are deliberately
|
||||||
|
/// excluded so a later Magnet cannot inherit arbitrary HTTP resources.
|
||||||
|
pub fn torrent_metadata_is_safe_for_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
|
||||||
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
@@ -561,11 +607,16 @@ pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<b
|
|||||||
.get(b"info".as_slice())
|
.get(b"info".as_slice())
|
||||||
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
|
||||||
parse_info(info)?;
|
parse_info(info)?;
|
||||||
|
if !torrent_tracker_metadata_is_safe(&root) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(root.keys().all(|key| {
|
Ok(root.keys().all(|key| {
|
||||||
matches!(
|
matches!(
|
||||||
key.as_slice(),
|
key.as_slice(),
|
||||||
b"info"
|
b"info"
|
||||||
|
| b"announce"
|
||||||
|
| b"announce-list"
|
||||||
| b"comment"
|
| b"comment"
|
||||||
| b"comment.utf-8"
|
| b"comment.utf-8"
|
||||||
| b"created by"
|
| b"created by"
|
||||||
@@ -665,7 +716,7 @@ pub fn magnet_allows_cached_metadata(source: &str) -> bool {
|
|||||||
}
|
}
|
||||||
has_info_hash = true;
|
has_info_hash = true;
|
||||||
}
|
}
|
||||||
"dn" => {}
|
"dn" | "tr" => {}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1076,7 +1127,7 @@ async fn read_cached_torrent_by_info_hash_unlocked<R: tauri::Runtime>(
|
|||||||
}
|
}
|
||||||
Err(error) => return Err(format!("could not read cached torrent metadata: {error}")),
|
Err(error) => return Err(format!("could not read cached torrent metadata: {error}")),
|
||||||
};
|
};
|
||||||
let reusable = torrent_metadata_is_safe_for_plain_magnet_reuse(&bytes).is_ok_and(|safe| safe);
|
let reusable = torrent_metadata_is_safe_for_magnet_reuse(&bytes).is_ok_and(|safe| safe);
|
||||||
match parse_torrent_bytes(&bytes) {
|
match parse_torrent_bytes(&bytes) {
|
||||||
Ok(parsed) if reusable && parsed.info_hash == info_hash => {}
|
Ok(parsed) if reusable && parsed.info_hash == info_hash => {}
|
||||||
Ok(_) | Err(_) => {
|
Ok(_) | Err(_) => {
|
||||||
@@ -1101,7 +1152,7 @@ pub async fn cache_torrent_info_hash<R: tauri::Runtime>(
|
|||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
let _guard = canonical_torrent_cache_lock().lock().await;
|
let _guard = canonical_torrent_cache_lock().lock().await;
|
||||||
let parsed = parse_torrent_bytes(bytes)?;
|
let parsed = parse_torrent_bytes(bytes)?;
|
||||||
if !torrent_metadata_is_safe_for_plain_magnet_reuse(bytes)? {
|
if !torrent_metadata_is_safe_for_magnet_reuse(bytes)? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let info_hash = parsed.info_hash;
|
let info_hash = parsed.info_hash;
|
||||||
@@ -1302,16 +1353,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_magnet_reuse_rejects_tracker_and_web_seed_metadata() {
|
fn magnet_reuse_rejects_web_seed_metadata_but_retains_tracker_metadata() {
|
||||||
assert!(torrent_metadata_is_safe_for_plain_magnet_reuse(
|
assert!(torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
b"d4:infod6:lengthi5e4:name4:testee"
|
b"d4:infod6:lengthi5e4:name4:testee"
|
||||||
)
|
)
|
||||||
.expect("plain torrent metadata should parse"));
|
.expect("plain torrent metadata should parse"));
|
||||||
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
|
assert!(torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
|
b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
)
|
)
|
||||||
.expect("tracker-bearing torrent metadata should parse"));
|
.expect("tracker-bearing torrent metadata should parse"));
|
||||||
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
|
assert!(torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
|
b"d13:announce-listll32:https://tracker.example/announceee4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.expect("tracker-list-bearing torrent metadata should parse"));
|
||||||
|
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
|
b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.expect("unsupported tracker metadata should parse"));
|
||||||
|
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
|
b"d8:announce42:https://user:pass@tracker.example/announce4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.expect("credential-bearing tracker metadata should parse"));
|
||||||
|
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
|
b"d13:announce-listl1:xe4:infod6:lengthi5e4:name4:testee"
|
||||||
|
)
|
||||||
|
.expect("malformed tracker-list metadata should parse"));
|
||||||
|
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
|
||||||
b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe"
|
b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe"
|
||||||
)
|
)
|
||||||
.expect("web-seed-bearing torrent metadata should parse"));
|
.expect("web-seed-bearing torrent metadata should parse"));
|
||||||
@@ -1365,13 +1432,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!path.exists());
|
assert!(!path.exists());
|
||||||
|
|
||||||
|
let tracker_bytes = b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee";
|
||||||
|
let tracker_hash = parse_torrent_bytes(tracker_bytes)
|
||||||
|
.expect("tracker-bearing torrent should parse")
|
||||||
|
.info_hash;
|
||||||
|
assert!(
|
||||||
|
cache_torrent_info_hash(app.handle(), tracker_bytes)
|
||||||
|
.await
|
||||||
|
.expect("tracker metadata should be reusable")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_cached_torrent_by_info_hash(app.handle(), &tracker_hash)
|
||||||
|
.await
|
||||||
|
.expect("tracker cache should be readable"),
|
||||||
|
Some(tracker_bytes.to_vec())
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
cache_torrent_info_hash(
|
cache_torrent_info_hash(
|
||||||
app.handle(),
|
app.handle(),
|
||||||
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
|
b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae"
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("source-specific metadata should be handled")
|
.expect("web-seed metadata should be handled")
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1405,16 +1488,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_plain_magnets_can_reuse_hash_keyed_metadata() {
|
fn magnets_with_safe_parameters_can_reuse_hash_keyed_metadata() {
|
||||||
assert!(magnet_allows_cached_metadata(
|
assert!(magnet_allows_cached_metadata(
|
||||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
|
||||||
));
|
));
|
||||||
assert!(magnet_allows_cached_metadata(
|
assert!(magnet_allows_cached_metadata(
|
||||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent"
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent"
|
||||||
));
|
));
|
||||||
assert!(!magnet_allows_cached_metadata(
|
assert!(magnet_allows_cached_metadata(
|
||||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce"
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce"
|
||||||
));
|
));
|
||||||
|
assert!(magnet_allows_cached_metadata(
|
||||||
|
"magnet:?tr=udp%3A%2F%2Ftracker.invalid%3A1337%2Fannounce&xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
|
||||||
|
));
|
||||||
assert!(!magnet_allows_cached_metadata(
|
assert!(!magnet_allows_cached_metadata(
|
||||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile"
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type TorrentPeerSummary = { totalPeers: number, totalSeeders: number, };
|
||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||||
import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, Copy, type LucideIcon } from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
} from '../utils/addDownloadMetadata';
|
} from '../utils/addDownloadMetadata';
|
||||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||||
|
import { copyTorrentFilePath as writeTorrentFilePath } from '../utils/torrentFilePath';
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -187,6 +189,16 @@ export const AddDownloadsModal = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const copyTorrentFilePath = useCallback(async (path: string) => {
|
||||||
|
try {
|
||||||
|
await writeTorrentFilePath(path, writeClipboardText);
|
||||||
|
addToast({ message: t($ => $.logs.copied), variant: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to copy Torrent file path:', error);
|
||||||
|
addToast({ message: t($ => $.downloadTable.copyPathFailed), variant: 'error', isActionable: true });
|
||||||
|
}
|
||||||
|
}, [addToast, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAddModalOpen) cleanupDraftTorrentCache();
|
if (!isAddModalOpen) cleanupDraftTorrentCache();
|
||||||
}, [cleanupDraftTorrentCache, isAddModalOpen]);
|
}, [cleanupDraftTorrentCache, isAddModalOpen]);
|
||||||
@@ -2219,20 +2231,35 @@ export const AddDownloadsModal = () => {
|
|||||||
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
|
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
|
||||||
const checked = !selectedIndices || selectedIndices.includes(file.index);
|
const checked = !selectedIndices || selectedIndices.includes(file.index);
|
||||||
return (
|
return (
|
||||||
<label
|
<div
|
||||||
key={file.index}
|
key={file.index}
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover rounded"
|
className="flex min-w-0 items-center gap-2 rounded px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<input
|
<label className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={checked}
|
type="checkbox"
|
||||||
onChange={() => toggleTorrentFile(file.index)}
|
checked={checked}
|
||||||
aria-label={file.path}
|
onChange={() => toggleTorrentFile(file.index)}
|
||||||
className="accent-blue-500"
|
aria-label={file.path}
|
||||||
/>
|
className="accent-blue-500"
|
||||||
<span className="truncate flex-1" title={file.path}>{file.path}</span>
|
/>
|
||||||
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
<span className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap" dir="ltr" title={file.path}>{file.path}</span>
|
||||||
</label>
|
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-icon-button shrink-0"
|
||||||
|
aria-label={t($ => $.downloadTable.copyFilePath)}
|
||||||
|
title={t($ => $.downloadTable.copyFilePath)}
|
||||||
|
onClick={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void copyTorrentFilePath(file.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilit
|
|||||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||||
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
||||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||||
|
import type { TorrentPeerSummary } from '../bindings/TorrentPeerSummary';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import {
|
import {
|
||||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||||
|
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from '../utils/propertiesPeerSummary';
|
||||||
import { WindowControls } from './WindowControls';
|
import { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
@@ -66,8 +68,7 @@ const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'
|
|||||||
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);
|
||||||
|
|
||||||
const isTorrentPollingStatus = (status: string) =>
|
const isTorrentPollingStatus = isTorrentPeerSummaryStatus;
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
|
||||||
|
|
||||||
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status);
|
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status);
|
||||||
|
|
||||||
@@ -214,6 +215,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null);
|
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | 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 [peerSummary, setPeerSummary] = useState<TorrentPeerSummary | null>(null);
|
||||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||||
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
||||||
const [diagnosticError, setDiagnosticError] = useState('');
|
const [diagnosticError, setDiagnosticError] = useState('');
|
||||||
@@ -267,11 +269,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
const revealInFlightRef = useRef(false);
|
const revealInFlightRef = useRef(false);
|
||||||
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
||||||
const diagnosticsInFlightRef = useRef(new Set<string>());
|
const diagnosticsInFlightRef = useRef(new Set<string>());
|
||||||
|
const peerSummaryInFlightRef = useRef(new Set<string>());
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
const activeTabRef = useRef(activeTab);
|
const activeTabRef = useRef(activeTab);
|
||||||
const downloadIdRef = useRef(downloadId);
|
const downloadIdRef = useRef(downloadId);
|
||||||
const fileProgressRef = useRef(fileProgress);
|
const fileProgressRef = useRef(fileProgress);
|
||||||
const peersRef = useRef(peers);
|
const peersRef = useRef(peers);
|
||||||
|
const peerSummaryRef = useRef(peerSummary);
|
||||||
const availabilityRef = useRef(availability);
|
const availabilityRef = useRef(availability);
|
||||||
const detailsRef = useRef(details);
|
const detailsRef = useRef(details);
|
||||||
const diagnosticAttemptsRef = useRef(new Set<string>());
|
const diagnosticAttemptsRef = useRef(new Set<string>());
|
||||||
@@ -289,6 +293,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
downloadIdRef.current = downloadId;
|
downloadIdRef.current = downloadId;
|
||||||
fileProgressRef.current = fileProgress;
|
fileProgressRef.current = fileProgress;
|
||||||
peersRef.current = peers;
|
peersRef.current = peers;
|
||||||
|
peerSummaryRef.current = peerSummary;
|
||||||
availabilityRef.current = availability;
|
availabilityRef.current = availability;
|
||||||
detailsRef.current = details;
|
detailsRef.current = details;
|
||||||
|
|
||||||
@@ -506,7 +511,19 @@ export const PropertiesWindowApp = () => {
|
|||||||
invoke('get_torrent_availability', { id }),
|
invoke('get_torrent_availability', { id }),
|
||||||
]);
|
]);
|
||||||
if (isCurrent()) {
|
if (isCurrent()) {
|
||||||
if (peerResult.status === 'fulfilled') setPeers(peerResult.value);
|
if (peerResult.status === 'fulfilled') {
|
||||||
|
setPeers(peerResult.value);
|
||||||
|
if (isTorrentPollingStatus(snapshotRef.current?.status ?? '')) {
|
||||||
|
peerSummaryRef.current = {
|
||||||
|
totalPeers: peerResult.value.totalPeers,
|
||||||
|
totalSeeders: peerResult.value.totalSeeders,
|
||||||
|
};
|
||||||
|
setPeerSummary(peerSummaryRef.current);
|
||||||
|
}
|
||||||
|
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
|
}
|
||||||
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
||||||
const peerOutcome = peerResult.status === 'fulfilled'
|
const peerOutcome = peerResult.status === 'fulfilled'
|
||||||
? 'success'
|
? 'success'
|
||||||
@@ -571,6 +588,40 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshPeerSummary = useCallback(async (id: string) => {
|
||||||
|
if (!isTorrentPollingStatus(snapshotRef.current?.status ?? '')) return;
|
||||||
|
const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current;
|
||||||
|
const requestKey = `${id}:${requestLifecycleEpoch}`;
|
||||||
|
if (peerSummaryInFlightRef.current.has(requestKey)) return;
|
||||||
|
peerSummaryInFlightRef.current.add(requestKey);
|
||||||
|
const isCurrent = () => isCurrentTorrentPeerSummary({
|
||||||
|
currentDownloadId: downloadIdRef.current,
|
||||||
|
requestDownloadId: id,
|
||||||
|
currentLifecycleEpoch: diagnosticLifecycleEpochRef.current,
|
||||||
|
requestLifecycleEpoch,
|
||||||
|
currentStatus: snapshotRef.current?.status ?? '',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const nextSummary = await invoke('get_torrent_peer_summary', { id });
|
||||||
|
if (isCurrent()) {
|
||||||
|
peerSummaryRef.current = nextSummary;
|
||||||
|
setPeerSummary(nextSummary);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
// A GID replacement or terminal transition can invalidate an in-flight
|
||||||
|
// summary after Aria2 has already answered. The next fenced poll will
|
||||||
|
// acquire the new GID; do not turn that expected transition into a
|
||||||
|
// repeating Properties error.
|
||||||
|
if (isExpectedPropertiesDiagnosticUnavailable(error)) {
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
peerSummaryInFlightRef.current.delete(requestKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let readyHeartbeatTimer: number | undefined;
|
let readyHeartbeatTimer: number | undefined;
|
||||||
@@ -595,6 +646,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleEpochRef.current += 1;
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
const lostAction = pendingActionRef.current;
|
const lostAction = pendingActionRef.current;
|
||||||
const lostDraftAction = lostAction === 'apply-properties'
|
const lostDraftAction = lostAction === 'apply-properties'
|
||||||
|| lostAction === 'set-torrent-file-selection';
|
|| lostAction === 'set-torrent-file-selection';
|
||||||
@@ -637,6 +690,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
setDiagnosticError('');
|
setDiagnosticError('');
|
||||||
setDiagnosticsLoading(false);
|
setDiagnosticsLoading(false);
|
||||||
setDiagnosticsRefreshing(false);
|
setDiagnosticsRefreshing(false);
|
||||||
@@ -723,6 +778,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleEpochRef.current += 1;
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
draftTabRef.current = null;
|
draftTabRef.current = null;
|
||||||
isDirtyRef.current = false;
|
isDirtyRef.current = false;
|
||||||
@@ -797,6 +854,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
setDiagnosticError('');
|
setDiagnosticError('');
|
||||||
setDiagnosticsLoading(false);
|
setDiagnosticsLoading(false);
|
||||||
setDiagnosticsRefreshing(false);
|
setDiagnosticsRefreshing(false);
|
||||||
@@ -812,19 +871,30 @@ export const PropertiesWindowApp = () => {
|
|||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
peerSummaryRef.current = null;
|
||||||
|
setPeerSummary(null);
|
||||||
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
setDiagnosticPhase('idle');
|
setDiagnosticPhase('idle');
|
||||||
setPeerDiagnosticPhase('idle');
|
setPeerDiagnosticPhase('idle');
|
||||||
setAvailabilityDiagnosticPhase('idle');
|
setAvailabilityDiagnosticPhase('idle');
|
||||||
}
|
}
|
||||||
void refreshDiagnostics(activeTab, downloadId);
|
void refreshDiagnostics(activeTab, downloadId);
|
||||||
if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return;
|
if (isTorrentPollingStatus(snapshot.status) && activeTab !== 'peers') {
|
||||||
|
void refreshPeerSummary(downloadId);
|
||||||
|
}
|
||||||
|
const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab);
|
||||||
|
const shouldPollSummary = activeTab !== 'peers';
|
||||||
|
if (!isTorrentPollingStatus(snapshot.status) || (!shouldPollDiagnostics && !shouldPollSummary)) return;
|
||||||
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
||||||
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
||||||
// create overlapping refreshes.
|
// create overlapping refreshes.
|
||||||
const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), 1000);
|
const interval = window.setInterval(() => {
|
||||||
|
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
|
||||||
|
if (shouldPollSummary) void refreshPeerSummary(downloadId);
|
||||||
|
}, 1000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1124,12 +1194,18 @@ export const PropertiesWindowApp = () => {
|
|||||||
? 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 connectionPresentation = getPropertiesConnectionPresentation(snapshot);
|
const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary);
|
||||||
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||||
? t($ => $.properties.fragmentConcurrency)
|
? t($ => $.properties.fragmentConcurrency)
|
||||||
: connectionPresentation.labelKey === 'torrentConnectedPeers'
|
: connectionPresentation.labelKey === 'torrentConnectedPeers'
|
||||||
? t($ => $.properties.torrentConnectedPeers)
|
? t($ => $.properties.torrentConnectedPeers)
|
||||||
: t($ => $.properties.connections);
|
: t($ => $.properties.connections);
|
||||||
|
const connectionValue = connectionPresentation.torrentPeerSummary
|
||||||
|
? t($ => $.properties.torrentPeerSummary, {
|
||||||
|
total: connectionPresentation.torrentPeerSummary.totalPeers,
|
||||||
|
seeders: connectionPresentation.torrentPeerSummary.totalSeeders,
|
||||||
|
})
|
||||||
|
: connectionPresentation.value;
|
||||||
const queuePlacement = formatPropertiesQueuePlacement(
|
const queuePlacement = formatPropertiesQueuePlacement(
|
||||||
snapshot.queueName,
|
snapshot.queueName,
|
||||||
snapshot.queuePosition,
|
snapshot.queuePosition,
|
||||||
@@ -1221,7 +1297,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionPresentation.value}</strong></div></div>}
|
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionValue}</strong></div></div>}
|
||||||
{isTorrent && <>
|
{isTorrent && <>
|
||||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||||
@@ -1579,7 +1655,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||||
<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 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">{connectionLabel}</span><p className="mt-1">{connectionPresentation.value}</p></div>
|
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionValue}</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.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.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.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const common = {
|
|||||||
torrentWebSeedsRemove: 'Remove web seed',
|
torrentWebSeedsRemove: 'Remove web seed',
|
||||||
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
||||||
torrentPeerCount: '{{total}} peers — {{seeders}} seeders',
|
torrentPeerCount: '{{total}} peers — {{seeders}} seeders',
|
||||||
|
torrentPeerSummary: '{{total}} peers · {{seeders}} seeders',
|
||||||
torrentPeerDownload: 'Download',
|
torrentPeerDownload: 'Download',
|
||||||
torrentPeerUpload: 'Upload',
|
torrentPeerUpload: 'Upload',
|
||||||
torrentPeerSeeder: 'Seeder',
|
torrentPeerSeeder: 'Seeder',
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const fa = {
|
|||||||
torrentWebSeedsRemove: 'حذف وبسید',
|
torrentWebSeedsRemove: 'حذف وبسید',
|
||||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||||
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
||||||
|
torrentPeerSummary: '{{total}} همتا · {{seeders}} سید',
|
||||||
torrentPeerDownload: 'دریافت',
|
torrentPeerDownload: 'دریافت',
|
||||||
torrentPeerUpload: 'آپلود',
|
torrentPeerUpload: 'آپلود',
|
||||||
torrentPeerSeeder: 'سید',
|
torrentPeerSeeder: 'سید',
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const he = {
|
|||||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||||
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
||||||
|
torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||||
torrentPeerDownload: 'הורדה',
|
torrentPeerDownload: 'הורדה',
|
||||||
torrentPeerUpload: 'העלאה',
|
torrentPeerUpload: 'העלאה',
|
||||||
torrentPeerSeeder: 'משתף',
|
torrentPeerSeeder: 'משתף',
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const ru = {
|
|||||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
||||||
|
torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров',
|
||||||
torrentPeerDownload: 'Загрузка',
|
torrentPeerDownload: 'Загрузка',
|
||||||
torrentPeerUpload: 'Отдача',
|
torrentPeerUpload: 'Отдача',
|
||||||
torrentPeerSeeder: 'Сидер',
|
torrentPeerSeeder: 'Сидер',
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const uk = {
|
|||||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
||||||
|
torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів',
|
||||||
torrentPeerDownload: 'Завантаження',
|
torrentPeerDownload: 'Завантаження',
|
||||||
torrentPeerUpload: 'Віддача',
|
torrentPeerUpload: 'Віддача',
|
||||||
torrentPeerSeeder: 'Сідер',
|
torrentPeerSeeder: 'Сідер',
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ const zhCN = {
|
|||||||
torrentWebSeedsRemove: '移除 Web 做种',
|
torrentWebSeedsRemove: '移除 Web 做种',
|
||||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||||
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
||||||
|
torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||||
torrentPeerDownload: '下载',
|
torrentPeerDownload: '下载',
|
||||||
torrentPeerUpload: '上传',
|
torrentPeerUpload: '上传',
|
||||||
torrentPeerSeeder: '做种',
|
torrentPeerSeeder: '做种',
|
||||||
|
|||||||
@@ -1446,6 +1446,11 @@ html[data-list-density="relaxed"] {
|
|||||||
color: hsl(var(--text-primary));
|
color: hsl(var(--text-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-icon-button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-icon-button:active:not(:disabled) {
|
.app-icon-button:active:not(:disabled) {
|
||||||
background: hsl(var(--border-color));
|
background: hsl(var(--border-color));
|
||||||
transform: scale(0.94);
|
transform: scale(0.94);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
|||||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||||
|
import type { TorrentPeerSummary } from './bindings/TorrentPeerSummary';
|
||||||
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
||||||
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
||||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||||
@@ -94,6 +95,7 @@ type CommandMap = {
|
|||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||||
|
get_torrent_peer_summary: { args: { id: string }; result: TorrentPeerSummary };
|
||||||
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
||||||
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
||||||
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ describe('Properties window bridge', () => {
|
|||||||
downloadedBytes: 3,
|
downloadedBytes: 3,
|
||||||
totalBytes: 4,
|
totalBytes: 4,
|
||||||
totalIsEstimate: false,
|
totalIsEstimate: false,
|
||||||
connectedPeers: 4,
|
|
||||||
torrentUploadedBytes: 9,
|
torrentUploadedBytes: 9,
|
||||||
uploadSpeed: '1 MiB/s',
|
uploadSpeed: '1 MiB/s',
|
||||||
torrentSeeders: 6,
|
torrentSeeders: 6,
|
||||||
@@ -403,6 +402,7 @@ describe('Properties window bridge', () => {
|
|||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true);
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true);
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true);
|
||||||
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer summary'))).toBe(true);
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false);
|
||||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false);
|
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
lastResolverFallback?: boolean;
|
lastResolverFallback?: boolean;
|
||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
requestedConnections?: number;
|
requestedConnections?: number;
|
||||||
connectedPeers?: number;
|
|
||||||
uploadSpeed?: string;
|
uploadSpeed?: string;
|
||||||
torrentSeeders?: number;
|
torrentSeeders?: number;
|
||||||
moveProgress?: number;
|
moveProgress?: number;
|
||||||
@@ -425,9 +424,6 @@ const copyWithoutSecrets = (
|
|||||||
...(live.progress.total_is_estimate !== undefined
|
...(live.progress.total_is_estimate !== undefined
|
||||||
? { totalIsEstimate: live.progress.total_is_estimate }
|
? { totalIsEstimate: live.progress.total_is_estimate }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.active_connections !== undefined && item.isTorrent === true
|
|
||||||
? { connectedPeers: live.progress.active_connections }
|
|
||||||
: {}),
|
|
||||||
...(live.progress.active_connections !== undefined
|
...(live.progress.active_connections !== undefined
|
||||||
&& item.isTorrent !== true
|
&& item.isTorrent !== true
|
||||||
&& item.isMedia !== true
|
&& item.isMedia !== true
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from './propertiesPeerSummary';
|
||||||
|
|
||||||
|
describe('Torrent peer summary lifecycle', () => {
|
||||||
|
it('accepts only the current active Torrent lifecycle', () => {
|
||||||
|
const request = {
|
||||||
|
currentDownloadId: 'torrent-1',
|
||||||
|
requestDownloadId: 'torrent-1',
|
||||||
|
currentLifecycleEpoch: 8,
|
||||||
|
requestLifecycleEpoch: 8,
|
||||||
|
currentStatus: 'downloading',
|
||||||
|
};
|
||||||
|
expect(isCurrentTorrentPeerSummary(request)).toBe(true);
|
||||||
|
expect(isCurrentTorrentPeerSummary({ ...request, currentLifecycleEpoch: 9 })).toBe(false);
|
||||||
|
expect(isCurrentTorrentPeerSummary({ ...request, requestDownloadId: 'torrent-2' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops live summary polling for paused and completed lifecycles', () => {
|
||||||
|
expect(isTorrentPeerSummaryStatus('paused')).toBe(false);
|
||||||
|
expect(isTorrentPeerSummaryStatus('completed')).toBe(false);
|
||||||
|
expect(isTorrentPeerSummaryStatus('seeding')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const TORRENT_PEER_SUMMARY_ACTIVE_STATUSES = [
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'seeding',
|
||||||
|
'waitingToSeed',
|
||||||
|
'retrying',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const isTorrentPeerSummaryStatus = (status: string): boolean =>
|
||||||
|
(TORRENT_PEER_SUMMARY_ACTIVE_STATUSES as readonly string[]).includes(status);
|
||||||
|
|
||||||
|
export const isCurrentTorrentPeerSummary = ({
|
||||||
|
currentDownloadId,
|
||||||
|
requestDownloadId,
|
||||||
|
currentLifecycleEpoch,
|
||||||
|
requestLifecycleEpoch,
|
||||||
|
currentStatus,
|
||||||
|
}: {
|
||||||
|
currentDownloadId: string | null;
|
||||||
|
requestDownloadId: string;
|
||||||
|
currentLifecycleEpoch: number;
|
||||||
|
requestLifecycleEpoch: number;
|
||||||
|
currentStatus: string;
|
||||||
|
}): boolean => currentDownloadId === requestDownloadId
|
||||||
|
&& currentLifecycleEpoch === requestLifecycleEpoch
|
||||||
|
&& isTorrentPeerSummaryStatus(currentStatus);
|
||||||
@@ -58,16 +58,34 @@ describe('Properties connection presentation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses connected peers for Torrents', () => {
|
it('does not use tellActive connections for the Torrent header', () => {
|
||||||
expect(getPropertiesConnectionPresentation({
|
expect(getPropertiesConnectionPresentation({
|
||||||
isMedia: false,
|
isMedia: false,
|
||||||
isTorrent: true,
|
isTorrent: true,
|
||||||
connectedPeers: 4,
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
kind: 'torrent',
|
kind: 'torrent',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'torrentConnectedPeers',
|
labelKey: 'torrentConnectedPeers',
|
||||||
value: '4',
|
value: '—',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the explicit live peer and seeder summary values', () => {
|
||||||
|
expect(getPropertiesConnectionPresentation({
|
||||||
|
isMedia: false,
|
||||||
|
isTorrent: true,
|
||||||
|
}, {
|
||||||
|
totalPeers: 41,
|
||||||
|
totalSeeders: 2,
|
||||||
|
})).toEqual({
|
||||||
|
kind: 'torrent',
|
||||||
|
showHeaderMetric: true,
|
||||||
|
labelKey: 'torrentConnectedPeers',
|
||||||
|
value: '—',
|
||||||
|
torrentPeerSummary: {
|
||||||
|
totalPeers: 41,
|
||||||
|
totalSeeders: 2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { resolveDownloadFraction } from './downloadProgress';
|
|||||||
|
|
||||||
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
||||||
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
||||||
|
export type PropertiesTorrentPeerSummary = {
|
||||||
|
totalPeers: number;
|
||||||
|
totalSeeders: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type PropertiesConnectionPresentation = {
|
export type PropertiesConnectionPresentation = {
|
||||||
kind: PropertiesConnectionKind;
|
kind: PropertiesConnectionKind;
|
||||||
showHeaderMetric: boolean;
|
showHeaderMetric: boolean;
|
||||||
labelKey: PropertiesConnectionLabelKey;
|
labelKey: PropertiesConnectionLabelKey;
|
||||||
value: string;
|
value: string;
|
||||||
|
torrentPeerSummary?: PropertiesTorrentPeerSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
||||||
@@ -20,7 +25,8 @@ export const getPropertiesProgress = (
|
|||||||
: resolveDownloadFraction(snapshot);
|
: resolveDownloadFraction(snapshot);
|
||||||
|
|
||||||
export const getPropertiesConnectionPresentation = (
|
export const getPropertiesConnectionPresentation = (
|
||||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections'>,
|
||||||
|
torrentPeerSummary?: PropertiesTorrentPeerSummary | null,
|
||||||
): PropertiesConnectionPresentation => {
|
): PropertiesConnectionPresentation => {
|
||||||
if (snapshot.isMedia === true) {
|
if (snapshot.isMedia === true) {
|
||||||
return {
|
return {
|
||||||
@@ -36,7 +42,8 @@ export const getPropertiesConnectionPresentation = (
|
|||||||
kind: 'torrent',
|
kind: 'torrent',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'torrentConnectedPeers',
|
labelKey: 'torrentConnectedPeers',
|
||||||
value: displayCount(snapshot.connectedPeers),
|
value: '—',
|
||||||
|
...(torrentPeerSummary ? { torrentPeerSummary } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { copyTorrentFilePath } from './torrentFilePath';
|
||||||
|
|
||||||
|
describe('Torrent file paths', () => {
|
||||||
|
it('copies the complete long path without changing its separators or characters', async () => {
|
||||||
|
const path = 'Season 03/Scenes/This-is-a-deliberately-long-file-name-with-unicode-字幕.mkv';
|
||||||
|
const writeText = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
await copyTorrentFilePath(path, writeText);
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledOnce();
|
||||||
|
expect(writeText).toHaveBeenCalledWith(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type ClipboardWriter = (text: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** Copy the exact Torrent-relative path without normalizing or truncating it. */
|
||||||
|
export const copyTorrentFilePath = async (
|
||||||
|
path: string,
|
||||||
|
writeText: ClipboardWriter,
|
||||||
|
): Promise<void> => {
|
||||||
|
await writeText(path);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user