mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(desktop): integrate native deep links and tray lifecycle
This commit is contained in:
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-17
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
"y": 28
|
||||
},
|
||||
"hiddenTitle": true,
|
||||
"transparent": true
|
||||
"transparent": true,
|
||||
"windowEffects": {
|
||||
"effects": ["sidebar", "mica"],
|
||||
"state": "active"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user