feat(desktop): modernize download core and type IPC

Replace shared download locking with a Tokio coordinator actor and async streamed file writes.

Generate TypeScript IPC payload types from Rust and route frontend commands and events through typed wrappers.
This commit is contained in:
NimBold
2026-06-14 17:38:16 +03:30
parent 77fdc30b80
commit f806fd2cbf
40 changed files with 1360 additions and 425 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
+51 -1
View File
@@ -3482,6 +3482,7 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util",
"h2", "h2",
"http", "http",
"http-body", "http-body",
@@ -3503,12 +3504,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
] ]
@@ -3542,7 +3545,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@@ -4440,6 +4443,8 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tower-http", "tower-http",
"ts-rs",
"uuid",
"window-vibrancy 0.7.1", "window-vibrancy 0.7.1",
] ]
@@ -4776,6 +4781,15 @@ dependencies = [
"utf-8", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -5188,6 +5202,29 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 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]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.29.0" version = "0.29.0"
@@ -5511,6 +5548,19 @@ dependencies = [
"wasmparser", "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]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
+4 -2
View File
@@ -23,9 +23,11 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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" 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" tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3" sysinfo = "0.39.3"
keyring = "3" keyring = "3"
+539
View File
@@ -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<String>,
pub output_path: PathBuf,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub headers: Option<String>,
pub cookies: Option<String>,
pub user_agent: Option<String>,
pub max_tries: u32,
pub proxy: Option<String>,
}
#[derive(Clone)]
pub struct DownloadCoordinator {
tx: mpsc::Sender<DownloadCmd>,
media_tx: mpsc::Sender<MediaCmd>,
}
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<watch::Receiver<bool>, 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<bool>,
},
Pause(String),
Finished(String),
}
#[derive(Debug, Clone, Copy)]
enum DownloadControl {
Pause,
Cancel,
Replace,
}
struct ActiveDownload {
generation: u64,
control_tx: mpsc::Sender<DownloadControl>,
}
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<DownloadCmd>,
mut media_rx: mpsc::Receiver<MediaCmd>,
) {
let (worker_tx, mut worker_rx) = mpsc::channel(128);
let mut active = HashMap::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::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<DownloadControl>,
) -> 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<DownloadControl>,
) -> 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<Client, String> {
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<u64>,
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<u64> {
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::<f64>()
.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);
}
}
@@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use ts_rs::TS;
pub const EXTENSION_SERVER_PORT: u16 = 23522; pub const EXTENSION_SERVER_PORT: u16 = 23522;
const MAX_URL_COUNT: usize = 200; const MAX_URL_COUNT: usize = 200;
@@ -45,8 +46,9 @@ struct ExtensionRequest {
filename: Option<String>, filename: Option<String>,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize, TS)]
struct ExtensionDownload { #[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionDownload {
urls: Vec<String>, urls: Vec<String>,
referer: Option<String>, referer: Option<String>,
silent: bool, silent: bool,
+236
View File
@@ -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<f64>,
#[ts(optional)]
pub speed: Option<String>,
#[ts(optional)]
pub eta: Option<String>,
#[ts(optional)]
pub size: Option<String>,
pub category: DownloadCategory,
pub date_added: String,
#[ts(optional)]
pub connections: Option<i32>,
#[ts(optional)]
pub speed_limit: Option<String>,
#[ts(optional)]
pub username: Option<String>,
#[ts(optional)]
pub password: Option<String>,
#[ts(optional)]
pub headers: Option<String>,
#[ts(optional)]
pub checksum: Option<String>,
#[ts(optional)]
pub cookies: Option<String>,
#[ts(optional)]
pub mirrors: Option<String>,
#[ts(optional)]
pub destination: Option<String>,
#[ts(optional)]
pub is_media: Option<bool>,
#[ts(optional)]
pub media_format_selector: Option<String>,
pub queue_id: String,
#[serde(rename = "_dispatched")]
#[ts(optional)]
pub dispatched: Option<bool>,
}
#[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<u32>,
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<String, String>,
pub site_logins: Vec<SiteLogin>,
pub extension_pairing_token: String,
pub auto_check_updates: bool,
}
+144 -246
View File
@@ -5,11 +5,15 @@ use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use regex::Regex; use regex::Regex;
use serde::Serialize; use serde::Serialize;
use ts_rs::TS;
use uuid::Uuid;
#[derive(Serialize)] #[derive(Serialize, TS)]
struct MetadataResponse { #[ts(export, export_to = "../../src/bindings/")]
pub struct MetadataResponse {
filename: String, filename: String,
size: String, size: String,
#[ts(type = "number")]
size_bytes: u64, size_bytes: u64,
} }
@@ -336,18 +340,23 @@ fn resign_aria2_debug_bundle(aria2c_path: &std::path::Path) -> Result<(), String
Ok(()) Ok(())
} }
mod download;
#[allow(dead_code)]
mod ipc;
mod parity; mod parity;
pub mod error; pub mod error;
pub use error::AppError; pub use error::AppError;
// Retained only for compatibility with the optional aria2 diagnostic monitor.
// Active downloads are owned by DownloadCoordinator.
pub enum TaskHandle { pub enum TaskHandle {
Aria2(String), Aria2(String),
Pid(u32), #[doc(hidden)]
Queued, Inactive,
} }
pub struct AppState { pub struct AppState {
pub tasks: Arc<Mutex<HashMap<String, TaskHandle>>>, pub download_coordinator: download::DownloadCoordinator,
pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_pairing_token: extension_server::SharedExtensionToken,
pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_frontend_ready: extension_server::SharedFrontendReady,
pub aria2_port: u16, pub aria2_port: u16,
@@ -356,8 +365,9 @@ pub struct AppState {
pub sleep_preventer: Arc<Mutex<Option<keepawake::KeepAwake>>>, pub sleep_preventer: Arc<Mutex<Option<keepawake::KeepAwake>>>,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize, TS)]
struct DownloadProgressEvent { #[ts(export, export_to = "../../src/bindings/")]
pub struct DownloadProgressEvent {
id: String, id: String,
fraction: f64, fraction: f64,
speed: String, speed: String,
@@ -443,9 +453,7 @@ async fn start_download(
proxy: Option<String>, proxy: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
println!("start_download called for id: {}", id); println!("start_download called for id: {}", id);
let state_aria2_port = state.aria2_port; let download_id = Uuid::parse_str(&id).map_err(|error| AppError::Internal(error.to_string()))?;
let state_aria2_secret = state.aria2_secret.clone();
let tasks_map = state.tasks.clone();
let mut resolved_dest = std::path::PathBuf::from(&destination); let mut resolved_dest = std::path::PathBuf::from(&destination);
if destination.starts_with("~/") { if destination.starts_with("~/") {
@@ -457,80 +465,25 @@ async fn start_download(
resolved_dest = home; resolved_dest = home;
} }
} }
let _ = connections;
if !resolved_dest.exists() { let _ = checksum;
let _ = tokio::fs::create_dir_all(&resolved_dest).await; state
} .download_coordinator
.send(download::DownloadCmd::Start(download::DownloadPayload {
let gid: String = id.replace("-", "").chars().take(16).collect(); id: download_id,
// ensure exactly 16 chars urls: collect_download_uris(&url, mirrors.as_deref()),
let gid = format!("{:0<16}", gid); output_path: resolved_dest.join(filename),
speed_limit,
tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Aria2(gid.clone())); username,
password,
let mut options = serde_json::Map::new(); headers,
options.insert("gid".to_string(), serde_json::json!(gid)); cookies,
options.insert("dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string())); user_agent,
options.insert("out".to_string(), serde_json::json!(filename)); max_tries: max_tries.unwrap_or(1).max(1) as u32,
proxy,
if let Some(conn) = connections { }))
options.insert("split".to_string(), serde_json::json!(conn.to_string())); .await
options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string())); .map_err(AppError::Internal)?;
}
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?;
Ok(()) Ok(())
} }
@@ -553,30 +506,21 @@ async fn start_media_download(
user_agent: Option<String>, user_agent: Option<String>,
max_tries: Option<i32>, max_tries: Option<i32>,
) -> Result<(), String> { ) -> Result<(), String> {
let tasks_map = state.tasks.clone();
let media_semaphore = state.media_semaphore.clone(); let media_semaphore = state.media_semaphore.clone();
let coordinator = state.download_coordinator.clone();
// Mark task as queued let mut cancel_rx = coordinator.register_media(id.clone()).await?;
tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Queued);
let id_clone = id.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// Wait in queue via semaphore let permit = tokio::select! {
let permit = media_semaphore.acquire().await; permit = media_semaphore.acquire() => permit,
_ = cancel_rx.changed() => {
// Check if user cancelled the task while it was waiting in queue coordinator.finish_media(id).await;
{
let map = tasks_map.lock().unwrap();
if !map.contains_key(&id_clone) {
return; return;
} }
} };
let _ = start_media_download_internal( let _ = start_media_download_internal(
app_handle, app_handle,
tasks_map, &id, url,
id_clone,
url,
destination, destination,
filename, filename,
format_selector, format_selector,
@@ -588,9 +532,11 @@ async fn start_media_download(
proxy, proxy,
user_agent, user_agent,
max_tries, max_tries,
&mut cancel_rx,
).await; ).await;
drop(permit); // Release semaphore permit drop(permit);
coordinator.finish_media(id).await;
}); });
Ok(()) Ok(())
@@ -598,8 +544,7 @@ async fn start_media_download(
pub(crate) async fn start_media_download_internal( pub(crate) async fn start_media_download_internal(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
tasks_map: Arc<Mutex<HashMap<String, TaskHandle>>>, id: &str,
id: String,
url: String, url: String,
destination: String, destination: String,
filename: String, filename: String,
@@ -612,6 +557,7 @@ pub(crate) async fn start_media_download_internal(
proxy: Option<String>, proxy: Option<String>,
user_agent: Option<String>, user_agent: Option<String>,
max_tries: Option<i32>, max_tries: Option<i32>,
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
println!("start_media_download called for id: {}", id); println!("start_media_download called for id: {}", id);
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; 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 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 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 stdout = child.stdout.take().unwrap();
let app_handle_clone = app_handle.clone();
let id_clone = id.clone();
// yt-dlp parsing regex // yt-dlp parsing regex
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap(); let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap();
let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap(); let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap();
tauri::async_runtime::spawn(async move { let _keep_alive = config_path;
let _keep_alive = config_path; // Keep the temp file alive let mut reader = BufReader::new(stdout).lines();
let mut reader = BufReader::new(stdout).lines(); let mut current_track: f64 = 0.0;
let mut current_track: f64 = 0.0; let mut last_fraction: 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 { loop {
tokio::select! { tokio::select! {
line_result = reader.next_line() => { _ = cancel_rx.changed() => {
match line_result { let _ = child.kill().await;
Ok(Some(line)) => { return Ok(());
if line.contains("[download]") && line.contains("%") { }
let fraction = pct_re.captures(&line) line_result = reader.next_line() => {
.and_then(|cap| cap.get(1)) match line_result {
.and_then(|m| m.as_str().parse::<f64>().ok()) Ok(Some(line)) => {
.unwrap_or(0.0) / 100.0; 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::<f64>().ok())
.unwrap_or(0.0) / 100.0;
if fraction < last_fraction && (last_fraction - fraction) > 0.5 { if fraction < last_fraction && (last_fraction - fraction) > 0.5 {
current_track += 1.0; current_track += 1.0;
} }
last_fraction = fraction; 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) let speed = spd_re.captures(&line)
.and_then(|cap| cap.get(1)) .and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
.unwrap_or_else(|| "-".to_string()); .unwrap_or_else(|| "-".to_string());
let eta = eta_re.captures(&line) let eta = eta_re.captures(&line)
.and_then(|cap| cap.get(1)) .and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
.unwrap_or_else(|| "-".to_string()); .unwrap_or_else(|| "-".to_string());
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { let now = std::time::Instant::now();
id: id_clone.clone(), 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, fraction: overall_fraction,
speed, speed,
eta, eta,
}); });
last_progress_at = now;
} }
} }
_ => break,
} }
} _ => 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;
} }
} }
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(()) Ok(())
} }
#[tauri::command] #[tauri::command]
async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
println!("pause_download called for id: {}", id); println!("pause_download called for id: {}", id);
let handle_opt = state.tasks.lock().unwrap().remove(&id); if let Ok(download_id) = Uuid::parse_str(&id) {
if let Some(handle) = handle_opt { state
match handle { .download_coordinator
TaskHandle::Aria2(gid) => { .send(download::DownloadCmd::Pause(download_id))
let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([gid])).await; .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());
}
}
}
}
}
} }
Ok(()) state.download_coordinator.pause_media(id).await
} }
#[tauri::command] #[tauri::command]
async fn remove_download(state: tauri::State<'_, AppState>, id: String, filepath: Option<String>) -> Result<(), String> { async fn remove_download(state: tauri::State<'_, AppState>, id: String, filepath: Option<String>) -> Result<(), String> {
println!("remove_download called for id: {}", id); println!("remove_download called for id: {}", id);
// Check if it's aria2 first, so we can call remove instead of pause if let Ok(download_id) = Uuid::parse_str(&id) {
let mut is_aria2 = false; state
let mut gid_to_remove = String::new(); .download_coordinator
if let Some(TaskHandle::Aria2(gid)) = state.tasks.lock().unwrap().get(&id) { .send(download::DownloadCmd::Cancel(download_id))
is_aria2 = true; .await?;
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;
} }
state.download_coordinator.pause_media(id).await?;
if let Some(path) = filepath { if let Some(path) = filepath {
if !path.is_empty() { if !path.is_empty() {
@@ -938,12 +833,18 @@ fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
} }
#[tauri::command] #[tauri::command]
fn perform_system_action(action: String) -> Result<(), String> { fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> {
match action.as_str() { match action {
"shutdown" => system_shutdown::shutdown().map_err(|e| e.to_string()), crate::ipc::PostQueueAction::Shutdown => {
"restart" => system_shutdown::reboot().map_err(|e| e.to_string()), system_shutdown::shutdown().map_err(|e| e.to_string())
"sleep" => system_shutdown::sleep().map_err(|e| e.to_string()), }
_ => Err("Invalid action".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()) .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))) .manage(Aria2DaemonGuard(std::sync::Mutex::new(None)))
.setup(move |app| { .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"); 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) }); app.manage(crate::db::DbState { conn: std::sync::Mutex::new(db_conn) });
@@ -1346,15 +1247,13 @@ pub fn run() {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text.as_str()) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(text.as_str()) {
let state = app_handle_clone.state::<AppState>(); let tasks = HashMap::<String, TaskHandle>::new();
let tasks = state.tasks.clone();
// Process progress // Process progress
if json.get("id").and_then(|i| i.as_str()) == Some("progress") { if json.get("id").and_then(|i| i.as_str()) == Some("progress") {
let mut gid_to_id = HashMap::new(); let mut gid_to_id = HashMap::new();
{ {
let map = tasks.lock().unwrap(); for (id, handle) in tasks.iter() {
for (id, handle) in map.iter() {
if let TaskHandle::Aria2(gid) = handle { if let TaskHandle::Aria2(gid) = handle {
gid_to_id.insert(gid.clone(), id.clone()); 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()) { if let Some(gid) = event_info.get("gid").and_then(|g| g.as_str()) {
let mut target_id = None; let mut target_id = None;
{ {
let map = tasks.lock().unwrap(); for (id, handle) in tasks.iter() {
for (id, handle) in map.iter() {
if let TaskHandle::Aria2(task_gid) = handle { if let TaskHandle::Aria2(task_gid) = handle {
if task_gid == gid { if task_gid == gid {
target_id = Some(id.clone()); target_id = Some(id.clone());
@@ -1428,7 +1326,6 @@ pub fn run() {
} else { } else {
let _ = app_handle_clone.emit("download-failed", id.clone()); 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<crate::db::DbState>) -> Result<Vec<S
} }
#[tauri::command] #[tauri::command]
fn db_save_download(state: tauri::State<crate::db::DbState>, id: String, status: String, queue_id: String, data: String) -> Result<(), String> { fn db_save_download(state: tauri::State<crate::db::DbState>, id: String, status: crate::ipc::DownloadStatus, queue_id: String, data: String) -> Result<(), String> {
let conn = state.conn.lock().unwrap(); 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] #[tauri::command]
+14 -9
View File
@@ -1,4 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::ipc::DownloadCategory;
#[tauri::command] #[tauri::command]
pub async fn get_system_proxy() -> Result<Option<String>, String> { pub async fn get_system_proxy() -> Result<Option<String>, String> {
@@ -17,7 +20,7 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
} }
#[tauri::command] #[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) let ext = std::path::Path::new(&filename)
.extension() .extension()
.and_then(|s| s.to_str()) .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"]; 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()) { if music_exts.contains(&ext.as_str()) {
"musics".to_string() DownloadCategory::Musics
} else if movie_exts.contains(&ext.as_str()) { } else if movie_exts.contains(&ext.as_str()) {
"movies".to_string() DownloadCategory::Movies
} else if compressed_exts.contains(&ext.as_str()) { } else if compressed_exts.contains(&ext.as_str()) {
"compressed".to_string() DownloadCategory::Compressed
} else if picture_exts.contains(&ext.as_str()) { } else if picture_exts.contains(&ext.as_str()) {
"pictures".to_string() DownloadCategory::Pictures
} else if document_exts.contains(&ext.as_str()) { } else if document_exts.contains(&ext.as_str()) {
"documents".to_string() DownloadCategory::Documents
} else { } 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 struct AvailableReleaseUpdate {
pub version: String, pub version: String,
pub tag_name: String, pub tag_name: String,
@@ -55,8 +59,9 @@ pub struct AvailableReleaseUpdate {
pub published_at: Option<String>, pub published_at: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[serde(tag = "type")] #[serde(tag = "type")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ReleaseCheckOutcome { pub enum ReleaseCheckOutcome {
UpdateAvailable { update: AvailableReleaseUpdate }, UpdateAvailable { update: AvailableReleaseUpdate },
UpToDate { latest_version: String, local_version: String }, UpToDate { latest_version: String, local_version: String },
+5 -6
View File
@@ -4,11 +4,10 @@ import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal"; import { AddDownloadsModal } from "./components/AddDownloadsModal";
import SettingsView from "./components/SettingsView"; import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal"; 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 { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { useSettingsStore } from "./store/useSettingsStore"; import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; 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 { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import SchedulerView from "./components/SchedulerView"; import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView"; import SpeedLimiterView from "./components/SpeedLimiterView";
@@ -211,7 +210,7 @@ function App() {
}, [theme]); }, [theme]);
useEffect(() => { useEffect(() => {
const unlistenProgress = listen('download-progress', (event: any) => { const unlistenProgress = listen('download-progress', (event) => {
const { id, fraction, speed, eta } = event.payload; const { id, fraction, speed, eta } = event.payload;
const state = useDownloadStore.getState(); const state = useDownloadStore.getState();
const current = state.downloads.find(d => d.id === id); 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: '-' }); updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' });
const settings = useSettingsStore.getState(); 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) // 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); const current = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
if (current && current.status !== 'paused') { 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); useDownloadStore.getState().handleExtensionDownload(event.payload);
}); });
unlistenExtension unlistenExtension
+3
View File
@@ -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";
+3
View File
@@ -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";
@@ -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, };
@@ -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";
@@ -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, };
@@ -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, };
@@ -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";
@@ -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<string>, referer: string | null, silent: boolean, filename: string | null, };
@@ -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";
@@ -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";
@@ -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, };
@@ -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<SiteLogin>, extensionPairingToken: string, autoCheckUpdates: boolean, };
@@ -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";
+3
View File
@@ -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";
+3
View File
@@ -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, };
@@ -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, };
@@ -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<number>, postQueueAction: PostQueueAction, };
+3
View File
@@ -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";
+3
View File
@@ -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, };
+3
View File
@@ -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";
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore'; 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 { 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 { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
@@ -19,6 +19,15 @@ interface RawMediaFormat {
filesize_approx?: number; filesize_approx?: number;
} }
interface MediaFormat {
name: string;
selector: string;
ext: string;
detail: string;
type: string;
bytes: number;
}
interface ParsedDownloadItem { interface ParsedDownloadItem {
url: string; url: string;
file: string; file: string;
@@ -26,7 +35,7 @@ interface ParsedDownloadItem {
sizeBytes?: number; sizeBytes?: number;
status?: string; status?: string;
isMedia?: boolean; isMedia?: boolean;
formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[]; formats?: MediaFormat[];
selectedFormat?: number; selectedFormat?: number;
} }
@@ -134,10 +143,14 @@ const formatBytes = (bytes: number) => {
const parseMediaFormats = (jsonStr: string) => { const parseMediaFormats = (jsonStr: string) => {
try { try {
const data = JSON.parse(jsonStr); const parsed: unknown = JSON.parse(jsonStr);
let title = data.title || 'Media'; 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, '-'); 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 = []; const options = [];
@@ -297,7 +310,7 @@ export const AddDownloadsModal = () => {
useEffect(() => { useEffect(() => {
if (!saveLocation) return; if (!saveLocation) return;
invoke<string>('get_free_space', { path: saveLocation }) invoke('get_free_space', { path: saveLocation })
.then(space => setFreeSpace(space)) .then(space => setFreeSpace(space))
.catch(() => setFreeSpace('Unknown')); .catch(() => setFreeSpace('Unknown'));
}, [saveLocation, isAddModalOpen]); }, [saveLocation, isAddModalOpen]);
@@ -338,13 +351,13 @@ export const AddDownloadsModal = () => {
let keychainPassword = null; let keychainPassword = null;
if (login) { if (login) {
try { try {
keychainPassword = await invoke<string>('get_keychain_password', { id: login.id }); keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) { } catch (e) {
console.warn("Could not fetch keychain password:", e); console.warn("Could not fetch keychain password:", e);
} }
} }
const jsonStr = await invoke<string>('fetch_media_metadata', { const jsonStr = await invoke('fetch_media_metadata', {
url, url,
cookieBrowser: browserArg, cookieBrowser: browserArg,
username: login?.username || null, username: login?.username || null,
@@ -371,13 +384,14 @@ export const AddDownloadsModal = () => {
let keychainPassword = null; let keychainPassword = null;
if (login) { if (login) {
try { try {
keychainPassword = await invoke<string>('get_keychain_password', { id: login.id }); keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) { } catch (e) {
console.warn("Could not fetch keychain password:", 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, url,
userAgent: settingsStore.customUserAgent || null,
username: login?.username || null, username: login?.username || null,
password: keychainPassword password: keychainPassword
}); });
@@ -467,7 +481,7 @@ export const AddDownloadsModal = () => {
let fileExistsOnDisk = false; let fileExistsOnDisk = false;
try { try {
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation; const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
fileExistsOnDisk = await invoke<boolean>('check_file_exists', { path: `${cleanLocation}/${finalFile}` }); fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
} catch (e) {} } catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) { if (fileExistsInStore || fileExistsOnDisk) {
@@ -487,7 +501,7 @@ export const AddDownloadsModal = () => {
}; };
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
let itemsToAdd = [...parsedItems]; let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) { if (resolutions) {
for (const res of resolutions) { for (const res of resolutions) {
@@ -496,7 +510,7 @@ export const AddDownloadsModal = () => {
if (!item) continue; if (!item) continue;
if (res.resolution === 'skip') { if (res.resolution === 'skip') {
itemsToAdd[idx] = null as any; // mark for skip itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') { } else if (res.resolution === 'rename') {
let finalFile = item.file; let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) { if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
@@ -519,7 +533,7 @@ export const AddDownloadsModal = () => {
return dest === finalLocation && d.fileName === newName && d.status !== 'failed'; return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
}); });
let diskHas = false; let diskHas = false;
try { diskHas = await invoke<boolean>('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {} try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
exists = storeHas || diskHas; exists = storeHas || diskHas;
count++; 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 { try {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
let finalFile = item.file; let finalFile = item.file;
@@ -596,7 +610,12 @@ export const AddDownloadsModal = () => {
toggleAddModal(false); 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;
}) => (
<div className="flex flex-col bg-bg-input/50 border border-border-modal/40 rounded-lg p-2.5 shadow-sm"> <div className="flex flex-col bg-bg-input/50 border border-border-modal/40 rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1.5 text-text-muted mb-1"> <div className="flex items-center gap-1.5 text-text-muted mb-1">
<Icon size={12} className={color} /> <Icon size={12} className={color} />
@@ -737,7 +756,7 @@ export const AddDownloadsModal = () => {
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label> <label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1"> <div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
{parsedItems[selectedItemIndex].formats!.map((f: any, idx: number) => { {parsedItems[selectedItemIndex].formats!.map((f, idx) => {
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx; const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
const Icon = f.type === 'Audio' ? Music : Film; const Icon = f.type === 'Audio' ? Music : Film;
return ( return (
@@ -3,7 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar'; 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 { 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'; import { homeDir } from '@tauri-apps/api/path';
interface DownloadTableProps { interface DownloadTableProps {
@@ -1,24 +1,25 @@
import { useState } from 'react'; import { useState } from 'react';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string }; export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
export interface DuplicateConflict { export interface DuplicateConflict {
id: string; // id of the pending item id: string; // id of the pending item
fileName: string; fileName: string;
reason: DuplicateReason; reason: DuplicateReason;
resolution: 'rename' | 'replace' | 'skip'; resolution: DuplicateResolution;
} }
interface Props { interface Props {
conflicts: DuplicateConflict[]; conflicts: DuplicateConflict[];
onConfirm: (resolutions: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => void; onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
onCancel: () => void; onCancel: () => void;
} }
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => { export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts); const [conflicts, setConflicts] = useState<DuplicateConflict[]>(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)); setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
}; };
@@ -39,7 +40,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
</div> </div>
<select <select
value={conflict.resolution} value={conflict.resolution}
onChange={(e) => updateResolution(conflict.id, e.target.value as any)} onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
className="app-control w-24 shrink-0 px-2 py-1 text-xs" className="app-control w-24 shrink-0 px-2 py-1 text-xs"
> >
<option value="rename">Rename</option> <option value="rename">Rename</option>
@@ -124,13 +124,13 @@ export const PropertiesModal = () => {
fileName, fileName,
destination: saveLocation, destination: saveLocation,
connections: Number(connections), connections: Number(connections),
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : null, speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
username: loginMode === 'custom' ? username.trim() : null, username: loginMode === 'custom' ? username.trim() : undefined,
password: loginMode === 'custom' ? password.trim() : null, password: loginMode === 'custom' ? password.trim() : undefined,
headers: headers.trim() || null, headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : null, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || null, cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || null, mirrors: mirrors.trim() || undefined,
}; };
updateDownload(item.id, updates); updateDownload(item.id, updates);
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invokeCommand as invoke } from '../ipc';
import { import {
CheckCircle2, Clock3, List, Moon, LockKeyhole, CheckCircle2, Clock3, List, Moon, LockKeyhole,
Pause, Play, Power, RotateCcw, Save Pause, Play, Power, RotateCcw, Save
+17 -23
View File
@@ -1,11 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { SettingsTab, useSettingsStore } from '../store/useSettingsStore'; import {
type AppFontSize,
type ListRowDensity,
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import { import {
Download, Palette, Globe, Folder, Key, Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
} from 'lucide-react'; } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core'; import { invokeCommand as invoke } from '../ipc';
import { WindowDragRegion } from './WindowDragRegion'; import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png'; import appIcon from '../assets/app-icon.png';
@@ -21,19 +26,6 @@ const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[
{ type: 'about', label: 'About', icon: Info }, { type: 'about', label: 'About', icon: Info },
]; ];
interface AvailableReleaseUpdate {
version: string;
tag_name: string;
title: string;
release_notes: string;
release_url: string;
published_at: string | null;
}
type ReleaseCheckOutcome =
| { type: 'UpdateAvailable'; update: AvailableReleaseUpdate }
| { type: 'UpToDate'; latest_version: string; local_version: string };
export default function SettingsView() { export default function SettingsView() {
const settings = useSettingsStore(); const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab; const activeTab = settings.activeSettingsTab;
@@ -70,19 +62,19 @@ export default function SettingsView() {
// Fetch engine versions when Engine tab is opened // Fetch engine versions when Engine tab is opened
useEffect(() => { useEffect(() => {
if (settings.activeView === 'settings' && activeTab === 'engine') { if (settings.activeView === 'settings' && activeTab === 'engine') {
invoke<string>('test_aria2c') invoke('test_aria2c')
.then(v => setAria2Version(v)) .then(v => setAria2Version(v))
.catch(e => setAria2Version('Error: ' + e)); .catch(e => setAria2Version('Error: ' + e));
invoke<string>('test_ytdlp') invoke('test_ytdlp')
.then(v => setYtdlpVersion(v)) .then(v => setYtdlpVersion(v))
.catch(e => setYtdlpVersion('Error: ' + e)); .catch(e => setYtdlpVersion('Error: ' + e));
invoke<string>('test_ffmpeg') invoke('test_ffmpeg')
.then(v => setFfmpegVersion(v)) .then(v => setFfmpegVersion(v))
.catch(e => setFfmpegVersion('Error: ' + e)); .catch(e => setFfmpegVersion('Error: ' + e));
invoke<string>('test_deno') invoke('test_deno')
.then(v => setDenoVersion(v)) .then(v => setDenoVersion(v))
.catch(e => setDenoVersion('Error: ' + e)); .catch(e => setDenoVersion('Error: ' + e));
} }
@@ -99,7 +91,7 @@ export default function SettingsView() {
showToast('Checking for updates...'); showToast('Checking for updates...');
try { try {
const result = await invoke<ReleaseCheckOutcome>('check_for_updates'); const result = await invoke('check_for_updates');
if (result.type === 'UpToDate') { if (result.type === 'UpToDate') {
showToast(`Firelink ${result.latest_version} is up to date`); showToast(`Firelink ${result.latest_version} is up to date`);
@@ -363,7 +355,7 @@ export default function SettingsView() {
<span className="text-[13px] text-text-primary">Font Size</span> <span className="text-[13px] text-text-primary">Font Size</span>
<select <select
value={settings.appFontSize} value={settings.appFontSize}
onChange={(e) => settings.setAppFontSize(e.target.value as any)} onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
className="app-control w-40" className="app-control w-40"
> >
<option value="small">Small</option> <option value="small">Small</option>
@@ -375,7 +367,7 @@ export default function SettingsView() {
<span className="text-[13px] text-text-primary">List Row Density</span> <span className="text-[13px] text-text-primary">List Row Density</span>
<select <select
value={settings.listRowDensity} value={settings.listRowDensity}
onChange={(e) => settings.setListRowDensity(e.target.value as any)} onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
className="app-control w-40" className="app-control w-40"
> >
<option value="compact">Compact</option> <option value="compact">Compact</option>
@@ -722,7 +714,9 @@ export default function SettingsView() {
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label> <label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
<select <select
value={settings.mediaCookieSource} value={settings.mediaCookieSource}
onChange={(e) => 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" className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
> >
<option value="none">None</option> <option value="none">None</option>
+4 -3
View File
@@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
import { import {
Inbox, Zap, CheckCircle2, CircleDashed, Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, 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'; } from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore'; import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
@@ -57,7 +58,7 @@ export const Sidebar: React.FC<SidebarProps> = (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; const isSelected = activeView === 'downloads' && selectedFilter === filter;
return ( return (
@@ -141,7 +142,7 @@ export const Sidebar: React.FC<SidebarProps> = (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; const isSelected = activeView === view;
return ( return (
<button <button
+125
View File
@@ -0,0 +1,125 @@
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { MediaCookieSource } from './bindings/MediaCookieSource';
import type { MetadataResponse } from './bindings/MetadataResponse';
import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
type StartDownloadArgs = {
id: string;
url: string;
destination: string;
filename: string;
connections: number | null;
speedLimit: string | null;
username: string | null;
password: string | null;
headers: string | null;
checksum: string | null;
cookies: string | null;
mirrors: string | null;
userAgent: string | null;
maxTries: number | null;
proxy: string | null;
};
type StartMediaDownloadArgs = {
id: string;
url: string;
destination: string;
filename: string;
formatSelector: string | null;
cookieSource: Exclude<MediaCookieSource, 'none'> | null;
speedLimit: string | null;
username: string | null;
password: string | null;
headers: string | null;
proxy: string | null;
userAgent: string | null;
maxTries: number | null;
};
type CommandMap = {
fetch_metadata: {
args: { url: string; userAgent: string | null; username: string | null; password: string | null };
result: MetadataResponse;
};
fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
result: string;
};
greet: { args: { name: string }; result: string };
test_ytdlp: { args: undefined; result: string };
test_aria2c: { args: undefined; result: string };
test_ffmpeg: { args: undefined; result: string };
test_deno: { args: undefined; result: string };
open_file: { args: { path: string }; result: void };
show_in_folder: { args: { path: string }; result: void };
start_download: { args: StartDownloadArgs; result: void };
start_media_download: { args: StartMediaDownloadArgs; result: void };
pause_download: { args: { id: string }; result: void };
remove_download: { args: { id: string; filepath: string | null }; result: void };
update_dock_badge: { args: { count: number }; result: void };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
perform_system_action: { args: { action: PostQueueAction }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void };
open_automation_settings: { args: undefined; result: void };
get_free_space: { args: { path: string }; result: string };
set_keychain_password: { args: { id: string; password: string }; result: void };
get_keychain_password: { args: { id: string }; result: string };
delete_keychain_password: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
delete_file: { args: { path: string }; result: void };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
get_system_proxy: { args: undefined; result: string | null };
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
is_supported_media: { args: { url: string }; result: boolean };
db_save_settings: { args: { data: string }; result: void };
db_load_settings: { args: undefined; result: string | null };
db_get_all_downloads: { args: undefined; result: string[] };
db_save_download: {
args: { id: string; status: DownloadStatus; queueId: string; data: string };
result: void;
};
db_delete_download: { args: { id: string }; result: void };
db_get_all_queues: { args: undefined; result: string[] };
db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void };
};
type CommandName = keyof CommandMap;
type CommandArgs<K extends CommandName> = CommandMap[K]['args'];
type CommandResult<K extends CommandName> = CommandMap[K]['result'];
export function invokeCommand<K extends CommandName>(
command: K,
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
): Promise<CommandResult<K>> {
return tauriInvoke<CommandResult<K>>(command, args[0]);
}
type EventMap = {
'schedule-trigger': 'start' | 'stop';
'download-progress': DownloadProgressEvent;
'download-complete': string;
'download-failed': string;
'extension-add-download': ExtensionDownload;
};
export function listenEvent<K extends keyof EventMap>(
event: K,
handler: EventCallback<EventMap[K]>,
): Promise<UnlistenFn> {
return tauriListen<EventMap[K]>(event, handler);
}
export type IpcEvent<K extends keyof EventMap> = Event<EventMap[K]>;
+40 -53
View File
@@ -1,11 +1,14 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core'; import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { import {
categoryForFileName, categoryForFileName,
fileNameFromUrl, fileNameFromUrl,
isMediaUrl, isMediaUrl
type DownloadCategory
} from '../utils/downloads'; } from '../utils/downloads';
export type { DownloadCategory } from '../utils/downloads'; export type { DownloadCategory } from '../utils/downloads';
@@ -50,48 +53,11 @@ const syncSystemIntegrations = () => {
// Legacy manual speed limit math removed // Legacy manual speed limit math removed
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued'; export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export interface Queue { export type { DownloadItem, Queue };
id: string; export type ExtensionDownloadRequest = ExtensionDownload;
name: string;
isMain: boolean;
}
export interface DownloadItem {
id: string;
url: string;
fileName: string;
status: DownloadStatus;
fraction?: number;
speed?: string;
eta?: string;
size?: string;
category: DownloadCategory;
dateAdded: string;
// Advanced Settings
connections?: number | null;
speedLimit?: string | null;
username?: string | null;
password?: string | null;
headers?: string | null;
checksum?: string | null;
cookies?: string | null;
mirrors?: string | null;
destination?: string;
isMedia?: boolean;
mediaFormatSelector?: string;
queueId: string;
_dispatched?: boolean;
}
export interface ExtensionDownloadRequest {
urls: string[];
referer?: string | null;
silent?: boolean;
filename?: string | null;
}
interface DownloadState { interface DownloadState {
downloads: DownloadItem[]; downloads: DownloadItem[];
@@ -349,10 +315,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}, },
initDB: async () => { initDB: async () => {
try { try {
const queuesStr = await invoke<string[]>('db_get_all_queues'); const queuesStr = await invoke('db_get_all_queues');
const queues = queuesStr.map(q => JSON.parse(q)); const queues = queuesStr.map(q => JSON.parse(q));
const downloadsStr = await invoke<string[]>('db_get_all_downloads'); const downloadsStr = await invoke('db_get_all_downloads');
const downloads = downloadsStr.map(d => JSON.parse(d)); const downloads = downloadsStr.map(d => JSON.parse(d));
set(state => ({ set(state => ({
@@ -365,16 +331,37 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
active.forEach(item => { active.forEach(item => {
if (item.isMedia) { if (item.isMedia) {
invoke('start_media_download', { invoke('start_media_download', {
id: item.id, url: item.url, destination: item.destination, id: item.id,
filename: item.fileName, format_selector: item.mediaFormatSelector, url: item.url,
proxy: null destination: item.destination || '~/Downloads',
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: null,
speedLimit: item.speedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
proxy: null,
userAgent: null,
maxTries: null
}).catch(console.error); }).catch(console.error);
} else { } else {
invoke('start_download', { invoke('start_download', {
id: item.id, url: item.url, destination: item.destination, filename: item.fileName, id: item.id,
connections: item.connections, speed_limit: item.speedLimit, username: item.username, url: item.url,
password: item.password, headers: item.headers, checksum: item.checksum, cookies: item.cookies, destination: item.destination || '~/Downloads',
mirrors: item.mirrors, user_agent: null, max_tries: null, proxy: null filename: item.fileName,
connections: item.connections ?? null,
speedLimit: item.speedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: null,
maxTries: null,
proxy: null
}).catch(console.error); }).catch(console.error);
} }
}); });
@@ -399,7 +386,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
let keychainPassword = null; let keychainPassword = null;
if (login) { if (login) {
try { try {
keychainPassword = await invoke<string>('get_keychain_password', { id: login.id }); keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) { } catch (e) {
console.warn("Could not fetch keychain password for login:", e); console.warn("Could not fetch keychain password for login:", e);
} }
+49 -39
View File
@@ -1,12 +1,23 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invokeCommand as invoke } from '../ipc';
import type { ActiveView } from '../bindings/ActiveView';
import type { AppFontSize } from '../bindings/AppFontSize';
import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction';
import type { PersistedSettings } from '../bindings/PersistedSettings';
import type { ProxyMode } from '../bindings/ProxyMode';
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
const tauriStorage: StateStorage = { const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => { getItem: async (name: string): Promise<string | null> => {
if (name === 'firelink-settings') { if (name === 'firelink-settings') {
try { try {
const data = await invoke<string | null>('db_load_settings'); const data = await invoke('db_load_settings');
return data; return data;
} catch (e) { } catch (e) {
console.error("Failed to load settings from DB", e); console.error("Failed to load settings from DB", e);
@@ -29,30 +40,21 @@ const tauriStorage: StateStorage = {
}, },
}; };
export interface SiteLogin { export type {
id: string; ActiveView,
urlPattern: string; AppFontSize,
username: string; ListRowDensity,
} MediaCookieSource,
PostQueueAction,
export type AppFontSize = 'small' | 'standard' | 'large'; ProxyMode,
export type ListRowDensity = 'compact' | 'standard' | 'relaxed'; SchedulerSettings,
export type SettingsTab = 'downloads' | 'lookandfeel' | 'network' | 'locations' | 'sitelogins' | 'power' | 'engine' | 'integrations' | 'about'; SettingsTab,
export type ActiveView = 'downloads' | 'settings' | 'scheduler' | 'speedLimiter'; SiteLogin,
export type PostQueueAction = 'none' | 'sleep' | 'restart' | 'shutdown'; Theme
};
export interface SchedulerSettings {
enabled: boolean;
startTime: string;
stopTimeEnabled: boolean;
stopTime: string;
everyday: boolean;
selectedDays: number[];
postQueueAction: PostQueueAction;
}
export interface SettingsState { export interface SettingsState {
theme: 'dark' | 'light' | 'system' | 'dracula' | 'nord'; theme: Theme;
defaultDownloadPath: string; defaultDownloadPath: string;
maxConcurrentDownloads: number; maxConcurrentDownloads: number;
globalSpeedLimit: string; globalSpeedLimit: string;
@@ -74,19 +76,19 @@ export interface SettingsState {
listRowDensity: ListRowDensity; listRowDensity: ListRowDensity;
showDockBadge: boolean; showDockBadge: boolean;
showMenuBarIcon: boolean; showMenuBarIcon: boolean;
proxyMode: 'none' | 'system' | 'custom'; proxyMode: ProxyMode;
proxyHost: string; proxyHost: string;
proxyPort: number; proxyPort: number;
customUserAgent: string; customUserAgent: string;
askWhereToSaveEachFile: boolean; askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean; preventsSleepWhileDownloading: boolean;
mediaCookieSource: 'none' | 'safari' | 'chrome' | 'firefox' | 'edge' | 'brave'; mediaCookieSource: MediaCookieSource;
downloadDirectories: Record<string, string>; downloadDirectories: Record<string, string>;
siteLogins: SiteLogin[]; siteLogins: SiteLogin[];
extensionPairingToken: string; extensionPairingToken: string;
autoCheckUpdates: boolean; autoCheckUpdates: boolean;
setTheme: (theme: 'dark' | 'light' | 'system' | 'dracula' | 'nord') => void; setTheme: (theme: Theme) => void;
setDefaultDownloadPath: (path: string) => void; setDefaultDownloadPath: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void; setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void; setGlobalSpeedLimit: (limit: string) => void;
@@ -107,13 +109,13 @@ export interface SettingsState {
setListRowDensity: (density: ListRowDensity) => void; setListRowDensity: (density: ListRowDensity) => void;
setShowDockBadge: (show: boolean) => void; setShowDockBadge: (show: boolean) => void;
setShowMenuBarIcon: (show: boolean) => void; setShowMenuBarIcon: (show: boolean) => void;
setProxyMode: (mode: 'none' | 'system' | 'custom') => void; setProxyMode: (mode: ProxyMode) => void;
setProxyHost: (host: string) => void; setProxyHost: (host: string) => void;
setProxyPort: (port: number) => void; setProxyPort: (port: number) => void;
setCustomUserAgent: (userAgent: string) => void; setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void; setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: 'none' | 'safari' | 'chrome' | 'firefox' | 'edge' | 'brave') => void; setMediaCookieSource: (source: MediaCookieSource) => void;
setCategoryDirectory: (category: string, path: string) => void; setCategoryDirectory: (category: string, path: string) => void;
resetCategoryDirectories: () => void; resetCategoryDirectories: () => void;
addSiteLogin: (login: SiteLogin) => void; addSiteLogin: (login: SiteLogin) => void;
@@ -158,7 +160,10 @@ const normalizeDownloadDirectories = (directories: unknown): Record<string, stri
const generateSecureToken = () => { const generateSecureToken = () => {
try { try {
const cryptoObj = typeof window !== 'undefined' ? (window.crypto || (window as any).msCrypto) : null; const cryptoObj = typeof window !== 'undefined'
? (window as Window & { msCrypto?: Crypto }).crypto
|| (window as Window & { msCrypto?: Crypto }).msCrypto
: null;
if (cryptoObj && cryptoObj.getRandomValues) { if (cryptoObj && cryptoObj.getRandomValues) {
const arr = new Uint8Array(24); const arr = new Uint8Array(24);
cryptoObj.getRandomValues(arr); cryptoObj.getRandomValues(arr);
@@ -280,7 +285,7 @@ export const useSettingsStore = create<SettingsState>()(
{ {
name: 'firelink-settings', name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage), storage: createJSONStorage(() => tauriStorage),
partialize: (state) => ({ partialize: (state): PersistedSettings => ({
theme: state.theme, theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath, defaultDownloadPath: state.defaultDownloadPath,
maxConcurrentDownloads: state.maxConcurrentDownloads, maxConcurrentDownloads: state.maxConcurrentDownloads,
@@ -312,16 +317,21 @@ export const useSettingsStore = create<SettingsState>()(
extensionPairingToken: state.extensionPairingToken, extensionPairingToken: state.extensionPairingToken,
autoCheckUpdates: state.autoCheckUpdates autoCheckUpdates: state.autoCheckUpdates
}), }),
merge: (persistedState: any, currentState) => ({ merge: (persistedState: unknown, currentState) => {
const persisted = persistedState && typeof persistedState === 'object'
? persistedState as Partial<SettingsState>
: {};
return ({
...currentState, ...currentState,
...persistedState, ...persisted,
appFontSize: persistedState?.appFontSize === 'extra-large' ? 'large' : (persistedState?.appFontSize || currentState.appFontSize), appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persistedState?.listRowDensity === 'spacious' ? 'relaxed' : (persistedState?.listRowDensity || currentState.listRowDensity), listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
downloadDirectories: normalizeDownloadDirectories(persistedState?.downloadDirectories), downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
siteLogins: (persistedState && typeof persistedState === 'object' && Array.isArray(persistedState.siteLogins)) siteLogins: Array.isArray(persisted.siteLogins)
? persistedState.siteLogins ? persisted.siteLogins
: currentState.siteLogins : currentState.siteLogins
}) });
}
} }
) )
); );
+2 -8
View File
@@ -1,11 +1,5 @@
export type DownloadCategory = import type { DownloadCategory } from '../bindings/DownloadCategory';
| 'Musics' export type { DownloadCategory } from '../bindings/DownloadCategory';
| 'Movies'
| 'Compressed'
| 'Documents'
| 'Pictures'
| 'Applications'
| 'Other';
const MEDIA_DOMAINS = [ const MEDIA_DOMAINS = [
'youtube.com', 'youtube.com',