From 894c238bb7c2ebcfb4c209d9494e986d775148fb Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 20 Jun 2026 11:41:55 +0330 Subject: [PATCH] feat(queue): implement concurrent deduplication and safe backend detachment - Add backendRegisteredIds and backendDispatchPromises for single dispatch enforcement - Add detach_download_for_reconfigure to safely modify properties of active downloads - Add applyProperties logic handling completed, failed, paused, and active queues - Add extractValidDownloadUrls and fix multi-url paste handling - Setup vitest and add useDownloadStore unit tests for backend registration lifecycle --- INTERACTION_REVIEW.md | 273 +++++++++++++-------- package-lock.json | 378 ++++++++++++++++++++++++++++- package.json | 6 +- src-tauri/src/download.rs | 34 +++ src-tauri/src/extension_server.rs | 29 ++- src-tauri/src/ipc.rs | 12 + src-tauri/src/lib.rs | 92 ++++++- src-tauri/src/queue.rs | 50 +++- src/App.tsx | 8 +- src/bindings/DownloadItem.ts | 2 +- src/bindings/EnqueueResult.ts | 3 + src/components/PropertiesModal.tsx | 21 +- src/ipc.ts | 6 +- src/store/downloadStore.ts | 6 + src/store/useDownloadStore.test.ts | 94 +++++++ src/store/useDownloadStore.ts | 332 ++++++++++++------------- src/utils/url.ts | 24 ++ 17 files changed, 1058 insertions(+), 312 deletions(-) create mode 100644 src/bindings/EnqueueResult.ts create mode 100644 src/store/useDownloadStore.test.ts create mode 100644 src/utils/url.ts diff --git a/INTERACTION_REVIEW.md b/INTERACTION_REVIEW.md index 5544966..c3e3b06 100644 --- a/INTERACTION_REVIEW.md +++ b/INTERACTION_REVIEW.md @@ -1,121 +1,180 @@ -# Firelink interaction/functionality review +# Firelink UI Interaction Inventory -Date: 2026-06-19 +## 1. Main Window & Sidebar -## Review method +### Sidebar Actions +- **Select Filter/Category/Queue** + - **Location**: Sidebar item clicks (`NavItem`, `QueueItem`). + - **Component**: `Sidebar.tsx` (`onSelectFilter`) + - **Store Action**: `useSettingsStore.setActiveView('downloads')`, updates local state filter. + - **Expected**: Main table filters to show matching downloads. +- **Add Queue** + - **Location**: Sidebar '+' button. + - **Component**: `Sidebar.tsx` (`handleAddQueueSubmit`) + - **Store Action**: `useDownloadStore.addQueue()` + - **Expected**: A new custom queue is created. +- **Queue Context Menu** + - **Location**: Right-click on a custom queue. + - **Component**: `Sidebar.tsx` (`handleQueueContextMenu`) + - **Actions**: + - **Start Queue**: `useDownloadStore.startQueue()` -> IPC `start_download` (for active items) + - **Pause Queue**: `useDownloadStore.pauseQueue()` -> IPC `pause_download` + - **Rename Queue**: `useDownloadStore.renameQueue()` + - **Delete Queue**: `useDownloadStore.removeQueue()` -> IPC `db_delete_queue` + - **Expected**: Context menu opens, performing the respective queue manipulation. +- **Resize Sidebar** + - **Location**: Sidebar drag handle. + - **Component**: `App.tsx` (`startSidebarResize`) + - **Expected**: Drags and resizes the sidebar width dynamically. -Code-traced visible UI actions through React components, Zustand stores, typed IPC wrappers, registered Tauri commands, Rust side effects, and frontend update/error paths. Build and Rust tests were run after fixes. +### Main Toolbar +- **Toggle Sidebar** + - **Location**: Top-left icon (when sidebar is hidden). + - **Component**: `DownloadTable.tsx` / `Sidebar.tsx` + - **Store Action**: `useSettingsStore.toggleSidebar()` +- **Add Download** + - **Location**: '+' Button. + - **Component**: `DownloadTable.tsx` + - **Store Action**: `useDownloadStore.toggleAddModal(true)` +- **Resume All / Pause All** + - **Location**: Toolbar Play/Pause icons. + - **Component**: `DownloadTable.tsx` + - **Store Action**: `handleResume` -> `resumeDownload`, `handlePause` -> IPC `pause_download` + - **Rust**: `commands::pause_download` + - **Expected**: Iterates through visible items and pauses/resumes them. -## Root causes found +## 2. Download Table -1. The handwritten frontend IPC map had drifted from generated Rust bindings. - - Queue reordering sent `Up`/`Down`, but Rust deserializes `QueueDirection` as lowercase `up`/`down`. +- **Table Column Resize** + - **Location**: Table headers drag handle. + - **Component**: `DownloadTable.tsx` (`startColumnResize`) + - **Expected**: Modifies local state grid template widths. +- **Row Double Click** + - **Location**: Table row. + - **Component**: `DownloadTable.tsx` (`handleDownloadDoubleClick`) + - **Expected**: If completed, opens file (IPC `open_downloaded_file`). Otherwise, opens properties (`useDownloadStore.setSelectedPropertiesDownloadId`). +- **Row Context Menu (Right Click)** + - **Location**: Table row. + - **Component**: `DownloadTable.tsx` + - **Actions**: + - **Open**: IPC `open_downloaded_file` -> Rust `commands::open_downloaded_file` + - **Show in Finder**: IPC `reveal_in_file_manager` -> Rust `commands::reveal_in_file_manager` + - **Pause**: IPC `pause_download` -> Rust `commands::pause_download` + - **Resume**: `useDownloadStore.resumeDownload()` -> IPC `resume_download` -> Rust `commands::resume_download` + - **Redownload**: `useDownloadStore.redownload()` -> IPC `remove_download` -> Rust `commands::enqueue_download` + - **Copy Address**: `navigator.clipboard.writeText(url)` + - **Copy File Path**: `navigator.clipboard.writeText(fullPath)` + - **Remove**: `useDownloadStore.openDeleteModal()` + - **Properties**: `useDownloadStore.setSelectedPropertiesDownloadId()` -2. Some UI buttons were visually present but not wired. - - Add Download `Refresh Metadata` had no click handler. +## 3. Add Downloads Window -3. Some UI promises did not match the backend side effect. - - Settings global speed limit was labeled KiB/s, but bare numeric input was passed as bytes/s. - - Download Properties allowed editing speed for active transfers even though no backend command applied a per-download active speed change. +- **Input URLs** + - **Location**: Main textarea. + - **Component**: `AddDownloadsModal.tsx` + - **Expected**: Parses text, fetches metadata (IPC `fetch_metadata` or `fetchMediaMetadataDeduped`), and populates preview list. +- **Refresh Metadata** + - **Location**: "Refresh Metadata" button. + - **Component**: `AddDownloadsModal.tsx` + - **Expected**: Re-triggers metadata fetch for URLs. +- **Preview List Selection** + - **Location**: Preview items list. + - **Component**: `AddDownloadsModal.tsx` (`setSelectedItemIndex`) + - **Expected**: Updates the right-side form to reflect the selected item's specific options. +- **Media Format Selection** + - **Location**: Format list (if media). + - **Component**: `AddDownloadsModal.tsx` (`selectMediaFormat`) + - **Expected**: Updates the selected format, updating estimated sizes and file extensions. +- **Browse Save Location** + - **Location**: "Select" button next to save location. + - **Component**: `AddDownloadsModal.tsx` (`handleBrowse`) + - **Expected**: Opens Tauri native directory picker. +- **Options Toggles & Inputs** + - **Location**: Right-side pane. + - **Actions**: Change connections, toggle speed limit, set username/password, expand advanced, toggle checksum, edit headers/cookies/mirrors. + - **Expected**: Mutates local component state for the download configuration. +- **Start / Add to Queue** + - **Location**: Bottom right buttons. + - **Component**: `AddDownloadsModal.tsx` (`handleStart`) + - **Store Action**: `useDownloadStore.addDownload()` + - **IPC Command**: `enqueue_download` (called by store) + - **Rust**: `commands::enqueue_download` + - **Validation**: Checks for duplicates on disk (IPC `check_file_exists`) and in store, potentially opening `DuplicateResolutionModal`. -4. Duplicate-resolution semantics were too broad. - - The duplicate dialog offered `Replace` for URL duplicates, but replacement only makes sense for destination-file conflicts. +## 4. Download Properties Window -5. File-path actions used inconsistent destination resolution. - - `Copy File Path` bypassed the same effective destination logic used by Open/Reveal. +- **Browse Save Location** + - **Location**: "Select" button. + - **Component**: `PropertiesModal.tsx` + - **Expected**: Opens Tauri native directory picker. +- **Form Inputs** + - **Location**: Text inputs and selects. + - **Component**: `PropertiesModal.tsx` + - **Expected**: Adjusts URL, filename, connections, speed limits, auth (matches/custom/none), advanced settings. Disabled if the download is locked/active. +- **Save Changes** + - **Location**: "Save" button. + - **Component**: `PropertiesModal.tsx` + - **Store Action**: `useDownloadStore.updateDownload()` + - **Expected**: Validates inputs, saves to store, syncs to DB. -6. Clipboard actions lacked visible error handling. - - Copy failures were previously silent. +## 5. Settings Window -## Fixed +- **Tab Navigation** + - **Location**: Top horizontal tabs. + - **Store Action**: `useSettingsStore.setActiveSettingsTab()` +- **Downloads Settings** + - **Location**: "Downloads" tab. + - **Actions**: Change default connections, parallel downloads (IPC `set_concurrent_limit`), global speed limit (IPC `set_global_speed_limit`), max retries, notification toggles. +- **Look and feel** + - **Location**: "Look and feel" tab. + - **Actions**: Theme switch, font size, density, dock badge toggle (IPC `update_dock_badge`), menu bar icon (IPC `toggle_tray_icon`). +- **Network** + - **Location**: "Network" tab. + - **Actions**: Change proxy mode/host/port, custom user agent. +- **Locations** + - **Location**: "Locations" tab. + - **Actions**: + - Browse Default Path + - Toggle "Ask where to save" + - Browse "All Categories Base" -> Tauri Picker -> IPC `create_category_directories` -> Rust `commands::create_category_directories` + - Browse specific category paths. + - Reset Defaults: `useSettingsStore.resetCategoryDirectories()` +- **Site Logins** + - **Location**: "Site Logins" tab. + - **Actions**: + - Add Login: IPC `set_keychain_password` -> `useSettingsStore.addSiteLogin()` + - Delete Login (Trash icon): IPC `delete_keychain_password` -> `useSettingsStore.removeSiteLogin()` +- **Engine** + - **Location**: "Engine" tab (when mounted). + - **Component**: `SettingsView.tsx` (`runEngineChecks`) + - **IPC**: `get_aria2_engine_status`, `get_ytdlp_engine_status`, `get_ffmpeg_engine_status`, `get_deno_engine_status`. + - **Expected**: Polls backend for binary status and updates UI. Re-triggered with "Refresh" button. -### Main window +## 6. Other Modals & Actions -| Surface/action | Status after review | Notes | -| --- | --- | --- | -| Start/resume paused or failed download | Works | Store enqueues through `enqueue_download`; existing aria2 GID resume path is handled by backend. | -| Pause active/queued/retrying download | Works | Frontend calls `pause_download`; backend removes pending task and pauses/removes active work as appropriate. | -| Remove download | Works | Frontend calls `remove_download`, then removes local persisted item. | -| Delete file with remove | Works | Uses `trash_download_assets` with backend ownership/path authorization. | -| Redownload | Works | Creates a new queued item with preserved metadata and enqueues through queue manager. | -| Open downloaded file | Works | Uses owned-path `open_downloaded_file`. | -| Show in Finder | Works for completed files/partials | Uses owned-path `reveal_in_file_manager`; non-completed double-click/menu behavior opens properties. | -| Copy URL/address | Fixed | Now reports clipboard errors. | -| Copy file path | Fixed | Now uses the same effective path resolution as file actions and reports clipboard errors. | -| Queue up/down ordering | Fixed | Sends lowercase `up`/`down` to match Rust-generated `QueueDirection`. | -| Double-click completed download | Works | Opens file; otherwise opens Properties. | -| Status/progress/speed/ETA updates | Works | Store listener handles progress/state events; existing media-size finalization behavior is preserved. | -| Column resize/layout | Works | Local UI-only state; no backend side effect. | - -### Add Download window - -| Surface/action | Status after review | Notes | -| --- | --- | --- | -| URL/multiple URL input | Works | Lines are parsed and invalid URLs surface per-item error state. | -| Paste/deep-link/extension prefill | Works | App-level paste/deep-link/extension handlers open the modal with URLs. | -| Metadata detection | Works | HTTP metadata and media metadata go through IPC. | -| Refresh Metadata | Fixed | Button now re-runs the existing parser/metadata flow. | -| Media format selection | Works | Selected format selector/ext is applied before enqueue. | -| File name/category/destination detection | Works | Category-specific destination routing is preserved. | -| Destination folder selection | Works | Uses Tauri directory picker. | -| Auth/site-login credentials | Works | Keychain-backed site-login password lookup is preserved. | -| Add paused / start queued | Works | Adds local item and enqueues when start is requested. | -| Duplicate URL conflict | Fixed | `Replace` is no longer offered for URL-only duplicates. | -| Duplicate file conflict | Works | Rename/replace/skip flows remain available for file conflicts. | -| Cancel/close | Works | Modal state is reset through store. | - -### Download Properties - -| Surface/action | Status after review | Notes | -| --- | --- | --- | -| Opens for queued/downloading/paused/failed/completed | Works | Selected item is read live from store. | -| Live progress/speed/ETA/size summary | Works | Summary uses current store item, not stale initial-only form state. | -| Save editable queued/paused/failed settings | Works | Updates local persisted item used by resume/redownload/enqueue paths. | -| Completed download settings for redownload | Works | Identity remains read-only; transfer settings can be saved for redownload. | -| Active-transfer speed controls | Fixed | Controls are locked while transfer is active; copy now states that current backend options remain active until pause/stop. | -| Browse destination | Works when not locked | Uses directory picker. | - -### Settings - -| Tab/action | Status after review | Notes | -| --- | --- | --- | -| Downloads tab settings | Fixed/Works | Bare global speed-limit values now normalize as KiB/s for live backend calls and newly queued downloads. | -| Look and feel tab | Works | Theme, font size, row density, dock badge, menu bar icon persist and drive app effects. | -| Network tab | Works | Proxy mode/host/port/user-agent values are used for enqueue payloads. | -| Locations tab | Works | Default/category paths persist; bulk category creation calls backend. | -| Site Logins tab | Works | Add/remove uses settings plus keychain password storage/deletion. | -| Power tab | Works | Prevent-sleep setting drives backend keep-awake effect. | -| Engine tab diagnostics | Works | Recheck calls registered engine status commands and surfaces errors/details. | -| Integrations tab | Works | Token copy/regenerate and extension pairing token updates are wired; token remains keychain-backed. | -| About tab updates | Works | Check Now calls update check and surfaces result via toast. | - -### Menus/context menus/dialog buttons - -| Surface/action | Status after review | Notes | -| --- | --- | --- | -| Download row context menu | Fixed/Works | Copy actions now resolve correctly and surface errors; start/pause/remove/redownload/properties remain wired. | -| Sidebar queue context menu | Works | Start/pause/rename/remove queues are wired through store. | -| Delete confirmation dialog | Works | Remove-only and remove+trash paths preserved. | -| Duplicate resolution dialog | Fixed | Replacement is now file-conflict-only. | - -### Backend IPC coverage - -| Check | Status | -| --- | --- | -| Frontend IPC command names exist in Rust registration | Works | -| Queue direction argument casing | Fixed | -| Global speed-limit unit handling | Fixed in frontend and backend startup/live command handling | -| File open/reveal/trash path authorization | Preserved | -| Generic duplicate-check/delete path guards | Preserved; no security loosening introduced | - -## Not fixed / follow-up - -- Full packaged GUI/manual click-through was not launched in this pass; validation was code-trace plus `npm run build` and Rust tests. -- Existing generic `check_file_exists` / `delete_file` remain scoped by `is_safe_path`, but they are broader than ownership-based open/reveal/trash commands. I did not loosen them. A future hardening pass should replace duplicate-file replacement with an explicit user-selected-destination trash command or ownership-aware pre-registration flow. -- Per-download speed changes for already-running transfers are not implemented because the current backend has no active-transfer per-item speed-limit IPC. The UI now stops promising that behavior. - -## Validation - -- `npm run build`: pass -- `cd src-tauri && cargo test --quiet`: pass +- **Delete Confirmation Modal** + - **Location**: Prompt when removing a download. + - **Component**: `DeleteConfirmationModal.tsx` + - **Actions**: + - Remove from list: `useDownloadStore.removeDownload()` -> IPC `db_delete_download` + - Delete file & remove: `useDownloadStore.removeDownload()` + IPC `trash_download_assets` -> Rust `commands::trash_download_assets` +- **Duplicate Resolution Modal** + - **Location**: Triggered when adding a download that already exists. + - **Component**: `DuplicateResolutionModal.tsx` + - **Actions**: Select "Rename", "Replace", or "Skip". + - **Expected**: Modifies the batch of downloads added via `executeAddDownloads`. +- **Quality Modal** + - **Location**: Triggered when an active media download requires format resolution. + - **Component**: `QualityModal.tsx` + - **Expected**: Approves formats via local store or cancels metadata request. +- **Global Keyboard Shortcuts** + - **Location**: `App.tsx` (paste event listener) + - **Expected**: Pasting URLs anywhere outside inputs triggers `useDownloadStore.openAddModalWithUrls(text)`. +- **System Sleep / Power** + - **Location**: "Power" tab. + - **Expected**: Interacts with IPC `set_prevent_sleep` -> Rust `commands::set_prevent_sleep`. +## Likely Validation Methods +- **UI State**: Checking Zustand store (`useDownloadStore`, `useSettingsStore`) and React DevTools. +- **Rust Backend**: Monitoring standard output/stderr for `Invoke command` errors, and inspecting `src-tauri/src/commands.rs`. +- **File System**: Checking disk for queue persistence and newly created directories (e.g. `create_category_directories`). diff --git a/package-lock.json b/package-lock.json index 41b2c99..07d1c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "~5.8.3", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.9" }, "engines": { "node": ">=22" @@ -405,6 +406,13 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -971,6 +979,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1017,6 +1050,129 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1122,6 +1278,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1158,6 +1331,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1168,6 +1348,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1535,6 +1735,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1648,6 +1869,13 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1657,6 +1885,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -1676,6 +1918,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1692,6 +1951,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1821,6 +2090,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 371157f..2eb2807 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "bindings": "cd src-tauri && cargo test export_bindings --lib", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest" }, "dependencies": { "@tailwindcss/vite": "^4.3.0", @@ -55,6 +56,7 @@ "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "~5.8.3", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.9" } } diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index f442e28..47bebd6 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -25,6 +25,7 @@ const WRITE_BUFFER_CAPACITY: usize = 256 * 1024; pub enum DownloadCmd { Start(Box), Pause(Uuid), + PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>), Cancel(Uuid), CaptureUrls(Vec), FrontendReady(bool), @@ -116,6 +117,13 @@ impl DownloadCoordinator { .map_err(|_| "download coordinator is unavailable".to_string()) } + pub async fn pause_media_with_ack(&self, id: String, ack: tokio::sync::oneshot::Sender<()>) -> Result<(), String> { + self.media_tx + .send(MediaCmd::PauseWithAck(id, ack)) + .await + .map_err(|_| "download coordinator is unavailable".to_string()) + } + pub async fn finish_media(&self, id: String) { let _ = self.media_tx.send(MediaCmd::Finished(id)).await; } @@ -243,6 +251,7 @@ enum MediaCmd { cancel_tx: watch::Sender, }, Pause(String), + PauseWithAck(String, tokio::sync::oneshot::Sender<()>), Finished(String), } @@ -281,6 +290,8 @@ async fn run_coordinator( let (worker_tx, mut worker_rx) = mpsc::channel(128); let mut active = HashMap::::new(); let mut active_media = HashMap::>::new(); + let mut pending_acks = HashMap::>::new(); + let mut pending_media_acks = HashMap::>::new(); let mut pending_captured_urls = Vec::::new(); let mut frontend_ready = false; let mut next_generation = 0_u64; @@ -319,6 +330,14 @@ async fn run_coordinator( let _ = download.control_tx.send(DownloadControl::Pause).await; } } + DownloadCmd::PauseWithAck(id, ack) => { + if let Some(download) = active.remove(&id) { + let _ = download.control_tx.send(DownloadControl::Pause).await; + pending_acks.insert(id, ack); + } else { + let _ = ack.send(()); + } + } DownloadCmd::Cancel(id) => { if let Some(download) = active.remove(&id) { let _ = download.control_tx.send(DownloadControl::Cancel).await; @@ -356,6 +375,10 @@ async fn run_coordinator( active.remove(&id); } + if let Some(ack) = pending_acks.remove(&id) { + let _ = ack.send(()); + } + match (is_current, outcome) { (true, DownloadOutcome::Completed) => { events.emit_completed(id); @@ -381,8 +404,19 @@ async fn run_coordinator( let _ = cancel_tx.send(true); } } + MediaCmd::PauseWithAck(id, ack) => { + if let Some(cancel_tx) = active_media.remove(&id) { + let _ = cancel_tx.send(true); + pending_media_acks.insert(id, ack); + } else { + let _ = ack.send(()); + } + } MediaCmd::Finished(id) => { active_media.remove(&id); + if let Some(ack) = pending_media_acks.remove(&id) { + let _ = ack.send(()); + } } } } diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 8dece5c..618483c 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -268,6 +268,7 @@ async fn enqueue_extension_download( 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, @@ -292,12 +293,36 @@ async fn enqueue_extension_download( is_media: Some(false), } .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); } - state.queue_manager.enqueue_many(tasks).await; - let _ = app_handle.emit("extension-downloads-queued", created_items); + 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(()) } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 7be5c2a..9e2e599 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -95,6 +95,18 @@ pub struct DownloadItem { #[ts(optional)] pub media_format_selector: Option, pub queue_id: String, + #[ts(optional)] + pub has_been_dispatched: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct EnqueueResult { + pub id: String, + pub success: bool, + #[ts(optional)] + pub error: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 576e7e2..c78b730 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2172,6 +2172,7 @@ async fn pause_download( state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; + state.queue_manager.release_registered_id(&id).await; return Err(format!( "cannot pause aria2 gid {gid} in terminal state {terminal}" )); @@ -2214,11 +2215,13 @@ async fn resume_download( ) -> Result { let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else { log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id); + state.queue_manager.release_registered_id(&id).await; return Ok(false); }; if gid.starts_with("native:") { state.queue_manager.forget_aria2_gid(&id).await; log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id); + state.queue_manager.release_registered_id(&id).await; return Ok(false); } @@ -2263,6 +2266,7 @@ async fn resume_download( state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; + state.queue_manager.release_registered_id(&id).await; log::info!( "aria2 resume [{}]: gid {} is {}; re-enqueue is permitted", id, @@ -2293,6 +2297,7 @@ async fn remove_download( ) -> Result<(), String> { log::info!("remove_download called for id: {}", id); let _ = crate::download_ownership::remove(&app_handle, &id); + state.queue_manager.release_registered_id(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; state.queue_manager.remove_from_pending(&id).await; @@ -2355,6 +2360,69 @@ async fn remove_download( Ok(()) } +#[tauri::command] +async fn detach_download_for_reconfigure( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + log::info!("detach_download_for_reconfigure called for id: {}", id); + let active_kind = state.queue_manager.active_kind(&id).await; + state.queue_manager.remove_from_pending(&id).await; + state.queue_manager.cancel_aria2_retries(&id).await; + let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await; + let gid = state.queue_manager.aria2_gid_for_download(&id); + + if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) { + let removal_result = async { + rpc_call( + state.aria2_port, + &state.aria2_secret, + "aria2.forcePause", + serde_json::json!([gid]), + ) + .await?; + wait_for_aria2_stopped(state.aria2_port, &state.aria2_secret, gid).await + } + .await; + if let Err(error) = removal_result { + state.queue_manager.allow_aria2_retries(&id).await; + return Err(error); + } + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_permit(&id).await; + state.queue_manager.release_registered_id(&id).await; + log::info!("aria2 detach [{}]: gid {} stopped and forgotten", id, gid); + } else { + drop(retry_add_guard); + let (tx, rx) = tokio::sync::oneshot::channel(); + if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { + state.download_coordinator.pause_media_with_ack(id.clone(), tx).await?; + } else if let Ok(download_id) = Uuid::parse_str(&id) { + state.download_coordinator.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx)).await?; + } else { + let _ = tx.send(()); // Fallback if no task exists + } + let _ = rx.await; // Wait for the writer to stop + + if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) { + state.queue_manager.release_permit(&id).await; + } + state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_aria2_gid(&id).await; + state.queue_manager.release_registered_id(&id).await; + } + + use tauri::Emitter; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), + ); + + Ok(()) +} + fn ensure_aria2_gid_result( method: &str, expected_gid: &str, @@ -2506,7 +2574,11 @@ async fn enqueue_download( &item.destination, &item.filename, )?; - state.queue_manager.push(item.into_task()).await; + if let Err(e) = state.queue_manager.push(item.into_task()).await { + let _ = crate::download_ownership::remove(&app_handle, &id); + state.queue_manager.release_registered_id(&id).await; + return Err(AppError::Internal(e)); + } Ok(id) } @@ -2515,7 +2587,7 @@ async fn enqueue_many( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, items: Vec, -) -> Result<(), AppError> { +) -> Result, AppError> { for item in &items { crate::download_ownership::register_expected( &app_handle, @@ -2525,8 +2597,16 @@ async fn enqueue_many( )?; } let tasks = items.into_iter().map(queue::EnqueueItem::into_task).collect(); - state.queue_manager.enqueue_many(tasks).await; - Ok(()) + let results = state.queue_manager.enqueue_many(tasks).await; + + for result in &results { + if !result.success { + let _ = crate::download_ownership::remove(&app_handle, &result.id); + state.queue_manager.release_registered_id(&result.id).await; + } + } + + Ok(results) } #[tauri::command] @@ -2546,7 +2626,8 @@ async fn remove_from_queue( ) -> Result { let removed = state.queue_manager.remove_from_pending(&id).await; if removed { - crate::download_ownership::remove(&app_handle, &id)?; + let _ = crate::download_ownership::remove(&app_handle, &id); + state.queue_manager.release_registered_id(&id).await; } Ok(removed) } @@ -3475,6 +3556,7 @@ pub fn run() { set_keychain_password, get_keychain_password, delete_keychain_password, check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, + detach_download_for_reconfigure, enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index dd3d3e1..6f7620a 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -86,6 +86,7 @@ pub trait SidecarSpawner: Send + Sync + 'static { /// The centralized concurrency gatekeeper. One instance lives in AppState. pub struct QueueManager { + registered_ids: Mutex>, pending: Mutex>, semaphore: Arc, active_permits: Mutex>, @@ -136,6 +137,7 @@ impl QueueManager { spawner: Arc, ) -> Self { Self { + registered_ids: Mutex::new(HashSet::new()), pending: Mutex::new(VecDeque::new()), semaphore: Arc::new(Semaphore::new(capacity)), active_permits: Mutex::new(HashMap::new()), @@ -164,12 +166,25 @@ impl QueueManager { .collect() } - /// Enqueue a task. Notifies the dispatcher. Emits download-state{queued}. - pub async fn push(&self, task: QueuedTask) { + /// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach). + pub async fn release_registered_id(&self, id: &str) { + self.registered_ids.lock().await.remove(id); + } + + /// Enqueue a task. Checks the centralized `registered_ids` for deduplication. + pub async fn push(&self, task: QueuedTask) -> Result<(), String> { let id = task.id.clone(); + let mut registered = self.registered_ids.lock().await; + if registered.contains(&id) { + return Err("Duplicate task".to_string()); + } + registered.insert(id.clone()); + drop(registered); + self.pending.lock().await.push_back(task); self.emit_state(id, DownloadStatus::Queued); self.notify.notify_one(); + Ok(()) } /// Pop the next task, or None if empty. @@ -391,16 +406,20 @@ impl QueueManager { } } - /// Called when a Media runner exits. Releases the permit and emits + /// Terminal handler for non-aria2 transfers. Emits state and frees the permit. + /// Does not emit or release anything on intentional MEDIA_RUN_CANCELLED. + /// Note: `id` is the frontend download UUID, which survives indefinitely as /// the terminal state. async fn finish_runner(self: Arc, id: &str, outcome: Result<(), String>) { match outcome { Ok(()) => { self.emit_state(id, DownloadStatus::Completed); + self.release_registered_id(id).await; } Err(error) if error == MEDIA_RUN_CANCELLED => {} Err(error) => { self.emit_failed(id, error); + self.release_registered_id(id).await; } } self.release_permit(id).await; @@ -445,11 +464,13 @@ impl QueueManager { self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; self.emit_state(id, DownloadStatus::Completed); + self.release_registered_id(id).await; } PendingOutcome::Error(error) => { self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; self.emit_failed(id, error); + self.release_registered_id(id).await; } } self.release_permit(id).await; @@ -736,15 +757,34 @@ impl QueueManager { } /// Bulk enqueue by appending tasks. Used by startup and start-all. - pub async fn enqueue_many(&self, tasks: Vec) { + pub async fn enqueue_many(&self, tasks: Vec) -> Vec { + let mut results = Vec::new(); + let mut registered = self.registered_ids.lock().await; let mut pending = self.pending.lock().await; + for task in tasks { let id = task.id.clone(); + if registered.contains(&id) { + results.push(crate::ipc::EnqueueResult { + id: id.clone(), + success: false, + error: Some("Duplicate task".to_string()), + }); + continue; + } + registered.insert(id.clone()); pending.push_back(task); - self.emit_state(id, DownloadStatus::Queued); + self.emit_state(id.clone(), DownloadStatus::Queued); + results.push(crate::ipc::EnqueueResult { + id, + success: true, + error: None, + }); } drop(pending); + drop(registered); self.notify.notify_one(); + results } } diff --git a/src/App.tsx b/src/App.tsx index 389a3c3..77a9178 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,8 @@ import { DownloadTable } from "./components/DownloadTable"; import { AddDownloadsModal } from "./components/AddDownloadsModal"; import SettingsView from "./components/SettingsView"; import { PropertiesModal } from "./components/PropertiesModal"; -import { QualityModal } from './components/QualityModal'; import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal"; +import { extractValidDownloadUrls } from './utils/url'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; @@ -165,7 +165,10 @@ function App() { } const text = e.clipboardData?.getData('text/plain'); if (text && text.trim().length > 0) { - useDownloadStore.getState().openAddModalWithUrls(text.trim()); + const urls = extractValidDownloadUrls(text); + if (urls.length > 0) { + useDownloadStore.getState().openAddModalWithUrls(urls.join('\n')); + } } }; window.addEventListener('paste', handlePaste); @@ -310,7 +313,6 @@ function App() { - ); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 4b0e435..906a9dd 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -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, }; +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, }; diff --git a/src/bindings/EnqueueResult.ts b/src/bindings/EnqueueResult.ts new file mode 100644 index 0000000..a015624 --- /dev/null +++ b/src/bindings/EnqueueResult.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EnqueueResult = { id: string, success: boolean, error?: string, }; diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 03e1e17..6b09aae 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -9,7 +9,6 @@ type LoginMode = 'matching' | 'custom' | 'none'; export const PropertiesModal = () => { const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId); - const updateDownload = useDownloadStore(state => state.updateDownload); const item = useDownloadStore(state => selectedPropertiesDownloadId ? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null @@ -106,7 +105,7 @@ export const PropertiesModal = () => { } }; - const handleSave = () => { + const handleSave = async () => { if (!url.trim()) { setErrorMessage("Enter a valid URL."); return; @@ -130,17 +129,22 @@ export const PropertiesModal = () => { mirrors: mirrors.trim() || undefined, }; - updateDownload(item.id, updates); - setSelectedPropertiesDownloadId(null); + try { + setErrorMessage(''); + await useDownloadStore.getState().applyProperties(item.id, updates); + setSelectedPropertiesDownloadId(null); + } catch (e) { + setErrorMessage(e instanceof Error ? e.message : String(e)); + } }; - const isLocked = ['downloading', 'processing', 'completed'].includes(item.status); - const isTransferLocked = item.status === 'downloading' || item.status === 'processing'; + const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status); + const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying'; let statusColor = 'text-text-secondary'; let StatusIcon = Info; if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } - else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; } + else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; } else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } @@ -339,7 +343,8 @@ export const PropertiesModal = () => {