diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 5e89c2e..b7d7f20 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -719,14 +719,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -742,17 +766,34 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.11" @@ -774,6 +815,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1052,6 +1124,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1594,11 +1678,32 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -2107,6 +2212,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keepawake" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5521b450ec179362595d5cbda7c3abd5da3af3dff58456434ad3ca33c95226b7" +dependencies = [ + "cfg-if", + "derive_builder", + "objc2-core-foundation", + "objc2-io-kit", + "thiserror 2.0.18", + "windows 0.62.2", + "zbus", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2204,6 +2324,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2522,7 +2653,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2605,7 +2738,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", "libc", + "objc2", "objc2-core-foundation", ] @@ -3367,6 +3504,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -3721,7 +3883,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3758,6 +3920,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3867,6 +4040,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4022,6 +4207,16 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system_shutdown" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29396e5e1b637d102ec5037bf7fbb8da78264fa299101ced9ce827756b1bac02" +dependencies = [ + "windows 0.62.2", + "zbus", +] + [[package]] name = "tao" version = "0.35.3" @@ -4136,16 +4331,21 @@ name = "tauri-app" version = "0.7.3" dependencies = [ "axum", + "futures-util", "hmac", + "keepawake", "keyring", + "open", "regex 1.12.4", "reqwest 0.12.28", + "rusqlite", "semver", "serde", "serde_json", "sha2", "sysinfo", "sysproxy", + "system_shutdown", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -4156,6 +4356,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "tower-http", ] @@ -4653,6 +4854,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log 0.4.32", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4892,6 +5105,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log 0.4.32", + "rand", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 44f2fcb..f75adbc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -39,3 +39,10 @@ axum = "0.8.9" tower-http = { version = "0.6.11", features = ["cors"] } sysproxy = "0.3.0" semver = "1.0.28" +keepawake = "0.6.0" +open = "5.3.5" +system_shutdown = "4.1.0" +tokio-tungstenite = "0.29.0" +futures-util = { version = "0.3.32", features = ["sink"] } +rusqlite = { version = "0.40.1", features = ["bundled"] } +chrono = "0.4.38" diff --git a/apps/desktop/src-tauri/src/db.rs b/apps/desktop/src-tauri/src/db.rs new file mode 100644 index 0000000..6aac5a1 --- /dev/null +++ b/apps/desktop/src-tauri/src/db.rs @@ -0,0 +1,138 @@ +use rusqlite::{Connection, Result, params}; +use std::sync::Mutex; +use std::path::PathBuf; +use tauri::Manager; + +pub struct DbState { + pub conn: Mutex, +} + +pub fn init_db(app_handle: &tauri::AppHandle) -> Result { + let app_dir = app_handle.path().app_data_dir().unwrap_or_else(|_| PathBuf::from(".")); + if !app_dir.exists() { + let _ = std::fs::create_dir_all(&app_dir); + } + let db_path = app_dir.join("firelink.sqlite"); + let conn = Connection::open(db_path)?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT NOT NULL, + data TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS queues ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL + )", + [], + )?; + + Ok(conn) +} + +// Downloads CRUD +pub fn insert_download(conn: &Connection, id: &str, status: &str, queue_id: &str, data: &str) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)", + params![id, status, queue_id, data], + )?; + Ok(()) +} + +pub fn update_download_status(conn: &Connection, id: &str, status: &str) -> Result<()> { + conn.execute( + "UPDATE downloads SET status = ?1 WHERE id = ?2", + params![status, id], + )?; + Ok(()) +} + +pub fn update_download_data(conn: &Connection, id: &str, data: &str) -> Result<()> { + conn.execute( + "UPDATE downloads SET data = ?1 WHERE id = ?2", + params![data, id], + )?; + Ok(()) +} + +pub fn delete_download(conn: &Connection, id: &str) -> Result<()> { + conn.execute("DELETE FROM downloads WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn get_all_downloads(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT data FROM downloads")?; + let iter = stmt.query_map([], |row| row.get(0))?; + let mut res = Vec::new(); + for data in iter { + res.push(data?); + } + Ok(res) +} + +pub fn get_downloads_by_status(conn: &Connection, status: &str) -> Result> { + let mut stmt = conn.prepare("SELECT data FROM downloads WHERE status = ?1")?; + let iter = stmt.query_map(params![status], |row| row.get(0))?; + let mut res = Vec::new(); + for data in iter { + res.push(data?); + } + Ok(res) +} + +// Settings CRUD +pub fn get_settings(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT data FROM settings WHERE id = 1")?; + let mut iter = stmt.query_map([], |row| row.get(0))?; + if let Some(row) = iter.next() { + Ok(Some(row?)) + } else { + Ok(None) + } +} + +pub fn save_settings(conn: &Connection, data: &str) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO settings (id, data) VALUES (1, ?1)", + params![data], + )?; + Ok(()) +} + +// Queues CRUD +pub fn insert_queue(conn: &Connection, id: &str, data: &str) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO queues (id, data) VALUES (?1, ?2)", + params![id, data], + )?; + Ok(()) +} + +pub fn delete_queue(conn: &Connection, id: &str) -> Result<()> { + conn.execute("DELETE FROM queues WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn get_all_queues(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT data FROM queues")?; + let iter = stmt.query_map([], |row| row.get(0))?; + let mut res = Vec::new(); + for data in iter { + res.push(data?); + } + Ok(res) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d7ed9c0..3a8d9dc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -296,80 +296,14 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result { #[tauri::command] async fn open_file(path: String) -> Result<(), String> { println!("open_file called for path: {}", path); - #[cfg(target_os = "macos")] - { - let status = std::process::Command::new("open") - .arg(&path) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to open file: {:?}", status)), - } - } - #[cfg(target_os = "windows")] - { - let status = std::process::Command::new("cmd") - .arg("/c") - .arg("start") - .arg("") - .arg(&path) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to open file: {:?}", status)), - } - } - #[cfg(target_os = "linux")] - { - let status = std::process::Command::new("xdg-open") - .arg(&path) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to open file: {:?}", status)), - } - } + open::that(&path).map_err(|e| format!("Failed to open file: {}", e)) } #[tauri::command] -async fn show_in_folder(path: String) -> Result<(), String> { +async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { println!("show_in_folder called for path: {}", path); - #[cfg(target_os = "macos")] - { - let status = std::process::Command::new("open") - .arg("-R") - .arg(&path) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to show in Finder: {:?}", status)), - } - } - #[cfg(target_os = "windows")] - { - let status = std::process::Command::new("explorer") - .arg("/select,") - .arg(path.replace("/", "\\")) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to show in Explorer: {:?}", status)), - } - } - #[cfg(target_os = "linux")] - { - if let Some(parent) = std::path::Path::new(&path).parent() { - let status = std::process::Command::new("xdg-open") - .arg(parent) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - _ => Err(format!("Failed to open folder: {:?}", status)), - } - } else { - Err("No parent folder found".to_string()) - } - } + use tauri_plugin_opener::OpenerExt; + app.opener().reveal_item_in_dir(&path).map_err(|e| format!("Failed to reveal in folder: {}", e)) } use std::collections::HashMap; @@ -393,6 +327,7 @@ pub struct AppState { pub aria2_port: u16, pub aria2_secret: String, pub media_semaphore: Arc, + pub sleep_preventer: Arc>>, } #[derive(Clone, Serialize)] @@ -935,41 +870,25 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) { #[tauri::command] fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) { - #[cfg(target_os = "macos")] - { - let mut tasks = state.tasks.lock().unwrap(); - if prevent { - if !tasks.contains_key("sleep_prevent") { - if let Ok(child) = std::process::Command::new("caffeinate") - .arg("-i") - .spawn() - { - tasks.insert("sleep_prevent".to_string(), TaskHandle::Pid(child.id())); - } - } - } else { - if let Some(TaskHandle::Pid(pid)) = tasks.remove("sleep_prevent") { - let _ = std::process::Command::new("kill") - .arg("-15") - .arg(pid.to_string()) - .status(); + let mut current_preventer = state.sleep_preventer.lock().unwrap(); + if prevent { + if current_preventer.is_none() { + if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() { + *current_preventer = Some(keepawake); } } + } else { + *current_preventer = None; } } #[tauri::command] fn perform_system_action(action: String) -> Result<(), String> { - let status = match action.as_str() { - "shutdown" => std::process::Command::new("osascript").arg("-e").arg("tell app \"System Events\" to shut down").status(), - "restart" => std::process::Command::new("osascript").arg("-e").arg("tell app \"System Events\" to restart").status(), - "sleep" => std::process::Command::new("osascript").arg("-e").arg("tell app \"System Events\" to sleep").status(), - _ => return Err("Invalid action".to_string()) - }; - - match status { - Ok(_) => Ok(()), - Err(e) => Err(e.to_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()) } } @@ -1258,8 +1177,14 @@ pub fn run() { aria2_port, aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), + sleep_preventer: Arc::new(Mutex::new(None)), }) .setup(move |app| { + 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) }); + + crate::scheduler::spawn_scheduler(app.handle().clone()); + let resource_dir = app.path().resource_dir().unwrap(); let aria2c_path = resource_dir.join("binaries").join("aria2c"); @@ -1284,86 +1209,140 @@ pub fn run() { let aria2_port_clone = aria2_port; let aria2_secret_clone = aria2_secret.clone(); tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let ws_url = format!("ws://127.0.0.1:{}/jsonrpc", aria2_port_clone); + + use futures_util::{StreamExt, SinkExt}; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message; + + let (mut ws_stream, _) = match connect_async(&ws_url).await { + Ok(stream) => stream, + Err(e) => { + eprintln!("Failed to connect to aria2 WebSocket: {}", e); + return; + } + }; + + let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); + loop { - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - - let state = app_handle_clone.state::(); - let tasks = state.tasks.clone(); - - let mut gid_to_id = HashMap::new(); - { - let map = tasks.lock().unwrap(); - for (id, handle) in map.iter() { - if let TaskHandle::Aria2(gid) = handle { - gid_to_id.insert(gid.clone(), id.clone()); + tokio::select! { + _ = interval.tick() => { + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": "progress", + "method": "aria2.tellActive", + "params": [ + format!("token:{}", aria2_secret_clone), + ["gid", "status", "completedLength", "totalLength", "downloadSpeed"] + ] + }); + if let Ok(msg) = serde_json::to_string(&req) { + let _ = ws_stream.send(Message::Text(msg.into())).await; } } - } - - if let Ok(json) = crate::rpc_call(aria2_port_clone, &aria2_secret_clone, "aria2.tellActive", serde_json::json!([["gid", "status", "completedLength", "totalLength", "downloadSpeed"]])).await { - if let Some(arr) = json.get("result").and_then(|r| r.as_array()) { - for item in arr { - if let Some(gid) = item.get("gid").and_then(|v| v.as_str()) { - if let Some(id) = gid_to_id.get(gid) { - let completed = item.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); - let total = item.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(1.0); - let speed_bytes = item.get("downloadSpeed").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); + msg = ws_stream.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + if let Ok(json) = serde_json::from_str::(text.as_str()) { + let state = app_handle_clone.state::(); + let tasks = state.tasks.clone(); - let fraction = if total > 0.0 { completed / total } else { 0.0 }; - let speed = if speed_bytes > 1024.0 * 1024.0 { - format!("{:.1} MB/s", speed_bytes / (1024.0 * 1024.0)) - } else if speed_bytes > 1024.0 { - format!("{:.1} KB/s", speed_bytes / 1024.0) - } else { - format!("{:.0} B/s", speed_bytes) - }; - - let eta = if speed_bytes > 0.0 && total > completed { - let seconds = (total - completed) / speed_bytes; - 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!("{:.0}s", seconds) + // 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() { + if let TaskHandle::Aria2(gid) = handle { + gid_to_id.insert(gid.clone(), id.clone()); + } + } } - } else { - "-".to_string() - }; - - let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { - id: id.clone(), - fraction, - speed, - eta, - }); - } - } - } - } - } + + if let Some(arr) = json.get("result").and_then(|r| r.as_array()) { + for item in arr { + if let Some(gid) = item.get("gid").and_then(|v| v.as_str()) { + if let Some(id) = gid_to_id.get(gid) { + let completed = item.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); + let total = item.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(1.0); + let speed_bytes = item.get("downloadSpeed").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); + + let fraction = if total > 0.0 { completed / total } else { 0.0 }; + let speed = if speed_bytes > 1024.0 * 1024.0 { + format!("{:.1} MB/s", speed_bytes / (1024.0 * 1024.0)) + } else if speed_bytes > 1024.0 { + format!("{:.1} KB/s", speed_bytes / 1024.0) + } else { + format!("{:.0} B/s", speed_bytes) + }; - if let Ok(json) = crate::rpc_call(aria2_port_clone, &aria2_secret_clone, "aria2.tellStopped", serde_json::json!([0, 100, ["gid", "status", "completedLength", "totalLength"]])).await { - if let Some(arr) = json.get("result").and_then(|r| r.as_array()) { - for item in arr { - if let Some(gid) = item.get("gid").and_then(|v| v.as_str()) { - if let Some(id) = gid_to_id.get(gid) { - let status = item.get("status").and_then(|v| v.as_str()).unwrap_or(""); - if status == "complete" { - let _ = app_handle_clone.emit("download-complete", id.clone()); - tasks.lock().unwrap().remove(id); - } else if status == "error" { - let comp = item.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); - let tot = item.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(1.0); - if comp > 0.0 && comp >= tot { - let _ = app_handle_clone.emit("download-complete", id.clone()); - } else { - let _ = app_handle_clone.emit("download-failed", id.clone()); + let eta = if speed_bytes > 0.0 && total > completed { + let seconds = (total - completed) / speed_bytes; + 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!("{:.0}s", seconds) + } + } else { + "-".to_string() + }; + + let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { + id: id.clone(), + fraction, + speed, + eta, + }); + } + } + } + } + } + + // Process Events + if let Some(method) = json.get("method").and_then(|m| m.as_str()) { + if method == "aria2.onDownloadComplete" || method == "aria2.onDownloadError" { + if let Some(params) = json.get("params").and_then(|p| p.as_array()) { + if let Some(event_info) = params.get(0) { + 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() { + if let TaskHandle::Aria2(task_gid) = handle { + if task_gid == gid { + target_id = Some(id.clone()); + break; + } + } + } + } + + if let Some(id) = target_id { + if method == "aria2.onDownloadComplete" { + let _ = app_handle_clone.emit("download-complete", id.clone()); + } else { + let _ = app_handle_clone.emit("download-failed", id.clone()); + } + tasks.lock().unwrap().remove(&id); + } + } + } + } } - tasks.lock().unwrap().remove(id); } } } + Some(Err(e)) => { + eprintln!("WebSocket error: {}", e); + break; + } + None => break, // Stream closed + _ => {} // Ignore binary/ping/pong for now } } } @@ -1396,9 +1375,60 @@ pub fn run() { set_keychain_password, get_keychain_password, delete_keychain_password, check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, - parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media + parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, + db_save_settings, db_load_settings, db_get_all_downloads, db_save_download, db_delete_download, db_get_all_queues, db_save_queue, db_delete_queue ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } mod extension_server; +mod db; +mod scheduler; + +#[tauri::command] +fn db_save_settings(state: tauri::State, data: String) -> Result<(), String> { + let conn = state.conn.lock().unwrap(); + crate::db::save_settings(&conn, &data).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_load_settings(state: tauri::State) -> Result, String> { + let conn = state.conn.lock().unwrap(); + crate::db::get_settings(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_get_all_downloads(state: tauri::State) -> Result, String> { + let conn = state.conn.lock().unwrap(); + crate::db::get_all_downloads(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_save_download(state: tauri::State, id: String, status: String, 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()) +} + +#[tauri::command] +fn db_delete_download(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.conn.lock().unwrap(); + crate::db::delete_download(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_get_all_queues(state: tauri::State) -> Result, String> { + let conn = state.conn.lock().unwrap(); + crate::db::get_all_queues(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_save_queue(state: tauri::State, id: String, data: String) -> Result<(), String> { + let conn = state.conn.lock().unwrap(); + crate::db::insert_queue(&conn, &id, &data).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_delete_queue(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.conn.lock().unwrap(); + crate::db::delete_queue(&conn, &id).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/scheduler.rs b/apps/desktop/src-tauri/src/scheduler.rs new file mode 100644 index 0000000..908232b --- /dev/null +++ b/apps/desktop/src-tauri/src/scheduler.rs @@ -0,0 +1,86 @@ +use tauri::{Manager, Emitter}; +use chrono::{Local, Datelike}; +use std::time::Duration; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +struct SchedulerSettings { + enabled: bool, + #[serde(rename = "startTime")] + start_time: String, + #[serde(rename = "stopTimeEnabled")] + stop_time_enabled: bool, + #[serde(rename = "stopTime")] + stop_time: String, + everyday: bool, + #[serde(rename = "selectedDays")] + selected_days: Vec, + #[serde(rename = "postQueueAction")] + post_queue_action: String, +} + +pub fn spawn_scheduler(app_handle: tauri::AppHandle) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + + let settings_opt = { + let state = app_handle.state::(); + let conn = state.conn.lock().unwrap(); + crate::db::get_settings(&conn).unwrap_or(None) + }; + + if let Some(settings_str) = settings_opt { + if let Ok(mut settings) = serde_json::from_str::(&settings_str) { + if let Ok(scheduler) = serde_json::from_value::(settings.get("scheduler").unwrap_or(&serde_json::json!({})).clone()) { + if !scheduler.enabled { + continue; + } + + let now = Local::now(); + let current_time = now.format("%H:%M").to_string(); + let current_day = now.weekday().num_days_from_sunday(); + + let allowed_today = scheduler.everyday || scheduler.selected_days.contains(¤t_day); + if !allowed_today { + continue; + } + + let date_key = now.format("%Y-%m-%d").to_string(); + let trigger_key = format!("{}-{}", date_key, current_time); + + let last_start_key = settings.get("schedulerLastStartKey").and_then(|v| v.as_str()).unwrap_or(""); + let last_stop_key = settings.get("schedulerLastStopKey").and_then(|v| v.as_str()).unwrap_or(""); + + if scheduler.start_time == current_time && last_start_key != trigger_key { + settings["schedulerLastStartKey"] = serde_json::json!(trigger_key.clone()); + settings["schedulerRunning"] = serde_json::json!(true); + + let _ = app_handle.emit("schedule-trigger", "start"); + + if let Ok(updated) = serde_json::to_string(&settings) { + let state = app_handle.state::(); + let conn = state.conn.lock().unwrap(); + let _ = crate::db::save_settings(&conn, &updated); + } + } + + if scheduler.stop_time_enabled && scheduler.stop_time == current_time && last_stop_key != trigger_key { + settings["schedulerLastStopKey"] = serde_json::json!(trigger_key.clone()); + settings["schedulerRunning"] = serde_json::json!(false); + + let _ = app_handle.emit("schedule-trigger", "stop"); + + if let Ok(updated) = serde_json::to_string(&settings) { + let state = app_handle.state::(); + let conn = state.conn.lock().unwrap(); + let _ = crate::db::save_settings(&conn, &updated); + } + } + } + } + } + } + }); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f14ba71..11cd835 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -55,6 +55,10 @@ function App() { const previousSpeedLimit = useRef(globalSpeedLimit); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); + useEffect(() => { + useDownloadStore.getState().initDB(); + }, []); + useEffect(() => { window.document.documentElement.setAttribute('data-font-size', appFontSize); }, [appFontSize]); @@ -114,39 +118,20 @@ function App() { }, [globalSpeedLimit]); useEffect(() => { - const checkSchedule = async () => { + const unlisten = listen('schedule-trigger', async (event) => { const state = useSettingsStore.getState(); - const scheduler = state.scheduler; - if (!scheduler.enabled) return; - - const now = new Date(); - const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; - const allowedToday = scheduler.everyday || scheduler.selectedDays.includes(now.getDay()); - if (!allowedToday) return; - - const dateKey = localDateKey(now); - if (scheduler.startTime === currentTime) { - const triggerKey = `${dateKey}-${currentTime}`; - if (state.schedulerLastStartKey !== triggerKey) { - state.setSchedulerLastStartKey(triggerKey); - const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); - state.setSchedulerRunning(started > 0); - } - } - - if (scheduler.stopTimeEnabled && scheduler.stopTime === currentTime) { - const triggerKey = `${dateKey}-${currentTime}`; - if (state.schedulerLastStopKey !== triggerKey) { - state.setSchedulerLastStopKey(triggerKey); - await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); - state.setSchedulerRunning(false); - } + if (event.payload === 'start') { + const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); + state.setSchedulerRunning(started > 0); + } else if (event.payload === 'stop') { + await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); + state.setSchedulerRunning(false); } + }); + + return () => { + unlisten.then(f => f()).catch(console.error); }; - - void checkSchedule(); - const interval = window.setInterval(() => void checkSchedule(), 10_000); - return () => window.clearInterval(interval); }, []); useEffect(() => { diff --git a/apps/desktop/src/store/useDownloadStore.ts b/apps/desktop/src/store/useDownloadStore.ts index 7a406d4..8fbad76 100644 --- a/apps/desktop/src/store/useDownloadStore.ts +++ b/apps/desktop/src/store/useDownloadStore.ts @@ -116,6 +116,7 @@ interface DownloadState { addQueue: (name: string) => void; renameQueue: (id: string, name: string) => void; removeQueue: (id: string) => void; + initDB: () => Promise; } export const useDownloadStore = create((set, get) => ({ @@ -174,14 +175,23 @@ export const useDownloadStore = create((set, get) => ({ }); set(state => ({ downloads: [...state.downloads, ...downloads] })); + downloads.forEach(item => { + const toSave = { ...item }; + delete toSave.fraction; delete toSave.speed; delete toSave.eta; + invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error); + }); void get().processQueue(); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: (item) => { set((state) => ({ downloads: [...state.downloads, item] })); + const toSave = { ...item }; + delete toSave.fraction; delete toSave.speed; delete toSave.eta; + invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error); get().processQueue(); }, updateDownload: (id, updates) => { + let updatedItem: DownloadItem | null = null; set((state) => ({ downloads: state.downloads.map(d => { if (d.id === id) { @@ -189,16 +199,24 @@ export const useDownloadStore = create((set, get) => ({ if (newFraction === 0 && d.fraction && d.fraction > 0) { newFraction = d.fraction; } - return { + const updated = { ...d, ...updates, fraction: newFraction !== undefined ? newFraction : updates.fraction !== undefined ? updates.fraction : d.fraction }; + updatedItem = updated; + return updated; } return d; }) })); + if (updatedItem && Object.keys(updates).some(k => !['fraction', 'speed', 'eta'].includes(k))) { + const toSave = { ...(updatedItem as DownloadItem) }; + delete toSave.fraction; delete toSave.speed; delete toSave.eta; + invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error); + } + // If status changed to something that frees up a slot, process queue if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) { get().processQueue(); @@ -219,22 +237,37 @@ export const useDownloadStore = create((set, get) => ({ set((state) => ({ downloads: state.downloads.filter(d => d.id !== id) })); + invoke('db_delete_download', { id }).catch(console.error); get().processQueue(); syncSystemIntegrations(); }, clearFinished: () => { + const downloads = get().downloads; + const toRemove = downloads.filter(d => ['completed', 'failed'].includes(d.status)); set((state) => ({ downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status)) })); + toRemove.forEach(d => { + invoke('db_delete_download', { id: d.id }).catch(console.error); + }); }, redownload: (id) => { + let updatedItem: DownloadItem | null = null; set((state) => ({ - downloads: state.downloads.map(d => - d.id === id - ? { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' } - : d - ) + downloads: state.downloads.map(d => { + if (d.id === id) { + const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' }; + updatedItem = updated; + return updated; + } + return d; + }) })); + if (updatedItem) { + const toSave = { ...(updatedItem as DownloadItem) }; + delete toSave.fraction; delete toSave.speed; delete toSave.eta; + invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error); + } get().processQueue(); }, startQueue: async (queueId) => { @@ -273,18 +306,83 @@ export const useDownloadStore = create((set, get) => ({ return activeIds.length; }, addQueue: (name) => { - set((state) => ({ queues: [...state.queues, { id: crypto.randomUUID(), name, isMain: false }] })); + const id = crypto.randomUUID(); + const q = { id, name, isMain: false }; + set((state) => ({ + queues: [...state.queues, q] + })); + invoke('db_save_queue', { id, data: JSON.stringify(q) }).catch(console.error); }, renameQueue: (id, name) => { + let updatedQ: Queue | null = null; set((state) => ({ - queues: state.queues.map(q => q.id === id ? { ...q, name } : q) + queues: state.queues.map(q => { + if (q.id === id) { + const newQ = { ...q, name }; + updatedQ = newQ; + return newQ; + } + return q; + }) })); + if (updatedQ) { + invoke('db_save_queue', { id, data: JSON.stringify(updatedQ) }).catch(console.error); + } }, removeQueue: (id) => { + if (id === MAIN_QUEUE_ID) return; set((state) => ({ - queues: state.queues.filter(q => q.id !== id || q.isMain), - downloads: state.downloads.map(d => d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d) + queues: state.queues.filter(q => q.id !== id), + downloads: state.downloads.map(d => + d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d + ) })); + invoke('db_delete_queue', { id }).catch(console.error); + + // Also we need to save the updated downloads to DB + const downloads = get().downloads.filter(d => d.queueId === id); + downloads.forEach(d => { + const toSave = { ...d, queueId: MAIN_QUEUE_ID }; + delete toSave.fraction; delete toSave.speed; delete toSave.eta; + invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error); + }); + }, + initDB: async () => { + try { + const queuesStr = await invoke('db_get_all_queues'); + const queues = queuesStr.map(q => JSON.parse(q)); + + const downloadsStr = await invoke('db_get_all_downloads'); + const downloads = downloadsStr.map(d => JSON.parse(d)); + + set(state => ({ + queues: queues.length > 0 ? queues : state.queues, + downloads: downloads.length > 0 ? downloads : state.downloads + })); + + // Auto resume downloads that were active + const active = get().downloads.filter(d => d.status === 'downloading'); + active.forEach(item => { + if (item.isMedia) { + invoke('start_media_download', { + id: item.id, url: item.url, destination: item.destination, + filename: item.fileName, format_selector: item.mediaFormatSelector, + proxy: null + }).catch(console.error); + } else { + invoke('start_download', { + id: item.id, url: item.url, destination: item.destination, filename: item.fileName, + connections: item.connections, speed_limit: item.speedLimit, username: item.username, + password: item.password, headers: item.headers, checksum: item.checksum, cookies: item.cookies, + mirrors: item.mirrors, user_agent: null, max_tries: null, proxy: null + }).catch(console.error); + } + }); + + void get().processQueue(); + } catch (e) { + console.error("Failed to init DB", e); + } }, processQueue: async () => { const { downloads, updateDownload } = get(); diff --git a/apps/desktop/src/store/useSettingsStore.ts b/apps/desktop/src/store/useSettingsStore.ts index 5dee7ec..5bfe382 100644 --- a/apps/desktop/src/store/useSettingsStore.ts +++ b/apps/desktop/src/store/useSettingsStore.ts @@ -1,7 +1,34 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; +const tauriStorage: StateStorage = { + getItem: async (name: string): Promise => { + if (name === 'firelink-settings') { + try { + const data = await invoke('db_load_settings'); + return data; + } catch (e) { + console.error("Failed to load settings from DB", e); + return null; + } + } + return null; + }, + setItem: async (name: string, value: string): Promise => { + if (name === 'firelink-settings') { + try { + await invoke('db_save_settings', { data: value }); + } catch (e) { + console.error("Failed to save settings to DB", e); + } + } + }, + removeItem: async (name: string): Promise => { + // no-op for now + }, +}; + export interface SiteLogin { id: string; urlPattern: string; @@ -243,6 +270,7 @@ export const useSettingsStore = create()( }), { name: 'firelink-settings', + storage: createJSONStorage(() => tauriStorage), partialize: (state) => ({ theme: state.theme, defaultDownloadPath: state.defaultDownloadPath,