mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +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:
Generated
+51
-1
@@ -3482,6 +3482,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -3503,12 +3504,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3542,7 +3545,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -4440,6 +4443,8 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower-http",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
"window-vibrancy 0.7.1",
|
||||
]
|
||||
|
||||
@@ -4776,6 +4781,15 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5188,6 +5202,29 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -5511,6 +5548,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -23,9 +23,11 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "rt", "macros"] }
|
||||
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "macros", "sync", "time"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
uuid = "1"
|
||||
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
sysinfo = "0.39.3"
|
||||
keyring = "3"
|
||||
|
||||
@@ -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 tauri::{AppHandle, Emitter, Manager};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use ts_rs::TS;
|
||||
|
||||
pub const EXTENSION_SERVER_PORT: u16 = 23522;
|
||||
const MAX_URL_COUNT: usize = 200;
|
||||
@@ -45,8 +46,9 @@ struct ExtensionRequest {
|
||||
filename: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ExtensionDownload {
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct ExtensionDownload {
|
||||
urls: Vec<String>,
|
||||
referer: Option<String>,
|
||||
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,
|
||||
}
|
||||
+144
-246
@@ -5,11 +5,15 @@ use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MetadataResponse {
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MetadataResponse {
|
||||
filename: String,
|
||||
size: String,
|
||||
#[ts(type = "number")]
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
@@ -336,18 +340,23 @@ fn resign_aria2_debug_bundle(aria2c_path: &std::path::Path) -> Result<(), String
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod download;
|
||||
#[allow(dead_code)]
|
||||
mod ipc;
|
||||
mod parity;
|
||||
pub mod error;
|
||||
pub use error::AppError;
|
||||
|
||||
// Retained only for compatibility with the optional aria2 diagnostic monitor.
|
||||
// Active downloads are owned by DownloadCoordinator.
|
||||
pub enum TaskHandle {
|
||||
Aria2(String),
|
||||
Pid(u32),
|
||||
Queued,
|
||||
#[doc(hidden)]
|
||||
Inactive,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub tasks: Arc<Mutex<HashMap<String, TaskHandle>>>,
|
||||
pub download_coordinator: download::DownloadCoordinator,
|
||||
pub extension_pairing_token: extension_server::SharedExtensionToken,
|
||||
pub extension_frontend_ready: extension_server::SharedFrontendReady,
|
||||
pub aria2_port: u16,
|
||||
@@ -356,8 +365,9 @@ pub struct AppState {
|
||||
pub sleep_preventer: Arc<Mutex<Option<keepawake::KeepAwake>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct DownloadProgressEvent {
|
||||
#[derive(Clone, Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct DownloadProgressEvent {
|
||||
id: String,
|
||||
fraction: f64,
|
||||
speed: String,
|
||||
@@ -443,9 +453,7 @@ async fn start_download(
|
||||
proxy: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
println!("start_download called for id: {}", id);
|
||||
let state_aria2_port = state.aria2_port;
|
||||
let state_aria2_secret = state.aria2_secret.clone();
|
||||
let tasks_map = state.tasks.clone();
|
||||
let download_id = Uuid::parse_str(&id).map_err(|error| AppError::Internal(error.to_string()))?;
|
||||
|
||||
let mut resolved_dest = std::path::PathBuf::from(&destination);
|
||||
if destination.starts_with("~/") {
|
||||
@@ -457,80 +465,25 @@ async fn start_download(
|
||||
resolved_dest = home;
|
||||
}
|
||||
}
|
||||
|
||||
if !resolved_dest.exists() {
|
||||
let _ = tokio::fs::create_dir_all(&resolved_dest).await;
|
||||
}
|
||||
|
||||
let gid: String = id.replace("-", "").chars().take(16).collect();
|
||||
// ensure exactly 16 chars
|
||||
let gid = format!("{:0<16}", gid);
|
||||
|
||||
tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Aria2(gid.clone()));
|
||||
|
||||
let mut options = serde_json::Map::new();
|
||||
options.insert("gid".to_string(), serde_json::json!(gid));
|
||||
options.insert("dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string()));
|
||||
options.insert("out".to_string(), serde_json::json!(filename));
|
||||
|
||||
if let Some(conn) = connections {
|
||||
options.insert("split".to_string(), serde_json::json!(conn.to_string()));
|
||||
options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string()));
|
||||
}
|
||||
if let Some(limit) = speed_limit {
|
||||
if !limit.is_empty() {
|
||||
options.insert("max-download-limit".to_string(), serde_json::json!(limit));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user) = username {
|
||||
if !user.is_empty() {
|
||||
options.insert("http-user".to_string(), serde_json::json!(user));
|
||||
options.insert("ftp-user".to_string(), serde_json::json!(user));
|
||||
if let Some(pass) = password {
|
||||
options.insert("http-passwd".to_string(), serde_json::json!(pass));
|
||||
options.insert("ftp-passwd".to_string(), serde_json::json!(pass));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut hdrs = Vec::new();
|
||||
if let Some(hdr) = headers {
|
||||
for header in hdr.lines().map(str::trim).filter(|h| !h.is_empty()) {
|
||||
hdrs.push(header.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(cks) = cookies {
|
||||
if !cks.is_empty() {
|
||||
hdrs.push(format!("Cookie: {}", cks));
|
||||
}
|
||||
}
|
||||
if !hdrs.is_empty() {
|
||||
options.insert("header".to_string(), serde_json::json!(hdrs));
|
||||
}
|
||||
|
||||
if let Some(p) = proxy {
|
||||
if !p.is_empty() {
|
||||
options.insert("all-proxy".to_string(), serde_json::json!(p));
|
||||
}
|
||||
}
|
||||
if let Some(ua) = user_agent {
|
||||
if !ua.is_empty() {
|
||||
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
||||
}
|
||||
}
|
||||
if let Some(chk) = checksum {
|
||||
if !chk.is_empty() {
|
||||
options.insert("checksum".to_string(), serde_json::json!(chk));
|
||||
}
|
||||
}
|
||||
if let Some(tries) = max_tries {
|
||||
options.insert("max-tries".to_string(), serde_json::json!(tries.to_string()));
|
||||
}
|
||||
|
||||
let uris = collect_download_uris(&url, mirrors.as_deref());
|
||||
|
||||
let _ = rpc_call(state_aria2_port, &state_aria2_secret, "aria2.addUri", serde_json::json!([uris, options])).await?;
|
||||
let _ = connections;
|
||||
let _ = checksum;
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Start(download::DownloadPayload {
|
||||
id: download_id,
|
||||
urls: collect_download_uris(&url, mirrors.as_deref()),
|
||||
output_path: resolved_dest.join(filename),
|
||||
speed_limit,
|
||||
username,
|
||||
password,
|
||||
headers,
|
||||
cookies,
|
||||
user_agent,
|
||||
max_tries: max_tries.unwrap_or(1).max(1) as u32,
|
||||
proxy,
|
||||
}))
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -553,30 +506,21 @@ async fn start_media_download(
|
||||
user_agent: Option<String>,
|
||||
max_tries: Option<i32>,
|
||||
) -> Result<(), String> {
|
||||
let tasks_map = state.tasks.clone();
|
||||
let media_semaphore = state.media_semaphore.clone();
|
||||
|
||||
// Mark task as queued
|
||||
tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Queued);
|
||||
|
||||
let id_clone = id.clone();
|
||||
let coordinator = state.download_coordinator.clone();
|
||||
let mut cancel_rx = coordinator.register_media(id.clone()).await?;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Wait in queue via semaphore
|
||||
let permit = media_semaphore.acquire().await;
|
||||
|
||||
// Check if user cancelled the task while it was waiting in queue
|
||||
{
|
||||
let map = tasks_map.lock().unwrap();
|
||||
if !map.contains_key(&id_clone) {
|
||||
let permit = tokio::select! {
|
||||
permit = media_semaphore.acquire() => permit,
|
||||
_ = cancel_rx.changed() => {
|
||||
coordinator.finish_media(id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = start_media_download_internal(
|
||||
app_handle,
|
||||
tasks_map,
|
||||
id_clone,
|
||||
url,
|
||||
&id, url,
|
||||
destination,
|
||||
filename,
|
||||
format_selector,
|
||||
@@ -588,9 +532,11 @@ async fn start_media_download(
|
||||
proxy,
|
||||
user_agent,
|
||||
max_tries,
|
||||
&mut cancel_rx,
|
||||
).await;
|
||||
|
||||
drop(permit); // Release semaphore permit
|
||||
|
||||
drop(permit);
|
||||
coordinator.finish_media(id).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -598,8 +544,7 @@ async fn start_media_download(
|
||||
|
||||
pub(crate) async fn start_media_download_internal(
|
||||
app_handle: tauri::AppHandle,
|
||||
tasks_map: Arc<Mutex<HashMap<String, TaskHandle>>>,
|
||||
id: String,
|
||||
id: &str,
|
||||
url: String,
|
||||
destination: String,
|
||||
filename: String,
|
||||
@@ -612,6 +557,7 @@ pub(crate) async fn start_media_download_internal(
|
||||
proxy: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
max_tries: Option<i32>,
|
||||
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<(), String> {
|
||||
println!("start_media_download called for id: {}", id);
|
||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||
@@ -733,159 +679,108 @@ pub(crate) async fn start_media_download_internal(
|
||||
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
|
||||
let pid = child.id().unwrap_or(0);
|
||||
|
||||
// Update task handle from Queued to Pid
|
||||
tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Pid(pid));
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let id_clone = id.clone();
|
||||
|
||||
// yt-dlp parsing regex
|
||||
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
|
||||
let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap();
|
||||
let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _keep_alive = config_path; // Keep the temp file alive
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
let mut current_track: f64 = 0.0;
|
||||
let mut last_fraction: f64 = 0.0;
|
||||
let _keep_alive = config_path;
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
let mut current_track: f64 = 0.0;
|
||||
let mut last_fraction: f64 = 0.0;
|
||||
let mut last_progress_at = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_millis(150))
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
line_result = reader.next_line() => {
|
||||
match line_result {
|
||||
Ok(Some(line)) => {
|
||||
if line.contains("[download]") && line.contains("%") {
|
||||
let fraction = pct_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<f64>().ok())
|
||||
.unwrap_or(0.0) / 100.0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => {
|
||||
let _ = child.kill().await;
|
||||
return Ok(());
|
||||
}
|
||||
line_result = reader.next_line() => {
|
||||
match line_result {
|
||||
Ok(Some(line)) => {
|
||||
if line.contains("[download]") && line.contains("%") {
|
||||
let fraction = pct_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<f64>().ok())
|
||||
.unwrap_or(0.0) / 100.0;
|
||||
|
||||
if fraction < last_fraction && (last_fraction - fraction) > 0.5 {
|
||||
current_track += 1.0;
|
||||
}
|
||||
last_fraction = fraction;
|
||||
if fraction < last_fraction && (last_fraction - fraction) > 0.5 {
|
||||
current_track += 1.0;
|
||||
}
|
||||
last_fraction = fraction;
|
||||
|
||||
let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0);
|
||||
let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0);
|
||||
|
||||
let speed = spd_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let speed = spd_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
let eta = eta_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let eta = eta_re.captures(&line)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent {
|
||||
id: id_clone.clone(),
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(150) {
|
||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: overall_fraction,
|
||||
speed,
|
||||
eta,
|
||||
});
|
||||
last_progress_at = now;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
status = child.wait() => {
|
||||
println!("child exit status: {:?}", status);
|
||||
if let Ok(exit_status) = status {
|
||||
if exit_status.success() {
|
||||
let _ = app_handle_clone.emit("download-complete", id_clone.clone());
|
||||
} else {
|
||||
// If it exited with error, emit failed
|
||||
let _ = app_handle_clone.emit("download-failed", id_clone.clone());
|
||||
}
|
||||
}
|
||||
break;
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
status = child.wait() => {
|
||||
println!("child exit status: {:?}", status);
|
||||
if let Ok(exit_status) = status {
|
||||
if exit_status.success() {
|
||||
let _ = app_handle.emit("download-complete", id.to_string());
|
||||
} else {
|
||||
let _ = app_handle.emit("download-failed", id.to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
println!("pause_download called for id: {}", id);
|
||||
let handle_opt = state.tasks.lock().unwrap().remove(&id);
|
||||
if let Some(handle) = handle_opt {
|
||||
match handle {
|
||||
TaskHandle::Aria2(gid) => {
|
||||
let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([gid])).await;
|
||||
}
|
||||
TaskHandle::Queued => {
|
||||
// If it was just queued, it's already removed from tasks map.
|
||||
// The waiting Tokio task will wake up, see it's missing, and abort silently.
|
||||
println!("Queued download {} aborted before starting", id);
|
||||
}
|
||||
TaskHandle::Pid(pid) => {
|
||||
if pid > 0 {
|
||||
use sysinfo::{System, Pid};
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
|
||||
let parent_pid = Pid::from_u32(pid);
|
||||
let mut to_kill = vec![parent_pid];
|
||||
let mut idx = 0;
|
||||
|
||||
while idx < to_kill.len() {
|
||||
let current = to_kill[idx];
|
||||
for (proc_pid, proc) in sys.processes() {
|
||||
if let Some(p) = proc.parent() {
|
||||
if p == current {
|
||||
to_kill.push(*proc_pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
for p in to_kill.into_iter().rev() {
|
||||
if let Some(proc) = sys.process(p) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if proc.kill_with(sysinfo::Signal::Term).is_none() {
|
||||
proc.kill(); // Fallback to SIGKILL
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
proc.kill();
|
||||
|
||||
println!("Sent termination signal to pid: {}", p.as_u32());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Pause(download_id))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
state.download_coordinator.pause_media(id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_download(state: tauri::State<'_, AppState>, id: String, filepath: Option<String>) -> Result<(), String> {
|
||||
println!("remove_download called for id: {}", id);
|
||||
|
||||
// Check if it's aria2 first, so we can call remove instead of pause
|
||||
let mut is_aria2 = false;
|
||||
let mut gid_to_remove = String::new();
|
||||
if let Some(TaskHandle::Aria2(gid)) = state.tasks.lock().unwrap().get(&id) {
|
||||
is_aria2 = true;
|
||||
gid_to_remove = gid.clone();
|
||||
}
|
||||
|
||||
if is_aria2 {
|
||||
state.tasks.lock().unwrap().remove(&id);
|
||||
let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.remove", serde_json::json!([gid_to_remove])).await;
|
||||
} else {
|
||||
let _ = pause_download(state, id).await;
|
||||
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Cancel(download_id))
|
||||
.await?;
|
||||
}
|
||||
state.download_coordinator.pause_media(id).await?;
|
||||
|
||||
if let Some(path) = filepath {
|
||||
if !path.is_empty() {
|
||||
@@ -938,12 +833,18 @@ fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn perform_system_action(action: String) -> Result<(), String> {
|
||||
match action.as_str() {
|
||||
"shutdown" => system_shutdown::shutdown().map_err(|e| e.to_string()),
|
||||
"restart" => system_shutdown::reboot().map_err(|e| e.to_string()),
|
||||
"sleep" => system_shutdown::sleep().map_err(|e| e.to_string()),
|
||||
_ => Err("Invalid action".to_string())
|
||||
fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> {
|
||||
match action {
|
||||
crate::ipc::PostQueueAction::Shutdown => {
|
||||
system_shutdown::shutdown().map_err(|e| e.to_string())
|
||||
}
|
||||
crate::ipc::PostQueueAction::Restart => {
|
||||
system_shutdown::reboot().map_err(|e| e.to_string())
|
||||
}
|
||||
crate::ipc::PostQueueAction::Sleep => {
|
||||
system_shutdown::sleep().map_err(|e| e.to_string())
|
||||
}
|
||||
crate::ipc::PostQueueAction::None => Err("Invalid action".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,17 +1126,17 @@ pub fn run() {
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(AppState {
|
||||
tasks: Arc::new(Mutex::new(HashMap::new())),
|
||||
extension_pairing_token,
|
||||
extension_frontend_ready,
|
||||
aria2_port,
|
||||
aria2_secret: aria2_secret.clone(),
|
||||
media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)),
|
||||
sleep_preventer: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
.manage(Aria2DaemonGuard(std::sync::Mutex::new(None)))
|
||||
.setup(move |app| {
|
||||
app.manage(AppState {
|
||||
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
||||
extension_pairing_token,
|
||||
extension_frontend_ready,
|
||||
aria2_port,
|
||||
aria2_secret: aria2_secret.clone(),
|
||||
media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)),
|
||||
sleep_preventer: Arc::new(Mutex::new(None)),
|
||||
});
|
||||
let db_conn = crate::db::init_db(app.handle()).expect("Failed to init db");
|
||||
app.manage(crate::db::DbState { conn: std::sync::Mutex::new(db_conn) });
|
||||
|
||||
@@ -1346,15 +1247,13 @@ pub fn run() {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text.as_str()) {
|
||||
let state = app_handle_clone.state::<AppState>();
|
||||
let tasks = state.tasks.clone();
|
||||
let tasks = HashMap::<String, TaskHandle>::new();
|
||||
|
||||
// Process progress
|
||||
if json.get("id").and_then(|i| i.as_str()) == Some("progress") {
|
||||
let mut gid_to_id = HashMap::new();
|
||||
{
|
||||
let map = tasks.lock().unwrap();
|
||||
for (id, handle) in map.iter() {
|
||||
for (id, handle) in tasks.iter() {
|
||||
if let TaskHandle::Aria2(gid) = handle {
|
||||
gid_to_id.insert(gid.clone(), id.clone());
|
||||
}
|
||||
@@ -1411,8 +1310,7 @@ pub fn run() {
|
||||
if let Some(gid) = event_info.get("gid").and_then(|g| g.as_str()) {
|
||||
let mut target_id = None;
|
||||
{
|
||||
let map = tasks.lock().unwrap();
|
||||
for (id, handle) in map.iter() {
|
||||
for (id, handle) in tasks.iter() {
|
||||
if let TaskHandle::Aria2(task_gid) = handle {
|
||||
if task_gid == gid {
|
||||
target_id = Some(id.clone());
|
||||
@@ -1428,7 +1326,6 @@ pub fn run() {
|
||||
} else {
|
||||
let _ = app_handle_clone.emit("download-failed", id.clone());
|
||||
}
|
||||
tasks.lock().unwrap().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1504,9 +1401,10 @@ fn db_get_all_downloads(state: tauri::State<crate::db::DbState>) -> Result<Vec<S
|
||||
}
|
||||
|
||||
#[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();
|
||||
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]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::ipc::DownloadCategory;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
@@ -17,7 +20,7 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_category(filename: String) -> String {
|
||||
pub fn get_file_category(filename: String) -> DownloadCategory {
|
||||
let ext = std::path::Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
@@ -31,21 +34,22 @@ pub fn get_file_category(filename: String) -> String {
|
||||
let document_exts = ["azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx", "epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf", "numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx", "ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm", "xlsx", "xml", "xps", "yaml", "yml"];
|
||||
|
||||
if music_exts.contains(&ext.as_str()) {
|
||||
"musics".to_string()
|
||||
DownloadCategory::Musics
|
||||
} else if movie_exts.contains(&ext.as_str()) {
|
||||
"movies".to_string()
|
||||
DownloadCategory::Movies
|
||||
} else if compressed_exts.contains(&ext.as_str()) {
|
||||
"compressed".to_string()
|
||||
DownloadCategory::Compressed
|
||||
} else if picture_exts.contains(&ext.as_str()) {
|
||||
"pictures".to_string()
|
||||
DownloadCategory::Pictures
|
||||
} else if document_exts.contains(&ext.as_str()) {
|
||||
"documents".to_string()
|
||||
DownloadCategory::Documents
|
||||
} else {
|
||||
"other".to_string()
|
||||
DownloadCategory::Other
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct AvailableReleaseUpdate {
|
||||
pub version: String,
|
||||
pub tag_name: String,
|
||||
@@ -55,8 +59,9 @@ pub struct AvailableReleaseUpdate {
|
||||
pub published_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, TS)]
|
||||
#[serde(tag = "type")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum ReleaseCheckOutcome {
|
||||
UpdateAvailable { update: AvailableReleaseUpdate },
|
||||
UpToDate { latest_version: String, local_version: String },
|
||||
|
||||
Reference in New Issue
Block a user