diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 11e960d..45aec7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ serde_json = "1" tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } regex = "1.10" reqwest = { version = "0.12", features = ["json", "stream"] } -uuid = "1" +uuid = { version = "1", features = ["v4"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } tauri-plugin-notification = "2.3.3" sysinfo = "0.39.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1bcd7b5..38fb46f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,8 +42,7 @@ async fn fetch_metadata(url: String, user_agent: Option, username: Optio builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); } - let client = builder.build().map_err(|e| e.to_string())?; - + let mut resolved_addr = None; if let Ok(parsed) = reqwest::Url::parse(&url) { if let Some(host) = parsed.host_str() { let port = parsed.port_or_known_default().unwrap_or(80); @@ -57,13 +56,22 @@ async fn fetch_metadata(url: String, user_agent: Option, username: Optio std::net::IpAddr::V4(ipv4) if ipv4.is_private() || ipv4.is_link_local() => { return Err("SSRF blocked: Private/local IP not allowed".to_string()); } - _ => {} + _ => { + resolved_addr = Some((host.to_string(), addr)); + break; + } } } } } } + if let Some((host, addr)) = resolved_addr { + builder = builder.resolve(&host, addr); + } + + let client = builder.build().map_err(|e| e.to_string())?; + let mut head_req = client.head(&url); if let Some(ref user) = username { if !user.is_empty() { @@ -744,7 +752,7 @@ pub(crate) async fn start_media_download_internal( cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; - let stdout = child.stdout.take().unwrap(); + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; // yt-dlp parsing regex let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); @@ -807,17 +815,16 @@ pub(crate) async fn start_media_download_internal( _ => break, } } - status = child.wait() => { - println!("child exit status: {:?}", status); - if let Ok(exit_status) = status { - if exit_status.success() { - let _ = app_handle.emit("download-complete", id.to_string()); - } else { - let _ = app_handle.emit("download-failed", id.to_string()); - } - } - break; - } + } + } + + let status = child.wait().await; + println!("child exit status: {:?}", status); + if let Ok(exit_status) = status { + if exit_status.success() { + let _ = app_handle.emit("download-complete", id.to_string()); + } else { + let _ = app_handle.emit("download-failed", id.to_string()); } } @@ -886,7 +893,7 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) { #[tauri::command] fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) { - let mut current_preventer = state.sleep_preventer.lock().unwrap(); + let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner()); if prevent { if current_preventer.is_none() { if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() { @@ -1231,7 +1238,7 @@ pub fn run() { .and_then(|listener| listener.local_addr()) .map(|addr| addr.port()) .unwrap_or(6800); - let aria2_secret = format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); + let aria2_secret = uuid::Uuid::new_v4().to_string(); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { restore_main_window(app); @@ -1280,7 +1287,7 @@ pub fn run() { .arg("--summary-interval=1") .arg("--console-log-level=warn") .arg("--download-result=hide") - .arg("--check-certificate=false") + .arg("--check-certificate=true") .spawn(); match aria2_process { diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 40d851d..37baca1 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -98,9 +98,7 @@ export const DownloadTable: React.FC = ({ filter }) => { }; const handleResume = (item: DownloadItem) => { - useDownloadStore.setState((state) => ({ - downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d) - })); + updateDownload(item.id, { status: 'queued', _dispatched: false, speed: '-', eta: '-' }); useDownloadStore.getState().processQueue(); }; diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 37426e3..5fc7290 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -10,7 +10,6 @@ export const PropertiesModal = () => { const { selectedPropertiesDownloadId, setSelectedPropertiesDownloadId, - downloads, updateDownload } = useDownloadStore(); @@ -43,7 +42,7 @@ export const PropertiesModal = () => { useEffect(() => { if (selectedPropertiesDownloadId) { - const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId); + const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); if (activeItem) { setItem(activeItem); setUrl(activeItem.url); @@ -89,7 +88,7 @@ export const PropertiesModal = () => { } else { setItem(null); } - }, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]); + }, [selectedPropertiesDownloadId, defaultDownloadPath]); if (!selectedPropertiesDownloadId || !item) return null; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 09294c8..4a0f762 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -87,6 +87,8 @@ interface DownloadState { initDB: () => Promise; } +let isProcessingQueue = false; + export const useDownloadStore = create((set, get) => ({ downloads: [], queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], @@ -167,13 +169,15 @@ export const useDownloadStore = create((set, get) => ({ const item = get().downloads.find(d => d.id === id); if (item && item.status === 'downloading') { try { - await invoke('remove_download', { id, filepath: item.destination || null }); + const filepath = item.destination ? `${item.destination}/${item.fileName}` : null; + await invoke('remove_download', { id, filepath }); } catch (e) { console.error("Failed to terminate download on deletion:", e); } } else if (item && deleteFile) { try { - await invoke('remove_download', { id, filepath: item.destination || null }); + const filepath = item.destination ? `${item.destination}/${item.fileName}` : null; + await invoke('remove_download', { id, filepath }); } catch (e) { console.error("Failed to delete file from disk:", e); } @@ -218,6 +222,14 @@ export const useDownloadStore = create((set, get) => ({ : item ) })); + + const downloadsToSave = get().downloads.filter(item => runnableIds.includes(item.id)); + downloadsToSave.forEach(d => { + const toSave = { ...d }; + 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); + }); + await get().processQueue(); return runnableIds.length; }, @@ -235,6 +247,14 @@ export const useDownloadStore = create((set, get) => ({ : item ) })); + + const downloadsToSave = get().downloads.filter(item => activeIds.includes(item.id)); + downloadsToSave.forEach(d => { + const toSave = { ...d }; + 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); + }); + await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {}))); syncSystemIntegrations(); return activeIds.length; @@ -265,6 +285,9 @@ export const useDownloadStore = create((set, get) => ({ }, removeQueue: (id) => { if (id === MAIN_QUEUE_ID) return; + + const affectedDownloads = get().downloads.filter(d => d.queueId === id); + set((state) => ({ queues: state.queues.filter(q => q.id !== id), downloads: state.downloads.map(d => @@ -274,8 +297,7 @@ export const useDownloadStore = create((set, get) => ({ 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 => { + affectedDownloads.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); @@ -296,6 +318,7 @@ export const useDownloadStore = create((set, get) => ({ // Auto resume downloads that were active const active = get().downloads.filter(d => d.status === 'downloading'); + const settings = useSettingsStore.getState(); active.forEach(item => { if (item.isMedia) { invoke('start_media_download', { @@ -305,7 +328,7 @@ export const useDownloadStore = create((set, get) => ({ filename: item.fileName, formatSelector: item.mediaFormatSelector || null, cookieSource: null, - speedLimit: item.speedLimit || null, + speedLimit: item.speedLimit || settings.globalSpeedLimit || null, username: item.username || null, password: item.password || null, headers: item.headers || null, @@ -320,7 +343,7 @@ export const useDownloadStore = create((set, get) => ({ destination: item.destination || '~/Downloads', filename: item.fileName, connections: item.connections ?? null, - speedLimit: item.speedLimit || null, + speedLimit: item.speedLimit || settings.globalSpeedLimit || null, username: item.username || null, password: item.password || null, headers: item.headers || null, @@ -340,70 +363,84 @@ export const useDownloadStore = create((set, get) => ({ } }, processQueue: async () => { - const { downloads, updateDownload } = get(); + if (isProcessingQueue) return; + isProcessingQueue = true; - // Find all queued items that haven't been dispatched to the backend yet - const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched); - - for (const item of itemsToStart) { - // Mark as dispatched so we don't send it again on the next pass - updateDownload(item.id, { _dispatched: true }); - try { - const settings = useSettingsStore.getState(); - const login = getSiteLogin(item.url, settings); - let keychainPassword = null; - if (login) { - try { - keychainPassword = await invoke('get_keychain_password', { id: login.id }); - } catch (e) { - console.warn("Could not fetch keychain password for login:", e); - } - } - - const destPath = item.destination || - (settings.downloadDirectories && settings.downloadDirectories[item.category]) || - settings.defaultDownloadPath || - '~/Downloads'; + try { + const { downloads, updateDownload } = get(); + const settings = useSettingsStore.getState(); + const concurrentLimit = settings.maxConcurrentDownloads || 3; + const activeCount = downloads.filter(d => d.status === 'downloading').length; + let availableSlots = concurrentLimit - activeCount; - if (item.isMedia) { - await invoke('start_media_download', { - id: item.id, - url: item.url, - destination: destPath, - filename: item.fileName, - formatSelector: item.mediaFormatSelector || null, - cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, - speedLimit: item.speedLimit || null, - username: item.username || (login ? login.username : null), - password: item.password || keychainPassword, - headers: item.headers || null, - proxy: getProxyArgs(settings), - userAgent: settings.customUserAgent || null, - maxTries: settings.maxAutomaticRetries - }); - } else { - await invoke('start_download', { - id: item.id, - url: item.url, - destination: destPath, - filename: item.fileName, - connections: item.connections || settings.perServerConnections || null, - speedLimit: item.speedLimit || null, - 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) - }); + if (availableSlots <= 0) return; + + const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched); + + for (const item of itemsToStart) { + if (availableSlots <= 0) break; + availableSlots--; + + // Mark as dispatched so we don't send it again on the next pass + updateDownload(item.id, { _dispatched: true }); + try { + const login = getSiteLogin(item.url, settings); + let keychainPassword = null; + if (login) { + try { + keychainPassword = await invoke('get_keychain_password', { id: login.id }); + } catch (e) { + console.warn("Could not fetch keychain password for login:", e); + } + } + + const destPath = item.destination || + (settings.downloadDirectories && settings.downloadDirectories[item.category]) || + settings.defaultDownloadPath || + '~/Downloads'; + + if (item.isMedia) { + await invoke('start_media_download', { + id: item.id, + url: item.url, + destination: destPath, + filename: item.fileName, + formatSelector: item.mediaFormatSelector || null, + cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, + speedLimit: item.speedLimit || settings.globalSpeedLimit || null, + username: item.username || (login ? login.username : null), + password: item.password || keychainPassword, + headers: item.headers || null, + proxy: getProxyArgs(settings), + userAgent: settings.customUserAgent || null, + maxTries: settings.maxAutomaticRetries + }); + } else { + await invoke('start_download', { + id: item.id, + url: item.url, + destination: destPath, + filename: item.fileName, + connections: item.connections || settings.perServerConnections || null, + speedLimit: item.speedLimit || settings.globalSpeedLimit || null, + 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) + }); + } + } catch (e) { + console.error("Failed to start queued download:", e); + updateDownload(item.id, { status: 'failed' }); } - } catch (e) { - console.error("Failed to start queued download:", e); - updateDownload(item.id, { status: 'failed' }); } + } finally { + isProcessingQueue = false; } } })); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index ac756aa..21e85d4 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -233,11 +233,9 @@ export const useSettingsStore = create()( setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }), setMaxConcurrentDownloads: (max) => { set({ maxConcurrentDownloads: max }); - invoke('set_concurrent_limit', { limit: max }).catch(console.error); }, setGlobalSpeedLimit: (limit) => { set({ globalSpeedLimit: limit }); - invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error); }, setActiveView: (view) => set({ activeView: view }), setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),