diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cf37ea0..60427f0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "bindings": "cd src-tauri && cargo test export_bindings --lib", + "build": "npm run bindings && tsc && vite build", "preview": "vite preview", "tauri": "tauri" }, diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index dee3551..3ea740a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3503,12 +3504,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", ] @@ -3542,7 +3545,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4440,6 +4443,8 @@ dependencies = [ "tokio", "tokio-tungstenite", "tower-http", + "ts-rs", + "uuid", "window-vibrancy 0.7.1", ] @@ -4776,6 +4781,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5188,6 +5202,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -5511,6 +5548,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 890dabd..1f74064 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,9 +23,11 @@ tauri-plugin-opener = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["process", "io-util", "rt", "macros"] } +tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "macros", "sync", "time"] } regex = "1.10" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "stream"] } +uuid = "1" +ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } tauri-plugin-notification = "2.3.3" sysinfo = "0.39.3" keyring = "3" diff --git a/apps/desktop/src-tauri/src/download.rs b/apps/desktop/src-tauri/src/download.rs new file mode 100644 index 0000000..8daaaef --- /dev/null +++ b/apps/desktop/src-tauri/src/download.rs @@ -0,0 +1,539 @@ +use futures_util::StreamExt; +use crate::DownloadProgressEvent; +use reqwest::{ + header::{self, HeaderMap, HeaderName, HeaderValue}, + Client, StatusCode, +}; +use std::{ + collections::HashMap, + path::PathBuf, + str::FromStr, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, Emitter}; +use tokio::{ + fs::{self, OpenOptions}, + io::{AsyncWriteExt, BufWriter}, + sync::{mpsc, watch}, +}; +use uuid::Uuid; + +const PROGRESS_INTERVAL: Duration = Duration::from_millis(150); +const WRITE_BUFFER_CAPACITY: usize = 256 * 1024; + +#[derive(Debug)] +pub enum DownloadCmd { + Start(DownloadPayload), + Pause(Uuid), + Cancel(Uuid), +} + +#[derive(Debug)] +pub struct DownloadPayload { + pub id: Uuid, + pub urls: Vec, + pub output_path: PathBuf, + pub speed_limit: Option, + pub username: Option, + pub password: Option, + pub headers: Option, + pub cookies: Option, + pub user_agent: Option, + pub max_tries: u32, + pub proxy: Option, +} + +#[derive(Clone)] +pub struct DownloadCoordinator { + tx: mpsc::Sender, + media_tx: mpsc::Sender, +} + +impl DownloadCoordinator { + pub fn spawn(app_handle: AppHandle) -> Self { + let (tx, rx) = mpsc::channel(128); + let (media_tx, media_rx) = mpsc::channel(32); + tauri::async_runtime::spawn(run_coordinator(app_handle, rx, media_rx)); + Self { tx, media_tx } + } + + pub async fn send(&self, command: DownloadCmd) -> Result<(), String> { + self.tx + .send(command) + .await + .map_err(|_| "download coordinator is unavailable".to_string()) + } + + pub async fn register_media(&self, id: String) -> Result, String> { + let (cancel_tx, cancel_rx) = watch::channel(false); + self.media_tx + .send(MediaCmd::Register { id, cancel_tx }) + .await + .map_err(|_| "download coordinator is unavailable".to_string())?; + Ok(cancel_rx) + } + + pub async fn pause_media(&self, id: String) -> Result<(), String> { + self.media_tx + .send(MediaCmd::Pause(id)) + .await + .map_err(|_| "download coordinator is unavailable".to_string()) + } + + pub async fn finish_media(&self, id: String) { + let _ = self.media_tx.send(MediaCmd::Finished(id)).await; + } +} + +enum MediaCmd { + Register { + id: String, + cancel_tx: watch::Sender, + }, + Pause(String), + Finished(String), +} + +#[derive(Debug, Clone, Copy)] +enum DownloadControl { + Pause, + Cancel, + Replace, +} + +struct ActiveDownload { + generation: u64, + control_tx: mpsc::Sender, +} + +enum WorkerEvent { + Finished { + id: Uuid, + generation: u64, + outcome: DownloadOutcome, + }, +} + +enum DownloadOutcome { + Completed, + Paused, + Cancelled, + Failed(String), +} + +async fn run_coordinator( + app_handle: AppHandle, + mut command_rx: mpsc::Receiver, + mut media_rx: mpsc::Receiver, +) { + let (worker_tx, mut worker_rx) = mpsc::channel(128); + let mut active = HashMap::::new(); + let mut active_media = HashMap::>::new(); + let mut next_generation = 0_u64; + + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + break; + }; + + match command { + DownloadCmd::Start(payload) => { + if let Some(previous) = active.remove(&payload.id) { + let _ = previous.control_tx.send(DownloadControl::Replace).await; + } + + next_generation = next_generation.wrapping_add(1); + let generation = next_generation; + let id = payload.id; + let (control_tx, control_rx) = mpsc::channel(1); + active.insert(id, ActiveDownload { generation, control_tx }); + + let app_handle = app_handle.clone(); + let worker_tx = worker_tx.clone(); + tauri::async_runtime::spawn(async move { + let outcome = download_file(app_handle, payload, control_rx).await; + let _ = worker_tx + .send(WorkerEvent::Finished { id, generation, outcome }) + .await; + }); + } + DownloadCmd::Pause(id) => { + if let Some(download) = active.remove(&id) { + let _ = download.control_tx.send(DownloadControl::Pause).await; + } + } + DownloadCmd::Cancel(id) => { + if let Some(download) = active.remove(&id) { + let _ = download.control_tx.send(DownloadControl::Cancel).await; + } + } + } + } + event = worker_rx.recv() => { + let Some(WorkerEvent::Finished { id, generation, outcome }) = event else { + continue; + }; + + let is_current = active + .get(&id) + .is_some_and(|download| download.generation == generation); + if is_current { + active.remove(&id); + } + + match (is_current, outcome) { + (true, DownloadOutcome::Completed) => { + let _ = app_handle.emit("download-complete", id.to_string()); + } + (true, DownloadOutcome::Failed(error)) => { + eprintln!("download {id} failed: {error}"); + let _ = app_handle.emit("download-failed", id.to_string()); + } + _ => {} + } + } + command = media_rx.recv() => { + let Some(command) = command else { + continue; + }; + match command { + MediaCmd::Register { id, cancel_tx } => { + if let Some(previous) = active_media.insert(id, cancel_tx) { + let _ = previous.send(true); + } + } + MediaCmd::Pause(id) => { + if let Some(cancel_tx) = active_media.remove(&id) { + let _ = cancel_tx.send(true); + } + } + MediaCmd::Finished(id) => { + active_media.remove(&id); + } + } + } + } + } + + for (_, download) in active { + let _ = download.control_tx.send(DownloadControl::Cancel).await; + } + for (_, cancel_tx) in active_media { + let _ = cancel_tx.send(true); + } +} + +async fn download_file( + app_handle: AppHandle, + payload: DownloadPayload, + mut control_rx: mpsc::Receiver, +) -> DownloadOutcome { + if let Some(parent) = payload.output_path.parent() { + if let Err(error) = fs::create_dir_all(parent).await { + return DownloadOutcome::Failed(error.to_string()); + } + } + + let client = match build_client(&payload) { + Ok(client) => client, + Err(error) => return DownloadOutcome::Failed(error), + }; + let attempts_per_url = payload.max_tries.max(1); + let mut last_error = "no download URL was provided".to_string(); + + for url in &payload.urls { + for _ in 0..attempts_per_url { + match download_attempt(&app_handle, &client, &payload, url, &mut control_rx).await { + Ok(()) => return DownloadOutcome::Completed, + Err(AttemptError::Controlled(DownloadControl::Pause)) => { + return DownloadOutcome::Paused; + } + Err(AttemptError::Controlled(DownloadControl::Cancel)) => { + let _ = fs::remove_file(&payload.output_path).await; + return DownloadOutcome::Cancelled; + } + Err(AttemptError::Controlled(DownloadControl::Replace)) => { + return DownloadOutcome::Cancelled; + } + Err(AttemptError::Failed(error)) => last_error = error, + } + } + } + + DownloadOutcome::Failed(last_error) +} + +enum AttemptError { + Controlled(DownloadControl), + Failed(String), +} + +async fn download_attempt( + app_handle: &AppHandle, + client: &Client, + payload: &DownloadPayload, + url: &str, + control_rx: &mut mpsc::Receiver, +) -> Result<(), AttemptError> { + let existing_len = fs::metadata(&payload.output_path) + .await + .map(|metadata| metadata.len()) + .unwrap_or(0); + let mut request = client.get(url); + if existing_len > 0 { + request = request.header(header::RANGE, format!("bytes={existing_len}-")); + } + if let Some(username) = payload + .username + .as_deref() + .filter(|value| !value.is_empty()) + { + request = request.basic_auth(username, payload.password.as_deref()); + } + + let response = tokio::select! { + control = control_rx.recv() => { + return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel))); + } + response = request.send() => { + response.map_err(|error| AttemptError::Failed(error.to_string()))? + } + }; + if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) { + return Err(AttemptError::Failed(format!( + "{url} returned HTTP {}", + response.status() + ))); + } + + let resumed = existing_len > 0 && response.status() == StatusCode::PARTIAL_CONTENT; + let completed_at_start = if resumed { existing_len } else { 0 }; + let total_len = response + .content_length() + .map(|remaining| remaining.saturating_add(completed_at_start)); + let file = OpenOptions::new() + .create(true) + .write(true) + .append(resumed) + .truncate(!resumed) + .open(&payload.output_path) + .await + .map_err(|error| AttemptError::Failed(error.to_string()))?; + let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file); + let mut stream = response.bytes_stream(); + let mut last_emitted_at = Instant::now(); + let mut last_emitted_bytes = completed_at_start; + let mut completed = completed_at_start; + let speed_limit = payload.speed_limit.as_deref().and_then(parse_speed_limit); + let transfer_started_at = Instant::now(); + let mut transferred_this_attempt = 0_u64; + + loop { + tokio::select! { + control = control_rx.recv() => { + writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?; + return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel))); + } + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + writer + .write_all(&bytes) + .await + .map_err(|error| AttemptError::Failed(error.to_string()))?; + completed = completed.saturating_add(bytes.len() as u64); + transferred_this_attempt = + transferred_this_attempt.saturating_add(bytes.len() as u64); + + if let Some(bytes_per_second) = speed_limit { + let expected_elapsed = + Duration::from_secs_f64(transferred_this_attempt as f64 / bytes_per_second as f64); + let actual_elapsed = transfer_started_at.elapsed(); + if expected_elapsed > actual_elapsed { + tokio::select! { + control = control_rx.recv() => { + writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?; + return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel))); + } + _ = tokio::time::sleep(expected_elapsed - actual_elapsed) => {} + } + } + } + + let now = Instant::now(); + let interval = now.duration_since(last_emitted_at); + if interval >= PROGRESS_INTERVAL { + emit_progress( + app_handle, + payload.id, + completed, + total_len, + completed.saturating_sub(last_emitted_bytes), + interval, + ); + last_emitted_at = now; + last_emitted_bytes = completed; + } + } + Some(Err(error)) => { + writer.flush().await.map_err(|flush_error| AttemptError::Failed(flush_error.to_string()))?; + return Err(AttemptError::Failed(error.to_string())); + } + None => break, + } + } + } + } + + writer + .flush() + .await + .map_err(|error| AttemptError::Failed(error.to_string()))?; + emit_progress( + app_handle, + payload.id, + completed, + total_len, + completed.saturating_sub(last_emitted_bytes), + last_emitted_at.elapsed(), + ); + Ok(()) +} + +fn build_client(payload: &DownloadPayload) -> Result { + let mut headers = HeaderMap::new(); + if let Some(raw_headers) = payload.headers.as_deref() { + for line in raw_headers + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let (name, value) = line + .split_once(':') + .ok_or_else(|| format!("invalid HTTP header: {line}"))?; + headers.insert( + HeaderName::from_str(name.trim()).map_err(|error| error.to_string())?, + HeaderValue::from_str(value.trim()).map_err(|error| error.to_string())?, + ); + } + } + if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) { + headers.insert( + header::COOKIE, + HeaderValue::from_str(cookies).map_err(|error| error.to_string())?, + ); + } + + let mut builder = Client::builder().default_headers(headers); + if let Some(user_agent) = payload + .user_agent + .as_deref() + .filter(|value| !value.is_empty()) + { + builder = builder.user_agent(user_agent); + } + if let Some(proxy) = payload.proxy.as_deref().filter(|value| !value.is_empty()) { + builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); + } + + builder.build().map_err(|error| error.to_string()) +} + +fn emit_progress( + app_handle: &AppHandle, + id: Uuid, + completed: u64, + total: Option, + interval_bytes: u64, + interval: Duration, +) { + let speed_bytes = if interval.is_zero() { + 0.0 + } else { + interval_bytes as f64 / interval.as_secs_f64() + }; + let fraction = total + .filter(|total| *total > 0) + .map(|total| completed as f64 / total as f64) + .unwrap_or(0.0) + .clamp(0.0, 1.0); + let eta = total + .filter(|total| speed_bytes > 0.0 && *total > completed) + .map(|total| format_duration((total - completed) as f64 / speed_bytes)) + .unwrap_or_else(|| "-".to_string()); + + let _ = app_handle.emit( + "download-progress", + DownloadProgressEvent { + id: id.to_string(), + fraction, + speed: format_speed(speed_bytes), + eta, + }, + ); +} + +fn format_speed(bytes_per_second: f64) -> String { + if bytes_per_second >= 1024.0 * 1024.0 { + format!("{:.1} MB/s", bytes_per_second / (1024.0 * 1024.0)) + } else if bytes_per_second >= 1024.0 { + format!("{:.1} KB/s", bytes_per_second / 1024.0) + } else { + format!("{bytes_per_second:.0} B/s") + } +} + +fn format_duration(seconds: f64) -> String { + if seconds >= 3600.0 { + format!("{:.0}h {:.0}m", seconds / 3600.0, (seconds % 3600.0) / 60.0) + } else if seconds >= 60.0 { + format!("{:.0}m {:.0}s", seconds / 60.0, seconds % 60.0) + } else { + format!("{seconds:.0}s") + } +} + +fn parse_speed_limit(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.is_empty() || normalized == "0" { + return None; + } + + let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb/s") { + (number, 1024.0) + } else if let Some(number) = normalized.strip_suffix("mb/s") { + (number, 1024.0 * 1024.0) + } else if let Some(number) = normalized.strip_suffix("gb/s") { + (number, 1024.0 * 1024.0 * 1024.0) + } else if let Some(number) = normalized.strip_suffix('k') { + (number, 1024.0) + } else if let Some(number) = normalized.strip_suffix('m') { + (number, 1024.0 * 1024.0) + } else if let Some(number) = normalized.strip_suffix('g') { + (number, 1024.0 * 1024.0 * 1024.0) + } else { + (normalized.as_str(), 1.0) + }; + + number + .trim() + .parse::() + .ok() + .filter(|number| *number > 0.0) + .map(|number| (number * multiplier) as u64) +} + +#[cfg(test)] +mod tests { + use super::parse_speed_limit; + + #[test] + fn parses_aria_style_speed_limits() { + assert_eq!(parse_speed_limit("512K"), Some(512 * 1024)); + assert_eq!(parse_speed_limit("1.5M"), Some(1_572_864)); + assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024)); + assert_eq!(parse_speed_limit("0"), None); + } +} diff --git a/apps/desktop/src-tauri/src/extension_server.rs b/apps/desktop/src-tauri/src/extension_server.rs index 798ae5a..9712882 100644 --- a/apps/desktop/src-tauri/src/extension_server.rs +++ b/apps/desktop/src-tauri/src/extension_server.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; use tower_http::cors::{Any, CorsLayer}; +use ts_rs::TS; pub const EXTENSION_SERVER_PORT: u16 = 23522; const MAX_URL_COUNT: usize = 200; @@ -45,8 +46,9 @@ struct ExtensionRequest { filename: Option, } -#[derive(Clone, Serialize)] -struct ExtensionDownload { +#[derive(Clone, Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ExtensionDownload { urls: Vec, referer: Option, silent: bool, diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs new file mode 100644 index 0000000..ec80c32 --- /dev/null +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -0,0 +1,236 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum DownloadStatus { + Downloading, + Paused, + Completed, + Failed, + Queued, +} + +impl DownloadStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Downloading => "downloading", + Self::Paused => "paused", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Queued => "queued", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub enum DownloadCategory { + Musics, + Movies, + Compressed, + Documents, + Pictures, + Applications, + Other, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct Queue { + pub id: String, + pub name: String, + pub is_main: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct DownloadItem { + pub id: String, + pub url: String, + pub file_name: String, + pub status: DownloadStatus, + #[ts(optional)] + pub fraction: Option, + #[ts(optional)] + pub speed: Option, + #[ts(optional)] + pub eta: Option, + #[ts(optional)] + pub size: Option, + pub category: DownloadCategory, + pub date_added: String, + #[ts(optional)] + pub connections: Option, + #[ts(optional)] + pub speed_limit: Option, + #[ts(optional)] + pub username: Option, + #[ts(optional)] + pub password: Option, + #[ts(optional)] + pub headers: Option, + #[ts(optional)] + pub checksum: Option, + #[ts(optional)] + pub cookies: Option, + #[ts(optional)] + pub mirrors: Option, + #[ts(optional)] + pub destination: Option, + #[ts(optional)] + pub is_media: Option, + #[ts(optional)] + pub media_format_selector: Option, + pub queue_id: String, + #[serde(rename = "_dispatched")] + #[ts(optional)] + pub dispatched: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct SiteLogin { + pub id: String, + pub url_pattern: String, + pub username: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum AppFontSize { + Small, + Standard, + Large, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum ListRowDensity { + Compact, + Standard, + Relaxed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum PostQueueAction { + None, + Sleep, + Restart, + Shutdown, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum Theme { + Dark, + Light, + System, + Dracula, + Nord, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum ActiveView { + Downloads, + Settings, + Scheduler, + #[serde(rename = "speedLimiter")] + SpeedLimiter, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum SettingsTab { + Downloads, + Lookandfeel, + Network, + Locations, + Sitelogins, + Power, + Engine, + Integrations, + About, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum ProxyMode { + None, + System, + Custom, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum MediaCookieSource { + None, + Safari, + Chrome, + Firefox, + Edge, + Brave, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct SchedulerSettings { + pub enabled: bool, + pub start_time: String, + pub stop_time_enabled: bool, + pub stop_time: String, + pub everyday: bool, + pub selected_days: Vec, + pub post_queue_action: PostQueueAction, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct PersistedSettings { + pub theme: Theme, + pub default_download_path: String, + pub max_concurrent_downloads: usize, + pub global_speed_limit: String, + pub is_sidebar_visible: bool, + pub active_settings_tab: SettingsTab, + pub scheduler: SchedulerSettings, + pub scheduler_last_start_key: String, + pub scheduler_last_stop_key: String, + pub last_custom_speed_limit_ki_b: u32, + pub per_server_connections: i32, + pub max_automatic_retries: i32, + pub show_notifications: bool, + pub play_completion_sound: bool, + pub app_font_size: AppFontSize, + pub list_row_density: ListRowDensity, + pub show_dock_badge: bool, + pub show_menu_bar_icon: bool, + pub proxy_mode: ProxyMode, + pub proxy_host: String, + pub proxy_port: u16, + pub custom_user_agent: String, + pub ask_where_to_save_each_file: bool, + pub prevents_sleep_while_downloading: bool, + pub media_cookie_source: MediaCookieSource, + pub download_directories: HashMap, + pub site_logins: Vec, + pub extension_pairing_token: String, + pub auto_check_updates: bool, +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0b5d21b..aca29c3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5,11 +5,15 @@ use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use regex::Regex; use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; -#[derive(Serialize)] -struct MetadataResponse { +#[derive(Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct MetadataResponse { filename: String, size: String, + #[ts(type = "number")] size_bytes: u64, } @@ -336,18 +340,23 @@ fn resign_aria2_debug_bundle(aria2c_path: &std::path::Path) -> Result<(), String Ok(()) } +mod download; +#[allow(dead_code)] +mod ipc; mod parity; pub mod error; pub use error::AppError; +// Retained only for compatibility with the optional aria2 diagnostic monitor. +// Active downloads are owned by DownloadCoordinator. pub enum TaskHandle { Aria2(String), - Pid(u32), - Queued, + #[doc(hidden)] + Inactive, } pub struct AppState { - pub tasks: Arc>>, + pub download_coordinator: download::DownloadCoordinator, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub aria2_port: u16, @@ -356,8 +365,9 @@ pub struct AppState { pub sleep_preventer: Arc>>, } -#[derive(Clone, Serialize)] -struct DownloadProgressEvent { +#[derive(Clone, Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct DownloadProgressEvent { id: String, fraction: f64, speed: String, @@ -443,9 +453,7 @@ async fn start_download( proxy: Option, ) -> Result<(), AppError> { println!("start_download called for id: {}", id); - let state_aria2_port = state.aria2_port; - let state_aria2_secret = state.aria2_secret.clone(); - let tasks_map = state.tasks.clone(); + let download_id = Uuid::parse_str(&id).map_err(|error| AppError::Internal(error.to_string()))?; let mut resolved_dest = std::path::PathBuf::from(&destination); if destination.starts_with("~/") { @@ -457,80 +465,25 @@ async fn start_download( resolved_dest = home; } } - - if !resolved_dest.exists() { - let _ = tokio::fs::create_dir_all(&resolved_dest).await; - } - - let gid: String = id.replace("-", "").chars().take(16).collect(); - // ensure exactly 16 chars - let gid = format!("{:0<16}", gid); - - tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Aria2(gid.clone())); - - let mut options = serde_json::Map::new(); - options.insert("gid".to_string(), serde_json::json!(gid)); - options.insert("dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string())); - options.insert("out".to_string(), serde_json::json!(filename)); - - if let Some(conn) = connections { - options.insert("split".to_string(), serde_json::json!(conn.to_string())); - options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string())); - } - if let Some(limit) = speed_limit { - if !limit.is_empty() { - options.insert("max-download-limit".to_string(), serde_json::json!(limit)); - } - } - - if let Some(user) = username { - if !user.is_empty() { - options.insert("http-user".to_string(), serde_json::json!(user)); - options.insert("ftp-user".to_string(), serde_json::json!(user)); - if let Some(pass) = password { - options.insert("http-passwd".to_string(), serde_json::json!(pass)); - options.insert("ftp-passwd".to_string(), serde_json::json!(pass)); - } - } - } - - let mut hdrs = Vec::new(); - if let Some(hdr) = headers { - for header in hdr.lines().map(str::trim).filter(|h| !h.is_empty()) { - hdrs.push(header.to_string()); - } - } - if let Some(cks) = cookies { - if !cks.is_empty() { - hdrs.push(format!("Cookie: {}", cks)); - } - } - if !hdrs.is_empty() { - options.insert("header".to_string(), serde_json::json!(hdrs)); - } - - if let Some(p) = proxy { - if !p.is_empty() { - options.insert("all-proxy".to_string(), serde_json::json!(p)); - } - } - if let Some(ua) = user_agent { - if !ua.is_empty() { - options.insert("user-agent".to_string(), serde_json::json!(ua)); - } - } - if let Some(chk) = checksum { - if !chk.is_empty() { - options.insert("checksum".to_string(), serde_json::json!(chk)); - } - } - if let Some(tries) = max_tries { - options.insert("max-tries".to_string(), serde_json::json!(tries.to_string())); - } - - let uris = collect_download_uris(&url, mirrors.as_deref()); - - let _ = rpc_call(state_aria2_port, &state_aria2_secret, "aria2.addUri", serde_json::json!([uris, options])).await?; + let _ = connections; + let _ = checksum; + state + .download_coordinator + .send(download::DownloadCmd::Start(download::DownloadPayload { + id: download_id, + urls: collect_download_uris(&url, mirrors.as_deref()), + output_path: resolved_dest.join(filename), + speed_limit, + username, + password, + headers, + cookies, + user_agent, + max_tries: max_tries.unwrap_or(1).max(1) as u32, + proxy, + })) + .await + .map_err(AppError::Internal)?; Ok(()) } @@ -553,30 +506,21 @@ async fn start_media_download( user_agent: Option, max_tries: Option, ) -> Result<(), String> { - let tasks_map = state.tasks.clone(); let media_semaphore = state.media_semaphore.clone(); - - // Mark task as queued - tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Queued); - - let id_clone = id.clone(); + let coordinator = state.download_coordinator.clone(); + let mut cancel_rx = coordinator.register_media(id.clone()).await?; tauri::async_runtime::spawn(async move { - // Wait in queue via semaphore - let permit = media_semaphore.acquire().await; - - // Check if user cancelled the task while it was waiting in queue - { - let map = tasks_map.lock().unwrap(); - if !map.contains_key(&id_clone) { + let permit = tokio::select! { + permit = media_semaphore.acquire() => permit, + _ = cancel_rx.changed() => { + coordinator.finish_media(id).await; return; } - } + }; let _ = start_media_download_internal( app_handle, - tasks_map, - id_clone, - url, + &id, url, destination, filename, format_selector, @@ -588,9 +532,11 @@ async fn start_media_download( proxy, user_agent, max_tries, + &mut cancel_rx, ).await; - - drop(permit); // Release semaphore permit + + drop(permit); + coordinator.finish_media(id).await; }); Ok(()) @@ -598,8 +544,7 @@ async fn start_media_download( pub(crate) async fn start_media_download_internal( app_handle: tauri::AppHandle, - tasks_map: Arc>>, - id: String, + id: &str, url: String, destination: String, filename: String, @@ -612,6 +557,7 @@ pub(crate) async fn start_media_download_internal( proxy: Option, user_agent: Option, max_tries: Option, + cancel_rx: &mut tokio::sync::watch::Receiver, ) -> Result<(), String> { println!("start_media_download called for id: {}", id); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; @@ -733,159 +679,108 @@ pub(crate) async fn start_media_download_internal( cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; - let pid = child.id().unwrap_or(0); - - // Update task handle from Queued to Pid - tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Pid(pid)); - let stdout = child.stdout.take().unwrap(); - let app_handle_clone = app_handle.clone(); - let id_clone = id.clone(); // yt-dlp parsing regex let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap(); let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap(); - tauri::async_runtime::spawn(async move { - let _keep_alive = config_path; // Keep the temp file alive - let mut reader = BufReader::new(stdout).lines(); - let mut current_track: f64 = 0.0; - let mut last_fraction: f64 = 0.0; + let _keep_alive = config_path; + let mut reader = BufReader::new(stdout).lines(); + let mut current_track: f64 = 0.0; + let mut last_fraction: f64 = 0.0; + let mut last_progress_at = std::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(150)) + .unwrap_or_else(std::time::Instant::now); - loop { - tokio::select! { - line_result = reader.next_line() => { - match line_result { - Ok(Some(line)) => { - if line.contains("[download]") && line.contains("%") { - let fraction = pct_re.captures(&line) - .and_then(|cap| cap.get(1)) - .and_then(|m| m.as_str().parse::().ok()) - .unwrap_or(0.0) / 100.0; + loop { + tokio::select! { + _ = cancel_rx.changed() => { + let _ = child.kill().await; + return Ok(()); + } + line_result = reader.next_line() => { + match line_result { + Ok(Some(line)) => { + if line.contains("[download]") && line.contains("%") { + let fraction = pct_re.captures(&line) + .and_then(|cap| cap.get(1)) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0.0) / 100.0; - if fraction < last_fraction && (last_fraction - fraction) > 0.5 { - current_track += 1.0; - } - last_fraction = fraction; + if fraction < last_fraction && (last_fraction - fraction) > 0.5 { + current_track += 1.0; + } + last_fraction = fraction; - let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0); + let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0); - let speed = spd_re.captures(&line) - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()) - .unwrap_or_else(|| "-".to_string()); + let speed = spd_re.captures(&line) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "-".to_string()); - let eta = eta_re.captures(&line) - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()) - .unwrap_or_else(|| "-".to_string()); + let eta = eta_re.captures(&line) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "-".to_string()); - let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { - id: id_clone.clone(), + let now = std::time::Instant::now(); + if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(150) { + let _ = app_handle.emit("download-progress", DownloadProgressEvent { + id: id.to_string(), fraction: overall_fraction, speed, eta, }); + last_progress_at = now; } } - _ => break, } - } - status = child.wait() => { - println!("child exit status: {:?}", status); - if let Ok(exit_status) = status { - if exit_status.success() { - let _ = app_handle_clone.emit("download-complete", id_clone.clone()); - } else { - // If it exited with error, emit failed - let _ = app_handle_clone.emit("download-failed", id_clone.clone()); - } - } - break; + _ => break, } } + status = child.wait() => { + println!("child exit status: {:?}", status); + if let Ok(exit_status) = status { + if exit_status.success() { + let _ = app_handle.emit("download-complete", id.to_string()); + } else { + let _ = app_handle.emit("download-failed", id.to_string()); + } + } + break; + } } - }); - + } + Ok(()) } #[tauri::command] async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { println!("pause_download called for id: {}", id); - let handle_opt = state.tasks.lock().unwrap().remove(&id); - if let Some(handle) = handle_opt { - match handle { - TaskHandle::Aria2(gid) => { - let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([gid])).await; - } - TaskHandle::Queued => { - // If it was just queued, it's already removed from tasks map. - // The waiting Tokio task will wake up, see it's missing, and abort silently. - println!("Queued download {} aborted before starting", id); - } - TaskHandle::Pid(pid) => { - if pid > 0 { - use sysinfo::{System, Pid}; - let mut sys = System::new_all(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - let parent_pid = Pid::from_u32(pid); - let mut to_kill = vec![parent_pid]; - let mut idx = 0; - - while idx < to_kill.len() { - let current = to_kill[idx]; - for (proc_pid, proc) in sys.processes() { - if let Some(p) = proc.parent() { - if p == current { - to_kill.push(*proc_pid); - } - } - } - idx += 1; - } - - for p in to_kill.into_iter().rev() { - if let Some(proc) = sys.process(p) { - #[cfg(unix)] - { - if proc.kill_with(sysinfo::Signal::Term).is_none() { - proc.kill(); // Fallback to SIGKILL - } - } - #[cfg(windows)] - proc.kill(); - - println!("Sent termination signal to pid: {}", p.as_u32()); - } - } - } - } - } + if let Ok(download_id) = Uuid::parse_str(&id) { + state + .download_coordinator + .send(download::DownloadCmd::Pause(download_id)) + .await?; } - Ok(()) + state.download_coordinator.pause_media(id).await } #[tauri::command] async fn remove_download(state: tauri::State<'_, AppState>, id: String, filepath: Option) -> Result<(), String> { println!("remove_download called for id: {}", id); - - // Check if it's aria2 first, so we can call remove instead of pause - let mut is_aria2 = false; - let mut gid_to_remove = String::new(); - if let Some(TaskHandle::Aria2(gid)) = state.tasks.lock().unwrap().get(&id) { - is_aria2 = true; - gid_to_remove = gid.clone(); - } - - if is_aria2 { - state.tasks.lock().unwrap().remove(&id); - let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.remove", serde_json::json!([gid_to_remove])).await; - } else { - let _ = pause_download(state, id).await; + + if let Ok(download_id) = Uuid::parse_str(&id) { + state + .download_coordinator + .send(download::DownloadCmd::Cancel(download_id)) + .await?; } + state.download_coordinator.pause_media(id).await?; if let Some(path) = filepath { if !path.is_empty() { @@ -938,12 +833,18 @@ fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) { } #[tauri::command] -fn perform_system_action(action: String) -> Result<(), String> { - match action.as_str() { - "shutdown" => system_shutdown::shutdown().map_err(|e| e.to_string()), - "restart" => system_shutdown::reboot().map_err(|e| e.to_string()), - "sleep" => system_shutdown::sleep().map_err(|e| e.to_string()), - _ => Err("Invalid action".to_string()) +fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { + match action { + crate::ipc::PostQueueAction::Shutdown => { + system_shutdown::shutdown().map_err(|e| e.to_string()) + } + crate::ipc::PostQueueAction::Restart => { + system_shutdown::reboot().map_err(|e| e.to_string()) + } + crate::ipc::PostQueueAction::Sleep => { + system_shutdown::sleep().map_err(|e| e.to_string()) + } + crate::ipc::PostQueueAction::None => Err("Invalid action".to_string()), } } @@ -1225,17 +1126,17 @@ pub fn run() { } })) .plugin(tauri_plugin_deep_link::init()) - .manage(AppState { - tasks: Arc::new(Mutex::new(HashMap::new())), - extension_pairing_token, - extension_frontend_ready, - aria2_port, - aria2_secret: aria2_secret.clone(), - media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), - sleep_preventer: Arc::new(Mutex::new(None)), - }) .manage(Aria2DaemonGuard(std::sync::Mutex::new(None))) .setup(move |app| { + app.manage(AppState { + download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), + extension_pairing_token, + extension_frontend_ready, + aria2_port, + aria2_secret: aria2_secret.clone(), + media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), + sleep_preventer: Arc::new(Mutex::new(None)), + }); let db_conn = crate::db::init_db(app.handle()).expect("Failed to init db"); app.manage(crate::db::DbState { conn: std::sync::Mutex::new(db_conn) }); @@ -1346,15 +1247,13 @@ pub fn run() { match msg { Some(Ok(Message::Text(text))) => { if let Ok(json) = serde_json::from_str::(text.as_str()) { - let state = app_handle_clone.state::(); - let tasks = state.tasks.clone(); + let tasks = HashMap::::new(); // Process progress if json.get("id").and_then(|i| i.as_str()) == Some("progress") { let mut gid_to_id = HashMap::new(); { - let map = tasks.lock().unwrap(); - for (id, handle) in map.iter() { + for (id, handle) in tasks.iter() { if let TaskHandle::Aria2(gid) = handle { gid_to_id.insert(gid.clone(), id.clone()); } @@ -1411,8 +1310,7 @@ pub fn run() { if let Some(gid) = event_info.get("gid").and_then(|g| g.as_str()) { let mut target_id = None; { - let map = tasks.lock().unwrap(); - for (id, handle) in map.iter() { + for (id, handle) in tasks.iter() { if let TaskHandle::Aria2(task_gid) = handle { if task_gid == gid { target_id = Some(id.clone()); @@ -1428,7 +1326,6 @@ pub fn run() { } else { let _ = app_handle_clone.emit("download-failed", id.clone()); } - tasks.lock().unwrap().remove(&id); } } } @@ -1504,9 +1401,10 @@ fn db_get_all_downloads(state: tauri::State) -> Result, id: String, status: String, queue_id: String, data: String) -> Result<(), String> { +fn db_save_download(state: tauri::State, id: String, status: crate::ipc::DownloadStatus, queue_id: String, data: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); - crate::db::insert_download(&conn, &id, &status, &queue_id, &data).map_err(|e| e.to_string()) + crate::db::insert_download(&conn, &id, status.as_str(), &queue_id, &data) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/parity.rs b/apps/desktop/src-tauri/src/parity.rs index 125bee1..e469ac5 100644 --- a/apps/desktop/src-tauri/src/parity.rs +++ b/apps/desktop/src-tauri/src/parity.rs @@ -1,4 +1,7 @@ use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::ipc::DownloadCategory; #[tauri::command] pub async fn get_system_proxy() -> Result, String> { @@ -17,7 +20,7 @@ pub async fn get_system_proxy() -> Result, String> { } #[tauri::command] -pub fn get_file_category(filename: String) -> String { +pub fn get_file_category(filename: String) -> DownloadCategory { let ext = std::path::Path::new(&filename) .extension() .and_then(|s| s.to_str()) @@ -31,21 +34,22 @@ pub fn get_file_category(filename: String) -> String { let document_exts = ["azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx", "epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf", "numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx", "ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm", "xlsx", "xml", "xps", "yaml", "yml"]; if music_exts.contains(&ext.as_str()) { - "musics".to_string() + DownloadCategory::Musics } else if movie_exts.contains(&ext.as_str()) { - "movies".to_string() + DownloadCategory::Movies } else if compressed_exts.contains(&ext.as_str()) { - "compressed".to_string() + DownloadCategory::Compressed } else if picture_exts.contains(&ext.as_str()) { - "pictures".to_string() + DownloadCategory::Pictures } else if document_exts.contains(&ext.as_str()) { - "documents".to_string() + DownloadCategory::Documents } else { - "other".to_string() + DownloadCategory::Other } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, TS)] +#[ts(export, export_to = "../../src/bindings/")] pub struct AvailableReleaseUpdate { pub version: String, pub tag_name: String, @@ -55,8 +59,9 @@ pub struct AvailableReleaseUpdate { pub published_at: Option, } -#[derive(Serialize)] +#[derive(Serialize, TS)] #[serde(tag = "type")] +#[ts(export, export_to = "../../src/bindings/")] pub enum ReleaseCheckOutcome { UpdateAvailable { update: AvailableReleaseUpdate }, UpToDate { latest_version: String, local_version: String }, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e9831fe..cdc5c4b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -4,11 +4,10 @@ import { DownloadTable } from "./components/DownloadTable"; import { AddDownloadsModal } from "./components/AddDownloadsModal"; import SettingsView from "./components/SettingsView"; import { PropertiesModal } from "./components/PropertiesModal"; -import { listen } from "@tauri-apps/api/event"; +import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; -import { invoke } from "@tauri-apps/api/core"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; @@ -211,7 +210,7 @@ function App() { }, [theme]); useEffect(() => { - const unlistenProgress = listen('download-progress', (event: any) => { + const unlistenProgress = listen('download-progress', (event) => { const { id, fraction, speed, eta } = event.payload; const state = useDownloadStore.getState(); const current = state.downloads.find(d => d.id === id); @@ -222,7 +221,7 @@ function App() { } }); - const unlistenComplete = listen('download-complete', (event: any) => { + const unlistenComplete = listen('download-complete', (event) => { updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' }); const settings = useSettingsStore.getState(); @@ -238,7 +237,7 @@ function App() { } }); - const unlistenFailed = listen('download-failed', (event: any) => { + const unlistenFailed = listen('download-failed', (event) => { // If it's already paused, don't mark as failed (since we aborted it) const current = useDownloadStore.getState().downloads.find(d => d.id === event.payload); if (current && current.status !== 'paused') { @@ -246,7 +245,7 @@ function App() { } }); - const unlistenExtension = listen('extension-add-download', (event: any) => { + const unlistenExtension = listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload); }); unlistenExtension diff --git a/apps/desktop/src/bindings/ActiveView.ts b/apps/desktop/src/bindings/ActiveView.ts new file mode 100644 index 0000000..650b12b --- /dev/null +++ b/apps/desktop/src/bindings/ActiveView.ts @@ -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 ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter"; diff --git a/apps/desktop/src/bindings/AppFontSize.ts b/apps/desktop/src/bindings/AppFontSize.ts new file mode 100644 index 0000000..6a7f0ca --- /dev/null +++ b/apps/desktop/src/bindings/AppFontSize.ts @@ -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 AppFontSize = "small" | "standard" | "large"; diff --git a/apps/desktop/src/bindings/AvailableReleaseUpdate.ts b/apps/desktop/src/bindings/AvailableReleaseUpdate.ts new file mode 100644 index 0000000..d1f3133 --- /dev/null +++ b/apps/desktop/src/bindings/AvailableReleaseUpdate.ts @@ -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 AvailableReleaseUpdate = { version: string, tag_name: string, title: string, release_notes: string, release_url: string, published_at: string | null, }; diff --git a/apps/desktop/src/bindings/DownloadCategory.ts b/apps/desktop/src/bindings/DownloadCategory.ts new file mode 100644 index 0000000..3ba62cb --- /dev/null +++ b/apps/desktop/src/bindings/DownloadCategory.ts @@ -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 DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other"; diff --git a/apps/desktop/src/bindings/DownloadItem.ts b/apps/desktop/src/bindings/DownloadItem.ts new file mode 100644 index 0000000..b53f64f --- /dev/null +++ b/apps/desktop/src/bindings/DownloadItem.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DownloadCategory } from "./DownloadCategory"; +import type { DownloadStatus } from "./DownloadStatus"; + +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, _dispatched?: boolean, }; diff --git a/apps/desktop/src/bindings/DownloadProgressEvent.ts b/apps/desktop/src/bindings/DownloadProgressEvent.ts new file mode 100644 index 0000000..1a135ac --- /dev/null +++ b/apps/desktop/src/bindings/DownloadProgressEvent.ts @@ -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 DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, }; diff --git a/apps/desktop/src/bindings/DownloadStatus.ts b/apps/desktop/src/bindings/DownloadStatus.ts new file mode 100644 index 0000000..5456aa2 --- /dev/null +++ b/apps/desktop/src/bindings/DownloadStatus.ts @@ -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 DownloadStatus = "downloading" | "paused" | "completed" | "failed" | "queued"; diff --git a/apps/desktop/src/bindings/ExtensionDownload.ts b/apps/desktop/src/bindings/ExtensionDownload.ts new file mode 100644 index 0000000..2474503 --- /dev/null +++ b/apps/desktop/src/bindings/ExtensionDownload.ts @@ -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 ExtensionDownload = { urls: Array, referer: string | null, silent: boolean, filename: string | null, }; diff --git a/apps/desktop/src/bindings/ListRowDensity.ts b/apps/desktop/src/bindings/ListRowDensity.ts new file mode 100644 index 0000000..9b5f979 --- /dev/null +++ b/apps/desktop/src/bindings/ListRowDensity.ts @@ -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 ListRowDensity = "compact" | "standard" | "relaxed"; diff --git a/apps/desktop/src/bindings/MediaCookieSource.ts b/apps/desktop/src/bindings/MediaCookieSource.ts new file mode 100644 index 0000000..916596f --- /dev/null +++ b/apps/desktop/src/bindings/MediaCookieSource.ts @@ -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 MediaCookieSource = "none" | "safari" | "chrome" | "firefox" | "edge" | "brave"; diff --git a/apps/desktop/src/bindings/MetadataResponse.ts b/apps/desktop/src/bindings/MetadataResponse.ts new file mode 100644 index 0000000..d6cbc3e --- /dev/null +++ b/apps/desktop/src/bindings/MetadataResponse.ts @@ -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 MetadataResponse = { filename: string, size: string, size_bytes: number, }; diff --git a/apps/desktop/src/bindings/PersistedSettings.ts b/apps/desktop/src/bindings/PersistedSettings.ts new file mode 100644 index 0000000..9ef53e9 --- /dev/null +++ b/apps/desktop/src/bindings/PersistedSettings.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppFontSize } from "./AppFontSize"; +import type { ListRowDensity } from "./ListRowDensity"; +import type { MediaCookieSource } from "./MediaCookieSource"; +import type { ProxyMode } from "./ProxyMode"; +import type { SchedulerSettings } from "./SchedulerSettings"; +import type { SettingsTab } from "./SettingsTab"; +import type { SiteLogin } from "./SiteLogin"; +import type { Theme } from "./Theme"; + +export type PersistedSettings = { theme: Theme, defaultDownloadPath: string, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, downloadDirectories: { [key in string]: string }, siteLogins: Array, extensionPairingToken: string, autoCheckUpdates: boolean, }; diff --git a/apps/desktop/src/bindings/PostQueueAction.ts b/apps/desktop/src/bindings/PostQueueAction.ts new file mode 100644 index 0000000..b9ce01a --- /dev/null +++ b/apps/desktop/src/bindings/PostQueueAction.ts @@ -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 PostQueueAction = "none" | "sleep" | "restart" | "shutdown"; diff --git a/apps/desktop/src/bindings/ProxyMode.ts b/apps/desktop/src/bindings/ProxyMode.ts new file mode 100644 index 0000000..b1d97d8 --- /dev/null +++ b/apps/desktop/src/bindings/ProxyMode.ts @@ -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 ProxyMode = "none" | "system" | "custom"; diff --git a/apps/desktop/src/bindings/Queue.ts b/apps/desktop/src/bindings/Queue.ts new file mode 100644 index 0000000..bb24c4e --- /dev/null +++ b/apps/desktop/src/bindings/Queue.ts @@ -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 Queue = { id: string, name: string, isMain: boolean, }; diff --git a/apps/desktop/src/bindings/ReleaseCheckOutcome.ts b/apps/desktop/src/bindings/ReleaseCheckOutcome.ts new file mode 100644 index 0000000..0caad67 --- /dev/null +++ b/apps/desktop/src/bindings/ReleaseCheckOutcome.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AvailableReleaseUpdate } from "./AvailableReleaseUpdate"; + +export type ReleaseCheckOutcome = { "type": "UpdateAvailable", update: AvailableReleaseUpdate, } | { "type": "UpToDate", latest_version: string, local_version: string, }; diff --git a/apps/desktop/src/bindings/SchedulerSettings.ts b/apps/desktop/src/bindings/SchedulerSettings.ts new file mode 100644 index 0000000..5d7274d --- /dev/null +++ b/apps/desktop/src/bindings/SchedulerSettings.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PostQueueAction } from "./PostQueueAction"; + +export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array, postQueueAction: PostQueueAction, }; diff --git a/apps/desktop/src/bindings/SettingsTab.ts b/apps/desktop/src/bindings/SettingsTab.ts new file mode 100644 index 0000000..2409202 --- /dev/null +++ b/apps/desktop/src/bindings/SettingsTab.ts @@ -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 SettingsTab = "downloads" | "lookandfeel" | "network" | "locations" | "sitelogins" | "power" | "engine" | "integrations" | "about"; diff --git a/apps/desktop/src/bindings/SiteLogin.ts b/apps/desktop/src/bindings/SiteLogin.ts new file mode 100644 index 0000000..11e1e85 --- /dev/null +++ b/apps/desktop/src/bindings/SiteLogin.ts @@ -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 SiteLogin = { id: string, urlPattern: string, username: string, }; diff --git a/apps/desktop/src/bindings/Theme.ts b/apps/desktop/src/bindings/Theme.ts new file mode 100644 index 0000000..871a23c --- /dev/null +++ b/apps/desktop/src/bindings/Theme.ts @@ -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 Theme = "dark" | "light" | "system" | "dracula" | "nord"; diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index 3fce13a..b568569 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; -import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react'; +import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; -import { invoke } from '@tauri-apps/api/core'; +import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; @@ -19,6 +19,15 @@ interface RawMediaFormat { filesize_approx?: number; } +interface MediaFormat { + name: string; + selector: string; + ext: string; + detail: string; + type: string; + bytes: number; +} + interface ParsedDownloadItem { url: string; file: string; @@ -26,7 +35,7 @@ interface ParsedDownloadItem { sizeBytes?: number; status?: string; isMedia?: boolean; - formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[]; + formats?: MediaFormat[]; selectedFormat?: number; } @@ -134,10 +143,14 @@ const formatBytes = (bytes: number) => { const parseMediaFormats = (jsonStr: string) => { try { - const data = JSON.parse(jsonStr); - let title = data.title || 'Media'; + const parsed: unknown = JSON.parse(jsonStr); + if (!parsed || typeof parsed !== 'object') return null; + const data = parsed as { title?: unknown; formats?: unknown }; + let title = typeof data.title === 'string' ? data.title : 'Media'; title = title.replace(/[\/\\?%*:|"<>]/g, '-'); - const rawFormats: RawMediaFormat[] = data.formats || []; + const rawFormats = Array.isArray(data.formats) + ? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object') + : []; const options = []; @@ -297,7 +310,7 @@ export const AddDownloadsModal = () => { useEffect(() => { if (!saveLocation) return; - invoke('get_free_space', { path: saveLocation }) + invoke('get_free_space', { path: saveLocation }) .then(space => setFreeSpace(space)) .catch(() => setFreeSpace('Unknown')); }, [saveLocation, isAddModalOpen]); @@ -338,13 +351,13 @@ export const AddDownloadsModal = () => { let keychainPassword = null; if (login) { try { - keychainPassword = await invoke('get_keychain_password', { id: login.id }); + keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password:", e); } } - const jsonStr = await invoke('fetch_media_metadata', { + const jsonStr = await invoke('fetch_media_metadata', { url, cookieBrowser: browserArg, username: login?.username || null, @@ -371,13 +384,14 @@ export const AddDownloadsModal = () => { let keychainPassword = null; if (login) { try { - keychainPassword = await invoke('get_keychain_password', { id: login.id }); + keychainPassword = await invoke('get_keychain_password', { id: login.id }); } catch (e) { console.warn("Could not fetch keychain password:", e); } } - const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { + const meta = await invoke('fetch_metadata', { url, + userAgent: settingsStore.customUserAgent || null, username: login?.username || null, password: keychainPassword }); @@ -467,7 +481,7 @@ export const AddDownloadsModal = () => { let fileExistsOnDisk = false; try { const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation; - fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` }); + fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` }); } catch (e) {} if (fileExistsInStore || fileExistsOnDisk) { @@ -487,7 +501,7 @@ export const AddDownloadsModal = () => { }; const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { - let itemsToAdd = [...parsedItems]; + let itemsToAdd: Array = [...parsedItems]; if (resolutions) { for (const res of resolutions) { @@ -496,7 +510,7 @@ export const AddDownloadsModal = () => { if (!item) continue; if (res.resolution === 'skip') { - itemsToAdd[idx] = null as any; // mark for skip + itemsToAdd[idx] = null; } else if (res.resolution === 'rename') { let finalFile = item.file; if (item.isMedia && item.formats && item.selectedFormat !== undefined) { @@ -519,7 +533,7 @@ export const AddDownloadsModal = () => { return dest === finalLocation && d.fileName === newName && d.status !== 'failed'; }); let diskHas = false; - try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {} + try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {} exists = storeHas || diskHas; count++; } @@ -550,9 +564,9 @@ export const AddDownloadsModal = () => { } } - itemsToAdd = itemsToAdd.filter(Boolean); + const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null); - for (const item of itemsToAdd) { + for (const item of resolvedItems) { try { const id = crypto.randomUUID(); let finalFile = item.file; @@ -596,7 +610,12 @@ export const AddDownloadsModal = () => { toggleAddModal(false); }; - const SummaryBox = ({ title, value, icon: Icon, color }: any) => ( + const SummaryBox = ({ title, value, icon: Icon, color }: { + title: string; + value: string | number; + icon: LucideIcon; + color: string; + }) => (
@@ -737,7 +756,7 @@ export const AddDownloadsModal = () => {
- {parsedItems[selectedItemIndex].formats!.map((f: any, idx: number) => { + {parsedItems[selectedItemIndex].formats!.map((f, idx) => { const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx; const Icon = f.type === 'Audio' ? Music : Film; return ( diff --git a/apps/desktop/src/components/DownloadTable.tsx b/apps/desktop/src/components/DownloadTable.tsx index 07b59fa..37a3b0a 100644 --- a/apps/desktop/src/components/DownloadTable.tsx +++ b/apps/desktop/src/components/DownloadTable.tsx @@ -3,7 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; import { Play, Pause, Plus, Trash2, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, MoreVertical, PanelLeft, ArrowDownCircle, Command } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; +import { invokeCommand as invoke } from '../ipc'; import { homeDir } from '@tauri-apps/api/path'; interface DownloadTableProps { diff --git a/apps/desktop/src/components/DuplicateResolutionModal.tsx b/apps/desktop/src/components/DuplicateResolutionModal.tsx index 6100880..953043f 100644 --- a/apps/desktop/src/components/DuplicateResolutionModal.tsx +++ b/apps/desktop/src/components/DuplicateResolutionModal.tsx @@ -1,24 +1,25 @@ import { useState } from 'react'; export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string }; +type DuplicateResolution = 'rename' | 'replace' | 'skip'; export interface DuplicateConflict { id: string; // id of the pending item fileName: string; reason: DuplicateReason; - resolution: 'rename' | 'replace' | 'skip'; + resolution: DuplicateResolution; } interface Props { conflicts: DuplicateConflict[]; - onConfirm: (resolutions: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => void; + onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void; onCancel: () => void; } export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => { const [conflicts, setConflicts] = useState(initialConflicts); - const updateResolution = (id: string, resolution: 'rename' | 'replace' | 'skip') => { + const updateResolution = (id: string, resolution: DuplicateResolution) => { setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c)); }; @@ -39,7 +40,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
settings.setAppFontSize(e.target.value as any)} + onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)} className="app-control w-40" > @@ -375,7 +367,7 @@ export default function SettingsView() { List Row Density settings.setMediaCookieSource(e.target.value as any)} + onChange={(e) => settings.setMediaCookieSource( + e.target.value as typeof settings.mediaCookieSource + )} className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent" > diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index fff871e..7efd5cf 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { Inbox, Zap, CheckCircle2, CircleDashed, Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, - List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft + List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft, + type LucideIcon } from 'lucide-react'; import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; import { ActiveView, useSettingsStore } from '../store/useSettingsStore'; @@ -57,7 +58,7 @@ export const Sidebar: React.FC = (props) => { } }; - const NavItem = ({ icon: Icon, label, filter }: { icon: any, label: string, filter: SidebarFilter }) => { + const NavItem = ({ icon: Icon, label, filter }: { icon: LucideIcon, label: string, filter: SidebarFilter }) => { const isSelected = activeView === 'downloads' && selectedFilter === filter; return ( @@ -141,7 +142,7 @@ export const Sidebar: React.FC = (props) => { ); }; - const ToolItem = ({ icon: Icon, label, view }: { icon: any; label: string; view: ActiveView }) => { + const ToolItem = ({ icon: Icon, label, view }: { icon: LucideIcon; label: string; view: ActiveView }) => { const isSelected = activeView === view; return (