feat(ui): add download progress controls

Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.

Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.

Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check

Refs: design reference e308009a08
This commit is contained in:
2026-05-20 23:20:53 +02:00
parent e308009a08
commit 47e2bbd454
16 changed files with 776 additions and 48 deletions
@@ -3,7 +3,7 @@ use std::fs::File;
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
@@ -279,6 +279,108 @@ async fn remove_downloaded_game(
}
}
#[tauri::command]
async fn cancel_download(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let is_active_download = {
let active_operations = state.inner().active_operations.read().await;
matches!(
active_operations.get(&id),
Some(UiOperationKind::Downloading)
)
};
if !is_active_download {
log::warn!("Ignoring cancel request for inactive download: {id}");
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::CancelDownload { id }) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
Ok(true)
} else {
log::warn!("Peer system not initialized yet");
Ok(false)
}
}
#[tauri::command]
async fn open_game_files(
id: String,
app_handle: AppHandle,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let Some(target) = resolve_game_root_for_open(&id, &state).await else {
return Ok(false);
};
#[allow(deprecated)]
if let Err(e) = app_handle.shell().open(target.display().to_string(), None) {
log::error!("Failed to open file viewer for {}: {e}", target.display());
return Ok(false);
}
Ok(true)
}
async fn resolve_game_root_for_open(
id: &str,
state: &tauri::State<'_, LanSpreadState>,
) -> Option<PathBuf> {
if !is_single_component_game_id(id) {
log::warn!("Ignoring file viewer request for invalid game id: {id}");
return None;
}
if state
.inner()
.games
.read()
.await
.get_game_by_id(id)
.is_none()
{
log::warn!("Ignoring file viewer request for unknown game: {id}");
return None;
}
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
let Ok(root) = games_folder.canonicalize() else {
log::warn!(
"Cannot open files because game directory is unavailable: {}",
games_folder.display()
);
return None;
};
let target = root.join(id);
let Ok(target) = target.canonicalize() else {
log::warn!(
"Cannot open files because game root is unavailable: {}",
target.display()
);
return None;
};
if !target.is_dir() || !target.starts_with(&root) {
log::warn!(
"Refusing to open file viewer outside game directory: {}",
target.display()
);
return None;
}
Some(target)
}
fn is_single_component_game_id(id: &str) -> bool {
let mut components = Path::new(id).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
#[tauri::command]
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
@@ -1328,6 +1430,16 @@ mod tests {
);
}
#[test]
fn game_file_viewer_ids_must_be_single_path_components() {
assert!(is_single_component_game_id("game"));
assert!(is_single_component_game_id("game.v1"));
assert!(!is_single_component_game_id(""));
assert!(!is_single_component_game_id("../game"));
assert!(!is_single_component_game_id("nested/game"));
assert!(!is_single_component_game_id("/game"));
}
#[test]
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
let mut alpha = game_fixture("alpha", "Catalog Alpha");
@@ -1398,6 +1510,8 @@ pub fn run() {
update_game,
uninstall_game,
remove_downloaded_game,
cancel_download,
open_game_files,
get_peer_count,
get_game_thumbnail,
get_unpack_logs