diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c4fc3aa..374ecf5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } diff --git a/apps/desktop/src-tauri/src/extension_server.rs b/apps/desktop/src-tauri/src/extension_server.rs new file mode 100644 index 0000000..73be8b1 --- /dev/null +++ b/apps/desktop/src-tauri/src/extension_server.rs @@ -0,0 +1,57 @@ +use std::net::TcpListener; +use std::io::{Read, Write}; +use std::thread; +use tauri::{AppHandle, Emitter}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +struct ExtensionPayload { + url: String, + token: String, +} + +pub fn start_server(app_handle: AppHandle) { + thread::spawn(move || { + let listener = match TcpListener::bind("127.0.0.1:23522") { + Ok(l) => l, + Err(_) => return, // Port might be in use, ignore for now + }; + + for stream in listener.incoming() { + match stream { + Ok(mut stream) => { + let mut buffer = [0; 1024]; + if let Ok(size) = stream.read(&mut buffer) { + let request = String::from_utf8_lossy(&buffer[..size]); + + // Parse simple HTTP POST + if request.starts_with("POST") { + if let Some(body_start) = request.find("\r\n\r\n") { + let body = &request[body_start + 4..]; + if let Ok(payload) = serde_json::from_str::(body) { + // Emit event to frontend + let _ = app_handle.emit("extension-add-download", payload); + + // Send success response + let response = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Type: application/json\r\n\r\n{\"success\":true}"; + let _ = stream.write_all(response.as_bytes()); + continue; + } + } + } + + // Handle OPTIONS for CORS + if request.starts_with("OPTIONS") { + let response = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + } else { + let response = "HTTP/1.1 400 Bad Request\r\nAccess-Control-Allow-Origin: *\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + } + } + } + Err(_) => {} + } + } + }); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0c36a99..377b68a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -371,6 +371,9 @@ async fn start_download( username: Option, password: Option, headers: Option, + checksum: Option, + cookies: Option, + mirrors: Option, user_agent: Option, max_tries: Option, proxy: Option, @@ -432,6 +435,16 @@ async fn start_download( cmd.arg(format!("--header={}", hdr)); } } + if let Some(chk) = checksum { + if !chk.is_empty() { + cmd.arg(format!("--checksum={}", chk)); + } + } + if let Some(cks) = cookies { + if !cks.is_empty() { + cmd.arg(format!("--header=Cookie: {}", cks)); + } + } if let Some(ua) = user_agent { if !ua.is_empty() { cmd.arg(format!("--user-agent={}", ua)); @@ -447,6 +460,18 @@ async fn start_download( } cmd.arg(&url); + + if let Some(m) = mirrors { + if !m.is_empty() { + for mirror_url in m.lines() { + let trimmed = mirror_url.trim(); + if !trimmed.is_empty() { + cmd.arg(trimmed); + } + } + } + } + cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::null()); // We remove kill_on_drop(true) so we can gracefully SIGTERM it @@ -527,6 +552,7 @@ async fn start_media_download( destination: String, filename: String, format_selector: Option, + cookie_source: Option, speed_limit: Option, username: Option, password: Option, @@ -575,6 +601,12 @@ async fn start_media_download( } } + if let Some(cs) = cookie_source { + if !cs.is_empty() && cs != "none" { + cmd.arg("--cookies-from-browser").arg(cs); + } + } + if let Some(format) = format_selector { cmd.arg("-f").arg(format); // If the filename implies an audio format, use it as audio output @@ -958,9 +990,51 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> } } +#[tauri::command] +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() { + let quit_i = MenuItem::with_id(&app_handle, "quit", "Quit", true, None::<&str>).map_err(|e| e.to_string())?; + let show_i = MenuItem::with_id(&app_handle, "show", "Show Firelink", true, None::<&str>).map_err(|e| e.to_string())?; + let menu = Menu::with_items(&app_handle, &[&show_i, &quit_i]).map_err(|e| e.to_string())?; + + let _tray = TrayIconBuilder::with_id("main") + .icon(app_handle.default_window_icon().unwrap().clone()) + .menu(&menu) + .on_menu_event(|app, event| match event.id.as_ref() { + "quit" => { + std::process::exit(0); + } + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + _ => {} + }) + .build(&app_handle) + .map_err(|e| e.to_string())?; + } + } else { + if let Some(_tray) = app_handle.tray_by_id("main") { + let _ = app_handle.remove_tray_by_id("main"); + } + } + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .setup(|app| { + extension_server::start_server(app.handle().clone()); + Ok(()) + }) .manage(AppState { tasks: Mutex::new(HashMap::new()), }) @@ -973,8 +1047,9 @@ pub fn run() { update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, - check_file_exists, delete_file + check_file_exists, delete_file, toggle_tray_icon ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } +mod extension_server; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d58af92..7bc0592 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -27,6 +27,7 @@ function App() { const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); const showDockBadge = useSettingsStore(state => state.showDockBadge); + const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length; const queuedCount = downloads.filter(download => download.status === 'queued').length; @@ -43,6 +44,10 @@ function App() { invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); }, [showDockBadge, activeDownloadCount]); + useEffect(() => { + invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); + }, [showMenuBarIcon]); + useEffect(() => { if (previousSpeedLimit.current === globalSpeedLimit) return; previousSpeedLimit.current = globalSpeedLimit; @@ -172,10 +177,21 @@ function App() { } }); + const unlistenExtension = listen('extension-add-download', (event: any) => { + const { url, token } = event.payload; + const settings = useSettingsStore.getState(); + if (settings.extensionPairingToken && token === settings.extensionPairingToken) { + useDownloadStore.getState().openAddModalWithUrls(url); + } else { + console.warn('Extension add download rejected: invalid token'); + } + }); + return () => { unlistenProgress.then(f => f()); unlistenComplete.then(f => f()); unlistenFailed.then(f => f()); + unlistenExtension.then(f => f()); }; }, []); diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index ca371aa..84faf1e 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -250,7 +250,7 @@ const isMediaUrl = (url: string) => { export const AddDownloadsModal = () => { - const { isAddModalOpen, toggleAddModal, addDownload, queues } = useDownloadStore(); + const { isAddModalOpen, pendingAddUrls, toggleAddModal, addDownload, queues } = useDownloadStore(); const { defaultDownloadPath } = useSettingsStore(); const [selectedQueueId, setSelectedQueueId] = useState(MAIN_QUEUE_ID); @@ -286,12 +286,12 @@ export const AddDownloadsModal = () => { useEffect(() => { if (isAddModalOpen) { setSaveLocation(defaultDownloadPath); - setUrls(''); + setUrls(pendingAddUrls || ''); setParsedItems([]); setSelectedItemIndex(null); setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID); } - }, [isAddModalOpen, defaultDownloadPath]); + }, [isAddModalOpen, pendingAddUrls, defaultDownloadPath]); useEffect(() => { if (!saveLocation) return; diff --git a/apps/desktop/src/components/PropertiesModal.tsx b/apps/desktop/src/components/PropertiesModal.tsx index 4cc1867..78d8248 100644 --- a/apps/desktop/src/components/PropertiesModal.tsx +++ b/apps/desktop/src/components/PropertiesModal.tsx @@ -69,6 +69,19 @@ export const PropertiesModal = () => { } setHeaders(activeItem.headers || ''); + setChecksumEnabled(!!activeItem.checksum); + if (activeItem.checksum) { + const [algo, val] = activeItem.checksum.split('='); + if (val) { + setChecksumAlgorithm(algo); + setChecksumValue(val); + } + } else { + setChecksumAlgorithm('SHA-256'); + setChecksumValue(''); + } + setCookies(activeItem.cookies || ''); + setMirrors(activeItem.mirrors || ''); setErrorMessage(''); } else { setItem(null); @@ -115,6 +128,9 @@ export const PropertiesModal = () => { username: loginMode === 'custom' ? username.trim() : null, password: loginMode === 'custom' ? password.trim() : null, headers: headers.trim() || null, + checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : null, + cookies: cookies.trim() || null, + mirrors: mirrors.trim() || null, }; updateDownload(item.id, updates); diff --git a/apps/desktop/src/store/useDownloadStore.ts b/apps/desktop/src/store/useDownloadStore.ts index 187a332..caadb77 100644 --- a/apps/desktop/src/store/useDownloadStore.ts +++ b/apps/desktop/src/store/useDownloadStore.ts @@ -104,6 +104,9 @@ export interface DownloadItem { username?: string | null; password?: string | null; headers?: string | null; + checksum?: string | null; + cookies?: string | null; + mirrors?: string | null; destination?: string; isMedia?: boolean; mediaFormatSelector?: string; @@ -114,8 +117,10 @@ interface DownloadState { downloads: DownloadItem[]; queues: Queue[]; isAddModalOpen: boolean; + pendingAddUrls: string; selectedPropertiesDownloadId: string | null; toggleAddModal: (isOpen: boolean) => void; + openAddModalWithUrls: (urls: string) => void; setSelectedPropertiesDownloadId: (id: string | null) => void; addDownload: (item: DownloadItem) => void; updateDownload: (id: string, updates: Partial) => void; @@ -135,8 +140,10 @@ export const useDownloadStore = create((set, get) => ({ downloads: [], queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], isAddModalOpen: false, + pendingAddUrls: '', selectedPropertiesDownloadId: null, toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }), + openAddModalWithUrls: (urls) => set({ isAddModalOpen: true, pendingAddUrls: urls }), setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: (item) => { set((state) => ({ downloads: [...state.downloads, item] })); @@ -317,6 +324,7 @@ export const useDownloadStore = create((set, get) => ({ destination: destPath, filename: item.fileName, formatSelector: item.mediaFormatSelector || null, + cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, speedLimit, username: item.username || (login ? login.username : null), password: item.password || keychainPassword @@ -337,6 +345,9 @@ export const useDownloadStore = create((set, get) => ({ username: item.username || (login ? login.username : null), password: item.password || keychainPassword, headers: item.headers || null, + checksum: item.checksum || null, + cookies: item.cookies || null, + mirrors: item.mirrors || null, userAgent: settings.customUserAgent || null, maxTries: settings.maxAutomaticRetries, proxy: getProxyArgs(settings)