diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 82da8b5..248858b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -756,7 +756,14 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { // These values are accepted from users, browser extensions, or URLs and // may contain credentials or bearer tokens. Portable queues keep their // useful metadata, but never persist these values beside the executable. + let mut removed_transfer_context = false; for key in ["password", "cookies", "headers", "mirrors", "proxy"] { + if object + .get(key) + .is_some_and(|value| !value.is_null() && !value_is_empty(value)) + { + removed_transfer_context = true; + } object.remove(key); } @@ -790,6 +797,20 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { mark_portable_download_unresumable(object); } } + + // Do not silently resume a queued transfer after removing request + // credentials or other transfer-specific context. The URL may still be + // valid, but its semantics have changed and the user must re-add it with + // the required request settings. + if removed_transfer_context { + mark_portable_download_unresumable(object); + } +} + +fn value_is_empty(value: &Value) -> bool { + value.as_str().is_some_and(str::is_empty) + || value.as_array().is_some_and(Vec::is_empty) + || value.as_object().is_some_and(serde_json::Map::is_empty) } fn mark_portable_download_unresumable(object: &mut serde_json::Map) { @@ -803,7 +824,7 @@ fn mark_portable_download_unresumable(object: &mut serde_json::Map { }); }); + it('keeps accepted startup registrations when pending-order refresh fails', async () => { + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'db_get_all_queues') return []; + if (cmd === 'db_get_all_downloads') { + return [JSON.stringify({ + id: 'startup-accepted', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + status: 'queued', + category: 'Other', + dateAdded: '', + queueId: '00000000-0000-0000-0000-000000000001', + hasBeenDispatched: true + })]; + } + if (cmd === 'enqueue_many') { + return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }]; + } + if (cmd === 'get_pending_order') throw new Error('queue state unavailable'); + if (cmd === 'resume_download') return true; + return undefined; + }); + + await useDownloadStore.getState().initDB(); + + expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true); + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + status: 'queued', + hasBeenDispatched: true + }); + + await expect(useDownloadStore.getState().startQueue('00000000-0000-0000-0000-000000000001')) + .resolves.toEqual(['startup-accepted']); + expect( + vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'enqueue_download') + ).toHaveLength(0); + }); + it('redownloads fallback media without requiring a format selector', async () => { useDownloadStore.setState({ downloads: [{ @@ -620,6 +658,38 @@ describe('useDownloadStore', () => { ); }); + it('does not claim a redownload when removing the old backend lifecycle fails', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'redownload-remove-failed', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + destination: '/tmp', + status: 'paused', + category: 'Other', + dateAdded: '2026-07-15T00:00:00.000Z', + hasBeenDispatched: true + }] as any[], + backendRegisteredIds: new Set(['redownload-remove-failed']) + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'remove_download') throw new Error('aria2 did not stop'); + return undefined; + }); + + await expect(useDownloadStore.getState().redownload('redownload-remove-failed')) + .rejects.toThrow('aria2 did not stop'); + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + status: 'paused', + dateAdded: '2026-07-15T00:00:00.000Z', + hasBeenDispatched: true + }); + expect(useDownloadStore.getState().backendRegisteredIds.has('redownload-remove-failed')).toBe(true); + expect( + vi.mocked(ipc.invokeCommand).mock.calls.some(call => call[0] === 'enqueue_download') + ).toBe(false); + }); + it('starts and pauses all items regardless of legacy missing queue ids', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 5bfbc94..4bc3d9b 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -818,6 +818,7 @@ export const useDownloadStore = create((set, get) => ({ get().unregisterBackendIds([id]); } catch (e) { console.warn("Could not remove old download from backend", e); + throw e; } get().updateDownload(id, { @@ -1175,9 +1176,12 @@ export const useDownloadStore = create((set, get) => ({ .filter(result => !result.success) .map(result => [result.id, result.error || 'Backend rejected the queued download.']) ); - const order = await invoke('get_pending_order', { queueId: null }); + + // Commit backend ownership as soon as enqueue_many accepts an item. + // The order query is a separate best-effort view read; if it fails, + // forgetting these registrations would let a later queue start + // enqueue the same backend lifecycle a second time. set(state => ({ - pendingOrder: order, backendRegisteredIds: new Set([ ...state.backendRegisteredIds, ...registeredIds @@ -1189,11 +1193,18 @@ export const useDownloadStore = create((set, get) => ({ status: 'failed' as const, lastError: failedErrors.get(download.id) } - : registeredIds.includes(download.id) + : registeredIds.includes(download.id) ? { ...download, hasBeenDispatched: true, lastError: undefined } : download ) })); + + try { + const order = await invoke('get_pending_order', { queueId: null }); + set({ pendingOrder: order }); + } catch (e) { + console.error("Failed to refresh pending order after auto-resume:", e); + } } catch (e) { console.error("Failed to auto-resume active downloads:", e); }