fix(downloads): harden enqueue lifecycle races

Reject superseded enqueue generations in the queue manager and coordinate frontend dispatch, pause, removal, and property mutations.
This commit is contained in:
NimBold
2026-07-10 00:04:21 +03:30
parent fbb89cde8e
commit b1c84a0fb9
13 changed files with 471 additions and 158 deletions
+7 -2
View File
@@ -442,7 +442,7 @@ function App() {
const trackedIds = state.schedulerActiveDownloadIds;
if (trackedIds.length > 0) {
const pauseResults = await Promise.allSettled(
trackedIds.map(id => invoke('pause_download', { id }))
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
);
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
if (failedPauses > 0) {
@@ -586,6 +586,7 @@ function App() {
useEffect(() => {
if (!coreReady) return;
let disposed = false;
const unlistenDownload = initDownloadListener();
const unlistenTerminalState = listen('download-state', (event) => {
@@ -630,10 +631,14 @@ function App() {
useDownloadStore.getState().openAddModalWithUrls(event.payload);
});
Promise.all([unlistenExtension, unlistenDeepLink])
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
.then(() => {
if (disposed) return;
return invoke('set_extension_frontend_ready', { ready: true });
})
.catch(error => console.error('Failed to activate browser extension integration:', error));
return () => {
disposed = true;
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
unlistenTerminalState.then(f => f());
unlistenExtension.then(f => f());
-52
View File
@@ -1,52 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
this.setState({ errorInfo });
}
public render() {
if (this.state.hasError) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-main-bg p-8 text-text-primary">
<div className="app-card max-w-lg space-y-4 p-6 text-center">
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
<p className="text-sm text-text-secondary">
The error was written to Logs. Reload the interface to reconnect to the running download service.
</p>
<button
type="button"
className="app-button app-button-primary px-4"
onClick={() => window.location.reload()}
>
Reload Firelink
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+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 EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, lifecycle_generation?: string, };
+1 -1
View File
@@ -338,7 +338,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}
try {
await invoke('pause_download', { id });
await useDownloadStore.getState().pauseDownload(id);
} catch (e) {
console.error("Failed to pause:", e);
showInteractionError('Could not pause download', e);
+1
View File
@@ -78,6 +78,7 @@ type CommandMap = {
is_log_paused: { args: undefined; result: boolean };
get_pending_order: { args: { queueId: string | null }; result: string[] };
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
+27 -2
View File
@@ -1,8 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useDownloadProgressStore } from './downloadStore';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn(),
listenEvent: vi.fn(),
}));
describe('useDownloadProgressStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useDownloadProgressStore.setState({ progressMap: {} });
});
@@ -20,4 +27,22 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
});
it('shares listener setup across overlapping consumers and tears down after the last release', async () => {
const unlisten = vi.fn();
vi.mocked(ipc.listenEvent).mockResolvedValue(unlisten);
const first = initDownloadListener();
const second = initDownloadListener();
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
const releaseFirst = await first;
const releaseSecond = await second;
releaseFirst();
expect(unlisten).not.toHaveBeenCalled();
releaseSecond();
expect(unlisten).toHaveBeenCalledTimes(3);
});
});
+79 -43
View File
@@ -35,40 +35,50 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
let unlistenProgress: UnlistenFn | null = null;
let unlistenState: UnlistenFn | null = null;
let unlistenTray: UnlistenFn | null = null;
let listenerSetup: Promise<void> | null = null;
let listenerConsumers = 0;
export async function initDownloadListener() {
if (unlistenProgress) return;
unlistenProgress = await listen('download-progress', (event) => {
const payload = event.payload;
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const disposeDownloadListeners = () => {
unlistenProgress?.();
unlistenProgress = null;
unlistenState?.();
unlistenState = null;
unlistenTray?.();
unlistenTray = null;
listenerSetup = null;
};
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
}
});
const startDownloadListeners = async () => {
const registrations = await Promise.allSettled([
listen('download-progress', (event) => {
const payload = event.payload;
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
if (!unlistenState) {
unlistenState = await listen('download-state', (event) => {
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
}
}),
listen('download-state', (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const status = payload.status as DownloadStatus;
// Prevent race condition: don't transition backwards from terminal state
if ((current.status === 'completed' || current.status === 'failed') &&
(status !== 'completed' && status !== 'failed')) {
@@ -111,32 +121,58 @@ export async function initDownloadListener() {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}
});
}
if (!unlistenTray) {
unlistenTray = await listen('tray-action', (event) => {
}),
listen('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
if (event.payload === 'pause-all') {
void mainStore.pauseAll();
} else if (event.payload === 'resume-all') {
void mainStore.startAll();
}
}),
]);
const failedRegistration = registrations.find(
(registration): registration is PromiseRejectedResult => registration.status === 'rejected'
);
if (failedRegistration) {
for (const registration of registrations) {
if (registration.status === 'fulfilled') registration.value();
}
throw failedRegistration.reason;
}
const [progress, state, tray] = registrations as [
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
];
unlistenProgress = progress.value;
unlistenState = state.value;
unlistenTray = tray.value;
};
export async function initDownloadListener(): Promise<() => void> {
listenerConsumers += 1;
if (!listenerSetup) {
listenerSetup = startDownloadListeners().catch(error => {
disposeDownloadListeners();
throw error;
});
}
try {
await listenerSetup;
} catch (error) {
listenerConsumers -= 1;
throw error;
}
let released = false;
return () => {
if (unlistenProgress) {
unlistenProgress();
unlistenProgress = null;
}
if (unlistenState) {
unlistenState();
unlistenState = null;
}
if (unlistenTray) {
unlistenTray();
unlistenTray = null;
}
if (released) return;
released = true;
listenerConsumers -= 1;
if (listenerConsumers === 0) disposeDownloadListeners();
};
}
+120 -1
View File
@@ -214,6 +214,120 @@ describe('useDownloadStore', () => {
});
});
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'late', url: 'http://test', fileName: 'late.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') return enqueue as never;
if (command === 'get_pending_order') return Promise.resolve(['late']) as never;
return Promise.resolve(undefined) as never;
});
const start = useDownloadStore.getState().startQueue('MAIN');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'late' }) })
);
});
await useDownloadStore.getState().removeDownload('late');
resolveEnqueue({ id: 'late', filename: 'late.bin' });
await expect(start).resolves.toEqual([]);
expect(useDownloadStore.getState().downloads).toEqual([]);
expect(useDownloadStore.getState().backendRegisteredIds.has('late')).toBe(false);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
).toHaveLength(2);
});
it('re-enqueues the edited values only after an obsolete queued dispatch is removed', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
let resolveFirstEnqueue!: (value: { id: string; filename: string }) => void;
const firstEnqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveFirstEnqueue = resolve;
});
let enqueueCount = 0;
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') {
enqueueCount += 1;
return (enqueueCount === 1
? firstEnqueue
: Promise.resolve({ id: 'edited', filename: 'new.bin' })) as never;
}
if (command === 'get_pending_order') return Promise.resolve(['edited']) as never;
return Promise.resolve(undefined) as never;
});
const start = useDownloadStore.getState().startQueue('MAIN');
await vi.waitFor(() => expect(enqueueCount).toBe(1));
const update = useDownloadStore.getState().applyProperties('edited', { fileName: 'new.bin' });
resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' });
await expect(update).resolves.toBeUndefined();
await expect(start).resolves.toEqual([]);
expect(enqueueCount).toBe(2);
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'get_pending_order',
{ queueId: 'MAIN' }
);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
fileName: 'new.bin',
hasBeenDispatched: true,
});
});
it('settles an in-flight dispatch before pausing so it cannot start after pause succeeds', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'paused', url: 'http://test', fileName: 'paused.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') return enqueue as never;
return Promise.resolve(undefined) as never;
});
const start = useDownloadStore.getState().startQueue('MAIN');
await vi.waitFor(() => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'paused' }) })
);
});
const pause = useDownloadStore.getState().pauseDownload('paused');
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
await expect(pause).resolves.toBeUndefined();
await expect(start).resolves.toEqual([]);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({ status: 'paused' });
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
).toHaveLength(1);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')
).toHaveLength(1);
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
useDownloadStore.setState({
downloads: [
@@ -536,7 +650,12 @@ describe('useDownloadStore', () => {
{ id: 'active', url: 'https://example.com/file', fileName: 'file', status: 'downloading', category: 'Other', dateAdded: '', queueId: 'main' }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('writer did not stop'));
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'remove_download') {
return Promise.reject(new Error('writer did not stop')) as never;
}
return Promise.resolve(undefined) as never;
});
await expect(useDownloadStore.getState().removeDownload('active', true))
.rejects.toThrow('writer did not stop');
+107 -16
View File
@@ -16,6 +16,53 @@ import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
export type { DownloadCategory } from '../utils/downloads';
const backendDispatchPromises = new Map<string, Promise<boolean>>();
const downloadLifecycleGenerations = new Map<string, bigint>();
const advanceDownloadLifecycle = (id: string): bigint => {
const nextGeneration = (downloadLifecycleGenerations.get(id) ?? 0n) + 1n;
downloadLifecycleGenerations.set(id, nextGeneration);
return nextGeneration;
};
const currentDownloadLifecycle = (id: string): bigint =>
downloadLifecycleGenerations.get(id) ?? 0n;
type DispatchInvalidation = {
generation: bigint;
pendingDispatch?: Promise<boolean>;
};
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
const generation = currentDownloadLifecycle(id);
const nextGeneration = advanceDownloadLifecycle(id);
try {
await invoke('cancel_enqueue_generation', { id, generation: generation.toString() });
} catch (error) {
console.warn(`Failed to cancel stale backend enqueue for ${id}:`, error);
}
return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) };
};
const invalidateAndWaitForDispatch = async (id: string): Promise<boolean> => {
const { pendingDispatch } = await invalidateDispatch(id);
if (!pendingDispatch) return false;
await pendingDispatch;
return true;
};
const isCurrentDownloadLifecycle = (id: string, generation: bigint): boolean =>
currentDownloadLifecycle(id) === generation &&
useDownloadStore.getState().downloads.some(download => download.id === id);
const removeStaleBackendDispatch = async (id: string): Promise<void> => {
try {
await invoke('remove_download', { id, deleteAssets: false });
} catch (error) {
// The original remove request may already have won this race. Either way,
// never allow a stale enqueue to make the deleted row live again.
console.warn(`Failed to remove stale backend dispatch for ${id}:`, error);
}
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
@@ -32,15 +79,19 @@ export async function dispatchItem(id: string): Promise<boolean> {
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
const promise = (async () => {
let lifecycleGeneration: bigint | null = null;
try {
const state = useDownloadStore.getState();
const item = state.downloads.find(d => d.id === id);
if (!item) return false;
if (state.backendRegisteredIds.has(id)) return true;
lifecycleGeneration = currentDownloadLifecycle(id);
const settings = useSettingsStore.getState();
const destination = item.destination ||
await resolveCategoryDestination(settings, item.category);
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
@@ -50,6 +101,10 @@ export async function dispatchItem(id: string): Promise<boolean> {
console.warn("Failed to retrieve keychain password for dispatch:", e);
}
}
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const proxy = await getProxyArgs(settings);
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const enqueueItem = {
id: item.id,
@@ -67,13 +122,19 @@ export async function dispatchItem(id: string): Promise<boolean> {
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
proxy,
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
is_media: item.isMedia || false,
lifecycle_generation: lifecycleGeneration.toString(),
};
const accepted = await invoke('enqueue_download', { item: enqueueItem });
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
return false;
}
const acceptedFilename = accepted?.filename || item.fileName;
if (acceptedFilename !== item.fileName) {
useDownloadStore.getState().updateDownload(id, {
@@ -82,16 +143,23 @@ export async function dispatchItem(id: string): Promise<boolean> {
});
}
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
return false;
}
useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]);
useDownloadStore.getState().updateDownload(id, { lastError: undefined });
return true;
} catch (e) {
console.error(`Failed to dispatch ${id}:`, e);
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
lastError: errorMessage(e)
});
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
lastError: errorMessage(e)
});
}
return false;
} finally {
backendDispatchPromises.delete(id);
@@ -289,6 +357,7 @@ interface DownloadState {
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<boolean>;
startQueue: (queueId: string) => Promise<string[]>;
@@ -510,6 +579,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
queuePosition,
hasBeenDispatched: false
};
advanceDownloadLifecycle(item.id);
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
if (action.type === 'add-to-queue') {
@@ -526,6 +596,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return false;
},
applyProperties: async (id, updates) => {
const wasDispatching = await invalidateAndWaitForDispatch(id);
const state = get();
const item = state.downloads.find(d => d.id === id);
if (!item) return;
@@ -548,8 +619,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
state.unregisterBackendIds([id]);
}
state.updateDownload(id, updates);
if (isRegistered) {
if (!await dispatchItem(id)) {
if (isRegistered || wasDispatching) {
const dispatched = await dispatchItem(id);
if (dispatched) {
state.updateDownload(id, { hasBeenDispatched: true });
} else {
state.removeFromQueue(id);
}
}
@@ -590,6 +664,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
removeDownload: async (id, deleteFile = false) => {
await invalidateDispatch(id);
const item = get().downloads.find(d => d.id === id);
if (item) {
@@ -606,6 +681,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
info(`Download ${id} removed`);
syncSystemIntegrations();
},
pauseDownload: async (id) => {
const { generation } = await invalidateDispatch(id);
await invoke('pause_download', { id });
if (!isCurrentDownloadLifecycle(id, generation)) return;
const current = get().downloads.find(download => download.id === id);
if (current && current.status !== 'completed' && current.status !== 'failed') {
get().updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
}
},
redownload: async (id) => {
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) {
@@ -619,6 +705,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const url = targetItem.url?.trim();
if (!url) throw new Error('Cannot redownload: original URL is missing.');
await invalidateAndWaitForDispatch(id);
// Remove from backend to clear its state and delete the existing file so we can overwrite
try {
await invoke('remove_download', { id, deleteAssets: true });
@@ -746,9 +834,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (activeIds.length === 0) return 0;
const results = await Promise.allSettled(
activeIds.map(id => invoke('pause_download', { id }))
);
const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id)));
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
const failedCount = activeIds.length - pausedCount;
if (failedCount > 0) {
@@ -778,9 +864,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
.map(item => item.id);
if (activeIds.length === 0) return 0;
const results = await Promise.allSettled(
activeIds.map(id => invoke('pause_download', { id }))
);
const results = await Promise.allSettled(activeIds.map(id => get().pauseDownload(id)));
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
syncSystemIntegrations();
return pausedCount;
@@ -793,7 +877,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
for (const item of selected) {
await Promise.all(
selected
.filter(item => item.status !== 'completed')
.map(item => invalidateAndWaitForDispatch(item.id))
);
for (const item of get().downloads.filter(item => selectedIds.has(item.id))) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
@@ -927,7 +1017,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
is_media: item.isMedia || false,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
const results = await invoke('enqueue_many', { items: itemsToEnqueue });