diff --git a/INTERACTION_REVIEW.md b/INTERACTION_REVIEW.md index a81c9de..02c64cc 100644 --- a/INTERACTION_REVIEW.md +++ b/INTERACTION_REVIEW.md @@ -1,437 +1,568 @@ -# Firelink — UI Interaction Inventory +# Firelink Interaction Inventory and Modernization Review -A read-only audit of every user-facing action in Firelink, mapping each to the -component/handler, Zustand store action, IPC command, Rust function, expected -behavior, and the likely validation method. **No code was changed.** +Static, inventory-only review of the current React/Tauri code. No application +click-through or manual UI testing was performed. -Legend: -- ✅ = wired end-to-end (UI → store → IPC → Rust) -- ⚠️ = present in UI/store but with a gap, mismatch, or risk (see notes) -- ❌ = declared/wired in one layer but missing in another (dead path) +## Implementation update — June 21, 2026 + +The following recommendations from this review have now been implemented: + +- all new and migrated downloads receive an explicit Main Queue identity unless + the user chooses another queue; +- shared action-policy helpers now drive Start, Resume, Pause, Redownload, and + Properties lock behavior; +- tray Pause/Resume All uses centralized global store actions, and tray + reconstruction retains the complete menu; +- queued, processing, and retrying lifecycle actions are handled consistently; +- Show in Finder now sends the exact owned output path for completed and partial + downloads; +- duplicate replacement no longer suppresses deletion errors, blocks replacement + of active transfers, reports batch failures, and fails explicitly when no + rename candidate is available; +- bulk removal reports partial failures instead of failing an opaque concurrent + batch; +- List Row Density and Automatically Check for Updates are functional; +- update notifications include an action to open the release; +- the duplicate global-speed editor was replaced with a single Speed Limiter + configuration path and its capability copy is now accurate; +- extension server status reports the actual bound port; +- queue assignment, IPC event listening, path resolution, and editable-state + policy are centralized; +- the enqueue payload now uses a Rust-generated TypeScript binding; +- column widths persist, menus close with Escape, async clipboard/keychain/log + export failures are surfaced, and fatal render errors provide recovery; +- the confirmed-dead legacy `QualityModal` and its unused metadata store state + were removed. The active Add Downloads media-format flow and all backend media + format/progress/size logic were preserved. + +The following larger architectural items remain recommendations rather than +being forced into this change: + +- generating the complete command/event client instead of maintaining command + names in `src/ipc.ts`; +- moving all settings migration logic to one generated cross-language schema; +- replacing custom menus with a complete focus-managed menu primitive; +- making scheduler runs backend-owned objects with explicit run membership; +- adding request-level acknowledgement from the app UI back to automatic + browser-download capture. + +## Status and validation legend + +- **wired**: the visible control reaches the intended state/IPC/backend path. +- **partially wired**: the main path exists, but a setting, branch, result, or + failure state is not fully applied. +- **unwired/dead**: code or a visible control exists without an effective + consumer. +- **fragile**: behavior depends on duplicated state, timing, implicit status + rules, swallowed failures, or incomplete synchronization. +- **duplicated**: materially similar behavior exists in multiple handlers. +- **outdated**: the implementation works or persists, but no longer matches the + current interaction architecture or expected desktop behavior. +- **unsafe**: destructive or privileged behavior lacks a sufficiently strong + confirmation/error boundary. +- **Manual QA needed**: static inspection cannot confirm native dialog, + notification, menu, focus, accessibility, or operating-system behavior. + +Validation methods named below are non-UI methods: **code trace**, **type +check**, **unit test**, **integration test**, **IPC contract check**, and +**build check**. + +## Executive risk inventory + +1. **Queue identity and global controls are inconsistent.** `start-now` and + `add-to-list` downloads have no `queueId`, while tray Pause/Resume All and + Scheduler Run Now operate by queue IDs. Those downloads can be omitted from + global actions. Status: **fragile / partially wired**. +2. **Tray menu rebuilding changes its action set.** Startup creates Show, + Pause All, Resume All, Quit; re-enabling the menu-bar icon creates only Show + and Quit. Status: **duplicated / partially wired**. +3. **Several settings advertise behavior that is not consumed.** List Row + Density and Automatically Check for Updates are persisted but have no + runtime consumer. Status: **unwired/dead**. +4. **Destructive duplicate replacement suppresses deletion failure.** The + existing list item may be removed before `delete_file` fails, and the + replacement continues. Status: **unsafe / fragile**. +5. **Action availability is implemented separately in rows, single-select + menus, multi-select menus, toolbar controls, queue controls, tray handlers, + and scheduler handlers.** Their status sets already differ, especially for + `processing` and `retrying`. Status: **duplicated / fragile**. +6. **The frontend IPC wrapper is incomplete and handwritten.** Rust-generated + data types exist, but command/event names and payloads are manually mapped; + some live calls bypass the wrapper and some mapped commands are legacy. + Status: **outdated / fragile**. +7. **Legacy media-quality state and component are dead.** `QualityModal`, + `activeMetadata`, `fetchMetadataAction`, and `activeDownloadId` are not + connected to the rendered app; the active add flow has its own media + selection implementation. Status: **unwired/dead / duplicated**. --- -## 1. Main Window +## 1. Main window -### 1.1 Title bar / Toolbar (`DownloadTable.tsx`) +### 1.1 Window shell, toolbar, and status bar -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| M1 | Add Download (+) | `DownloadTable` → `toggleAddModal(true)` | `useDownloadStore.toggleAddModal` | — | — | Opens Add Downloads modal | None | -| M2 | Resume All (▶) | `DownloadTable` maps over `paused` items → `handleResume` | `useDownloadStore.resumeDownload` (per item) | `resume_download` | `lib.rs:2211 resume_download` | Resumes every paused download in the current filter | Per-item status check | -| M3 | Pause All (⏸) | `DownloadTable` maps over `downloading` items → `handlePause` | — (direct) | `pause_download` | `lib.rs:2137 pause_download` | Pauses every downloading item in the current filter | Per-item aria2 status | -| M4 | Show Sidebar (PanelLeft, when hidden) | `DownloadTable` → `toggleSidebar` | `useSettingsStore.toggleSidebar` | — | — | Reveals sidebar; persisted | None | -| M5 | Drag window | `WindowDragRegion` / `data-tauri-drag-region` divs → `getCurrentWindow().startDragging()` | — | core window | Tauri core | Moves window | Native | -| M6 | Sidebar resize handle | `App.startSidebarResize` (pointer drag) | `setSidebarWidth` (local) → `localStorage` | — | — | Resizes sidebar 190–260px | Clamped in handler | +| ID | UI action | Component / function | Store action | IPC command | Rust function | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---|---| +| MW-01 | Drag window | `WindowDragRegion.onPointerDown`; title-bar drag regions | none | Tauri window API | Tauri runtime | Move the native window | wired; Manual QA needed | code trace, build check | +| MW-02 | Resize sidebar | `App.startSidebarResize` | local `sidebarWidth`, `localStorage` | none | none | Resize sidebar from 190–260 px and persist width | wired; fragile pointer-only interaction; Manual QA needed | code trace | +| MW-03 | Hide sidebar | `Sidebar` title control | `toggleSidebar` | none | none | Collapse sidebar | wired | code trace | +| MW-04 | Show sidebar | `DownloadTable` title control | `toggleSidebar` | none | none | Restore sidebar | wired | code trace | +| MW-05 | Add Download | `DownloadTable` plus button | `toggleAddModal(true)` | none | none | Open Add Downloads | wired | code trace | +| MW-06 | Resume All in current view | `DownloadTable` toolbar loop | `resumeDownload` per matching row | `resume_download`, sometimes `enqueue_download` | `resume_download`, `enqueue_download` | Start/resume ready or paused items visible in the current filter | wired but fragile: label sounds global and excludes failed/retrying | unit test, IPC contract check | +| MW-07 | Pause All in current view | `DownloadTable` toolbar loop | none; direct handler | `pause_download` per downloading row | `pause_download` | Pause downloading items visible in the current filter | wired but fragile: excludes processing/retrying and is not global | unit test, integration test | +| MW-08 | Read active/queued/done counts | `App` status bar | derived store state | none | none | Show application totals | wired; `queued` excludes `ready` | code trace | -⚠️ **M2/M3**: "Resume All"/"Pause All" only act on items in the **current sidebar -filter** (e.g. active vs. all). A user on the "Active" filter pausing all won't -touch paused items elsewhere — could surprise users who read the button as global. -Contrast with the tray's true global Pause/Resume All (`downloadStore.ts:79`). +Recommendation: replace the three toolbar buttons with a macOS-style primary +Add split button plus an overflow menu for view-scoped Start/Pause actions. +Name scope explicitly, for example “Pause Visible Downloads.” Route all status +eligibility through shared action selectors. -### 1.2 Download row actions (`DownloadItem.tsx`, `DownloadTable.tsx`) +### 1.2 Download list selection and row actions -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| M7 | Move Up / Move Down (queued) | `DownloadItem` → `moveInQueue(id,'up'\|'down')` | `useDownloadStore.moveInQueue` | `move_in_queue` | `lib.rs:2613 move_in_queue` | Reorders pending queue; disabled at bounds | `queueIndex === 0` / `length-1` | -| M8 | Pause (row, active) | `DownloadItem.handlePause` | — | `pause_download` | `pause_download` | Pauses aria2/native/media task | aria2 status check | -| M9 | Resume (row, paused) | `DownloadItem.handleResume` | `resumeDownload` | `resume_download` | `resume_download` | Resumes; re-enqueues if no gid | gid presence | -| M10 | Options menu (⋮) / right-click | `DownloadItem` → `setContextMenu` | — | — | — | Opens context menu | — | -| M11 | Double-click row | `handleDownloadDoubleClick` | — | `open_downloaded_file` | `commands.rs:27 open_downloaded_file` | Completed → open file; else → Properties | `item.status === 'completed'` | +| ID | UI action | Component / function | Store action | IPC command | Rust function | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---|---| +| MW-09 | Select row | `DownloadTable.handleItemClick` | local `selectedIds` | none | none | Select one row | wired | unit test | +| MW-10 | Toggle multi-selection | same; Cmd/Ctrl click | local `selectedIds` | none | none | Add/remove row from selection | wired; Manual QA needed for platform modifiers | unit test | +| MW-11 | Range selection | same; Shift click | local `selectedIds`, `lastSelectedId` | none | none | Select visible range | wired; fragile when filtering/reordering changes anchor | unit test | +| MW-12 | Right-click row | `DownloadItem.onContextMenu` | local context-menu state | none | none | Select row if needed and open menu | wired; Manual QA needed | code trace | +| MW-13 | Options button | `DownloadItem` overflow button | local context-menu state | none | none | Open same menu | wired | code trace | +| MW-14 | Double-click completed row | `handleDownloadDoubleClick` → `openDownloadFile` | none | `open_downloaded_file` | `commands::open_downloaded_file` | Open owned downloaded file | wired; Manual QA needed | IPC contract check, integration test | +| MW-15 | Double-click unfinished row | `handleDownloadDoubleClick` → `openProperties` | `setSelectedPropertiesDownloadId` | none | none | Open Properties | wired | code trace | +| MW-16 | Move queued item up/down | `DownloadItem` hover controls | `moveInQueue` | `move_in_queue` | `QueueManager::move_in_queue` | Reorder backend pending queue | wired; visible order remains download-list order rather than pending order | integration test | +| MW-17 | Pause row | `DownloadItem` hover control | none | `pause_download` | `pause_download` | Pause downloading, processing, or retrying item | wired; errors only reach console | integration test | +| MW-18 | Start/Resume row | `DownloadItem` hover control | `resumeDownload` | `resume_download`, fallback `enqueue_download` | `resume_download`, `enqueue_download` | Start ready item or resume paused item | wired; errors only reach console | unit test, integration test | +| MW-19 | Resize table column | `DownloadTable.startColumnResize` | local `columnWidths` | none | none | Resize column until view remount | wired; outdated because widths are not persisted and pointer-only; Manual QA needed | code trace | -### 1.3 Row context menu (`DownloadTable.tsx`, floating menu) +Recommendation: introduce a `DownloadAction` enum and a single +`getAvailableActions(download, selectionContext)` function. Row buttons, +context menus, toolbar actions, keyboard commands, tray actions, and queue +actions should call the same handlers and eligibility rules. -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| C1 | Open | `openDownloadFile` | — | `open_downloaded_file` | `open_downloaded_file` | Opens completed file | `status==='completed'`; path authorization | -| C2 | Show in Finder | `revealDownloadFile` | — | `reveal_in_file_manager` | `commands.rs:6 reveal_in_file_manager` | Reveals file/part in Finder | `status==='completed'`; path authorization | -| C3 | Pause | `handlePause` | — | `pause_download` | `pause_download` | Pauses | status in `{downloading,queued,retrying}` | -| C4 | Resume | `handleResume` | `resumeDownload` | `resume_download` | `resume_download` | Resumes | status in `{paused,failed,retrying}` | -| C5 | Redownload | `redownload(contextItem.id)` | `useDownloadStore.redownload` | `enqueue_download` (via `dispatchItem`) | `enqueue_download` | Creates a new queued copy | status in `{completed,failed,paused}` | -| C6 | Copy Address | `navigator.clipboard.writeText(url)` | — | — | — | Copies URL | try/catch toast | -| C7 | Copy File Path | clipboard write | — | — | — | Copies resolved path | `status==='completed'`; filename present | -| C8 | Remove | `handleDelete` → `openDeleteModal(id)` | `useDownloadStore.openDeleteModal` | — | — | Opens Delete modal | — | -| C9 | Properties | `openProperties` → `setSelectedPropertiesDownloadId` | `useDownloadStore.setSelectedPropertiesDownloadId` | — | — | Opens Properties modal | — | +### 1.3 Single-selection context menu -### 1.4 Column resize +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| CM-01 | Open | `openDownloadFile` | none | `open_downloaded_file` / `commands::open_downloaded_file` | Open completed owned file | wired; Manual QA needed | integration test | +| CM-02 | Show in Finder | `revealDownloadFile` | none | `reveal_in_file_manager` / `commands::reveal_in_file_manager` | Reveal completed file, partial file, or known destination | partially wired: backend requires an owned file path, but unfinished UI may send only a directory | integration test | +| CM-03 | Pause | `handlePause` | none | `pause_download` / `pause_download` | Pause queued/downloading/retrying item | wired, but menu omits `processing` although row action includes it | unit test | +| CM-04 | Start/Resume | `handleResume` | `resumeDownload` | `resume_download`, `enqueue_download` | Start ready or resume paused/failed/retrying | fragile: retrying exposes both Pause and Resume in the same menu | unit test, integration test | +| CM-05 | Redownload | `redownload` | `redownload` | `enqueue_download` / `enqueue_download` | Create and immediately enqueue a copy | wired; status is initialized as queued and queue identity may be absent | unit test | +| CM-06 | Add to Queue | inline menu handler | `updateDownload({queueId})` | none | Reassign logical queue | partially wired: frontend grouping changes but no explicit backend queue/order reconciliation | integration test | +| CM-07 | Copy Address | clipboard handler | none | browser clipboard | Copy URL | wired; Manual QA needed | code trace | +| CM-08 | Copy File Path | clipboard handler | none | browser clipboard | Copy resolved completed path | wired; Manual QA needed | unit test | +| CM-09 | Remove | `handleDelete` | `openDeleteModal` | none | Open removal confirmation | wired | code trace | +| CM-10 | Properties | `openProperties` | `setSelectedPropertiesDownloadId` | none | Open Properties | wired | code trace | -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| M12 | Resize column handle | `DownloadTable.startColumnResize` | `setColumnWidths` (local) | — | — | Resizes table columns | per-column minimums | +### 1.4 Multi-selection context menu -### 1.5 Sidebar (`Sidebar.tsx`) +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| CM-11 | Start/Resume selected | inline loop | `resumeDownload` | resume/enqueue commands | Start eligible selected items | wired but duplicated; eligibility differs from toolbar and single menu | unit test | +| CM-12 | Add selected to Queue | inline loop | `updateDownload({queueId})` | none | Reassign non-completed items | partially wired / duplicated | integration test | +| CM-13 | Copy selected addresses | inline clipboard handler | none | browser clipboard | Copy URLs separated by newlines | wired; no success feedback | code trace | +| CM-14 | Remove selected | `openDeleteModal(ids)` | modal state | none | Confirm bulk removal/deletion | wired; destructive operations execute concurrently | integration test | -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| S1 | Library filters (All/Active/Completed/Unfinished) | `NavItem` → `onSelectFilter` | `App.setFilter` + `setActiveView('downloads')` | — | — | Filters download list | — | -| S2 | Folder filters (Musics/Movies/…/Other) | `NavItem` | as above | — | — | Filters by `category` | — | -| S3 | Queue item select | `QueueItem` → `onSelectFilter('queue:')` | as above | — | — | Filters by `queueId` | — | -| S4 | Add new queue | "Add new queue" button → input → `handleAddQueueSubmit` | `useDownloadStore.addQueue` | — | — | Adds queue (persisted to store.bin) | name trimmed non-empty | -| S5 | Queue rename | context → Rename → `handleRenameQueueSubmit` | `useDownloadStore.renameQueue` | — | — | Renames queue | name trimmed non-empty | -| S6 | Queue delete | context → Delete | `useDownloadStore.removeQueue` | — | — | Deletes queue; reassigns downloads to Main | `id !== MAIN_QUEUE_ID` | -| S7 | Start Queue | context → Start Queue | `useDownloadStore.startQueue` | `enqueue_download`/`resume_download` | dispatchItem | Dispatches/resumes queue's runnable items | queued/paused/failed | -| S8 | Pause Queue | context → Pause Queue | `useDownloadStore.pauseQueue` | `pause_download` | `pause_download` | Pauses all downloading in queue | status `downloading` | -| S9 | Tools: Scheduler | `ToolItem view='scheduler'` | `setActiveView('scheduler')` | — | — | Shows Scheduler view | — | -| S10 | Tools: Speed Limiter | `ToolItem view='speedLimiter'` | `setActiveView('speedLimiter')` | — | — | Shows Speed Limiter view | — | -| S11 | Tools: Diagnostics | `ToolItem view='diagnostics'` | `setActiveView('diagnostics')` | — | — | Shows Diagnostics console | — | -| S12 | Settings | footer button | `setActiveView('settings')` | — | — | Shows Settings view | — | -| S13 | Hide Sidebar | `toggleSidebar` | `useSettingsStore.toggleSidebar` | — | — | Collapses sidebar | — | +Recommendation: use an accessible menu primitive with roving focus, Escape, +arrow-key navigation, viewport collision handling, and one action registry. +The current hover-only nested queue submenu is fragile for keyboard and trackpad +users. Manual QA is needed to confirm current focus behavior. -### 1.6 Global handlers (App-level `useEffect` listeners) +### 1.5 Sidebar navigation and queues -| # | Action | Trigger | Handler | Store/IPC | Rust fn | Expected behavior | Validation | -|---|--------|---------|---------|-----------|---------|-------------------|------------| -| G1 | Paste URLs | window `paste` (non-input) | `extractValidDownloadUrls` → `openAddModalWithUrls` | store | — | Opens Add modal with pasted URLs | URL scheme allowlist | -| G2 | Deep link | `deep-link-add-download` event | `openAddModalWithUrls(payload)` | store | `dispatch_deep_links` → `parse_firelink_urls` | Opens Add modal | scheme/host/length caps | -| G3 | Extension add | `extension-add-download` event | `handleExtensionDownload` | store | extension_server | Opens Add modal | dedupe URLs | -| G4 | Extension queued batch | `extension-downloads-queued` event | merges into `downloads`/`pendingOrder` | setState | extension_server | Appends new items | id dedupe | -| G5 | Schedule trigger | `schedule-trigger` event ('start'/'stop') | `startQueue`/`pauseQueue` + `setSchedulerRunning` | store+IPC | scheduler.rs | Starts/stops main queue | — | -| G6 | Post-queue system action | scheduler end + `postQueueAction !== 'none'` | `invoke('perform_system_action')` | — | `lib.rs:2555 perform_system_action` | Sleep/restart/shutdown | automation permission | -| G7 | Notification | `download-state` terminal | `sendNotification` | — | — | OS notification | permission; `showNotifications` | +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| SB-01 | Select Library filter | `Sidebar.NavItem` | `App.setFilter`, `setActiveView('downloads')` | none | Filter All/Active/Completed/Unfinished | wired | unit test | +| SB-02 | Select category folder | `Sidebar.NavItem` | same | none | Filter by category | wired | unit test | +| SB-03 | Select queue | `QueueItem` | same | none | Filter by queue ID | wired | unit test | +| SB-04 | Add queue | add input, Enter/blur | `addQueue` | DB persistence subscription | DB replace functions | Create a persisted logical queue | wired; duplicate/blank-after-trim names are not explicitly reported | unit test | +| SB-05 | Cancel add queue | Escape | local state | none | Close input without adding | wired; blur after Escape can still run submit with current value depending event order; Manual QA needed | unit test | +| SB-06 | Rename queue | context action, input Enter/blur | `renameQueue` | DB persistence subscription | DB replace functions | Rename queue | wired; same blur/keyboard fragility | unit test | +| SB-07 | Start Queue | queue context menu | `startQueue` | resume/enqueue commands | Start queued/paused/failed items with this queue ID | wired but fragile around `hasBeenDispatched` and absent queue IDs | unit test, integration test | +| SB-08 | Pause Queue | queue context menu | `pauseQueue` | `pause_download` | Pause active items in queue | partially wired: only `downloading`, not `processing` or `retrying` | unit test | +| SB-09 | Delete Queue | queue context menu | `removeQueue` | DB persistence subscription | Reassign items to Main Queue and delete custom queue | wired; no confirmation | unit test | +| SB-10 | Open Scheduler | `ToolItem` | `setActiveView` | none | Show Scheduler | wired | code trace | +| SB-11 | Open Speed Limiter | `ToolItem` | `setActiveView` | none | Show Speed Limiter | wired | code trace | +| SB-12 | Open Diagnostics | `ToolItem` | `setActiveView` | none | Show Diagnostics | wired | code trace | +| SB-13 | Open Settings | footer button | `setActiveView` | none | Show Settings | wired | code trace | + +Recommendation: make queue membership explicit and total. Every non-completed +download should either have a queue ID or an explicit `unassigned` state that +global commands intentionally include/exclude. Do not use optional `queueId` +as an implicit behavior switch. --- -## 2. Add Download Window (`AddDownloadsModal.tsx`) +## 2. Add Downloads window -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| A1 | Paste/edit URLs textarea | `setUrls` (local) → debounce parse | — | `fetch_metadata` / `fetch_media_metadata` (via `fetchMediaMetadataDeduped`) | `lib.rs:616 fetch_metadata`, `lib.rs:784 fetch_media_metadata` | Resolves filename/size/formats | `new URL()`; isMediaUrl | -| A2 | Refresh Metadata | `setMetadataRefreshNonce(n+1)` (re-runs effect) | — | as above | as above | Re-fetches metadata | active guard | -| A3 | Select preview row | `setSelectedItemIndex(i)` | — | — | — | Selects item for media-format panel | bounds | -| A4 | Select media format | `selectMediaFormat(idx)` | — (local parsedItems) | — | — | Sets chosen stream; updates filename/size | selectedFormat bounds | -| A5 | Browse Save Location | `handleBrowse` → `open({directory:true})` | — | — | — | Sets save location; marks manual | dialog result | -| A6 | Target Queue select | `setSelectedQueueId` | — | — | — | Sets target queue | queue exists | -| A7 | Connections slider | `setConnections` | — | — | — | 1–16 (disabled for media) | min/max | -| A8 | Limit speed per file (checkbox+input) | `setSpeedLimitEnabled`/`setSpeedLimit` | — | — | — | Per-file KiB/s cap | numeric | -| A9 | Use authorization (checkbox+user/pass) | `setUseAuth`/`setUsername`/`setPassword` | — | — | — | Sends basic auth | useAuth flag | -| A10 | Advanced toggle | `setAdvancedExpanded` | — | — | — | Expands advanced fields | — | -| A11 | Verify Checksum (algo+digest) | `setChecksumEnabled`/`setChecksumAlgo`/`setChecksumValue` | — | — | — | Sends `algo=digest` | checksumEnabled | -| A12 | Headers / Cookies / Mirrors | `setHeaders`/`setCookies`/`setMirrors` | — | — | — | Raw strings forwarded | trim/empty | -| A13 | Add to Queue | `handleStart(false)` → `executeAddDownloads(false,…)` | `addDownload` (status `'paused'`) | `enqueue_download` (via dispatchItem) | `enqueue_download` | Adds paused items | duplicate detection | -| A14 | Start Downloads | `handleStart(true)` → `executeAddDownloads(true,…)` | `addDownload` (status `'queued'`) + immediate dispatch | `enqueue_download` | `enqueue_download` | Adds + starts items | duplicate detection | -| A15 | Cancel | `toggleAddModal(false)` | `useDownloadStore.toggleAddModal` | — | — | Closes modal | — | +### 2.1 Input, metadata, preview, and destination -**Duplicate detection** (`handleStart` → `DuplicateResolutionModal`): -- Checks URL already in queue (`status !== failed/completed`) and file exists on - disk via `check_file_exists` and in-store path match. -- `DuplicateResolutionModal`: per-conflict **Rename** / **Replace** (file only) / - **Skip** select → on Confirm calls `executeAddDownloads(…,resolutions)`. +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| AD-01 | Enter/paste URL lines | URL textarea and metadata effect | local state | `fetch_metadata` or `fetch_media_metadata` | Parse each line and show metadata | wired but fragile: any non-empty line is counted as “valid”; metadata requests are sequential | unit test, integration test | +| AD-02 | Refresh Metadata | refresh button | nonce increment | same metadata commands | Re-run metadata lookup | wired | unit test | +| AD-03 | Select preview item | preview row click/Enter/Space | local selected index | none | Select item for media format details | wired | unit test | +| AD-04 | Select media stream | `selectMediaFormat` | local parsed item | none | Change format, extension, and estimated size | wired; duplicated with dead `QualityModal` implementation | unit test | +| AD-05 | Browse save location | `handleBrowse` | local manual destination | dialog plugin | Select one shared folder | wired; Manual QA needed | code trace | +| AD-06 | Ask where to save on action | `handleAction` | reads setting | dialog plugin | Prompt before committing additions | wired; name says “each file” but one folder is selected for the whole batch | unit test, Manual QA needed | +| AD-07 | Read free space | save-location effect | local `freeSpace` | `get_free_space` / `get_free_space` | Show available space for destination | wired; path is not constrained because this is read-only | IPC contract check | -| # | Action | Component / fn | Store/IPC | Rust fn | Expected behavior | Validation | -|---|--------|----------------|-----------|---------|-------------------|------------| -| A16 | Pick resolution per conflict | `updateResolution` (local) | — | — | rename/replace/skip | reason.type gates replace | -| A17 | Continue | `onConfirm(resolutions)` → `executeAddDownloads` | store+IPC | `enqueue_download`; `delete_file` (replace) | Applies resolutions | loop bounds | +Recommendation: separate URL parsing from metadata loading. Build a validated +draft list first, reject unsupported schemes immediately, then run metadata +lookups concurrently with bounded concurrency and per-item retry/error actions. -⚠️ **A14 "Start Downloads"** does **not** show any progress/confirmation that -duplicate conflicts were resolved; resolution loop uses `check_file_exists` only -to gate 'rename', but the rename loop caps at count < 1000 and silently keeps -the last tried name if exceeded. +### 2.2 Transfer and authentication controls + +| ID | Action | Component / function | Store effect | Backend payload | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| AD-08 | Connections slider | local state | passed to `addDownload` | `connections` | Set 1–16 connections for non-media | wired; defaults to 16 instead of current setting; disabled if any batch item is media | unit test | +| AD-09 | Per-file speed toggle/value | local state | passed to `addDownload` | `speed_limit` | Apply per-download limit | wired; accepts invalid/zero text until backend normalization/use | unit test | +| AD-10 | Authorization toggle | local state | passed to `addDownload` | username/password | Use ad-hoc credentials | wired; session-only secrets are intentionally not persisted | unit test | +| AD-11 | Username/password | local state | passed to store | username/password | Override matching site login | wired | integration test | +| AD-12 | Advanced disclosure | local state | none | none | Show advanced fields | wired | code trace | +| AD-13 | Checksum toggle/algorithm/digest | local state | passed to store | checksum | Verify checksum | wired; digest format is not validated in UI | unit test | +| AD-14 | Headers | local state | passed to store | headers | Add request headers, including extension referer | wired; raw multiline input | integration test | +| AD-15 | Cookies | local state | passed to store | cookies | Add Cookie header | wired; raw secret input | integration test | +| AD-16 | Mirrors | local state | passed to store | mirrors | Add alternate URIs | wired; scheme/value validation deferred | integration test | + +### 2.3 Commit actions and duplicate modal + +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| AD-17 | Start Downloads | `handleAction({type:'start-now'})` | `addDownload` → `dispatchItem` | `enqueue_download` | Add and immediately dispatch each item | wired; per-item failures are logged and modal still closes | integration test | +| AD-18 | Add to List | action menu | `addDownload({type:'add-to-list'})` | DB persistence only | Add as ready without dispatch | wired; omitted from queue-based global actions | unit test | +| AD-19 | Add to Queue | nested action menu | `addDownload({type:'add-to-queue'})` | no immediate backend enqueue | Add as queued for selected logical queue | wired; local pending order is updated before backend registration | unit test | +| AD-20 | Cancel | footer button | `toggleAddModal(false)` | none | Close and clear pending extension/deep-link fields | wired | code trace | +| AD-21 | Choose Rename/Replace/Skip | `DuplicateResolutionModal` | local conflict state | none | Select resolution per conflict | wired; URL duplicates default to Rename although renaming does not resolve URL duplication | unit test | +| AD-22 | Continue duplicate resolution | `executeAddDownloads` | remove/add actions | `check_file_exists`, `delete_file`, enqueue commands | Apply selected resolutions and add items | unsafe / fragile: replacement delete failures are swallowed; rename loop silently stops at 999 | integration test | +| AD-23 | Cancel duplicate resolution | duplicate modal Cancel | local state | none | Return to Add Downloads without committing | wired | code trace | + +Recommendation: move duplicate resolution into a transactional service that +returns typed outcomes. Never remove the existing list item until filesystem +replacement succeeds or a backend-owned overwrite operation has accepted the +request. Show a batch result summary when some additions fail. --- -## 3. Download Properties Window (`PropertiesModal.tsx`) +## 3. Download Properties window -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| P1 | Edit URL | `setUrl` | — | — | — | Saves on Save | non-empty on save | -| P2 | Edit File name | `setFileName` | — | — | — | Saves on Save | non-empty on save | -| P3 | Select Save Location | `handleBrowse` → `open({directory})` | — | — | — | Sets destination | dialog result | -| P4 | Connections | `setConnections` (1–16) | — | — | — | per-file connections | disabled if transferLocked | -| P5 | Limit speed (checkbox+value) | `setSpeedLimitEnabled`/`setSpeedLimitValue` | — | — | — | per-file KiB/s | disabled if transferLocked | -| P6 | Login mode toggle (matching/custom/none) | `setLoginMode` | — | — | — | Credentials source | disabled if transferLocked | -| P7 | Custom username/password | `setUsername`/`setPassword` | — | — | — | Override login | custom mode only | -| P8 | Checksum (verify+algo+digest) | `setChecksumEnabled`/`setChecksumAlgorithm`/`setChecksumValue` | — | — | — | `algo=digest` | disabled if transferLocked | -| P9 | Cookies / Headers / Mirrors | `setCookies`/`setHeaders`/`setMirrors` | — | — | — | Advanced fields | disabled if transferLocked | -| P10 | Advanced toggle | `setAdvancedExpanded` | — | — | — | Expand/collapse | — | -| P11 | Save | `handleSave` → `applyProperties(id, updates)` | `useDownloadStore.applyProperties` | `remove_from_queue`/`detach_download_for_reconfigure`/`enqueue_download` (re-dispatch) | `remove_from_queue`, `detach_download_for_reconfigure`, `enqueue_download` | Updates item; re-attaches backend | status guard (throws if active) | -| P12 | Cancel | `setSelectedPropertiesDownloadId(null)` | as above | — | — | Closes modal | — | +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---| +| PR-01 | Edit URL | local state | applied by `applyProperties` | may re-enqueue later | Change source for eligible item | wired | unit test | +| PR-02 | Edit filename | local state | `applyProperties` | may re-enqueue later | Change output name | wired | unit test | +| PR-03 | Select save location | `handleBrowse` | local state | dialog plugin | Change destination | wired; Manual QA needed | code trace | +| PR-04 | Edit connections | local state | `applyProperties` | enqueue payload | Change future/reconfigured transfer | wired; no on-blur clamp | unit test | +| PR-05 | Edit speed limit | local state | `applyProperties` | enqueue payload | Change future/reconfigured transfer | wired | unit test | +| PR-06 | Matching/custom/no-login mode | local state | `applyProperties` | dispatch credential resolution | Choose credential source | wired | unit test | +| PR-07 | Custom credentials | local state | `applyProperties` | enqueue payload | Use ad-hoc credentials | wired; secret is not persisted | integration test | +| PR-08 | Advanced transfer fields | local state | `applyProperties` | enqueue payload | Change checksum/cookies/headers/mirrors | wired | unit test | +| PR-09 | Save | `handleSave` | `applyProperties` | `remove_from_queue`, `detach_download_for_reconfigure`, `enqueue_download` depending status | Apply safe changes and close | wired but fragile: behavior is status-dependent and rollback is incomplete after re-dispatch failure | unit test, integration test | +| PR-10 | Cancel | footer button | clear selected ID | none | Close without applying | wired | code trace | -`applyProperties` rules (`useDownloadStore.ts:313`): -- **downloading/processing/retrying** → throws "Cannot change properties while - transfer is active." -- **completed/failed** → updates store only (read-only identity; settings saved - for redownload). -- **queued** → removes from queue, updates, re-dispatches; on dispatch failure - removes from queue. -- **paused** → `detach_download_for_reconfigure`, unregister, update (no - re-dispatch; resumes on next resume). +Status rules: -⚠️ **P5/P8/P9** use `isTransferLocked` (downloading/processing/retrying) to -disable inputs, but `isLocked` also includes `completed`. For a **completed** -item the URL/filename/destination are disabled (correct), yet speed-limit, -checksum, cookies, headers, mirrors are **editable** — these only matter for -redownload, which is the documented intent, but the disable logic is split -across two booleans (`isLocked` vs `isTransferLocked`) and is easy to misread. +- active `downloading`/`processing`/`retrying`: Save disabled and store rejects. +- `ready`/`completed`/`failed`: frontend store update only. +- backend-registered `queued`: remove, update, re-dispatch. +- backend-registered `paused`: detach with acknowledgement, update, re-dispatch on resume. + +Recommendation: model Properties as a typed edit session with explicit modes: +`identity-editable`, `transfer-options-only`, `requires-detach`, and +`read-only`. A single policy function should drive disabled fields, copy, save, +and backend transition behavior. The current `isLocked` and +`isTransferLocked` booleans are easy to diverge. --- -## 4. Settings Window (`SettingsView.tsx`, tabbed) +## 4. Removal confirmation modal -| # | Action | Tab | Store setter | IPC command | Rust fn | Validation | -|---|--------|-----|--------------|-------------|---------|------------| -| T1 | Tab select | — | `setActiveSettingsTab` | — | — | enum | -| **Downloads** | | | | | | | -| SD1 | Default connections (1–16) | downloads | `setPerServerConnections` | — | — | onBlur clamp 1–16 | -| SD2 | Parallel downloads (1–12) | downloads | `setMaxConcurrentDownloads` | `set_concurrent_limit` (App effect) | `set_concurrent_limit` | onBlur clamp 1–12 | -| SD3 | Global speed limit | downloads | `setGlobalSpeedLimit` | `set_global_speed_limit` (App effect) | `set_global_speed_limit` | free text | -| SD4 | Automatic retries (0–10) | downloads | `setMaxAutomaticRetries` | — | — | onBlur clamp 0–10 | -| SD5 | Show notification on completion | downloads | `setShowNotifications` | — | — | toggle | -| SD6 | Play sound on completion | downloads | `setPlayCompletionSound` | — | — | requires SD5 | -| **Look & Feel** | | | | | | | -| SL1 | Theme (system/light/dark/dracula/nord) | lookandfeel | `setTheme` | — | — | App effect applies classes | -| SL2 | Font size | lookandfeel | `setAppFontSize` | — | — | `data-font-size` attr | -| SL3 | List row density | lookandfeel | `setListRowDensity` | — | — | enum | -| SL4 | Dock badge | lookandfeel | `setShowDockBadge` | `update_dock_badge` (setter + effect) | `update_dock_badge` | toggle; clears badge when off | -| SL5 | Menu bar icon | lookandfeel | `setShowMenuBarIcon` | `toggle_tray_icon` (App effect) | `toggle_tray_icon` | toggle | -| **Network** | | | | | | | -| SN1 | Proxy mode (none/system/custom) | network | `setProxyMode` | — | — | radio | -| SN2 | Proxy host | network | `setProxyHost` | — | — | shown if custom | -| SN3 | Proxy port (1–65535) | network | `setProxyPort` | — | — | onBlur clamp | -| SN4 | Custom User Agent | network | `setCustomUserAgent` | — | — | free text + datalist | -| **Locations** | | | | | | | -| SO1 | Default download path (text+Browse) | locations | `setDefaultDownloadPath` | — | — | `pickDirectory` | -| SO2 | Ask where to save each file | locations | `setAskWhereToSaveEachFile` | — | — | toggle | -| SO3 | All Categories Base Browse | locations | `setCategoryDirectory` ×7 | `create_category_directories` | `parity::create_category_directories` | creates dirs on disk | -| SO4 | Per-category path (text+Browse) | locations | `setCategoryDirectory` | — | — | `handleBrowseCategory` | -| SO5 | Reset Defaults | locations | `resetCategoryDirectories` | — | — | restores defaults | -| **Site Logins** | | | | | | | -| SLg1 | Delete login | sitelogins | `removeSiteLogin` | `delete_keychain_password` | `delete_keychain_password` | removes keychain entry | -| SLg2 | Add login (pattern/user/pass) | sitelogins | `addSiteLogin` | `set_keychain_password` | `set_keychain_password` | pattern+user non-empty | -| **Power** | | | | | | | -| SP1 | Prevent sleep while downloading | power | `setPreventsSleepWhileDownloading` | `set_prevent_sleep` (syncSystemIntegrations + setter) | `set_prevent_sleep` | keepawake on/off | -| **Engine** | | | | | | | -| SE1 | Recheck engines | engine | — | `get_*_engine_status` ×4 | `check_*` | force re-run; cached otherwise | -| SE2 | Show/hide technical details | engine | — (local) | — | — | expand card | -| SE3 | Browser Cookies Source | engine | `setMediaCookieSource` | — | — | enum (none/safari/chrome/firefox/edge/brave) | -| **Integrations** | | | | | | | -| SI1 | Copy Token | integrations | — (clipboard) | — | — | copies pairing token | -| SI2 | Regenerate token | integrations | `regeneratePairingToken` | `set_keychain_password` + `set_extension_pairing_token` (App effect) | both | mints new token | -| SI3 | Get Extension links | integrations | — (anchor) | — | — | opens external URLs | -| **About** | | | | | | | -| SA1 | Check for Updates | about | — | `check_for_updates` | `parity::check_for_updates` | toast result | -| SA2 | Auto-check updates toggle | about | `setAutoCheckUpdates` | — | — | switch | +| ID | Action | Component / function | Store action | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---|---|---| +| RM-01 | Cancel | `handleCancel` | `closeDeleteModal` | none | Close without changes | wired | code trace | +| RM-02 | Remove from list | `handleRemoveFromList` | `removeDownload(id,false)` | `remove_download` | Stop backend work, remove record, retain file | wired; bulk operations run concurrently and partially completed batches remain if one fails | integration test | +| RM-03 | Delete file | `handleDeleteFile` | `removeDownload(id,true)` | `trash_download_assets`, then `remove_download` | Trash owned primary/partial assets and remove record | wired with strong ownership checks; bulk partial-failure UX is fragile | integration test | + +Recommendation: use a batch command returning per-item results, then display a +clear partial-success summary. Keep the exact owned-path authorization model in +`commands.rs`; do not replace it with broad folder-prefix authorization. --- -## 5. Tools Views +## 5. Settings window -### 5.1 Scheduler (`SchedulerView.tsx`) +### 5.1 Navigation and Downloads -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| SC1 | Enable Scheduler | `updateDraft('enabled')` | — (draft) | — | — | Toggles draft (saved on Save) | — | -| SC2 | Start/Stop time | `updateDraft('startTime'/'stopTime'/'stopTimeEnabled')` | — | — | — | Sets schedule window | time input | -| SC3 | Run Every Day | `updateDraft('everyday')` | — | — | — | All days | — | -| SC4 | Day toggles | `toggleDay` | — | — | — | Selected days | — | -| SC5 | Post-queue action radio | `updateDraft('postQueueAction')` | — | — | — | none/sleep/restart/shutdown | — | -| SC6 | Run Now | `runNow` → `startQueue(MAIN_QUEUE_ID)` | `setSchedulerRunning(true)` | enqueue/resume | dispatchItem | Starts main queue now | count>0 | -| SC7 | Pause | `pauseNow` → `pauseQueue(MAIN_QUEUE_ID)` | `setSchedulerRunning(false)` | `pause_download` | `pause_download` | Pauses main queue | — | -| SC8 | Save Settings | `save` → `setScheduler(normalized)` | `setScheduler` | — | — | Persists scheduler; registered by scheduler.rs | selectedDays fallback | -| SC9 | Grant/Revoke Automation | `handlePermissionAction` | — | `request_automation_permission` / `open_automation_settings` | both | macOS Automation for Finder | platform isMac | +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-01 | Select one of nine settings tabs | `setActiveSettingsTab` | Switch pane and persist selected tab | wired | unit test | +| ST-02 | Default connections | `setPerServerConnections` | Set default 1–16 | wired; only clamped on blur | unit test | +| ST-03 | Parallel downloads | `setMaxConcurrentDownloads` → App effect `set_concurrent_limit` | Resize backend concurrency | wired; transient invalid values can reach backend before blur | integration test | +| ST-04 | Global speed limit text | `setGlobalSpeedLimit` → App effect `set_global_speed_limit` | Apply global limit | wired but duplicated with Speed Limiter; invalid input silently becomes unlimited in backend normalization | unit test | +| ST-05 | Automatic retries | `setMaxAutomaticRetries` | Set future enqueue retry count | wired; current active tasks are unchanged | unit test | +| ST-06 | Completion notifications | `setShowNotifications` | Gate terminal OS notifications | wired; permission is requested at startup regardless of setting | integration test, Manual QA needed | +| ST-07 | Completion sound | `setPlayCompletionSound` | Add sound to completion notification | wired; failed notifications never use sound | code trace, Manual QA needed | -### 5.2 Speed Limiter (`SpeedLimiterView.tsx`) +### 5.2 Look and Feel -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| VL1 | Enable toggle | `setEnabled` | — (local) | — | — | Enables draft | — | -| VL2 | Value input | `setValue` | — | — | — | numeric | min 1; clamp | -| VL3 | Unit KB/s · MB/s | `setUnit` | — | — | — | unit select | — | -| VL4 | Quick presets (1/5/10 MB/s) | `preset(n)` | — | — | — | sets value+unit | — | -| VL5 | Save Limit | `save` → `setGlobalSpeedLimit` + `setLastCustomSpeedLimitKiB` | both | `set_global_speed_limit` (App effect) | `set_global_speed_limit` | Applies global cap | clamp; disabled→'' | +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-08 | Theme | `setTheme`; App root-class effect | Apply system/light/dark/Dracula/Nord | wired; Manual QA needed | code trace | +| ST-09 | Font size | `setAppFontSize`; App data attribute | Apply small/standard/large | wired; Manual QA needed | code trace | +| ST-10 | List Row Density | `setListRowDensity` | Change download-row density | **unwired/dead**: no consumer outside settings persistence | code trace, unit test | +| ST-11 | Dock badge | `setShowDockBadge`; `update_dock_badge` | Show active count | wired but duplicated between setter, App effect, and download-store sync | integration test, Manual QA needed | +| ST-12 | Menu bar icon | `setShowMenuBarIcon`; `toggle_tray_icon` | Show/hide tray | partially wired: re-created tray loses Pause/Resume All | integration test, Manual QA needed | -### 5.3 Diagnostics (`DiagnosticsView.tsx`) +### 5.3 Network -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| DG1 | Clear console | `handleClear` → `setLogs([])` | — (local) | — | — | Empties log view | — | -| DG2 | Export Logs | `handleExport` → `save({…})` | — | `export_logs` | `lib.rs:2800 export_logs` | Writes log to chosen path | path from save dialog | +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-13 | Proxy mode | `setProxyMode`; dispatch-time `getProxyArgs` | None/system/custom proxy for new dispatches | wired | unit test | +| ST-14 | Proxy host/port | setters | Build custom HTTP proxy URL | wired; host/scheme/auth validation is minimal | unit test | +| ST-15 | Custom User Agent | `setCustomUserAgent` | Apply to metadata and downloads | wired; preset strings are dated static examples | integration test | + +### 5.4 Locations + +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-16 | Edit base folder text | `setBaseDownloadFolder` | Change automatic category base | wired; does not create folders or verify access until used | unit test | +| ST-17 | Browse base folder | `handleBrowseBase`; `create_category_directories` | Select base and create normalized category folders | wired; directory creation warnings do not reach UI | integration test, Manual QA needed | +| ST-18 | Ask where to save each file | `setAskWhereToSaveEachFile` | Prompt during Add action | partially wired: one prompt per batch, not per file | unit test | +| ST-19 | Edit category path/subfolder | `CategoryFolderInput` | Use relative automatic subfolder or absolute override | wired but fragile: writes persisted settings on each keystroke and infers mode from string prefix | unit test | +| ST-20 | Custom folder | `handleBrowseCategory` | Set absolute category override | wired; Manual QA needed | code trace | +| ST-21 | Use automatic | clear override | Return to base/subfolder resolution | wired | unit test | +| ST-22 | Reset Defaults | `resetCategoryLocations` | Reset subfolders and overrides | wired; no confirmation | unit test | + +The project already has shared frontend location normalization and a backend +settings decoder/migration layer, but equivalent migration and normalization +logic still exists in both TypeScript and Rust. Recommendation: define one +versioned persisted-settings schema and generate both bindings and migrations, +or make Rust the authoritative migration service and return normalized settings +to the frontend. + +### 5.5 Site Logins + +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-23 | Add login | `handleAddLogin`; `set_keychain_password`; `addSiteLogin` | Store username/pattern and password in keychain | wired; pattern validation is deferred to matching logic | integration test | +| ST-24 | Delete login | inline delete; `delete_keychain_password`; `removeSiteLogin` | Remove keychain secret and persisted metadata | fragile: keychain deletion failure is logged but metadata is still removed | integration test | + +### 5.6 Power, Engines, Integrations, About + +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| ST-25 | Prevent system sleep | setter plus download-state sync; `set_prevent_sleep` | Keep system awake during active downloads | wired but duplicated in setter/store synchronization | integration test, Manual QA needed | +| ST-26 | Recheck engines | four engine status commands | Validate packaged sidecars | wired | integration test, build check | +| ST-27 | Show/hide engine details | local expanded state | Reveal diagnostics | wired | code trace | +| ST-28 | Browser Cookies Source | `setMediaCookieSource` | Pass selected browser to media metadata/downloads | wired; Manual QA needed for browser permissions | integration test | +| ST-29 | Copy pairing token | clipboard handler | Copy keychain-hydrated token | wired; success toast is shown without awaiting clipboard result | code trace, Manual QA needed | +| ST-30 | Regenerate pairing token | `regeneratePairingToken`; keychain + App effect | Rotate token and reconfigure local server | wired but fragile: UI reports success before keychain/server calls confirm | integration test | +| ST-31 | Open extension links | external anchors | Open Firefox store or GitHub releases | wired; Manual QA needed | code trace | +| ST-32 | Check Now | `check_for_updates` | Compare stable GitHub release version and show toast | wired; available update toast has no action to open release/download | integration test | +| ST-33 | Automatically check for updates | `setAutoCheckUpdates` | Check on startup/periodically | **unwired/dead**: persisted only | code trace | +| ST-34 | Open source/license links | external anchors | Open project pages | wired; Manual QA needed | code trace | + +The Integrations status text is hard-coded to “Active” and the full port range; +it is not backed by server state. Status: **outdated / partially wired**. --- -## 6. Menus / Tray +## 6. Scheduler -### 6.1 Tray menu (built twice — see risk note) +| ID | Action | Component / function | Store / IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---| +| SC-01 | Enable Scheduler | draft checkbox | local draft then `setScheduler` on Save | Enable timed triggers | wired | unit test | +| SC-02 | Set start/stop time | draft inputs | persisted scheduler; `scheduler::spawn_scheduler` | Trigger start/stop at local time | wired; one-second polling and string-key persistence are fragile | unit test, integration test | +| SC-03 | Run Every Day / day buttons | draft controls | persisted selected days | Select schedule days | wired | unit test | +| SC-04 | Select post-queue action | radio cards | persisted enum; `perform_system_action` | Do nothing/sleep/restart/shutdown after scheduled work | wired but high-impact; no final confirmation at execution | integration test, Manual QA needed | +| SC-05 | Run Now | `runNow` | `startQueue(MAIN_QUEUE_ID)` | Start Main Queue | partially wired: misses unassigned ready/paused/failed downloads despite UI text saying all paused/failed | unit test | +| SC-06 | Pause | `pauseNow` | `pauseQueue(MAIN_QUEUE_ID)` | Pause Main Queue | partially wired: only downloading items with Main Queue ID | unit test | +| SC-07 | Save Settings | `save` | `setScheduler` | Persist normalized draft | wired | unit test | +| SC-08 | Grant permission | `handlePermissionAction` | `request_automation_permission` | Prompt/check macOS Automation | wired; command is also used as a status probe and can prompt during passive view entry; Manual QA needed | integration test | +| SC-09 | “Revoke permission” | same handler | `open_automation_settings` | Explain and open System Settings | partially wired/outdated label: app cannot revoke directly | code trace, Manual QA needed | -Primary tray built in `setup()` (`lib.rs:3192`) with items: **Show Firelink, -Pause All, Resume All, Quit**. Menu events emit `tray-action` events handled by -`downloadStore.ts:79` (true global pause/resume across all queues). Left-click -tray icon restores main window. - -`toggle_tray_icon` (`lib.rs:2822`) **rebuilds** the tray when "Menu bar icon" is -toggled, but only registers **Show / Quit** — it omits the Pause All / Resume All -items. Because `toggle_tray_icon` checks `tray_by_id("main")` and the setup tray -already used id `"main"`, toggling on is a no-op while the setup tray exists. - -| # | Action | Trigger | Handler | IPC / Event | Rust fn | Expected behavior | -|---|--------|---------|---------|-------------|---------|-------------------| -| TR1 | Show Firelink | tray menu | `restore_main_window` | — | `restore_main_window` | Unminimize/show/focus | -| TR2 | Pause All | tray menu | emits `tray-action` 'pause-all' | event | — | Pauses all queues | -| TR3 | Resume All | tray menu | emits `tray-action` 'resume-all' | event | — | Starts all queues | -| TR4 | Quit | tray menu | `app.exit(0)` | — | — | Exits app | -| TR5 | Tray left-click | icon click | `restore_main_window` | — | — | Show window | - -⚠️ There is **no native macOS app menu** (no Edit menu with Cut/Copy/Paste / -Cmd+Q, no Window menu). Standard keyboard shortcuts and clipboard editing rely -on the webview default. This is a UX gap on macOS. - -### 6.2 Window controls - -Close/minimize/maximize are the native Tauri decorations (title bar hidden; -`WindowDragRegion` provides drag only). Close behavior default (no -minimize-to-tray override observed). +Recommendation: represent scheduler execution as a backend-owned state machine +with a specific run ID and set of download IDs. The frontend should observe +that run, not infer completion from all globally active statuses. --- -## 7. Delete Confirmation (`DeleteConfirmationModal.tsx`) +## 7. Speed Limiter -| # | Action | Component / fn | Store action | IPC command | Rust fn | Expected behavior | Validation | -|---|--------|----------------|--------------|-------------|---------|-------------------|------------| -| D1 | Remove (from list only) | `handleRemoveFromList` → `removeDownload(id,false)` | `useDownloadStore.removeDownload` | `remove_download` (filepath null) | `remove_download` | Removes item; keeps file | isRemoving flag | -| D2 | Delete file | `handleDeleteFile` → `removeDownload(id,true)` | `removeDownload` | `trash_download_assets` + `remove_download` | `trash_download_assets`, `remove_download` | Trashes file+.aria2+.part, removes item | path resolve | -| D3 | Cancel | `handleCancel` → `closeDeleteModal` | `closeDeleteModal` | — | — | Closes modal | — | +| ID | Action | Store / IPC | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| SP-01 | Enable/disable draft | local state | Prepare limit change | wired | unit test | +| SP-02 | Enter value | local state | Set numeric limit | wired | unit test | +| SP-03 | Select KB/s or MB/s | local state | Change unit | wired | unit test | +| SP-04 | Select 1/5/10 MB/s preset | local state | Fill common limit | wired | unit test | +| SP-05 | Save Limit | settings setters → App effect `set_global_speed_limit` | Apply backend global cap | wired; duplicated with Settings text field; UI claims active jobs are gracefully restarted, but backend only changes aria2 global option | IPC contract check, integration test | + +Recommendation: keep one global speed-limit editor and one parser/formatter. +Expose backend capability/result so copy does not promise behavior for media or +native transfers that the command does not implement. --- -## 8. IPC Command → Rust function map +## 8. Diagnostics -All commands declared in `src/ipc.ts` `CommandMap`. Registered handlers: -`src-tauri/src/lib.rs:3550 generate_handler![…]`. - -| IPC command | Rust fn | File:line | Status | -|-------------|---------|-----------|--------| -| `fetch_metadata` | `fetch_metadata` | lib.rs:616 | ✅ | -| `fetch_media_metadata` | `fetch_media_metadata` | lib.rs:784 | ✅ | -| `get_engine_status` | `get_engine_status` | lib.rs:1674 | ✅ | -| `get_aria2_engine_status` | `get_aria2_engine_status` | lib.rs:1694 | ✅ | -| `get_ytdlp_engine_status` | `get_ytdlp_engine_status` | lib.rs:1702 | ✅ | -| `get_ffmpeg_engine_status` | `get_ffmpeg_engine_status` | lib.rs:1707 | ✅ | -| `get_deno_engine_status` | `get_deno_engine_status` | lib.rs:1712 | ✅ | -| `open_file` | `open_file` | lib.rs:1018 | ✅ (unused by UI; UI uses open_downloaded_file) | -| `show_in_folder` | `show_in_folder` | lib.rs:1031 | ✅ (unused by UI; UI uses reveal_in_file_manager) | -| `reveal_in_file_manager` | `commands::reveal_in_file_manager` | commands.rs:6 | ✅ | -| `open_downloaded_file` | `commands::open_downloaded_file` | commands.rs:27 | ✅ | -| `trash_download_assets` | `commands::trash_download_assets` | commands.rs:45 | ✅ | -| `pause_download` | `pause_download` | lib.rs:2137 | ✅ | -| `resume_download` | `resume_download` | lib.rs:2211 | ✅ | -| `remove_download` | `remove_download` | lib.rs:2292 | ✅ | -| `detach_download_for_reconfigure` | `detach_download_for_reconfigure` | lib.rs:2364 | ✅ | -| `enqueue_download` | `enqueue_download` | lib.rs:2565 | ✅ | -| `enqueue_many` | `enqueue_many` | lib.rs:2586 | ✅ | -| `move_in_queue` | `move_in_queue` | lib.rs:2613 | ✅ | -| `remove_from_queue` | `remove_from_queue` | lib.rs:2622 | ✅ | -| `get_pending_order` | `get_pending_order` | lib.rs:2560 | ✅ | -| `set_concurrent_limit` | `set_concurrent_limit` | lib.rs:2636 | ✅ | -| `set_global_speed_limit` | `set_global_speed_limit` | lib.rs:2663 | ✅ | -| `update_dock_badge` | `update_dock_badge` | lib.rs:2507 | ✅ | -| `set_prevent_sleep` | `set_prevent_sleep` | lib.rs:2526 | ✅ | -| `perform_system_action` | `perform_system_action` | lib.rs:2555 | ✅ | -| `request_automation_permission` | `request_automation_permission` | lib.rs:2680 | ✅ | -| `open_automation_settings` | `open_automation_settings` | lib.rs:2707 | ✅ | -| `get_free_space` | `get_free_space` | lib.rs:2721 | ✅ | -| `set_keychain_password` | `set_keychain_password` | lib.rs:2758 | ✅ | -| `get_keychain_password` | `get_keychain_password` | lib.rs:2765 | ✅ | -| `delete_keychain_password` | `delete_keychain_password` | lib.rs:2771 | ✅ | -| `check_file_exists` | `check_file_exists` | lib.rs:2778 | ✅ | -| `delete_file` | `delete_file` | lib.rs:2787 | ✅ | -| `toggle_tray_icon` | `toggle_tray_icon` | lib.rs:2823 | ⚠️ see TR note | -| `set_extension_pairing_token` | `set_extension_pairing_token` | lib.rs:2875 | ✅ | -| `set_extension_frontend_ready` | `set_extension_frontend_ready` | lib.rs:2892 | ✅ | -| `get_system_proxy` | `parity::get_system_proxy` | parity.rs:7 | ✅ | -| `get_file_category` | `parity::get_file_category` | parity.rs:22 | ✅ (UI uses TS `categoryForFileName` instead) | -| `check_for_updates` | `parity::check_for_updates` | parity.rs:84 | ✅ | -| `is_supported_media` | `parity::is_supported_media` | parity.rs:180 | ✅ (declared in ipc.ts but UI uses TS `isMediaUrl`/`get_supported_media_domains`) | -| `create_category_directories` | `parity::create_category_directories` | parity.rs:144 | ✅ | -| `export_logs` | `export_logs` | lib.rs:2800 | ✅ | -| `db_save_settings` | — | — | ❌ declared in `ipc.ts` but **not registered** in Rust | -| `db_load_settings` | — | — | ❌ declared in `ipc.ts` but **not registered** in Rust | -| `db_get_all_downloads` | — | — | ❌ declared in `ipc.ts` but **not registered** in Rust | -| `db_save_download` | — | — | ❌ declared in `ipc.ts` but **not registered** in Rust | -| `db_delete_download` | — | — | ❌ declared in `ipc.ts` but **not registered** in Rust | -| `start_download` | — | — | ❌ declared in `ipc.ts` (`StartDownloadArgs`) but **not registered** in Rust | -| `start_media_download` | — | — | ❌ declared in `ipc.ts` (`StartMediaDownloadArgs`) but **not registered** in Rust | -| `get_supported_media_domains` | `parity::get_supported_media_domains` | parity.rs:175 | ✅ (called directly via `invoke` in `downloads.ts`, not in typed `CommandMap`) | - -> Persistence actually runs through the Tauri `plugin-store` (`LazyStore` on -> `store.bin`) in the Zustand subscriber (`useDownloadStore.ts:646`), not the -> `db_*` commands — those are dead declarations. Likewise `start_download` / -> `start_media_download` are superseded by `enqueue_download`/`enqueue_many` via -> the `dispatchItem` flow. - -### Backend events (Rust → Frontend) - -| Event | Payload | Emitted by | Listened by | -|-------|---------|------------|-------------| -| `download-progress` | `DownloadProgressEvent` | download/queue tasks | `downloadStore.ts:31` | -| `download-state` | `DownloadStateEvent` | pause/resume/remove + tasks | `downloadStore.ts:49`, `App.tsx:210` | -| `download-complete` / `download-failed` | string | (declared; not emitted in read code) | — | -| `schedule-trigger` | 'start'\|'stop' | scheduler.rs | `App.tsx:113` | -| `tray-action` | 'pause-all'\|'resume-all' | tray menu | `downloadStore.ts:80` | -| `extension-add-download` | `ExtensionDownload` | extension_server | `App.tsx:231` | -| `extension-downloads-queued` | `DownloadItem[]` | extension_server | `App.tsx:234` | -| `deep-link-add-download` | string | deep-link dispatch | `App.tsx:251` | -| `log` | `{level,message}` | tauri-plugin-log | `DiagnosticsView.tsx:19` | +| ID | Action | Component / function | IPC / Rust | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---| +| DG-01 | Filter severity | local `levelFilter` | none | Filter visible attached logs | wired | unit test | +| DG-02 | Clear console | `handleClear` | none | Clear only current in-memory view | wired; label could clarify that log file remains | unit test | +| DG-03 | Export Logs | `handleExport` | `export_logs` / `export_logs` | Copy current log file to selected path | wired; no success/error toast; Manual QA needed | integration test | --- -## 9. Highest-Risk Areas +## 9. Toasts, notifications, and error surfaces -Ranked by likelihood × impact: +| ID | User-facing action/event | Component / function | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| NT-01 | Dismiss toast | `ToastItem` close button | Remove toast with exit animation | wired | unit test | +| NT-02 | Hover/focus toast | `ToastItem` timer logic | Pause auto-dismiss | wired | unit test | +| NT-03 | Pairing-token migration: Copy token | actionable toast in `App` | Copy token and acknowledge notice | partially wired: clipboard failure is ignored; toast is not explicitly dismissed | unit test | +| NT-04 | Pairing-token migration: Integrations | actionable toast in `App` | Open Integrations and acknowledge | wired | unit test | +| NT-05 | Download complete/failed OS notification | `App` terminal-state listener | Show native notification when enabled | wired; permission request result is ignored; Manual QA needed | integration test | +| NT-06 | Root render failure | `ErrorBoundary` | Show fatal error and component stack | wired but outdated/unsafe for production: exposes technical stack and offers no recovery action | build check | -1. **Tray menu divergence / duplicate tray builder** (`lib.rs:3192` vs - `lib.rs:2822`). The setup tray registers Pause All / Resume All; the toggle - builder does not and silently no-ops when the setup tray already exists. - Toggling "Menu bar icon" off then on can leave the user with a tray that has - no pause/resume items, or no tray at all depending on ordering. **Risk: lost - global queue control + confusing state.** +The root toast provider is already the correct architectural direction. +Recommendation: standardize async user actions around a shared result-to-toast +helper, and ensure success is shown only after clipboard, keychain, filesystem, +or IPC completion. -2. **Resume/Pause All scope mismatch** (M2/M3 vs TR2/TR3). Toolbar Resume/Pause - All only act on the **current filter**, while tray Resume/Pause All act on - **all queues**. Same icon, different semantics — users will mis-pause and - leave downloads running, or fail to resume paused items. +--- -3. **`perform_system_action` (Sleep/Restart/Shutdown)** is invoked with no - confirmation dialog from the scheduler path (`App.tsx:139`) once the queue - drains. A user who enables "Shut down" and walks away loses unsaved work in - other apps. Permission is checked, but no undo. **Risk: destructive, - cross-application.** +## 10. Tray and native menus -4. **`applyProperties` state machine** (`useDownloadStore.ts:313`). Five - branches (active throws, completed/failed store-only, queued re-dispatch, - paused detach) with backend registration tracked in a separate `Set`. A - failed `detach_download_for_reconfigure` throws and preserves old props, but - a failed re-dispatch on queued silently removes from queue. Easy to reach a - state where the UI shows "queued" but the backend has no task - (`backendRegisteredIds` desync). +No custom main-window application menu actions were found. The native +user-facing menu is the menu-bar/tray menu. -5. **`dispatchItem` in-flight dedupe + failure → 'failed'** - (`useDownloadStore.ts:21`). On enqueue failure the item is marked `failed` - without surfacing the error to the user (only `console.error`). Users see a - mysteriously failed download with no diagnostic. +| ID | Action | Rust location | Frontend/store path | Expected behavior | Status | Validation | +|---|---|---|---|---|---|---| +| TR-01 | Left-click tray icon | startup and rebuilt tray handlers | `restore_main_window` | Show/focus main window | wired; Manual QA needed | integration test | +| TR-02 | Show Firelink | tray menu | `restore_main_window` | Show/focus main window | wired | integration test | +| TR-03 | Pause All | startup tray emits `tray-action` | `initDownloadListener` → `pauseQueue` per distinct queue ID | Pause all downloads | partially wired: excludes unassigned items and queue pause ignores processing/retrying | integration test | +| TR-04 | Resume All | startup tray emits `tray-action` | `startQueue` per distinct queue ID | Resume all downloads | partially wired: excludes unassigned items | integration test | +| TR-05 | Quit | tray handler | `app.exit(0)` | Exit application | wired; Manual QA needed | integration test | +| TR-06 | Close main window | `on_window_event` | hide instead of exit | Keep app running in background | wired; discoverability depends on Dock/tray state; Manual QA needed | integration test | -6. **Path authorization surface** (`commands.rs`). `open_downloaded_file`, - `reveal_in_file_manager`, `trash_download_assets` all gate on - `known_download_paths` (registered via `download_ownership`). If an item is - redownloaded or its destination changes, stale registration could either - block a legitimate open or — worse — the `delete_file` path used by - duplicate "Replace" (`AddDownloadsModal` A17) bypasses ownership checks and - uses `is_safe_path` only. **Risk: deleting the wrong file if a crafted path - passes `is_safe_path`.** +Recommendation: define one tray menu builder and update visibility in place. +Use backend commands for true global pause/resume rather than reconstructing +global behavior from optional frontend queue IDs. -7. **Dead IPC declarations** (`start_download`, `start_media_download`, all - `db_*`). Not exploitable, but a maintenance trap: any future caller using the - typed `invokeCommand` for these will get a runtime "command not found" with - no compile-time signal. +--- -8. **No native macOS app menu.** No Edit menu (cut/copy/paste/select-all), - Cmd+Q, or Window menu. Clipboard paste into the Add-URLs textarea works only - via the webview default; the global paste handler (G1) intentionally ignores - inputs, so Cmd+V in the textarea is the only path — fine, but no menu affordance. +## 11. Extension-triggered UI actions -9. **Duplicate-resolution rename loop** (A17) caps at 1000 attempts and on - exhaustion keeps the last tried name without warning, which could collide - and overwrite via the subsequent `addDownload` if the backend doesn't guard. +### 11.1 Firefox extension popup and browser menus -10. **`SpeedLimiterView` Save applies via effect** (`App.tsx:101`), not directly. - The `previousSpeedLimit` ref dedupes, but the indirection means the visible - toast ("Global limit saved…") can fire before the backend actually accepts - the limit, and a failed `set_global_speed_limit` is only logged. +| ID | Extension action | JS function/listener | App effect | Status | Validation | +|---|---|---|---|---|---| +| EX-01 | Toggle Capture All Downloads | popup `globalToggle` | Browser download interception on/off | wired | extension integration test | +| EX-02 | Disable capture on current site | popup `siteToggle` | Host-specific interception exclusion | wired; naming stores inverse boolean | unit test | +| EX-03 | Save pairing token | popup `saveTokenBtn` | Store token and probe `/ping` | wired; token remains extension-local plaintext storage by browser design | integration test | +| EX-04 | Expand/collapse token field | popup `pairingToggleBtn` | Show/hide pairing controls | wired | code trace | +| EX-05 | Toggle popup theme | popup `themeToggleBtn` | Persist popup light/dark theme | wired | code trace | +| EX-06 | Download link with Firelink | browser context menu | authenticated `/download` → app Add Downloads | wired | integration test | +| EX-07 | Download selected with Firelink | context menu + injected `content.js` | Extract selected links and open Add Downloads | wired; fallback paths are complex and duplicated | integration test | +| EX-08 | Automatic browser download capture | `chrome.downloads.onCreated` | Forward, then cancel/erase browser download after accepted response | wired but high-risk: acceptance means UI event emission, not confirmed app enqueue | integration test | +| EX-09 | Deep-link fallback | `sendToFirelink.triggerDeepLink` | `firelink://add` → `deep-link-add-download` | Open Add Downloads when local server unavailable | wired; browser fallback behavior needs Manual QA | integration test | + +### 11.2 Native extension server to UI + +| ID | Trigger | Backend / frontend path | Expected behavior | Status | Validation | +|---|---|---|---|---|---| +| EX-10 | Authenticated `/download` | `extension_server::download_handler` → `extension-add-download` → `handleExtensionDownload` | Wake/focus app and merge sanitized URLs into Add Downloads | wired | integration test | +| EX-11 | Deep link | deep-link parser/event → `openAddModalWithUrls` | Open Add Downloads with URLs | wired | integration test | +| EX-12 | Extension connection status shown in Settings | static Settings text | Report live server state | **unwired/dead**: text is hard-coded | code trace | + +Recommendation: return a request ID from `/download`, acknowledge only after +the frontend has created a draft, and optionally return a second “queued” +result. That would let automatic browser capture cancel the browser download +only after Firelink has actually accepted responsibility. + +--- + +## 12. IPC-backed background actions affecting UI + +| ID | Background action | Frontend path | IPC / Rust | User-facing result | Status | Validation | +|---|---|---|---|---|---|---| +| BG-01 | Database hydration | `initDB` | DB load commands | Restore downloads/queues and auto-enqueue queued items | fragile: `enqueue_many` per-item results are ignored and frontend backend-registration state is not rebuilt directly | integration test | +| BG-02 | Progress events | `initDownloadListener` | `download-progress` | Update progress, speed, ETA, size | wired | integration test | +| BG-03 | State events | `initDownloadListener` | `download-state` | Update lifecycle, pending order, registration state | wired but event errors/retry reasons are not surfaced in row UI | integration test | +| BG-04 | Scheduler trigger | `App` listener | `schedule-trigger` | Start/pause Main Queue | wired with queue-scope gaps | integration test | +| BG-05 | Pairing token hydration | App startup | keychain hydration commands | Configure extension authentication and migration toast | wired | integration test | +| BG-06 | Theme system change | App media-query listener | none | Follow macOS appearance | wired; Manual QA needed | code trace | +| BG-07 | Paste outside inputs | App paste listener | none | Extract supported URLs and open Add Downloads | wired | unit test | + +--- + +## 13. Dead, duplicated, outdated, and drift-prone implementation inventory + +| Area | Evidence | Status | Practical replacement | +|---|---|---|---| +| Legacy media quality flow | `QualityModal.tsx`, `activeMetadata`, `fetchMetadataAction`, `activeDownloadId` have no rendered caller | unwired/dead, duplicated | Remove after confirming no external import; keep the Add Downloads media-format flow | +| IPC command typing | `src/ipc.ts` manually lists commands; `utils/downloads.ts` and tray listener bypass it; several listed commands are legacy UI-unused | outdated, fragile | Generate command and event bindings from Rust, including argument/result types | +| Action rules | Status checks repeated across row, toolbar, menus, queues, tray, scheduler | duplicated, fragile | Shared action enum, selectors, and command handlers | +| Tray construction | Startup and `toggle_tray_icon` build different menus | duplicated, partially wired | One builder/update function | +| Global speed editor | Settings and Speed Limiter use different input models/copy | duplicated | One shared control and parser | +| Settings decoding/migration | TypeScript normalization plus Rust decoder/migration | duplicated, fragile | One authoritative versioned schema/migration boundary | +| Category classification | TypeScript and Rust maintain separate extension lists | duplicated, drift-prone | Generate/shared data table or backend-owned classification | +| Supported media domains | Rust list copied into frontend fallback, then asynchronously refreshed | duplicated, drift-prone | Generated/static shared binding or required startup capability payload | +| Path resolution | Shared frontend resolver exists, but store also has a separate tilde resolver and backend has additional resolution/authorization paths | duplicated, fragile | One frontend resolver plus backend canonical ownership API; avoid new scattered joins | +| Boolean lock policy | `isLocked`, `isTransferLocked`, many status condition arrays | fragile | Explicit edit/action policy enums | +| Update preference | `autoCheckUpdates` persisted without consumer | unwired/dead | Startup update-check service with throttling and actionable result | +| Row density preference | `listRowDensity` persisted without consumer | unwired/dead | Root data attribute/CSS variable or remove setting | +| Integration status | Static “Active” server label | outdated | Query actual bound port/readiness and render truthful state | +| Error handling | Many clipboard, pause, folder creation, export, metadata, and deletion errors go only to console or are swallowed | fragile | Standard async action result and root toast reporting | +| Custom menus | Hand-built floating div/button menus and hover submenus | outdated/fragile | Accessible menu/split-button primitive with keyboard and collision support | +| Scheduler lifecycle | Frontend boolean plus global download scan | fragile | Backend-owned run state machine with run membership | + +--- + +## 14. Recommended modernization sequence + +### Priority 1 — behavior correctness + +1. Define total queue ownership and make true global Pause/Resume backend + commands. +2. Centralize action eligibility and execution for row/menu/toolbar/queue/tray. +3. Make duplicate replacement transactional and surface batch failures. +4. Fix tray reconstruction so the same menu is retained after hide/show. +5. Wire or remove Row Density and Automatic Update Check. + +### Priority 2 — contract and lifecycle reliability + +1. Generate IPC command/event bindings from Rust and prohibit raw command-name + strings outside the generated client. +2. Reconcile `enqueue_many` results into frontend registration/status state. +3. Replace scheduler completion inference with an explicit backend run model. +4. Consolidate settings migration and location normalization around one + authoritative schema. + +### Priority 3 — modern desktop interaction + +1. Use an accessible Add split button and accessible context/overflow menus. +2. Add keyboard commands for Add, Start/Resume, Pause, Remove, Properties, and + search/filter. +3. Persist column widths and support reset-to-default. +4. Replace hard-coded extension status with live connection state. +5. Add actionable update notifications with a safe external release link. + +--- + +## 15. Static verification matrix + +Recommended non-UI checks for this inventory: + +- `npm run build` — TypeScript and production frontend build. +- `npm run test -- --run` — store and location unit tests. +- `cargo check --manifest-path src-tauri/Cargo.toml` — Rust command/type check. +- `cargo test --manifest-path src-tauri/Cargo.toml --all-targets` — queue, + ownership, settings, extension, and engine tests. +- `npm run bindings` followed by a clean diff check — generated Rust data + binding drift. +- Add a command-registration/TypeScript-command-map contract test until the IPC + client is generated. + +Manual QA remains needed for native dialogs, Finder/open behavior, tray +visibility and focus, notification permission/sound, macOS Automation prompts, +external links, browser extension fallback behavior, menu keyboard navigation, +and pointer resizing. diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 30dddfb..1cfa02a 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -27,6 +27,7 @@ const SIGNATURE_MAX_AGE_MS: u64 = 60_000; type HmacSha256 = Hmac; pub type SharedExtensionToken = Arc>; pub type SharedFrontendReady = Arc; +pub type SharedServerPort = Arc>>; type ReplayCache = Arc>>; #[derive(Clone)] @@ -67,6 +68,7 @@ pub async fn start_server( app_handle: AppHandle, pairing_token: SharedExtensionToken, frontend_ready: SharedFrontendReady, + server_port: SharedServerPort, mut shutdown_rx: watch::Receiver, ) -> Result<(), String> { let state = ServerState { @@ -91,10 +93,13 @@ pub async fn start_server( .with_state(state); let (port, listener) = bind_extension_listener().await?; + if let Ok(mut current_port) = server_port.write() { + *current_port = Some(port); + } log::info!("Browser extension server bound to 127.0.0.1:{port}"); - axum::serve(listener, app) + let server_result = axum::serve(listener, app) .with_graceful_shutdown(async move { if *shutdown_rx.borrow() { return; @@ -102,9 +107,13 @@ pub async fn start_server( let _ = shutdown_rx.changed().await; }) .await - .map_err(|e| format!("Server error: {}", e))?; + .map_err(|e| format!("Server error: {}", e)); - Ok(()) + if let Ok(mut current_port) = server_port.write() { + *current_port = None; + } + + server_result } async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20e4b07..a047542 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1225,6 +1225,7 @@ pub struct AppState { pub download_coordinator: download::DownloadCoordinator, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, + pub extension_server_port: extension_server::SharedServerPort, pub extension_server_shutdown: tokio::sync::watch::Sender, pub aria2_port: u16, pub aria2_secret: String, @@ -3007,53 +3008,74 @@ async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result< #[tauri::command] fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { - use tauri::tray::TrayIconBuilder; - use tauri::menu::{Menu, MenuItem}; - if show { - if app_handle.tray_by_id("main").is_none() { - let quit_i = MenuItem::with_id(&app_handle, "quit", "Quit", true, None::<&str>).map_err(|e| e.to_string())?; - let show_i = MenuItem::with_id(&app_handle, "show", "Show Firelink", true, None::<&str>).map_err(|e| e.to_string())?; - let menu = Menu::with_items(&app_handle, &[&show_i, &quit_i]).map_err(|e| e.to_string())?; - - let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png")) - .map_err(|e| e.to_string())?; - let _tray = TrayIconBuilder::with_id("main") - .icon(tray_icon) - .icon_as_template(true) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "quit" => { - app.exit(0); - } - "show" => { - restore_main_window(app); - } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; - - if matches!( - event, - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } - ) { - restore_main_window(tray.app_handle()); - } - }) - .build(&app_handle) - .map_err(|e| e.to_string())?; - } + build_main_tray(&app_handle) } else { - if let Some(_tray) = app_handle.tray_by_id("main") { + if app_handle.tray_by_id("main").is_some() { let _ = app_handle.remove_tray_by_id("main"); } + Ok(()) } +} + +fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::TrayIconBuilder; + + if app_handle.tray_by_id("main").is_some() { + return Ok(()); + } + + let show_i = MenuItem::with_id(app_handle, "show", "Show Firelink", true, None::<&str>) + .map_err(|e| e.to_string())?; + let pause_all_i = MenuItem::with_id(app_handle, "pause_all", "Pause All", true, None::<&str>) + .map_err(|e| e.to_string())?; + let resume_all_i = MenuItem::with_id(app_handle, "resume_all", "Resume All", true, None::<&str>) + .map_err(|e| e.to_string())?; + let quit_i = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>) + .map_err(|e| e.to_string())?; + let menu = Menu::with_items( + app_handle, + &[&show_i, &pause_all_i, &resume_all_i, &quit_i], + ) + .map_err(|e| e.to_string())?; + + let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png")) + .map_err(|e| e.to_string())?; + TrayIconBuilder::with_id("main") + .icon(tray_icon) + .icon_as_template(true) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => restore_main_window(app), + "pause_all" => { + use tauri::Emitter; + let _ = app.emit("tray-action", "pause-all"); + } + "resume_all" => { + use tauri::Emitter; + let _ = app.emit("tray-action", "resume-all"); + } + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + restore_main_window(tray.app_handle()); + } + }) + .build(app_handle) + .map_err(|e| e.to_string())?; + Ok(()) } @@ -3074,6 +3096,15 @@ fn set_extension_pairing_token( Ok(()) } +#[tauri::command] +fn get_extension_server_port(state: tauri::State<'_, AppState>) -> Option { + state + .extension_server_port + .read() + .ok() + .and_then(|port| *port) +} + #[tauri::command] fn set_extension_frontend_ready( state: tauri::State<'_, AppState>, @@ -3429,6 +3460,8 @@ pub fn run() { let server_pairing_token = extension_pairing_token.clone(); let extension_frontend_ready = Arc::new(AtomicBool::new(false)); let server_frontend_ready = extension_frontend_ready.clone(); + let extension_server_port = Arc::new(RwLock::new(None)); + let server_extension_port = extension_server_port.clone(); let (extension_server_shutdown_tx, extension_server_shutdown_rx) = tokio::sync::watch::channel(false); let aria2_port = std::net::TcpListener::bind("127.0.0.1:0") @@ -3447,48 +3480,8 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard::new()) .setup(move |app| { - use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}; - use tauri::menu::{Menu, MenuItem}; - - let show_i = MenuItem::with_id(app, "show", "Show Firelink", true, None::<&str>).unwrap(); - let pause_all_i = MenuItem::with_id(app, "pause_all", "Pause All", true, None::<&str>).unwrap(); - let resume_all_i = MenuItem::with_id(app, "resume_all", "Resume All", true, None::<&str>).unwrap(); - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(); - let menu = Menu::with_items(app, &[&show_i, &pause_all_i, &resume_all_i, &quit_i]).unwrap(); - - let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png")).unwrap(); - let _tray = TrayIconBuilder::with_id("main") - .icon(tray_icon) - .icon_as_template(true) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "show" => { restore_main_window(app); } - "pause_all" => { - use tauri::Emitter; - let _ = app.emit("tray-action", "pause-all"); - } - "resume_all" => { - use tauri::Emitter; - let _ = app.emit("tray-action", "resume-all"); - } - "quit" => { app.exit(0); } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if matches!( - event, - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } - ) { - restore_main_window(tray.app_handle()); - } - }) - .build(app) - .unwrap(); + build_main_tray(app.handle()) + .map_err(|error| format!("failed to create tray menu: {error}"))?; let database = crate::db::init(app.handle()) .map_err(|error| format!("failed to initialize persistence: {error}"))?; @@ -3513,6 +3506,7 @@ pub fn run() { download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), extension_pairing_token, extension_frontend_ready, + extension_server_port, extension_server_shutdown: extension_server_shutdown_tx.clone(), aria2_port, aria2_secret: aria2_secret.clone(), @@ -3768,6 +3762,7 @@ pub fn run() { ext_app_handle, server_pairing_token.clone(), server_frontend_ready.clone(), + server_extension_port.clone(), extension_server_shutdown_rx, ).await { eprintln!("Browser extension server unavailable: {error}"); @@ -3815,7 +3810,7 @@ pub fn run() { set_keychain_password, get_keychain_password, delete_keychain_password, hydrate_extension_pairing_token, acknowledge_pairing_token_change, 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, + get_extension_server_port, 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, diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index 0a26c72..f1b945c 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -147,6 +147,7 @@ pub async fn create_category_directories( subfolders: std::collections::HashMap, ) -> Result<(), String> { let base = crate::resolve_path(&base_folder, &app_handle); + let mut errors = Vec::new(); for subfolder in subfolders.values() { let normalized = subfolder.replace('\\', "/"); @@ -164,16 +165,19 @@ pub async fn create_category_directories( let expanded = base.join(relative); if !expanded.exists() { if let Err(e) = tokio::fs::create_dir_all(&expanded).await { - log::warn!( - "Failed to create category directory '{}': {}", - expanded.display(), - e - ); + errors.push(format!("{}: {}", expanded.display(), e)); } } } - Ok(()) + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "Failed to create one or more category directories ({})", + errors.join("; ") + )) + } } pub static SUPPORTED_DOMAINS: &[&str] = &[ diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 6f7620a..46825ff 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Manager}; use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, Semaphore}; +use ts_rs::TS; use serde_json; use log; @@ -997,7 +998,8 @@ impl SidecarSpawner for ProductionSpawner { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] pub struct EnqueueItem { pub id: String, pub url: String, diff --git a/src/App.tsx b/src/App.tsx index ed02447..8bdf888 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,9 @@ import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; import DiagnosticsView from "./components/DiagnosticsView"; import { useToast } from "./contexts/ToastContext"; +import { openUrl } from '@tauri-apps/plugin-opener'; + +let automaticUpdateCheckStarted = false; function App() { const [filter, setFilter] = useState('all'); @@ -29,6 +32,9 @@ function App() { const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); + const listRowDensity = useSettingsStore(state => state.listRowDensity); + const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates); + const showNotifications = useSettingsStore(state => state.showNotifications); const showDockBadge = useSettingsStore(state => state.showDockBadge); const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); @@ -90,12 +96,20 @@ function App() { + + ) + }); + }) + .catch(error => { + automaticUpdateCheckStarted = false; + console.error('Automatic update check failed:', error); + }); + }; + + if (useSettingsStore.persist.hasHydrated()) { + checkForUpdate(); + return; + } + return useSettingsStore.persist.onFinishHydration(checkForUpdate); + }, [addToast, autoCheckUpdates]); + useEffect(() => { invoke('set_concurrent_limit', { limit: maxConcurrentDownloads }).catch(console.error); }, [maxConcurrentDownloads]); @@ -191,15 +249,22 @@ function App() { }, [downloads, schedulerRunning]); useEffect(() => { - // Request notification permissions const initNotifications = async () => { + if (!useSettingsStore.getState().showNotifications) return; let permissionGranted = await isPermissionGranted(); if (!permissionGranted) { await requestPermission(); } }; - initNotifications(); - }, []); + + if (useSettingsStore.persist.hasHydrated()) { + void initNotifications(); + return; + } + return useSettingsStore.persist.onFinishHydration(() => { + void initNotifications(); + }); + }, [showNotifications]); useEffect(() => { const handlePaste = (e: ClipboardEvent) => { diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx index 754b264..faaa760 100644 --- a/src/ErrorBoundary.tsx +++ b/src/ErrorBoundary.tsx @@ -29,11 +29,20 @@ export class ErrorBoundary extends Component { public render() { if (this.state.hasError) { return ( -
-

Something went wrong.

-

{this.state.error?.toString()}

-
-

{this.state.errorInfo?.componentStack}

+
+
+

Firelink could not display this window.

+

+ The error was written to Diagnostics. Reload the interface to reconnect to the running download service. +

+ +
); } diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts new file mode 100644 index 0000000..8887fab --- /dev/null +++ b/src/bindings/EnqueueItem.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 EnqueueItem = { 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, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 5b39797..f5202eb 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -15,6 +15,8 @@ import { resolveCategoryDestination, resolveDownloadFilePath } from '../utils/downloadLocations'; +import { isTransferLocked } from '../utils/downloadActions'; +import { useToast } from '../contexts/ToastContext'; interface MediaFormat { name: string; @@ -47,6 +49,7 @@ const formatBytes = (bytes: number) => { }; export const AddDownloadsModal = () => { + const { addToast } = useToast(); const { isAddModalOpen, pendingAddUrls, @@ -58,7 +61,7 @@ export const AddDownloadsModal = () => { addDownload, queues } = useDownloadStore(); - const { baseDownloadFolder } = useSettingsStore(); + const { baseDownloadFolder, perServerConnections } = useSettingsStore(); const [urls, setUrls] = useState(''); const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0); @@ -77,7 +80,7 @@ export const AddDownloadsModal = () => { // Right Form const [saveLocation, setSaveLocation] = useState(baseDownloadFolder); const [isSaveLocationManual, setIsSaveLocationManual] = useState(false); - const [connections, setConnections] = useState(16); + const [connections, setConnections] = useState(perServerConnections); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimit, setSpeedLimit] = useState('1024'); const [freeSpace, setFreeSpace] = useState('Unknown'); @@ -102,6 +105,7 @@ export const AddDownloadsModal = () => { setParsedItems([]); setSelectedItemIndex(null); setPendingUseSharedDestination(false); + setConnections(perServerConnections); setUseAuth(false); setUsername(''); setPassword(''); @@ -126,7 +130,8 @@ export const AddDownloadsModal = () => { pendingAddReferer, pendingAddHeaders, pendingAddCookies, - baseDownloadFolder + baseDownloadFolder, + perServerConnections ]); useEffect(() => { @@ -141,6 +146,21 @@ export const AddDownloadsModal = () => { return () => window.removeEventListener('pointerdown', closeMenu); }, [isActionMenuOpen]); + useEffect(() => { + if (!isActionMenuOpen && !showingDuplicates) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (showingDuplicates) { + setShowingDuplicates(false); + } else { + setIsActionMenuOpen(false); + setIsQueueMenuOpen(false); + } + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [isActionMenuOpen, showingDuplicates]); + useEffect(() => { if (!saveLocation) return; invoke('get_free_space', { path: saveLocation }) @@ -445,9 +465,12 @@ export const AddDownloadsModal = () => { exists = storeHas || diskHas; count++; } + if (exists) { + throw new Error(`Could not find an available name for ${finalFile}.`); + } itemsToAdd[idx] = { ...item, file: newName }; - } else if (res.resolution === 'replace') { + } else if (res.resolution === 'replace') { if (conflict?.reason.type !== 'file') { itemsToAdd[idx] = null; continue; @@ -479,16 +502,21 @@ export const AddDownloadsModal = () => { } } - if (existingItem) { - await store.removeDownload(existingItem.id); + if (existingItem && isTransferLocked(existingItem.status)) { + throw new Error(`Pause ${existingItem.fileName} before replacing it.`); + } + + await invoke('delete_file', { path: fullPath }); + if (existingItem) { + await store.removeDownload(existingItem.id); } - - try { await invoke('delete_file', { path: fullPath }); } catch(e) {} } } } const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null); + let addedCount = 0; + const failures: string[] = []; for (const item of resolvedItems) { try { @@ -527,11 +555,25 @@ export const AddDownloadsModal = () => { mediaFormatSelector: formatSelector, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) }, action); + addedCount += 1; } catch (e) { console.error("Invalid URL or failed to add:", e); + failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`); } } toggleAddModal(false); + if (failures.length > 0) { + addToast({ + message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`, + variant: 'error', + isActionable: true + }); + } else if (addedCount > 0) { + addToast({ + message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`, + variant: 'success' + }); + } }; const SummaryBox = ({ title, value, icon: Icon, color }: { @@ -578,7 +620,14 @@ export const AddDownloadsModal = () => { conflicts={conflicts} onConfirm={(resolutions) => { setShowingDuplicates(false); - executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions); + void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions) + .catch(error => { + addToast({ + message: `Could not resolve duplicate downloads: ${String(error)}`, + variant: 'error', + isActionable: true + }); + }); }} onCancel={() => setShowingDuplicates(false)} /> diff --git a/src/components/DeleteConfirmationModal.tsx b/src/components/DeleteConfirmationModal.tsx index 2ac974c..fedfaec 100644 --- a/src/components/DeleteConfirmationModal.tsx +++ b/src/components/DeleteConfirmationModal.tsx @@ -13,33 +13,36 @@ export const DeleteConfirmationModal: React.FC = () => { closeDeleteModal(); }; - const handleRemoveFromList = async () => { - if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) { - setIsRemoving(true); + const removeMany = async (deleteFile: boolean) => { + const ids = deleteModalState.downloadIds ?? []; + if (ids.length === 0) { + closeDeleteModal(); + return; + } + + setIsRemoving(true); + setErrorMessage(''); + let succeeded = 0; + const failures: string[] = []; + for (const id of ids) { try { - await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, false))); + await removeDownload(id, deleteFile); + succeeded += 1; } catch (error) { - setErrorMessage(`Remove failed: ${String(error)}`); - setIsRemoving(false); - return; + failures.push(String(error)); } } + + if (failures.length > 0) { + setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`); + setIsRemoving(false); + return; + } closeDeleteModal(); }; - const handleDeleteFile = async () => { - if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) { - setIsRemoving(true); - try { - await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, true))); - } catch (error) { - setErrorMessage(`Delete failed: ${String(error)}`); - setIsRemoving(false); - return; - } - } - closeDeleteModal(); - }; + const handleRemoveFromList = () => removeMany(false); + const handleDeleteFile = () => removeMany(true); return (
diff --git a/src/components/DiagnosticsView.tsx b/src/components/DiagnosticsView.tsx index c8c938f..e2c777f 100644 --- a/src/components/DiagnosticsView.tsx +++ b/src/components/DiagnosticsView.tsx @@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc'; import { save } from '@tauri-apps/plugin-dialog'; import { FileDown, Trash2, Terminal, Filter } from 'lucide-react'; import { WindowDragRegion } from './WindowDragRegion'; +import { useToast } from '../contexts/ToastContext'; interface LogEntry { level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error'; @@ -22,6 +23,7 @@ const getLevelStr = (level: number): LogEntry['level'] => { }; export default function DiagnosticsView() { + const { addToast } = useToast(); const [logs, setLogs] = useState([]); const [levelFilter, setLevelFilter] = useState('All'); const scrollRef = useRef(null); @@ -59,8 +61,10 @@ export default function DiagnosticsView() { }); if (!path) return; await invoke('export_logs', { destPath: path }); + addToast({ message: 'Diagnostics exported', variant: 'success' }); } catch (e) { console.error('Export failed:', e); + addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true }); } }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index c2c9451..864c6fa 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -3,6 +3,7 @@ import { useDownloadStore } from '../store/useDownloadStore'; import { useDownloadProgressStore } from '../store/downloadStore'; import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; +import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions'; interface DownloadItemProps { downloadId: string; @@ -200,13 +201,13 @@ export const DownloadItem = React.memo(({ )} - {(download.status === 'downloading' || download.status === 'processing' || download.status === 'retrying') && ( + {canPauseDownload(download.status) && ( )} - {(download.status === 'ready' || download.status === 'paused') && ( - )} diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 6e24600..1305d84 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -10,13 +10,22 @@ import { resolveCategoryDestination, resolveDownloadFilePath } from '../utils/downloadLocations'; +import { + canPauseDownload, + canRedownload, + canStartDownload, + startActionLabel +} from '../utils/downloadActions'; interface DownloadTableProps { filter: SidebarFilter; } +const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170]; +const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; + export const DownloadTable: React.FC = ({ filter }) => { - const { downloads, queues, updateDownload, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); + const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); const { isSidebarVisible, toggleSidebar } = useSettingsStore(); const { addToast } = useToast(); @@ -25,7 +34,18 @@ export const DownloadTable: React.FC = ({ filter }) => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); - const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]); + const [columnWidths, setColumnWidths] = useState(() => { + try { + const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null'); + return Array.isArray(stored) && + stored.length === DEFAULT_COLUMN_WIDTHS.length && + stored.every(value => typeof value === 'number' && Number.isFinite(value)) + ? stored + : DEFAULT_COLUMN_WIDTHS; + } catch { + return DEFAULT_COLUMN_WIDTHS; + } + }); const columnMinimums = [0, 58, 92, 58, 48, 112]; const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' '); @@ -53,10 +73,21 @@ export const DownloadTable: React.FC = ({ filter }) => { useEffect(() => { const handleCloseMenu = () => setContextMenu(null); + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setContextMenu(null); + }; window.addEventListener('click', handleCloseMenu); - return () => window.removeEventListener('click', handleCloseMenu); + window.addEventListener('keydown', handleEscape); + return () => { + window.removeEventListener('click', handleCloseMenu); + window.removeEventListener('keydown', handleEscape); + }; }, []); + useEffect(() => { + window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths)); + }, [columnWidths]); + const showInteractionError = (message: string, error: unknown) => { const detail = error instanceof Error ? error.message : String(error); @@ -97,13 +128,7 @@ export const DownloadTable: React.FC = ({ filter }) => { }; const revealDownloadFile = async (item: DownloadItem) => { - let pathToReveal: string | null = null; - if (item.status === 'completed') { - pathToReveal = await getDownloadPath(item); - } else { - const settings = useSettingsStore.getState(); - pathToReveal = item.destination || await resolveCategoryDestination(settings, item.category); - } + const pathToReveal = await getDownloadPath(item); if (!pathToReveal) { openProperties(item.id); @@ -198,6 +223,7 @@ export const DownloadTable: React.FC = ({ filter }) => { await invoke('pause_download', { id }); } catch (e) { console.error("Failed to pause:", e); + showInteractionError('Could not pause download', e); } }; @@ -249,7 +275,7 @@ export const DownloadTable: React.FC = ({ filter }) => { disabled={filteredDownloads.length === 0} onClick={() => { filteredDownloads - .filter(d => d.status === 'ready' || d.status === 'paused') + .filter(d => canStartDownload(d.status)) .forEach(d => handleResume(d)); }} title="Resume All" @@ -261,7 +287,7 @@ export const DownloadTable: React.FC = ({ filter }) => { className="main-control-button" disabled={filteredDownloads.length === 0} onClick={() => { - filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id)); + filteredDownloads.filter(d => canPauseDownload(d.status)).forEach(d => handlePause(d.id)); }} title="Pause All" > @@ -348,6 +374,7 @@ export const DownloadTable: React.FC = ({ filter }) => { {/* Floating Context Menu */} {contextMenu && contextItem && (
= ({ filter }) => { setContextMenu(null); Array.from(selectedIds).forEach(id => { const item = downloads.find(d => d.id === id); - if (item && (item.status === 'ready' || item.status === 'paused' || item.status === 'failed' || item.status === 'retrying')) { + if (item && canStartDownload(item.status)) { handleResume(item); } }); @@ -384,12 +411,7 @@ export const DownloadTable: React.FC = ({ filter }) => { {queues.map(q => ( @@ -454,7 +476,7 @@ export const DownloadTable: React.FC = ({ filter }) => {
- {(contextItem.status === 'downloading' || contextItem.status === 'queued' || contextItem.status === 'retrying') && ( + {canPauseDownload(contextItem.status) && ( )} - {(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && ( + {canStartDownload(contextItem.status) && ( )} - {['completed', 'failed', 'paused'].includes(contextItem.status) && ( + {canRedownload(contextItem.status) && ( diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index da3b7e5..bdbe324 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -4,6 +4,10 @@ import { useSettingsStore } from '../store/useSettingsStore'; import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { resolveCategoryDestination } from '../utils/downloadLocations'; +import { + isIdentityLocked as getIdentityLocked, + isTransferLocked as getTransferLocked +} from '../utils/downloadActions'; type LoginMode = 'matching' | 'custom' | 'none'; @@ -98,7 +102,7 @@ export const PropertiesModal = () => { if (!selectedPropertiesDownloadId || !item) return null; const handleBrowse = async () => { - if (isLocked) return; + if (identityLocked) return; try { const selected = await open({ directory: true, @@ -146,8 +150,8 @@ export const PropertiesModal = () => { } }; - const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status); - const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying'; + const identityLocked = getIdentityLocked(item.status); + const transferLocked = getTransferLocked(item.status); let statusColor = 'text-text-secondary'; let StatusIcon = Info; @@ -196,7 +200,7 @@ export const PropertiesModal = () => { {/* Scrollable Form Content */}
- {isLocked && ( + {identityLocked && (
{item.status === 'completed' ? : } @@ -212,34 +216,34 @@ export const PropertiesModal = () => {

Download

- setUrl(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> + setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> - setFileName(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> + setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
- -
- setConnections(Number(e.target.value))} disabled={isTransferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> + setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> per file
{speedLimitEnabled && (
- setSpeedLimitValue(e.target.value)} disabled={isTransferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> + setSpeedLimitValue(e.target.value)} disabled={transferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" /> KiB/s
)} @@ -257,8 +261,8 @@ export const PropertiesModal = () => { {(['matching', 'custom', 'none'] as const).map((mode) => (
@@ -298,14 +302,14 @@ export const PropertiesModal = () => {
{checksumEnabled && ( <> - setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50"> @@ -313,21 +317,21 @@ export const PropertiesModal = () => { - setChecksumValue(e.target.value)} disabled={isTransferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> + setChecksumValue(e.target.value)} disabled={transferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> )} - setCookies(e.target.value)} disabled={isTransferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> + setCookies(e.target.value)} disabled={transferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
- +
- +
)} @@ -351,8 +355,8 @@ export const PropertiesModal = () => { -
-
- )} - - {activeMetadata && ( - <> -
-

{activeMetadata.title}

- {activeMetadata.duration &&

Duration: {Math.floor(activeMetadata.duration / 60)}:{String(activeMetadata.duration % 60).padStart(2, '0')}

} -
- -
- - -
- -
- - -
- - )} -
-
- ); -}); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 1b84864..8fe75e0 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { getVersion } from '@tauri-apps/api/app'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { invokeCommand as invoke } from '../ipc'; import { useToast, ToastVariant } from '../contexts/ToastContext'; @@ -126,34 +127,27 @@ const CategoryFolderInput = ({ type="text" value={value} onFocus={() => setLocalValue(displayPath)} - onChange={(e) => { - const val = e.target.value; - setLocalValue(val); + onChange={(e) => setLocalValue(e.target.value)} + onBlur={() => { + const val = localValue ?? displayPath; const basePrefix = base + '/'; - + if (!val.trim()) { settings.setCategoryDirectoryOverride(category, undefined); settings.setCategorySubfolder(category, ''); } else if (val.startsWith(basePrefix)) { settings.setCategoryDirectoryOverride(category, undefined); - settings.setCategorySubfolder(category, val.substring(basePrefix.length)); - } else { - settings.setCategoryDirectoryOverride(category, val); - } - }} - onBlur={() => { - setLocalValue(null); - // Normalize subfolder if not an override - const currentOverride = settings.categoryDirectoryOverrides[category]; - if (!currentOverride) { settings.setCategorySubfolder( category, normalizeCategorySubfolder( - settings.categorySubfolders[category] || '', + val.substring(basePrefix.length), DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS] ) ); + } else { + settings.setCategoryDirectoryOverride(category, val.trim()); } + setLocalValue(null); }} className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay" aria-label={`${category} subfolder`} @@ -189,6 +183,7 @@ const [expandedEngine, setExpandedEngine] = useState(null); const [isRecheckingEngines, setIsRecheckingEngines] = useState(false); const engineRunId = useRef(0); const [appVersion, setAppVersion] = useState('0.7.3'); +const [extensionServerPort, setExtensionServerPort] = useState(null); // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); @@ -204,6 +199,27 @@ useEffect(() => { getVersion().then(setAppVersion).catch(() => undefined); }, []); +useEffect(() => { + if (settings.activeView !== 'settings' || activeTab !== 'integrations') return; + + let active = true; + const refresh = () => { + invoke('get_extension_server_port') + .then(port => { + if (active) setExtensionServerPort(port); + }) + .catch(() => { + if (active) setExtensionServerPort(null); + }); + }; + refresh(); + const timer = window.setInterval(refresh, 3000); + return () => { + active = false; + window.clearInterval(timer); + }; +}, [settings.activeView, activeTab]); + const runEngineChecks = useCallback((force = false) => { const runId = ++engineRunId.current; const cached = engineChecks @@ -309,7 +325,24 @@ runEngineChecks(false); if (result.type === 'UpToDate') { showToast(`Firelink ${result.latest_version} is up to date`, 'success'); } else if (result.type === 'UpdateAvailable') { - showToast(`Firelink ${result.update.version} is available`, 'info'); + addToast({ + variant: 'info', + isActionable: true, + message: ( +
+ Firelink {result.update.version} is available. + +
+ ) + }); } else { showToast('The update check returned an unexpected response', 'warning'); } @@ -363,6 +396,8 @@ runEngineChecks(false); }); } catch (e) { console.error("Failed to create directories on disk:", e); + showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning'); + return; } showToast("Base download folder updated", 'success'); } @@ -400,9 +435,13 @@ runEngineChecks(false); showToast("Added site credential", 'success'); }; - const copyToken = () => { - navigator.clipboard.writeText(settings.extensionPairingToken); - showToast("Token copied to clipboard!", 'success'); + const copyToken = async () => { + try { + await navigator.clipboard.writeText(settings.extensionPairingToken); + showToast("Token copied to clipboard!", 'success'); + } catch (error) { + showToast(`Could not copy token: ${String(error)}`, 'error'); + } }; const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads'; @@ -486,18 +525,15 @@ runEngineChecks(false);
Global speed limit: - 0 = unlimited speed -
-
- settings.setGlobalSpeedLimit(e.target.value)} - placeholder="0" - className="app-control w-24 text-center font-mono pr-9" - /> - KiB/s + {settings.globalSpeedLimit || 'Unlimited'}
+
@@ -759,7 +795,7 @@ runEngineChecks(false);
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9fc4d8c..22eac10 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,8 +32,15 @@ export const Sidebar: React.FC = (props) => { useEffect(() => { const handleCloseMenu = () => setContextMenu(null); + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setContextMenu(null); + }; window.addEventListener('click', handleCloseMenu); - return () => window.removeEventListener('click', handleCloseMenu); + window.addEventListener('keydown', handleEscape); + return () => { + window.removeEventListener('click', handleCloseMenu); + window.removeEventListener('keydown', handleEscape); + }; }, []); useEffect(() => { @@ -247,6 +254,7 @@ export const Sidebar: React.FC = (props) => { {contextMenu && (
Global Speed Limit

- This cap is shared across the configured concurrent download slots. A lower per-download limit still takes precedence. - Saving a new limit gracefully restarts active jobs so the change takes effect immediately. + This cap is shared by transfers running through the core downloader. A lower per-download limit still takes precedence. + Saving updates the core downloader immediately; media extraction keeps its existing per-download options.

diff --git a/src/index.css b/src/index.css index da2e626..8f6ad8b 100644 --- a/src/index.css +++ b/src/index.css @@ -1491,6 +1491,18 @@ font-size: 12px; } +html[data-list-density="compact"] .download-row, +html[data-list-density="compact"] .download-ghost-row { + height: 26px; + margin-block: 1px; +} + +html[data-list-density="relaxed"] .download-row, +html[data-list-density="relaxed"] .download-ghost-row { + height: 40px; + margin-block: 3px; +} + .download-row > div { min-width: 0; overflow: hidden; diff --git a/src/ipc.ts b/src/ipc.ts index 24f8d88..f9734a2 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -5,48 +5,13 @@ import type { DownloadCategory } from './bindings/DownloadCategory'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; import type { ExtensionDownload } from './bindings/ExtensionDownload'; -import type { MediaCookieSource } from './bindings/MediaCookieSource'; import type { MediaMetadata } from './bindings/MediaMetadata'; import type { MetadataResponse } from './bindings/MetadataResponse'; import type { EngineStatusItem } from './bindings/EngineStatusItem'; -import type { EngineStatusResult } from './bindings/EngineStatusResult'; import type { PostQueueAction } from './bindings/PostQueueAction'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; import type { PairingTokenHydration } from './bindings/PairingTokenHydration'; - -type StartDownloadArgs = { - id: string; - url: string; - destination: string; - filename: string; - connections: number | null; - speedLimit: string | null; - username: string | null; - password: string | null; - headers: string | null; - checksum: string | null; - cookies: string | null; - mirrors: string | null; - userAgent: string | null; - maxTries: number | null; - proxy: string | null; -}; - -type StartMediaDownloadArgs = { - id: string; - url: string; - destination: string; - filename: string; - formatSelector: string | null; - cookieSource: Exclude | null; - speedLimit: string | null; - username: string | null; - password: string | null; - headers: string | null; - proxy: string | null; - userAgent: string | null; - maxTries: number | null; -}; +import type { EnqueueItem } from './bindings/EnqueueItem'; type CommandMap = { fetch_metadata: { @@ -57,18 +22,13 @@ type CommandMap = { args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null }; result: MediaMetadata; }; - get_engine_status: { args: undefined; result: EngineStatusResult }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem }; get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem }; get_deno_engine_status: { args: undefined; result: EngineStatusItem }; - open_file: { args: { path: string }; result: void }; - show_in_folder: { args: { path: string }; result: void }; reveal_in_file_manager: { args: { path: string }; result: void }; open_downloaded_file: { args: { path: string }; result: void }; trash_download_assets: { args: { path: string; partialPaths: string[] }; result: void }; - start_download: { args: StartDownloadArgs; result: void }; - start_media_download: { args: StartMediaDownloadArgs; result: void }; pause_download: { args: { id: string }; result: void }; resume_download: { args: { id: string }; result: boolean }; remove_download: { args: { id: string; filepath: string | null }; result: void }; @@ -88,13 +48,14 @@ type CommandMap = { delete_file: { args: { path: string }; result: void }; toggle_tray_icon: { args: { show: boolean }; result: void }; set_extension_pairing_token: { args: { token: string }; result: void }; + get_extension_server_port: { args: undefined; result: number | null }; hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration }; acknowledge_pairing_token_change: { args: undefined; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void }; get_system_proxy: { args: undefined; result: string | null }; get_file_category: { args: { filename: string }; result: DownloadCategory }; check_for_updates: { args: undefined; result: ReleaseCheckOutcome }; - is_supported_media: { args: { url: string }; result: boolean }; + get_supported_media_domains: { args: undefined; result: string[] }; db_save_settings: { args: { data: string }; result: void }; db_load_settings: { args: undefined; result: string | null }; db_get_all_downloads: { args: undefined; result: string[] }; @@ -107,8 +68,8 @@ type CommandMap = { }; export_logs: { args: { destPath: string }; result: string }; get_pending_order: { args: undefined; result: string[] }; - enqueue_download: { args: { item: any }; result: string }; - enqueue_many: { args: { items: any[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] }; + enqueue_download: { args: { item: EnqueueItem }; result: string }; + enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] }; move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] }; remove_from_queue: { args: { id: string }; result: boolean }; }; @@ -135,6 +96,7 @@ type EventMap = { 'download-failed': string; 'extension-add-download': ExtensionDownload; 'deep-link-add-download': string; + 'tray-action': 'pause-all' | 'resume-all'; }; export function listenEvent( diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 7cf145a..28ce82b 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; -import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import type { UnlistenFn } from '@tauri-apps/api/event'; import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent'; -import type { DownloadStateEvent } from '../bindings/DownloadStateEvent'; import type { DownloadStatus } from '../bindings/DownloadStatus'; +import { listenEvent as listen } from '../ipc'; interface DownloadProgressState { progressMap: Record; @@ -28,7 +28,7 @@ let unlistenTray: UnlistenFn | null = null; export async function initDownloadListener() { if (unlistenProgress) return; - unlistenProgress = await listen('download-progress', (event) => { + unlistenProgress = await listen('download-progress', (event) => { const payload = event.payload; useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload); @@ -46,7 +46,7 @@ export async function initDownloadListener() { }); if (!unlistenState) { - unlistenState = await listen('download-state', (event) => { + unlistenState = await listen('download-state', (event) => { const payload = event.payload; const mainStore = useDownloadStore.getState(); const current = mainStore.downloads.find(d => d.id === payload.id); @@ -77,18 +77,12 @@ export async function initDownloadListener() { } if (!unlistenTray) { - unlistenTray = await listen('tray-action', (event) => { + unlistenTray = await listen('tray-action', (event) => { const mainStore = useDownloadStore.getState(); if (event.payload === 'pause-all') { - const uniqueQueues = Array.from(new Set( - mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id)) - )); - uniqueQueues.forEach(qid => mainStore.pauseQueue(qid)); + void mainStore.pauseAll(); } else if (event.payload === 'resume-all') { - const uniqueQueues = Array.from(new Set( - mainStore.downloads.map(d => d.queueId).filter((id): id is string => Boolean(id)) - )); - uniqueQueues.forEach(qid => mainStore.startQueue(qid)); + void mainStore.startAll(); } }); } diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 667902f..b6722e5 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -59,8 +59,8 @@ describe('useDownloadStore', () => { it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => { useDownloadStore.setState({ downloads: [ - { id: '1', url: 'http://test1', fileName: 'f1', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true }, - { id: '2', url: 'http://test2', fileName: 'f2', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false }, + { id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true }, + { id: '2', url: 'http://test2', fileName: 'f2', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false }, ] as any[], backendRegisteredIds: new Set(['1']), // 1 is already registered, so it skips dispatch }); @@ -82,7 +82,7 @@ describe('useDownloadStore', () => { it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => { useDownloadStore.setState({ downloads: [ - { id: '1', url: 'http://test1', fileName: 'f1', status: 'paused', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true }, + { id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true }, ] as any[], backendRegisteredIds: new Set(['1']), }); @@ -102,7 +102,7 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem }); - it('adds to the list without assigning a queue or dispatching', async () => { + it('adds to the list in the main queue without dispatching', async () => { await useDownloadStore.getState().addDownload({ id: 'list-1', url: 'https://example.com/list.bin', @@ -113,7 +113,7 @@ describe('useDownloadStore', () => { const item = useDownloadStore.getState().downloads[0]; expect(item.status).toBe('ready'); - expect(item.queueId).toBeUndefined(); + expect(item.queueId).toBe('00000000-0000-0000-0000-000000000001'); expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); }); @@ -133,7 +133,7 @@ describe('useDownloadStore', () => { expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); }); - it('starts immediately without assigning a user queue', async () => { + it('starts immediately in the main queue', async () => { vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { if (cmd === 'get_pending_order') return ['start-1']; return undefined; @@ -148,7 +148,7 @@ describe('useDownloadStore', () => { }, { type: 'start-now' }); const item = useDownloadStore.getState().downloads[0]; - expect(item.queueId).toBeUndefined(); + expect(item.queueId).toBe('00000000-0000-0000-0000-000000000001'); expect(item.hasBeenDispatched).toBe(true); expect(ipc.invokeCommand).toHaveBeenCalledWith( 'enqueue_download', @@ -158,6 +158,83 @@ describe('useDownloadStore', () => { ); }); + it('starts and pauses all items regardless of legacy missing queue ids', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' }, + { id: 'active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' }, + ] as any[], + }); + + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'get_pending_order') return ['ready']; + return undefined; + }); + + expect(await useDownloadStore.getState().startAll()).toBe(1); + expect(await useDownloadStore.getState().pauseAll()).toBe(2); + + const calls = vi.mocked(ipc.invokeCommand).mock.calls; + expect(calls.some(call => call[0] === 'enqueue_download')).toBe(true); + expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true); + }); + + it('migrates legacy downloads without queue ids into the main queue', 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: 'legacy', + url: 'https://example.com/legacy.bin', + fileName: 'legacy.bin', + status: 'ready', + category: 'Other', + dateAdded: '' + })]; + } + return undefined; + }); + + await useDownloadStore.getState().initDB(); + + expect(useDownloadStore.getState().downloads[0].queueId) + .toBe('00000000-0000-0000-0000-000000000001'); + }); + + it('pauses queued, downloading, processing, and retrying queue items', async () => { + useDownloadStore.setState({ + downloads: ['queued', 'downloading', 'processing', 'retrying'].map((status, index) => ({ + id: `${index}`, + url: `https://example.com/${index}`, + fileName: `${index}.bin`, + status, + category: 'Other', + dateAdded: '', + queueId: 'queue-a' + })) as any[] + }); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never); + + expect(await useDownloadStore.getState().pauseQueue('queue-a')).toBe(4); + expect( + vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'pause_download') + ).toHaveLength(4); + }); + + it('assigns selected unfinished downloads to a queue without moving completed items', () => { + useDownloadStore.setState({ + downloads: [ + { id: 'ready', status: 'ready', queueId: 'old' }, + { id: 'done', status: 'completed', queueId: 'old' } + ] as any[] + }); + + 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('preserves extension request headers and cookies for the Add modal', () => { useDownloadStore.getState().handleExtensionDownload({ urls: ['https://example.com/file.bin'], diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index aa35987..db36751 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1,20 +1,19 @@ import { create } from 'zustand'; import { info } from '@tauri-apps/plugin-log'; -import { homeDir } from '@tauri-apps/api/path'; import { invokeCommand as invoke } from '../ipc'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; import type { Queue } from '../bindings/Queue'; -import type { MediaMetadata } from '../bindings/MediaMetadata'; import { useSettingsStore } from './useSettingsStore'; import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads'; -import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata'; import { + expandTilde, resolveCategoryDestination, resolveDownloadFilePath } from '../utils/downloadLocations'; +import { canPauseDownload, canStartDownload } from '../utils/downloadActions'; export type { DownloadCategory } from '../utils/downloads'; @@ -31,6 +30,8 @@ export async function dispatchItem(id: string): Promise { if (state.backendRegisteredIds.has(id)) return true; const settings = useSettingsStore.getState(); + const destination = item.destination || + await resolveCategoryDestination(settings, item.category); const login = getSiteLogin(item.url, settings); let keychainPassword = null; if (login) { @@ -42,7 +43,7 @@ export async function dispatchItem(id: string): Promise { const enqueueItem = { id: item.id, url: item.url, - destination: item.destination, + destination, filename: item.fileName, connections: item.connections || settings.perServerConnections || null, speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit), @@ -126,13 +127,7 @@ const syncSystemIntegrations = () => { }; const resolveDownloadPath = async (destination: string, fileName: string) => { - let resolvedDestination = destination; - if (destination.startsWith('~/')) { - resolvedDestination = await resolveDownloadFilePath(await homeDir(), destination.slice(2)); - } else if (destination === '~') { - resolvedDestination = await homeDir(); - } - return resolveDownloadFilePath(resolvedDestination, fileName); + return resolveDownloadFilePath(await expandTilde(destination), fileName); }; const effectiveDestinationForItem = async ( @@ -165,8 +160,6 @@ interface DownloadState { backendRegisteredIds: Set; registerBackendIds: (ids: string[]) => void; unregisterBackendIds: (ids: string[]) => void; - activeDownloadId: string | null; - setActiveDownloadId: (id: string | null) => void; applyProperties: (id: string, updates: Partial) => Promise; moveInQueue: (id: string, direction: 'up' | 'down') => Promise; removeFromQueue: (id: string) => Promise; @@ -197,17 +190,14 @@ interface DownloadState { resumeDownload: (id: string) => Promise; startQueue: (queueId: string) => Promise; pauseQueue: (queueId: string) => Promise; + startAll: () => Promise; + pauseAll: () => Promise; + assignToQueue: (ids: string[], queueId: string) => void; addQueue: (name: string) => void; renameQueue: (id: string, name: string) => void; removeQueue: (id: string) => void; initDB: () => Promise; - isParsing: boolean; - activeMetadata: MediaMetadata | null; - activeMetadataUrl: string | null; - parsingError: string | null; - fetchMetadataAction: (url: string) => Promise; - clearMetadata: () => void; } export const useDownloadStore = create((set, get) => ({ @@ -215,8 +205,6 @@ export const useDownloadStore = create((set, get) => ({ queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], pendingOrder: [], setPendingOrder: (order) => set({ pendingOrder: order }), - activeDownloadId: null, - setActiveDownloadId: (id) => set({ activeDownloadId: id }), moveInQueue: async (id, direction) => { try { const order = await invoke('move_in_queue', { id, direction }); @@ -253,10 +241,6 @@ export const useDownloadStore = create((set, get) => ({ pendingAddHeaders: '', pendingAddCookies: '', selectedPropertiesDownloadId: null, - isParsing: false, - activeMetadata: null, - activeMetadataUrl: null, - parsingError: null, deleteModalState: { isOpen: false }, openDeleteModal: (downloadIds) => set({ deleteModalState: { @@ -298,24 +282,6 @@ export const useDownloadStore = create((set, get) => ({ ); }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), - clearMetadata: () => set({ isParsing: false, activeMetadata: null, activeMetadataUrl: null, parsingError: null }), - fetchMetadataAction: async (url) => { - set({ isParsing: true, parsingError: null, activeMetadata: null, activeMetadataUrl: url }); - try { - const settings = useSettingsStore.getState(); - const metadata = await fetchMediaMetadataDeduped({ - url, - cookieBrowser: settings.mediaCookieSource === 'none' ? null : settings.mediaCookieSource, - username: null, - password: null - }); - set({ isParsing: false, activeMetadata: metadata }); - info(`Media metadata parsed for ${url}: found ${metadata.formats.length} formats`); - } catch (e) { - set({ isParsing: false, parsingError: String(e) }); - info(`Media metadata parsing failed for ${url}: ${e}`); - } - }, addDownload: async (item, action) => { const settings = useSettingsStore.getState(); const destPath = await effectiveDestinationForItem(item, settings); @@ -323,7 +289,7 @@ export const useDownloadStore = create((set, get) => ({ ...item, destination: destPath, status: action.type === 'add-to-queue' ? 'queued' : 'ready', - queueId: action.type === 'add-to-queue' ? action.queueId : undefined, + queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID, hasBeenDispatched: false }; set((state) => ({ downloads: [...state.downloads, ownedItem] })); @@ -427,7 +393,10 @@ export const useDownloadStore = create((set, get) => ({ set((state) => ({ downloads: state.downloads.filter(d => d.id !== id), - pendingOrder: state.pendingOrder.filter(x => x !== id) + pendingOrder: state.pendingOrder.filter(x => x !== id), + backendRegisteredIds: new Set( + Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id) + ) })); info(`Download ${id} removed`); syncSystemIntegrations(); @@ -483,7 +452,8 @@ export const useDownloadStore = create((set, get) => ({ destination: destPath, isMedia: targetItem.isMedia, mediaFormatSelector, - queueId: targetItem.queueId + queueId: targetItem.queueId || MAIN_QUEUE_ID, + hasBeenDispatched: false }; set((state) => ({ @@ -533,13 +503,13 @@ export const useDownloadStore = create((set, get) => ({ }, startQueue: async (queueId) => { const runnable = get().downloads - .filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed')); + .filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status))); if (runnable.length === 0) return 0; let dispatchedCount = 0; const promises = runnable.map(async (item) => { - if (item.status === 'failed' || !item.hasBeenDispatched) { + if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) { if (await dispatchItem(item.id)) { get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' }); dispatchedCount++; @@ -561,7 +531,7 @@ export const useDownloadStore = create((set, get) => ({ }, pauseQueue: async (queueId) => { const activeIds = get().downloads - .filter(item => item.queueId === queueId && item.status === 'downloading') + .filter(item => item.queueId === queueId && canPauseDownload(item.status)) .map(item => item.id); if (activeIds.length === 0) return 0; @@ -578,6 +548,43 @@ export const useDownloadStore = create((set, get) => ({ syncSystemIntegrations(); return pausedCount; }, + startAll: async () => { + set(state => ({ + downloads: state.downloads.map(item => + item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID } + ) + })); + const queueIds = new Set( + get().downloads + .filter(item => item.status === 'queued' || canStartDownload(item.status)) + .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); + }, + pauseAll: async () => { + const activeIds = get().downloads + .filter(item => canPauseDownload(item.status)) + .map(item => item.id); + if (activeIds.length === 0) return 0; + + const results = await Promise.allSettled( + activeIds.map(id => invoke('pause_download', { id })) + ); + const pausedCount = results.filter(result => result.status === 'fulfilled').length; + syncSystemIntegrations(); + return pausedCount; + }, + assignToQueue: (ids, queueId) => { + const selectedIds = new Set(ids); + set(state => ({ + downloads: state.downloads.map(item => + selectedIds.has(item.id) && item.status !== 'completed' + ? { ...item, queueId } + : item + ) + })); + }, addQueue: (name) => { const id = crypto.randomUUID(); const q = { id, name, isMain: false }; @@ -615,7 +622,12 @@ export const useDownloadStore = create((set, get) => ({ set(state => ({ queues: queues.length > 0 ? queues : state.queues, - downloads: downloads.length > 0 ? downloads : state.downloads + downloads: downloads.length > 0 + ? downloads.map(download => ({ + ...download, + queueId: download.queueId || MAIN_QUEUE_ID + })) + : state.downloads })); // Reset interrupted active downloads to queued. @@ -666,9 +678,24 @@ export const useDownloadStore = create((set, get) => ({ is_media: item.isMedia || false }); } - await invoke('enqueue_many', { items: itemsToEnqueue }); + 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'); - set({ pendingOrder: order }); + set(state => ({ + pendingOrder: order, + backendRegisteredIds: new Set([ + ...state.backendRegisteredIds, + ...registeredIds + ]), + downloads: state.downloads.map(download => + failedIds.has(download.id) + ? { ...download, status: 'failed' as const } + : registeredIds.includes(download.id) + ? { ...download, hasBeenDispatched: true } + : download + ) + })); } catch (e) { console.error("Failed to auto-resume active downloads:", e); } diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 9f52576..d8346c8 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -137,7 +137,7 @@ export interface SettingsState { resetCategoryLocations: () => void; addSiteLogin: (login: SiteLogin) => void; removeSiteLogin: (id: string) => void; - regeneratePairingToken: () => void; + regeneratePairingToken: () => Promise; setAutoCheckUpdates: (autoCheckUpdates: boolean) => void; hydratePairingToken: () => Promise; } @@ -286,12 +286,10 @@ export const useSettingsStore = create()( removeSiteLogin: (id) => set((state) => ({ siteLogins: state.siteLogins.filter((login) => login.id !== id) })), - regeneratePairingToken: () => { + regeneratePairingToken: async () => { const token = generateSecureToken(); + await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }); set({ extensionPairingToken: token }); - invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }).catch(e => { - console.error('Failed to persist regenerated extension pairing token to keychain:', e); - }); }, hydratePairingToken: async () => { const result = await invoke('hydrate_extension_pairing_token'); diff --git a/src/utils/downloadActions.test.ts b/src/utils/downloadActions.test.ts new file mode 100644 index 0000000..f79297b --- /dev/null +++ b/src/utils/downloadActions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + canPauseDownload, + canRedownload, + canStartDownload, + isIdentityLocked, + isTransferLocked, + startActionLabel, +} from './downloadActions'; + +describe('download action policy', () => { + it('keeps start and pause actions mutually exclusive', () => { + for (const status of ['ready', 'paused', 'failed'] as const) { + expect(canStartDownload(status)).toBe(true); + expect(canPauseDownload(status)).toBe(false); + } + for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) { + expect(canPauseDownload(status)).toBe(true); + expect(canStartDownload(status)).toBe(false); + } + }); + + it('limits redownload to terminal or paused states', () => { + expect(canRedownload('completed')).toBe(true); + expect(canRedownload('failed')).toBe(true); + expect(canRedownload('paused')).toBe(true); + expect(canRedownload('downloading')).toBe(false); + }); + + it('provides consistent labels and edit locks', () => { + expect(startActionLabel('ready')).toBe('Start'); + expect(startActionLabel('failed')).toBe('Start'); + expect(startActionLabel('paused')).toBe('Resume'); + expect(isTransferLocked('processing')).toBe(true); + expect(isIdentityLocked('completed')).toBe(true); + expect(isTransferLocked('completed')).toBe(false); + }); +}); diff --git a/src/utils/downloadActions.ts b/src/utils/downloadActions.ts new file mode 100644 index 0000000..ed6bba7 --- /dev/null +++ b/src/utils/downloadActions.ts @@ -0,0 +1,38 @@ +import type { DownloadStatus } from '../bindings/DownloadStatus'; + +const STARTABLE_STATUSES: ReadonlySet = new Set([ + 'ready', + 'paused', + 'failed', +]); + +const PAUSABLE_STATUSES: ReadonlySet = new Set([ + 'queued', + 'downloading', + 'processing', + 'retrying', +]); + +const REDOWNLOADABLE_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'paused', +]); + +export const canStartDownload = (status: DownloadStatus): boolean => + STARTABLE_STATUSES.has(status); + +export const canPauseDownload = (status: DownloadStatus): boolean => + PAUSABLE_STATUSES.has(status); + +export const canRedownload = (status: DownloadStatus): boolean => + REDOWNLOADABLE_STATUSES.has(status); + +export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' => + status === 'ready' || status === 'failed' ? 'Start' : 'Resume'; + +export const isTransferLocked = (status: DownloadStatus): boolean => + status === 'downloading' || status === 'processing' || status === 'retrying'; + +export const isIdentityLocked = (status: DownloadStatus): boolean => + isTransferLocked(status) || status === 'completed'; diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 3831917..a03dd4a 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -3,7 +3,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { DownloadItem } from '../bindings/DownloadItem'; export type { DownloadCategory } from '../bindings/DownloadCategory'; -import { invoke } from '@tauri-apps/api/core'; +import { invokeCommand as invoke } from '../ipc'; let MEDIA_DOMAINS = [ 'youtube.com', @@ -47,7 +47,7 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n export const initMediaDomains = async () => { try { - const domains = await invoke('get_supported_media_domains'); + const domains = await invoke('get_supported_media_domains'); if (domains && domains.length > 0) { MEDIA_DOMAINS = domains; }