fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+71 -5
View File
@@ -71,7 +71,7 @@ describe('useDownloadStore', () => {
});
const dispatched = await useDownloadStore.getState().startQueue('MAIN');
expect(dispatched).toBe(2); // Both items counted as dispatched/handled
expect(dispatched).toEqual(['1', '2']);
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
const enqueues = calls.filter(c => c[0] === 'enqueue_download');
@@ -79,6 +79,34 @@ describe('useDownloadStore', () => {
expect((enqueues[0] as any)[1].item.id).toBe('2');
});
it('does not overwrite a downloading event received while starting a queue', async () => {
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'enqueue_download') {
useDownloadStore.getState().updateDownload('1', {
status: 'downloading',
speed: '1 MB/s',
eta: '10s'
});
}
if (cmd === 'get_pending_order') return ['1'];
return undefined;
});
expect(await useDownloadStore.getState().startQueue('MAIN')).toEqual(['1']);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'downloading',
speed: '1 MB/s',
eta: '10s',
hasBeenDispatched: true
});
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
useDownloadStore.setState({
downloads: [
@@ -113,9 +141,9 @@ describe('useDownloadStore', () => {
}, { type: 'add-to-queue', queueId: 'queue-b' });
const item = useDownloadStore.getState().downloads[0];
expect(item.status).toBe('queued');
expect(item.status).toBe('staged');
expect(item.queueId).toBe('queue-b');
expect(useDownloadStore.getState().pendingOrder).toContain('queue-1');
expect(item.queuePosition).toBe(0);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
@@ -207,7 +235,7 @@ describe('useDownloadStore', () => {
).toHaveLength(4);
});
it('assigns selected unfinished downloads to a queue without moving completed items', () => {
it('assigns selected unfinished downloads to a queue without moving completed items', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'ready', status: 'ready', queueId: 'old' },
@@ -215,12 +243,50 @@ describe('useDownloadStore', () => {
] as any[]
});
useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
await useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
});
it('retains the UI item when backend removal fails', async () => {
useDownloadStore.setState({
downloads: [
{ 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'));
await expect(useDownloadStore.getState().removeDownload('active', true))
.rejects.toThrow('writer did not stop');
expect(useDownloadStore.getState().downloads.map(download => download.id))
.toEqual(['active']);
});
it('starts staged queue items in their persisted queue order', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'later', url: 'https://example.com/later', fileName: 'later', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 1 },
{ id: 'first', url: 'https://example.com/first', fileName: 'first', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 0 }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'get_pending_order') {
return [(args as { queueId: string }).queueId === 'queue-a' ? 'first' : 'later'];
}
return undefined;
});
expect(await useDownloadStore.getState().startQueue('queue-a')).toEqual(['first', 'later']);
const enqueuedIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(call => call[0] === 'enqueue_download')
.map(call => (call[1] as any).item.id);
expect(enqueuedIds).toEqual(['first', 'later']);
expect((vi.mocked(ipc.invokeCommand).mock.calls.find(call =>
call[0] === 'enqueue_download'
)?.[1] as any).item.queue_id).toBe('queue-a');
});
it('preserves extension request headers and cookies for the Add modal', () => {
useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'],
+130 -61
View File
@@ -7,11 +7,9 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
expandTilde,
resolveCategoryDestination,
resolveDownloadFilePath
resolveCategoryDestination
} from '../utils/downloadLocations';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
@@ -42,6 +40,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
const enqueueItem = {
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination,
filename: item.fileName,
@@ -61,8 +60,15 @@ export async function dispatchItem(id: string): Promise<boolean> {
is_media: item.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
const accepted = await invoke('enqueue_download', { item: enqueueItem });
const acceptedFilename = accepted?.filename || item.fileName;
if (acceptedFilename !== item.fileName) {
useDownloadStore.getState().updateDownload(id, {
fileName: acceptedFilename,
category: categoryForFileName(acceptedFilename)
});
}
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]);
return true;
@@ -126,16 +132,26 @@ const syncSystemIntegrations = () => {
}
};
const resolveDownloadPath = async (destination: string, fileName: string) => {
return resolveDownloadFilePath(await expandTilde(destination), fileName);
};
const effectiveDestinationForItem = async (
item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category);
const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
const nextPosition = new Map<string, number>();
return downloads.map(download => {
const queueId = download.queueId || MAIN_QUEUE_ID;
const position = nextPosition.get(queueId) || 0;
nextPosition.set(queueId, position + 1);
return {
...download,
queueId,
queuePosition: download.queuePosition ?? position
};
});
};
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -186,15 +202,15 @@ interface DownloadState {
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
startQueue: (queueId: string) => Promise<number>;
resumeDownload: (id: string) => Promise<boolean>;
startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => void;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void;
removeQueue: (id: string) => Promise<void>;
initDB: () => Promise<void>;
}
@@ -205,8 +221,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (id, direction) => {
const item = get().downloads.find(download => download.id === id);
if (!item) return;
const queueId = item.queueId || MAIN_QUEUE_ID;
const queueItems = get().downloads
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
const index = queueItems.findIndex(download => download.id === id);
const target = direction === 'up' ? index - 1 : index + 1;
if (index < 0 || target < 0 || target >= queueItems.length) return;
const reordered = [...queueItems];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
const positions = new Map(reordered.map((download, position) => [download.id, position]));
set(state => ({
downloads: state.downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download)
}));
if (!get().backendRegisteredIds.has(id)) return;
try {
const order = await invoke('move_in_queue', { id, direction });
const order = await invoke('move_in_queue', { id, queueId, direction });
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to move item in queue:", e);
@@ -284,20 +319,21 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
const destPath = await effectiveDestinationForItem(item, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
const queuePosition = get().downloads.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed'
).length;
const ownedItem: DownloadItem = {
...item,
destination: destPath,
status: action.type === 'add-to-queue' ? 'queued' : 'ready',
queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID,
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId,
queuePosition,
hasBeenDispatched: false
};
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
if (action.type === 'add-to-queue') {
const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]);
}
info(`Download ${item.id} added to queue ${action.queueId}`);
} else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) {
@@ -315,7 +351,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error("Cannot change properties while transfer is active. Pause it first.");
}
if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') {
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates);
return;
}
@@ -373,18 +409,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id);
if (item && deleteFile) {
const filepath = await resolveDownloadPath(item.destination || '~/Downloads', item.fileName);
const partialPaths = [`${filepath}.aria2`, `${filepath}.part`];
await invoke('trash_download_assets', { path: filepath, partialPaths });
}
if (item) {
try {
await invoke('remove_download', { id, filepath: null });
} catch (e) {
console.error("Failed to terminate download on backend during deletion, but will still remove from UI:", e);
}
await invoke('remove_download', { id, deleteAssets: deleteFile });
}
set((state) => ({
@@ -464,19 +490,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
resumeDownload: async (id) => {
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return;
if (!targetItem) return false;
try {
if (targetItem.status === 'ready') {
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
if (await dispatchItem(id)) {
get().updateDownload(id, { hasBeenDispatched: true });
return true;
}
return;
return false;
}
const resumedExisting = await invoke('resume_download', { id });
if (resumedExisting) {
return;
return true;
}
get().unregisterBackendIds([id]);
@@ -492,38 +519,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!await dispatchItem(id)) {
console.error("Failed to re-enqueue for resume");
return false;
}
return true;
} catch (e) {
console.error("Failed to resume download:", e);
return false;
}
},
startQueue: async (queueId) => {
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)));
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
if (runnable.length === 0) return 0;
if (runnable.length === 0) return [];
let dispatchedCount = 0;
const promises = runnable.map(async (item) => {
if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) {
const acceptedIds: string[] = [];
for (const item of runnable) {
if (
item.status === 'ready' ||
item.status === 'staged' ||
item.status === 'failed' ||
!item.hasBeenDispatched ||
!get().backendRegisteredIds.has(item.id)
) {
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
dispatchedCount++;
const current = get().downloads.find(download => download.id === item.id);
get().updateDownload(item.id, {
hasBeenDispatched: true,
...(current?.status === item.status ? { status: 'queued' as const } : {})
});
acceptedIds.push(item.id);
}
} else if (item.status === 'paused' || item.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (item.status === 'paused') {
await get().resumeDownload(item.id);
if (!await get().resumeDownload(item.id)) continue;
}
dispatchedCount++;
acceptedIds.push(item.id);
}
});
}
await Promise.all(promises);
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
return dispatchedCount;
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
},
pauseQueue: async (queueId) => {
const activeIds = get().downloads
@@ -556,7 +595,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
.map(item => item.queueId || MAIN_QUEUE_ID)
);
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
return results.reduce((total, count) => total + count, 0);
return results.reduce((total, ids) => total + ids.length, 0);
},
pauseAll: async () => {
const activeIds = get().downloads
@@ -571,12 +610,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
syncSystemIntegrations();
return pausedCount;
},
assignToQueue: (ids, queueId) => {
assignToQueue: async (ids, queueId) => {
const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
if (locked) {
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
for (const item of selected) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
} else if (item.status === 'paused') {
await invoke('detach_download_for_reconfigure', { id: item.id });
}
get().unregisterBackendIds([item.id]);
}
const nextPosition = get().downloads.filter(item =>
!selectedIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId &&
item.status !== 'completed'
).length;
set(state => ({
downloads: state.downloads.map(item =>
selectedIds.has(item.id) && item.status !== 'completed'
? { ...item, queueId }
? {
...item,
queueId,
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
: item
)
}));
@@ -599,9 +665,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
})
}));
},
removeQueue: (id) => {
removeQueue: async (id) => {
if (id === MAIN_QUEUE_ID) return;
const unfinishedIds = get().downloads
.filter(download => download.queueId === id && download.status !== 'completed')
.map(download => download.id);
if (unfinishedIds.length > 0) {
await get().assignToQueue(unfinishedIds, MAIN_QUEUE_ID);
}
set((state) => ({
queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d =>
@@ -619,10 +690,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
set(state => ({
queues: queues.length > 0 ? queues : state.queues,
downloads: downloads.length > 0
? downloads.map(download => ({
...download,
queueId: download.queueId || MAIN_QUEUE_ID
}))
? normalizeQueuePositions(downloads)
: state.downloads
}));
@@ -655,6 +723,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination: destPath,
filename: item.fileName,
@@ -677,7 +746,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedIds = new Set(results.filter(result => !result.success).map(result => result.id));
const order = await invoke('get_pending_order');
const order = await invoke('get_pending_order', { queueId: null });
set(state => ({
pendingOrder: order,
backendRegisteredIds: new Set([
+24 -1
View File
@@ -19,6 +19,7 @@ import {
} from '../utils/downloadLocations';
let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
@@ -80,6 +81,7 @@ export interface SettingsState {
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
schedulerRunning: boolean;
schedulerActiveDownloadIds: string[];
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
@@ -112,6 +114,7 @@ export interface SettingsState {
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
setSchedulerRunning: (running: boolean) => void;
setSchedulerActiveDownloadIds: (ids: string[]) => void;
setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void;
@@ -187,9 +190,11 @@ export const useSettingsStore = create<SettingsState>()(
stopTime: '08:00',
everyday: true,
selectedDays: [0, 1, 2, 3, 4, 5, 6],
selectedQueueIds: [DEFAULT_SCHEDULER_QUEUE_ID],
postQueueAction: 'none'
},
schedulerRunning: false,
schedulerActiveDownloadIds: [],
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
@@ -231,6 +236,7 @@ export const useSettingsStore = create<SettingsState>()(
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
@@ -301,7 +307,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 2,
version: 3,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -316,6 +322,15 @@ export const useSettingsStore = create<SettingsState>()(
return {
...persisted,
...locations,
scheduler: persisted.scheduler
? {
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: [DEFAULT_SCHEDULER_QUEUE_ID]
}
: persisted.scheduler,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState;
},
@@ -360,6 +375,14 @@ export const useSettingsStore = create<SettingsState>()(
...currentState,
...persisted,
...locations,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: currentState.scheduler.selectedQueueIds
},
appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
siteLogins: Array.isArray(persisted.siteLogins)