diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 5327b5f..0a98cf7 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", @@ -1656,15 +1655,6 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-deep-link": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", - "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 60427f0..19012a3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,7 +13,6 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3ea740a..fcebbcd 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4405,7 +4405,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy 0.6.0", + "window-vibrancy", "windows 0.61.3", ] @@ -4444,8 +4444,8 @@ dependencies = [ "tokio-tungstenite", "tower-http", "ts-rs", + "url", "uuid", - "window-vibrancy 0.7.1", ] [[package]] @@ -5734,21 +5734,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "window-vibrancy" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.60.2", - "windows-version", -] - [[package]] name = "windows" version = "0.61.3" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1f74064..37f1898 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -49,4 +49,4 @@ rusqlite = { version = "0.40.1", features = ["bundled"] } chrono = "0.4.38" cocoa = "0.25" objc = "0.2.7" -window-vibrancy = "0.7.1" +url = "2" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index abc3b22..1c33bc2 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -7,7 +7,6 @@ "core:default", "core:window:allow-start-dragging", "opener:default", - "dialog:default", - "deep-link:default" + "dialog:default" ] } diff --git a/apps/desktop/src-tauri/src/download.rs b/apps/desktop/src-tauri/src/download.rs index 8daaaef..a77108d 100644 --- a/apps/desktop/src-tauri/src/download.rs +++ b/apps/desktop/src-tauri/src/download.rs @@ -5,7 +5,7 @@ use reqwest::{ Client, StatusCode, }; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::PathBuf, str::FromStr, time::{Duration, Instant}, @@ -26,6 +26,8 @@ pub enum DownloadCmd { Start(DownloadPayload), Pause(Uuid), Cancel(Uuid), + CaptureUrls(Vec), + FrontendReady(bool), } #[derive(Debug)] @@ -129,6 +131,8 @@ async fn run_coordinator( let (worker_tx, mut worker_rx) = mpsc::channel(128); let mut active = HashMap::::new(); let mut active_media = HashMap::>::new(); + let mut pending_captured_urls = Vec::::new(); + let mut frontend_ready = false; let mut next_generation = 0_u64; loop { @@ -169,6 +173,24 @@ async fn run_coordinator( let _ = download.control_tx.send(DownloadControl::Cancel).await; } } + DownloadCmd::CaptureUrls(urls) => { + append_unique_urls(&mut pending_captured_urls, urls); + if frontend_ready && !pending_captured_urls.is_empty() { + let payload = pending_captured_urls.join("\n"); + if app_handle.emit("deep-link-add-download", payload).is_ok() { + pending_captured_urls.clear(); + } + } + } + DownloadCmd::FrontendReady(ready) => { + frontend_ready = ready; + if ready && !pending_captured_urls.is_empty() { + let payload = pending_captured_urls.join("\n"); + if app_handle.emit("deep-link-add-download", payload).is_ok() { + pending_captured_urls.clear(); + } + } + } } } event = worker_rx.recv() => { @@ -225,6 +247,11 @@ async fn run_coordinator( } } +fn append_unique_urls(target: &mut Vec, urls: Vec) { + let mut seen = target.iter().cloned().collect::>(); + target.extend(urls.into_iter().filter(|url| seen.insert(url.clone()))); +} + async fn download_file( app_handle: AppHandle, payload: DownloadPayload, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index aca29c3..8f12262 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ use regex::Regex; use serde::Serialize; use ts_rs::TS; use uuid::Uuid; +use tauri_plugin_deep_link::DeepLinkExt; #[derive(Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] @@ -385,6 +386,74 @@ fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec { uris } +const MAX_DEEP_LINK_PAYLOAD_LEN: usize = 65_536; +const MAX_DEEP_LINK_URLS: usize = 200; + +fn parse_firelink_urls(deep_links: impl IntoIterator) -> Vec { + let mut captured = Vec::new(); + + for deep_link in deep_links { + if deep_link.scheme() != "firelink" || deep_link.host_str() != Some("add") { + continue; + } + + let Some(raw_urls) = deep_link + .query_pairs() + .find_map(|(key, value)| (key == "url").then(|| value.into_owned())) + else { + continue; + }; + if raw_urls.is_empty() || raw_urls.len() >= MAX_DEEP_LINK_PAYLOAD_LEN { + continue; + } + + for raw_url in raw_urls.lines() { + let raw_url = raw_url.trim(); + let Ok(url) = url::Url::parse(raw_url) else { + continue; + }; + if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { + continue; + } + let url = url.to_string(); + if !captured.iter().any(|existing| existing == &url) { + captured.push(url); + if captured.len() == MAX_DEEP_LINK_URLS { + return captured; + } + } + } + } + + captured +} + +fn restore_main_window(app_handle: &tauri::AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec) { + let urls = parse_firelink_urls(deep_links); + if urls.is_empty() { + return; + } + + restore_main_window(&app_handle); + let coordinator = app_handle.state::().download_coordinator.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator + .send(download::DownloadCmd::CaptureUrls(urls)) + .await + { + eprintln!("Failed to dispatch deep link to download coordinator: {error}"); + } + }); +} + async fn rpc_call(port: u16, secret: &str, method: &str, params: serde_json::Value) -> Result { let url = format!("http://127.0.0.1:{}/jsonrpc", port); let mut payload = serde_json::Map::new(); @@ -1017,7 +1086,6 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { use tauri::tray::TrayIconBuilder; use tauri::menu::{Menu, MenuItem}; - use tauri::Manager; if show { if app_handle.tray_by_id("main").is_none() { @@ -1030,18 +1098,30 @@ fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), Stri .icon(tray_icon) .icon_as_template(true) .menu(&menu) + .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "quit" => { - std::process::exit(0); + app.exit(0); } "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + restore_main_window(app); } _ => {} }) + .on_tray_icon_event(|tray, event| { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; + + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + restore_main_window(tray.app_handle()); + } + }) .build(&app_handle) .map_err(|e| e.to_string())?; } @@ -1078,11 +1158,17 @@ fn set_extension_frontend_ready( state .extension_frontend_ready .store(ready, Ordering::Release); + let coordinator = state.download_coordinator.clone(); + tauri::async_runtime::spawn(async move { + let _ = coordinator + .send(download::DownloadCmd::FrontendReady(ready)) + .await; + }); } #[cfg(test)] mod tests { - use super::collect_download_uris; + use super::{collect_download_uris, parse_firelink_urls}; #[test] fn collects_primary_url_and_unique_mirrors_in_order() { @@ -1104,6 +1190,33 @@ mod tests { ] ); } + + #[test] + fn parses_valid_firelink_download_urls() { + let deep_link = url::Url::parse( + "firelink://add?url=https%3A%2F%2Fexample.com%2Fone.zip%0Aftp%3A%2F%2Fexample.com%2Ftwo.zip", + ) + .unwrap(); + + assert_eq!( + parse_firelink_urls([deep_link]), + vec![ + "https://example.com/one.zip", + "ftp://example.com/two.zip", + ] + ); + } + + #[test] + fn rejects_unexpected_deep_links_and_nested_schemes() { + let links = [ + url::Url::parse("firelink://open?url=https%3A%2F%2Fexample.com").unwrap(), + url::Url::parse("firelink://add?url=file%3A%2F%2F%2Ftmp%2Fsecret").unwrap(), + url::Url::parse("other://add?url=https%3A%2F%2Fexample.com").unwrap(), + ]; + + assert!(parse_firelink_urls(links).is_empty()); + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -1120,10 +1233,7 @@ pub fn run() { let aria2_secret = format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + restore_main_window(app); })) .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard(std::sync::Mutex::new(None))) @@ -1137,21 +1247,20 @@ pub fn run() { media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), sleep_preventer: Arc::new(Mutex::new(None)), }); + let deep_link_app = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + dispatch_deep_links(deep_link_app.clone(), event.urls()); + }); + match app.deep_link().get_current() { + Ok(Some(urls)) => dispatch_deep_links(app.handle().clone(), urls), + Ok(None) => {} + Err(error) => eprintln!("Failed to read startup deep link: {error}"), + } 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()); - #[cfg(target_os = "macos")] - { - use tauri::Manager; - use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; - if let Some(window) = app.get_webview_window("main") { - apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, None) - .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS"); - } - } - let resource_dir = app.path().resource_dir().unwrap(); let aria2c_path = resource_dir.join("binaries").join(get_binary_name("aria2c")); @@ -1364,6 +1473,16 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) + .on_window_event(|window, event| { + if window.label() == "main" { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window.app_handle().tray_by_id("main").is_some() { + api.prevent_close(); + let _ = window.hide(); + } + } + } + }) .invoke_handler(tauri::generate_handler![ greet, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 14f96c7..c252ca8 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -23,7 +23,11 @@ "y": 28 }, "hiddenTitle": true, - "transparent": true + "transparent": true, + "windowEffects": { + "effects": ["sidebar", "mica"], + "state": "active" + } } ], "security": { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index cdc5c4b..dbd8722 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,28 +8,9 @@ import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; -import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; - - -const handleDeepLinks = (deepLinks: string[]) => { - for (const rawDeepLink of deepLinks) { - try { - const deepLink = new URL(rawDeepLink); - if (deepLink.protocol !== 'firelink:' || deepLink.hostname !== 'add') continue; - const urls = deepLink.searchParams.get('url') || ''; - if (urls.length > 0 && urls.length < 65_536) { - useDownloadStore.getState().openAddModalWithUrls(urls); - return; - } - } catch (error) { - console.warn('Ignored invalid Firelink deep link:', error); - } - } -}; - function App() { const [filter, setFilter] = useState('all'); const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -104,19 +85,6 @@ function App() { }); }, [extensionPairingToken]); - useEffect(() => { - const unlisten = onOpenUrl(handleDeepLinks); - getCurrent() - .then(urls => { - if (urls) handleDeepLinks(urls); - }) - .catch(error => console.error('Failed to read startup deep link:', error)); - - return () => { - unlisten.then(dispose => dispose()); - }; - }, []); - useEffect(() => { if (previousSpeedLimit.current === globalSpeedLimit) return; previousSpeedLimit.current = globalSpeedLimit; @@ -248,7 +216,10 @@ function App() { const unlistenExtension = listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload); }); - unlistenExtension + const unlistenDeepLink = listen('deep-link-add-download', (event) => { + useDownloadStore.getState().openAddModalWithUrls(event.payload); + }); + Promise.all([unlistenExtension, unlistenDeepLink]) .then(() => invoke('set_extension_frontend_ready', { ready: true })) .catch(error => console.error('Failed to activate browser extension integration:', error)); @@ -258,6 +229,7 @@ function App() { unlistenComplete.then(f => f()); unlistenFailed.then(f => f()); unlistenExtension.then(f => f()); + unlistenDeepLink.then(f => f()); }; }, []); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 48e34aa..e5e2951 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -113,6 +113,7 @@ type EventMap = { 'download-complete': string; 'download-failed': string; 'extension-add-download': ExtensionDownload; + 'deep-link-add-download': string; }; export function listenEvent(