mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 08:26:40 +00:00
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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+51
-1
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
+141
-243
@@ -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,80 +679,81 @@ 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(())
|
||||||
}
|
}
|
||||||
@@ -814,78 +761,26 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
#[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]
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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, };
|
||||||
@@ -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";
|
||||||
@@ -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, };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]>;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user