fix(downloads): correct add window actions

This commit is contained in:
NimBold
2026-06-20 18:51:38 +03:30
parent 894c238bb7
commit 487e1fea43
13 changed files with 278 additions and 232 deletions
+17 -139
View File
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sha2::Sha256; use sha2::Sha256;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::Path;
use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
@@ -23,7 +23,6 @@ pub const EXTENSION_SERVER_PORT: u16 = 6412;
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422; pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
const MAX_URL_COUNT: usize = 200; const MAX_URL_COUNT: usize = 200;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const MAIN_QUEUE_ID: &str = "00000000-0000-0000-0000-000000000001";
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>; pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -34,6 +33,7 @@ type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
pub struct ServerState { pub struct ServerState {
pub app_handle: AppHandle, pub app_handle: AppHandle,
pub pairing_token: SharedExtensionToken, pub pairing_token: SharedExtensionToken,
pub frontend_ready: SharedFrontendReady,
pub replay_cache: ReplayCache, pub replay_cache: ReplayCache,
} }
@@ -66,12 +66,13 @@ pub struct ExtensionDownload {
pub async fn start_server( pub async fn start_server(
app_handle: AppHandle, app_handle: AppHandle,
pairing_token: SharedExtensionToken, pairing_token: SharedExtensionToken,
_frontend_ready: SharedFrontendReady, frontend_ready: SharedFrontendReady,
mut shutdown_rx: watch::Receiver<bool>, mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
let state = ServerState { let state = ServerState {
app_handle, app_handle,
pairing_token, pairing_token,
frontend_ready,
replay_cache: Arc::new(Mutex::new(HashMap::new())), replay_cache: Arc::new(Mutex::new(HashMap::new())),
}; };
@@ -206,8 +207,13 @@ async fn download_handler(
} }
} }
if enqueue_extension_download(&state.app_handle, &download) if !wait_for_frontend(&state.frontend_ready).await {
.await return Err(StatusCode::SERVICE_UNAVAILABLE);
}
if state
.app_handle
.emit("extension-add-download", download)
.is_err() .is_err()
{ {
return Err(StatusCode::INTERNAL_SERVER_ERROR); return Err(StatusCode::INTERNAL_SERVER_ERROR);
@@ -216,142 +222,14 @@ async fn download_handler(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
async fn enqueue_extension_download( async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
app_handle: &AppHandle, for _ in 0..40 {
download: &ExtensionDownload, if frontend_ready.load(Ordering::Acquire) {
) -> Result<(), String> { return true;
let Ok(settings) = crate::settings::load_settings(app_handle) else {
return Err("settings unavailable".to_string());
};
let state = app_handle.state::<crate::AppState>();
let mut created_items = Vec::new();
let mut tasks = Vec::new();
for url in &download.urls {
let filename = download
.filename
.as_deref()
.filter(|_| download.urls.len() == 1)
.map(str::to_string)
.unwrap_or_else(|| filename_from_url(url));
let category = crate::parity::get_file_category(filename.clone());
let category_key = format!("{category:?}");
let destination = settings
.download_directories
.get(&category_key)
.cloned()
.filter(|path| !path.trim().is_empty())
.unwrap_or_else(|| settings.default_download_path.clone());
let id = uuid::Uuid::new_v4().to_string();
let headers = merge_headers(download.referer.as_deref(), download.headers.as_deref());
let item = crate::ipc::DownloadItem {
id: id.clone(),
url: url.clone(),
file_name: filename.clone(),
status: crate::ipc::DownloadStatus::Queued,
fraction: Some(0.0),
speed: Some("-".to_string()),
eta: Some("-".to_string()),
size: None,
category,
date_added: chrono::Utc::now().to_rfc3339(),
connections: Some(settings.per_server_connections),
speed_limit: (!settings.global_speed_limit.trim().is_empty())
.then(|| settings.global_speed_limit.clone()),
username: None,
password: None,
headers: headers.clone(),
checksum: None,
cookies: download.cookies.clone(),
mirrors: None,
destination: Some(destination.clone()),
is_media: Some(false),
media_format_selector: None,
queue_id: MAIN_QUEUE_ID.to_string(),
has_been_dispatched: Some(true),
};
let task = crate::queue::EnqueueItem {
id,
url: url.clone(),
destination,
filename,
connections: Some(settings.per_server_connections),
speed_limit: (!settings.global_speed_limit.trim().is_empty())
.then(|| settings.global_speed_limit.clone()),
username: None,
password: None,
headers,
checksum: None,
cookies: download.cookies.clone(),
mirrors: None,
user_agent: (!settings.custom_user_agent.trim().is_empty())
.then(|| settings.custom_user_agent.clone()),
max_tries: Some(settings.max_automatic_retries),
proxy: None,
format_selector: None,
cookie_source: None,
is_media: Some(false),
} }
.into_task(); tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Err(e) = crate::download_ownership::register_expected(
app_handle,
&item.id,
item.destination.as_deref().unwrap_or(""),
&item.file_name,
) {
log::warn!("extension: ownership registration failed: {}", e);
continue;
}
created_items.push(item);
tasks.push(task);
} }
false
let results = state.queue_manager.enqueue_many(tasks).await;
let mut emitted_items = Vec::new();
for (item, result) in created_items.into_iter().zip(results) {
if result.success {
emitted_items.push(item);
} else {
let _ = crate::download_ownership::remove(app_handle, &item.id);
state.queue_manager.release_registered_id(&item.id).await;
}
}
if !emitted_items.is_empty() {
let _ = app_handle.emit("extension-downloads-queued", emitted_items);
}
Ok(())
}
fn merge_headers(referer: Option<&str>, headers: Option<&str>) -> Option<String> {
let mut values = Vec::new();
if let Some(referer) = referer {
values.push(format!("Referer: {referer}"));
}
if let Some(headers) = headers {
values.extend(
headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string),
);
}
(!values.is_empty()).then(|| values.join("\n"))
}
fn filename_from_url(raw_url: &str) -> String {
Url::parse(raw_url)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.and_then(sanitize_filename)
})
.unwrap_or_else(|| "download".to_string())
} }
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> { fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
+5 -1
View File
@@ -6,6 +6,8 @@ use ts_rs::TS;
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadStatus { pub enum DownloadStatus {
/// Added to the download list but not assigned to a queue or dispatched.
Ready,
Downloading, Downloading,
/// Post-download media processing such as yt-dlp/ffmpeg merging or /// Post-download media processing such as yt-dlp/ffmpeg merging or
/// extraction. The queue permit is still held. /// extraction. The queue permit is still held.
@@ -22,6 +24,7 @@ pub enum DownloadStatus {
impl DownloadStatus { impl DownloadStatus {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Ready => "ready",
Self::Downloading => "downloading", Self::Downloading => "downloading",
Self::Processing => "processing", Self::Processing => "processing",
Self::Paused => "paused", Self::Paused => "paused",
@@ -94,7 +97,8 @@ pub struct DownloadItem {
pub is_media: Option<bool>, pub is_media: Option<bool>,
#[ts(optional)] #[ts(optional)]
pub media_format_selector: Option<String>, pub media_format_selector: Option<String>,
pub queue_id: String, #[ts(optional)]
pub queue_id: Option<String>,
#[ts(optional)] #[ts(optional)]
pub has_been_dispatched: Option<bool>, pub has_been_dispatched: Option<bool>,
} }
+1 -19
View File
@@ -231,27 +231,10 @@ function App() {
const unlistenExtension = listen('extension-add-download', (event) => { const unlistenExtension = listen('extension-add-download', (event) => {
useDownloadStore.getState().handleExtensionDownload(event.payload); useDownloadStore.getState().handleExtensionDownload(event.payload);
}); });
const unlistenExtensionQueued = listen('extension-downloads-queued', (event) => {
const store = useDownloadStore.getState();
const incoming = event.payload;
const existing = new Set(store.downloads.map(download => download.id));
const additions = incoming.filter(download => !existing.has(download.id));
if (additions.length === 0) return;
useDownloadStore.setState(state => ({
downloads: [...state.downloads, ...additions],
pendingOrder: [
...state.pendingOrder,
...additions
.filter(download => download.status === 'queued')
.map(download => download.id)
.filter(id => !state.pendingOrder.includes(id)),
],
}));
});
const unlistenDeepLink = listen('deep-link-add-download', (event) => { const unlistenDeepLink = listen('deep-link-add-download', (event) => {
useDownloadStore.getState().openAddModalWithUrls(event.payload); useDownloadStore.getState().openAddModalWithUrls(event.payload);
}); });
Promise.all([unlistenExtension, unlistenExtensionQueued, unlistenDeepLink]) Promise.all([unlistenExtension, unlistenDeepLink])
.then(() => invoke('set_extension_frontend_ready', { ready: true })) .then(() => invoke('set_extension_frontend_ready', { ready: true }))
.catch(error => console.error('Failed to activate browser extension integration:', error)); .catch(error => console.error('Failed to activate browser extension integration:', error));
@@ -259,7 +242,6 @@ function App() {
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
unlistenTerminalState.then(f => f()); unlistenTerminalState.then(f => f());
unlistenExtension.then(f => f()); unlistenExtension.then(f => f());
unlistenExtensionQueued.then(f => f());
unlistenDeepLink.then(f => f()); unlistenDeepLink.then(f => f());
}; };
}, []); }, []);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus"; import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, hasBeenDispatched?: boolean, }; export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, hasBeenDispatched?: boolean, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying"; export type DownloadStatus = "ready" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
+113 -44
View File
@@ -1,7 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; import {
useDownloadStore,
MAIN_QUEUE_ID,
getSiteLogin,
type AddDownloadAction
} from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, ListPlus, Rows3, type LucideIcon } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
@@ -260,6 +265,8 @@ export const AddDownloadsModal = () => {
pendingAddUrls, pendingAddUrls,
pendingAddReferer, pendingAddReferer,
pendingAddFilename, pendingAddFilename,
pendingAddHeaders,
pendingAddCookies,
toggleAddModal, toggleAddModal,
addDownload, addDownload,
queues queues
@@ -275,9 +282,11 @@ export const AddDownloadsModal = () => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]); const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false); const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingStartFlag, setPendingStartFlag] = useState(false); const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false); const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [resolvedLocation, setResolvedLocation] = useState(''); const [resolvedLocation, setResolvedLocation] = useState('');
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form // Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath); const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
@@ -315,18 +324,35 @@ export const AddDownloadsModal = () => {
setChecksumEnabled(false); setChecksumEnabled(false);
setChecksumAlgo('SHA-256'); setChecksumAlgo('SHA-256');
setChecksumValue(''); setChecksumValue('');
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : ''); setHeaders([
setCookies(''); pendingAddReferer ? `Referer: ${pendingAddReferer}` : '',
pendingAddHeaders
].filter(Boolean).join('\n'));
setCookies(pendingAddCookies);
setMirrors(''); setMirrors('');
setIsActionMenuOpen(false);
} }
}, [ }, [
isAddModalOpen, isAddModalOpen,
pendingAddUrls, pendingAddUrls,
pendingAddReferer, pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
defaultDownloadPath, defaultDownloadPath,
queues queues
]); ]);
useEffect(() => {
if (!isActionMenuOpen) return;
const closeMenu = (event: PointerEvent) => {
if (!actionMenuRef.current?.contains(event.target as Node)) {
setIsActionMenuOpen(false);
}
};
window.addEventListener('pointerdown', closeMenu);
return () => window.removeEventListener('pointerdown', closeMenu);
}, [isActionMenuOpen]);
useEffect(() => { useEffect(() => {
if (!saveLocation) return; if (!saveLocation) return;
invoke('get_free_space', { path: saveLocation }) invoke('get_free_space', { path: saveLocation })
@@ -493,7 +519,7 @@ export const AddDownloadsModal = () => {
'~/Downloads'; '~/Downloads';
}; };
const handleStart = async (startImmediately: boolean) => { const handleAction = async (action: AddDownloadAction) => {
let finalLocation = saveLocation; let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual; let useSharedDestination = isSaveLocationManual;
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
@@ -553,16 +579,16 @@ export const AddDownloadsModal = () => {
if (newConflicts.length > 0) { if (newConflicts.length > 0) {
setConflicts(newConflicts); setConflicts(newConflicts);
setPendingStartFlag(startImmediately); setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination); setPendingUseSharedDestination(useSharedDestination);
setShowingDuplicates(true); setShowingDuplicates(true);
return; return;
} }
await executeAddDownloads(startImmediately, finalLocation, useSharedDestination); await executeAddDownloads(action, finalLocation, useSharedDestination);
}; };
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => { const executeAddDownloads = async (action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems]; let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) { if (resolutions) {
@@ -651,29 +677,27 @@ export const AddDownloadsModal = () => {
} }
const category = categoryForFileName(finalFile); const category = categoryForFileName(finalFile);
addDownload({ await addDownload({
id, id,
url: item.url, url: item.url,
fileName: finalFile, fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category, category,
dateAdded: new Date().toISOString(), dateAdded: new Date().toISOString(),
connections: Number(connections), connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined, username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined, password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined, headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim() checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}` ? `${checksumAlgo}=${checksumValue.trim()}`
: undefined, : undefined,
cookies: cookies.trim() || undefined, cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined, mirrors: mirrors.trim() || undefined,
destination: useSharedDestination ? finalLocation : undefined, destination: useSharedDestination ? finalLocation : undefined,
isMedia: item.isMedia, isMedia: item.isMedia,
mediaFormatSelector: formatSelector, mediaFormatSelector: formatSelector,
queueId: selectedQueueId, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) }, action);
});
} catch (e) { } catch (e) {
console.error("Invalid URL or failed to add:", e); console.error("Invalid URL or failed to add:", e);
} }
@@ -725,7 +749,7 @@ export const AddDownloadsModal = () => {
conflicts={conflicts} conflicts={conflicts}
onConfirm={(resolutions) => { onConfirm={(resolutions) => {
setShowingDuplicates(false); setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, pendingUseSharedDestination, resolutions); executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions);
}} }}
onCancel={() => setShowingDuplicates(false)} onCancel={() => setShowingDuplicates(false)}
/> />
@@ -925,14 +949,16 @@ export const AddDownloadsModal = () => {
<Settings size={16} className="text-blue-500" /> Transfer Settings <Settings size={16} className="text-blue-500" /> Transfer Settings
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{queues.length > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Target Queue</label> <label className="text-xs text-text-secondary font-medium">Queue for Add to Queue</label>
<select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="add-download-control add-download-select w-32 px-2 py-1 text-xs" aria-label="Target queue"> <select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="add-download-control add-download-select w-32 px-2 py-1 text-xs" aria-label="Target queue">
{queues.map(q => ( {queues.map(q => (
<option key={q.id} value={q.id}>{q.name}</option> <option key={q.id} value={q.id}>{q.name}</option>
))} ))}
</select> </select>
</div> </div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label> <label className="text-xs text-text-secondary font-medium">Connections per File</label>
@@ -1031,20 +1057,63 @@ export const AddDownloadsModal = () => {
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs"> <button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
Cancel Cancel
</button> </button>
<button <div ref={actionMenuRef} className="relative flex">
onClick={() => handleStart(false)} <button
disabled={parsedItems.length === 0} onClick={() => handleAction({ type: 'start-now' })}
className="add-download-button add-download-button-secondary px-4 text-xs" disabled={parsedItems.length === 0}
> className="add-download-button add-download-button-primary rounded-r-none px-5 text-xs"
Add to Queue >
</button> <Play size={12} fill="currentColor" /> Start Downloads
<button </button>
onClick={() => handleStart(true)} <button
disabled={parsedItems.length === 0} type="button"
className="add-download-button add-download-button-primary px-5 text-xs" onClick={() => setIsActionMenuOpen(open => !open)}
> disabled={parsedItems.length === 0}
<Play size={12} fill="currentColor" /> Start Downloads className="add-download-button add-download-button-primary rounded-l-none border-l border-white/20 px-2 text-xs"
</button> aria-label="More add actions"
aria-haspopup="menu"
aria-expanded={isActionMenuOpen}
>
<ChevronDown size={14} />
</button>
{isActionMenuOpen && (
<div
role="menu"
className="app-modal absolute bottom-full right-0 z-[70] mb-2 min-w-[230px] overflow-hidden py-1.5 text-xs"
>
<button
type="button"
role="menuitem"
onClick={() => {
setIsActionMenuOpen(false);
void handleAction({ type: 'add-to-list' });
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-item-hover"
>
<Rows3 size={14} />
<span>Add to List</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setIsActionMenuOpen(false);
const queueId = queues.length === 1 ? queues[0].id : selectedQueueId;
void handleAction({ type: 'add-to-queue', queueId });
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-item-hover"
>
<ListPlus size={14} />
<span className="min-w-0">
<span className="block">Add to Queue</span>
<span className="block truncate text-[10px] text-text-muted">
{queues.find(queue => queue.id === (queues.length === 1 ? queues[0].id : selectedQueueId))?.name}
</span>
</span>
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -200,8 +200,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<Pause size={14} fill="currentColor" /> <Pause size={14} fill="currentColor" />
</button> </button>
)} )}
{download.status === 'paused' && ( {(download.status === 'ready' || download.status === 'paused') && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title="Resume"> <button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'ready' ? 'Start' : 'Resume'}>
<Play size={14} fill="currentColor" /> <Play size={14} fill="currentColor" />
</button> </button>
)} )}
+5 -3
View File
@@ -221,7 +221,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
className="main-control-button" className="main-control-button"
disabled={filteredDownloads.length === 0} disabled={filteredDownloads.length === 0}
onClick={() => { onClick={() => {
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d)); filteredDownloads
.filter(d => d.status === 'ready' || d.status === 'paused')
.forEach(d => handleResume(d));
}} }}
title="Resume All" title="Resume All"
> >
@@ -360,7 +362,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</button> </button>
)} )}
{(contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( {(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
<button <button
onClick={() => { onClick={() => {
setContextMenu(null); setContextMenu(null);
@@ -368,7 +370,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}} }}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors" className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
> >
Resume {contextItem.status === 'ready' ? 'Start' : 'Resume'}
</button> </button>
)} )}
+3 -5
View File
@@ -41,19 +41,17 @@ export const QualityModal = React.memo(() => {
url: activeMetadataUrl, url: activeMetadataUrl,
fileName: filename, fileName: filename,
destination, destination,
status: 'queued' as const,
fraction: 0, fraction: 0,
size: format.filesize ? formatBytes(format.filesize) : 'Unknown', size: format.filesize ? formatBytes(format.filesize) : 'Unknown',
speed: '-', speed: '-',
eta: '-', eta: '-',
category,
dateAdded: new Date().toISOString(), dateAdded: new Date().toISOString(),
queueId: '00000000-0000-0000-0000-000000000001',
_dispatched: false,
isMedia: true, isMedia: true,
selectedFormatId: format.format_id mediaFormatSelector: format.format_id
}; };
addDownload(downloadItem as any); void addDownload(downloadItem, { type: 'start-now' });
clearMetadata(); clearMetadata();
}; };
-2
View File
@@ -2,7 +2,6 @@ import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { error as logError } from '@tauri-apps/plugin-log'; import { error as logError } from '@tauri-apps/plugin-log';
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event'; import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadCategory } from './bindings/DownloadCategory'; import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadItem } from './bindings/DownloadItem';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { DownloadStatus } from './bindings/DownloadStatus'; import type { DownloadStatus } from './bindings/DownloadStatus';
@@ -132,7 +131,6 @@ type EventMap = {
'download-complete': string; 'download-complete': string;
'download-failed': string; 'download-failed': string;
'extension-add-download': ExtensionDownload; 'extension-add-download': ExtensionDownload;
'extension-downloads-queued': DownloadItem[];
'deep-link-add-download': string; 'deep-link-add-download': string;
}; };
+6 -2
View File
@@ -80,10 +80,14 @@ export async function initDownloadListener() {
unlistenTray = await listen<string>('tray-action', (event) => { unlistenTray = await listen<string>('tray-action', (event) => {
const mainStore = useDownloadStore.getState(); const mainStore = useDownloadStore.getState();
if (event.payload === 'pause-all') { if (event.payload === 'pause-all') {
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId))); const uniqueQueues = Array.from(new Set(
mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id))
));
uniqueQueues.forEach(qid => mainStore.pauseQueue(qid)); uniqueQueues.forEach(qid => mainStore.pauseQueue(qid));
} else if (event.payload === 'resume-all') { } else if (event.payload === 'resume-all') {
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId))); const uniqueQueues = Array.from(new Set(
mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id))
));
uniqueQueues.forEach(qid => mainStore.startQueue(qid)); uniqueQueues.forEach(qid => mainStore.startQueue(qid));
} }
}); });
+75
View File
@@ -91,4 +91,79 @@ describe('useDownloadStore', () => {
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true); expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
}); });
it('adds to the list without assigning a queue or dispatching', async () => {
await useDownloadStore.getState().addDownload({
id: 'list-1',
url: 'https://example.com/list.bin',
fileName: 'list.bin',
category: 'Other',
dateAdded: ''
}, { type: 'add-to-list' });
const item = useDownloadStore.getState().downloads[0];
expect(item.status).toBe('ready');
expect(item.queueId).toBeUndefined();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('adds to the selected queue without dispatching', async () => {
await useDownloadStore.getState().addDownload({
id: 'queue-1',
url: 'https://example.com/queue.bin',
fileName: 'queue.bin',
category: 'Other',
dateAdded: ''
}, { type: 'add-to-queue', queueId: 'queue-b' });
const item = useDownloadStore.getState().downloads[0];
expect(item.status).toBe('queued');
expect(item.queueId).toBe('queue-b');
expect(useDownloadStore.getState().pendingOrder).toContain('queue-1');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('starts immediately without assigning a user queue', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['start-1'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'start-1',
url: 'https://example.com/start.bin',
fileName: 'start.bin',
category: 'Other',
dateAdded: ''
}, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0];
expect(item.queueId).toBeUndefined();
expect(item.hasBeenDispatched).toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({ id: 'start-1' })
})
);
});
it('preserves extension request headers and cookies for the Add modal', () => {
useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'],
referer: 'https://example.com/page',
silent: false,
filename: 'file.bin',
headers: 'X-Test: value',
cookies: 'session=secret'
});
const state = useDownloadStore.getState();
expect(state.isAddModalOpen).toBe(true);
expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
expect(state.pendingAddReferer).toBe('https://example.com/page');
expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret');
});
}); });
+49 -13
View File
@@ -147,6 +147,11 @@ export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export type { DownloadItem, Queue }; export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload; export type ExtensionDownloadRequest = ExtensionDownload;
export type AddDownloadAction =
| { type: 'start-now' }
| { type: 'add-to-list' }
| { type: 'add-to-queue'; queueId: string };
export type DownloadDraft = Omit<DownloadItem, 'status' | 'queueId' | 'hasBeenDispatched'>;
export type DeleteModalState = { export type DeleteModalState = {
isOpen: boolean; isOpen: boolean;
@@ -170,15 +175,23 @@ interface DownloadState {
pendingAddUrls: string; pendingAddUrls: string;
pendingAddReferer: string; pendingAddReferer: string;
pendingAddFilename: string; pendingAddFilename: string;
pendingAddHeaders: string;
pendingAddCookies: string;
selectedPropertiesDownloadId: string | null; selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => void; toggleAddModal: (isOpen: boolean) => void;
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void; openAddModalWithUrls: (
urls: string,
referer?: string | null,
filename?: string | null,
headers?: string | null,
cookies?: string | null
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void; handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
deleteModalState: DeleteModalState; deleteModalState: DeleteModalState;
openDeleteModal: (downloadId?: string) => void; openDeleteModal: (downloadId?: string) => void;
closeDeleteModal: () => void; closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void; setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadItem) => Promise<void>; addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<void>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void; updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>; removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>; redownload: (id: string) => Promise<void>;
@@ -238,6 +251,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddUrls: '', pendingAddUrls: '',
pendingAddReferer: '', pendingAddReferer: '',
pendingAddFilename: '', pendingAddFilename: '',
pendingAddHeaders: '',
pendingAddCookies: '',
selectedPropertiesDownloadId: null, selectedPropertiesDownloadId: null,
isParsing: false, isParsing: false,
activeMetadata: null, activeMetadata: null,
@@ -250,16 +265,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
isAddModalOpen: isOpen, isAddModalOpen: isOpen,
pendingAddUrls: '', pendingAddUrls: '',
pendingAddReferer: '', pendingAddReferer: '',
pendingAddFilename: '' pendingAddFilename: '',
pendingAddHeaders: '',
pendingAddCookies: ''
}), }),
openAddModalWithUrls: (urls, referer, filename) => set((state) => { openAddModalWithUrls: (urls, referer, filename, headers, cookies) => set((state) => {
const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : ''; const existingUrls = state.isAddModalOpen && state.pendingAddUrls ? state.pendingAddUrls : '';
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls; const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
return { return {
isAddModalOpen: true, isAddModalOpen: true,
pendingAddUrls: mergedUrls, pendingAddUrls: mergedUrls,
pendingAddReferer: referer?.trim() || state.pendingAddReferer || '', pendingAddReferer: referer?.trim() || state.pendingAddReferer || '',
pendingAddFilename: filename?.trim() || state.pendingAddFilename || '' pendingAddFilename: filename?.trim() || state.pendingAddFilename || '',
pendingAddHeaders: headers?.trim() || state.pendingAddHeaders || '',
pendingAddCookies: cookies?.trim() || state.pendingAddCookies || ''
}; };
}), }),
handleExtensionDownload: (request) => { handleExtensionDownload: (request) => {
@@ -269,7 +288,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().openAddModalWithUrls( get().openAddModalWithUrls(
urls.join('\n'), urls.join('\n'),
request.referer, request.referer,
urls.length === 1 ? request.filename : null urls.length === 1 ? request.filename : null,
request.headers,
request.cookies
); );
}, },
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
@@ -291,23 +312,31 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
info(`Media metadata parsing failed for ${url}: ${e}`); info(`Media metadata parsing failed for ${url}: ${e}`);
} }
}, },
addDownload: async (item) => { addDownload: async (item, action) => {
info(`Download ${item.id} added to queue`);
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings); const destPath = effectiveDestinationForItem(item, settings);
const ownedItem = { ...item, destination: destPath, hasBeenDispatched: false }; const ownedItem: DownloadItem = {
...item,
destination: destPath,
status: action.type === 'add-to-queue' ? 'queued' : 'ready',
queueId: action.type === 'add-to-queue' ? action.queueId : undefined,
hasBeenDispatched: false
};
set((state) => ({ downloads: [...state.downloads, ownedItem] })); set((state) => ({ downloads: [...state.downloads, ownedItem] }));
if (item.status === 'queued') { if (action.type === 'add-to-queue') {
const order = useDownloadStore.getState().pendingOrder; const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) { if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]); useDownloadStore.getState().setPendingOrder([...order, item.id]);
} }
} else { info(`Download ${item.id} added to queue ${action.queueId}`);
// Immediate dispatch (e.g., "Start immediately" from UI where status isn't just 'queued') } else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) { if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true }); get().updateDownload(item.id, { hasBeenDispatched: true });
} }
info(`Download ${item.id} started`);
} else {
info(`Download ${item.id} added to list`);
} }
}, },
applyProperties: async (id, updates) => { applyProperties: async (id, updates) => {
@@ -319,7 +348,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error("Cannot change properties while transfer is active. Pause it first."); throw new Error("Cannot change properties while transfer is active. Pause it first.");
} }
if (item.status === 'completed' || item.status === 'failed') { if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates); state.updateDownload(id, updates);
return; return;
} }
@@ -470,6 +499,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!targetItem) return; if (!targetItem) return;
try { try {
if (targetItem.status === 'ready') {
if (await dispatchItem(id)) {
get().updateDownload(id, { hasBeenDispatched: true });
}
return;
}
const resumedExisting = await invoke('resume_download', { id }); const resumedExisting = await invoke('resume_download', { id });
if (resumedExisting) { if (resumedExisting) {
return; return;