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 std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
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;
const MAX_URL_COUNT: usize = 200;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const MAIN_QUEUE_ID: &str = "00000000-0000-0000-0000-000000000001";
type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -34,6 +33,7 @@ type ReplayCache = Arc<Mutex<HashMap<String, u64>>>;
pub struct ServerState {
pub app_handle: AppHandle,
pub pairing_token: SharedExtensionToken,
pub frontend_ready: SharedFrontendReady,
pub replay_cache: ReplayCache,
}
@@ -66,12 +66,13 @@ pub struct ExtensionDownload {
pub async fn start_server(
app_handle: AppHandle,
pairing_token: SharedExtensionToken,
_frontend_ready: SharedFrontendReady,
frontend_ready: SharedFrontendReady,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), String> {
let state = ServerState {
app_handle,
pairing_token,
frontend_ready,
replay_cache: Arc::new(Mutex::new(HashMap::new())),
};
@@ -206,8 +207,13 @@ async fn download_handler(
}
}
if enqueue_extension_download(&state.app_handle, &download)
.await
if !wait_for_frontend(&state.frontend_ready).await {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
if state
.app_handle
.emit("extension-add-download", download)
.is_err()
{
return Err(StatusCode::INTERNAL_SERVER_ERROR);
@@ -216,142 +222,14 @@ async fn download_handler(
Ok(StatusCode::OK)
}
async fn enqueue_extension_download(
app_handle: &AppHandle,
download: &ExtensionDownload,
) -> Result<(), String> {
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),
async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
for _ in 0..40 {
if frontend_ready.load(Ordering::Acquire) {
return true;
}
.into_task();
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);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
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())
false
}
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
+5 -1
View File
@@ -6,6 +6,8 @@ use ts_rs::TS;
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum DownloadStatus {
/// Added to the download list but not assigned to a queue or dispatched.
Ready,
Downloading,
/// Post-download media processing such as yt-dlp/ffmpeg merging or
/// extraction. The queue permit is still held.
@@ -22,6 +24,7 @@ pub enum DownloadStatus {
impl DownloadStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Downloading => "downloading",
Self::Processing => "processing",
Self::Paused => "paused",
@@ -94,7 +97,8 @@ pub struct DownloadItem {
pub is_media: Option<bool>,
#[ts(optional)]
pub media_format_selector: Option<String>,
pub queue_id: String,
#[ts(optional)]
pub queue_id: Option<String>,
#[ts(optional)]
pub has_been_dispatched: Option<bool>,
}
+1 -19
View File
@@ -231,27 +231,10 @@ function App() {
const unlistenExtension = listen('extension-add-download', (event) => {
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) => {
useDownloadStore.getState().openAddModalWithUrls(event.payload);
});
Promise.all([unlistenExtension, unlistenExtensionQueued, unlistenDeepLink])
Promise.all([unlistenExtension, unlistenDeepLink])
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
.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(() => {});
unlistenTerminalState.then(f => f());
unlistenExtension.then(f => f());
unlistenExtensionQueued.then(f => f());
unlistenDeepLink.then(f => f());
};
}, []);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
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.
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 { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
import { useState, useEffect, useRef } from 'react';
import {
useDownloadStore,
MAIN_QUEUE_ID,
getSiteLogin,
type AddDownloadAction
} from '../store/useDownloadStore';
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 { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
@@ -260,6 +265,8 @@ export const AddDownloadsModal = () => {
pendingAddUrls,
pendingAddReferer,
pendingAddFilename,
pendingAddHeaders,
pendingAddCookies,
toggleAddModal,
addDownload,
queues
@@ -275,9 +282,11 @@ export const AddDownloadsModal = () => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingStartFlag, setPendingStartFlag] = useState(false);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [resolvedLocation, setResolvedLocation] = useState('');
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
@@ -315,18 +324,35 @@ export const AddDownloadsModal = () => {
setChecksumEnabled(false);
setChecksumAlgo('SHA-256');
setChecksumValue('');
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
setCookies('');
setHeaders([
pendingAddReferer ? `Referer: ${pendingAddReferer}` : '',
pendingAddHeaders
].filter(Boolean).join('\n'));
setCookies(pendingAddCookies);
setMirrors('');
setIsActionMenuOpen(false);
}
}, [
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
defaultDownloadPath,
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(() => {
if (!saveLocation) return;
invoke('get_free_space', { path: saveLocation })
@@ -493,7 +519,7 @@ export const AddDownloadsModal = () => {
'~/Downloads';
};
const handleStart = async (startImmediately: boolean) => {
const handleAction = async (action: AddDownloadAction) => {
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const settings = useSettingsStore.getState();
@@ -553,16 +579,16 @@ export const AddDownloadsModal = () => {
if (newConflicts.length > 0) {
setConflicts(newConflicts);
setPendingStartFlag(startImmediately);
setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination);
setShowingDuplicates(true);
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];
if (resolutions) {
@@ -651,29 +677,27 @@ export const AddDownloadsModal = () => {
}
const category = categoryForFileName(finalFile);
addDownload({
await addDownload({
id,
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination ? finalLocation : undefined,
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
queueId: selectedQueueId,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
});
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
}, action);
} catch (e) {
console.error("Invalid URL or failed to add:", e);
}
@@ -725,7 +749,7 @@ export const AddDownloadsModal = () => {
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, pendingUseSharedDestination, resolutions);
executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions);
}}
onCancel={() => setShowingDuplicates(false)}
/>
@@ -925,14 +949,16 @@ export const AddDownloadsModal = () => {
<Settings size={16} className="text-blue-500" /> Transfer Settings
</div>
<div className="flex flex-col gap-3">
{queues.length > 1 && (
<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">
{queues.map(q => (
<option key={q.id} value={q.id}>{q.name}</option>
))}
</select>
</div>
)}
<div className="flex items-center justify-between">
<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">
Cancel
</button>
<button
onClick={() => handleStart(false)}
disabled={parsedItems.length === 0}
className="add-download-button add-download-button-secondary px-4 text-xs"
>
Add to Queue
</button>
<button
onClick={() => handleStart(true)}
disabled={parsedItems.length === 0}
className="add-download-button add-download-button-primary px-5 text-xs"
>
<Play size={12} fill="currentColor" /> Start Downloads
</button>
<div ref={actionMenuRef} className="relative flex">
<button
onClick={() => handleAction({ type: 'start-now' })}
disabled={parsedItems.length === 0}
className="add-download-button add-download-button-primary rounded-r-none px-5 text-xs"
>
<Play size={12} fill="currentColor" /> Start Downloads
</button>
<button
type="button"
onClick={() => setIsActionMenuOpen(open => !open)}
disabled={parsedItems.length === 0}
className="add-download-button add-download-button-primary rounded-l-none border-l border-white/20 px-2 text-xs"
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>
+2 -2
View File
@@ -200,8 +200,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<Pause size={14} fill="currentColor" />
</button>
)}
{download.status === 'paused' && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title="Resume">
{(download.status === 'ready' || download.status === 'paused') && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'ready' ? 'Start' : 'Resume'}>
<Play size={14} fill="currentColor" />
</button>
)}
+5 -3
View File
@@ -221,7 +221,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
className="main-control-button"
disabled={filteredDownloads.length === 0}
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"
>
@@ -360,7 +362,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</button>
)}
{(contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
{(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
<button
onClick={() => {
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"
>
Resume
{contextItem.status === 'ready' ? 'Start' : 'Resume'}
</button>
)}
+3 -5
View File
@@ -41,19 +41,17 @@ export const QualityModal = React.memo(() => {
url: activeMetadataUrl,
fileName: filename,
destination,
status: 'queued' as const,
fraction: 0,
size: format.filesize ? formatBytes(format.filesize) : 'Unknown',
speed: '-',
eta: '-',
category,
dateAdded: new Date().toISOString(),
queueId: '00000000-0000-0000-0000-000000000001',
_dispatched: false,
isMedia: true,
selectedFormatId: format.format_id
mediaFormatSelector: format.format_id
};
addDownload(downloadItem as any);
void addDownload(downloadItem, { type: 'start-now' });
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 { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadItem } from './bindings/DownloadItem';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
@@ -132,7 +131,6 @@ type EventMap = {
'download-complete': string;
'download-failed': string;
'extension-add-download': ExtensionDownload;
'extension-downloads-queued': DownloadItem[];
'deep-link-add-download': string;
};
+6 -2
View File
@@ -80,10 +80,14 @@ export async function initDownloadListener() {
unlistenTray = await listen<string>('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
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));
} 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));
}
});
+75
View File
@@ -91,4 +91,79 @@ describe('useDownloadStore', () => {
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
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 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 = {
isOpen: boolean;
@@ -170,15 +175,23 @@ interface DownloadState {
pendingAddUrls: string;
pendingAddReferer: string;
pendingAddFilename: string;
pendingAddHeaders: string;
pendingAddCookies: string;
selectedPropertiesDownloadId: string | null;
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;
deleteModalState: DeleteModalState;
openDeleteModal: (downloadId?: string) => void;
closeDeleteModal: () => 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;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>;
@@ -238,6 +251,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: '',
pendingAddHeaders: '',
pendingAddCookies: '',
selectedPropertiesDownloadId: null,
isParsing: false,
activeMetadata: null,
@@ -250,16 +265,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
isAddModalOpen: isOpen,
pendingAddUrls: '',
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 mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
return {
isAddModalOpen: true,
pendingAddUrls: mergedUrls,
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) => {
@@ -269,7 +288,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,
urls.length === 1 ? request.filename : null
urls.length === 1 ? request.filename : null,
request.headers,
request.cookies
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
@@ -291,23 +312,31 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
info(`Media metadata parsing failed for ${url}: ${e}`);
}
},
addDownload: async (item) => {
info(`Download ${item.id} added to queue`);
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
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] }));
if (item.status === 'queued') {
if (action.type === 'add-to-queue') {
const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]);
}
} else {
// Immediate dispatch (e.g., "Start immediately" from UI where status isn't just 'queued')
info(`Download ${item.id} added to queue ${action.queueId}`);
} else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true });
}
info(`Download ${item.id} started`);
} else {
info(`Download ${item.id} added to list`);
}
},
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.");
}
if (item.status === 'completed' || item.status === 'failed') {
if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates);
return;
}
@@ -470,6 +499,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!targetItem) return;
try {
if (targetItem.status === 'ready') {
if (await dispatchItem(id)) {
get().updateDownload(id, { hasBeenDispatched: true });
}
return;
}
const resumedExisting = await invoke('resume_download', { id });
if (resumedExisting) {
return;