feat(desktop): integrate native deep links and tray lifecycle

This commit is contained in:
NimBold
2026-06-15 00:50:42 +03:30
parent f806fd2cbf
commit 20dcb69ae2
10 changed files with 183 additions and 87 deletions
-10
View File
@@ -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",
-1
View File
@@ -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",
+2 -17
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -7,7 +7,6 @@
"core:default",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default",
"deep-link:default"
"dialog:default"
]
}
+28 -1
View File
@@ -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<String>),
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::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_captured_urls = Vec::<String>::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<String>, urls: Vec<String>) {
let mut seen = target.iter().cloned().collect::<HashSet<_>>();
target.extend(urls.into_iter().filter(|url| seen.insert(url.clone())));
}
async fn download_file(
app_handle: AppHandle,
payload: DownloadPayload,
+140 -21
View File
@@ -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<String> {
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<Item = url::Url>) -> Vec<String> {
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<url::Url>) {
let urls = parse_firelink_urls(deep_links);
if urls.is_empty() {
return;
}
restore_main_window(&app_handle);
let coordinator = app_handle.state::<AppState>().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<serde_json::Value, String> {
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,
+5 -1
View File
@@ -23,7 +23,11 @@
"y": 28
},
"hiddenTitle": true,
"transparent": true
"transparent": true,
"windowEffects": {
"effects": ["sidebar", "mica"],
"state": "active"
}
}
],
"security": {
+5 -33
View File
@@ -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<SidebarFilter>('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());
};
}, []);
+1
View File
@@ -113,6 +113,7 @@ type EventMap = {
'download-complete': string;
'download-failed': string;
'extension-add-download': ExtensionDownload;
'deep-link-add-download': string;
};
export function listenEvent<K extends keyof EventMap>(