feat(settings): modernize download location settings

- Replace split 'Default Download Path' and 'All Categories Base' with a canonical baseDownloadFolder and categorySubfolders model
- Retain absolute category overrides only where required
- Update Add window to accurately reflect intended destination:
  - Single URL: specific category path
  - Multiple URLs: base folder with explanatory text
  - Manual Browse override: selected folder
- Centralize path resolution logic shared by Rust backend and UI
This commit is contained in:
NimBold
2026-06-20 19:22:20 +03:30
parent 79b579790d
commit 038f31b988
17 changed files with 1103 additions and 380 deletions
+423 -166
View File
@@ -1,180 +1,437 @@
# Firelink UI Interaction Inventory
# Firelink UI Interaction Inventory
## 1. Main Window & Sidebar
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.**
### Sidebar Actions
- **Select Filter/Category/Queue**
- **Location**: Sidebar item clicks (`NavItem`, `QueueItem`).
- **Component**: `Sidebar.tsx` (`onSelectFilter`)
- **Store Action**: `useSettingsStore.setActiveView('downloads')`, updates local state filter.
- **Expected**: Main table filters to show matching downloads.
- **Add Queue**
- **Location**: Sidebar '+' button.
- **Component**: `Sidebar.tsx` (`handleAddQueueSubmit`)
- **Store Action**: `useDownloadStore.addQueue()`
- **Expected**: A new custom queue is created.
- **Queue Context Menu**
- **Location**: Right-click on a custom queue.
- **Component**: `Sidebar.tsx` (`handleQueueContextMenu`)
- **Actions**:
- **Start Queue**: `useDownloadStore.startQueue()` -> IPC `start_download` (for active items)
- **Pause Queue**: `useDownloadStore.pauseQueue()` -> IPC `pause_download`
- **Rename Queue**: `useDownloadStore.renameQueue()`
- **Delete Queue**: `useDownloadStore.removeQueue()` -> IPC `db_delete_queue`
- **Expected**: Context menu opens, performing the respective queue manipulation.
- **Resize Sidebar**
- **Location**: Sidebar drag handle.
- **Component**: `App.tsx` (`startSidebarResize`)
- **Expected**: Drags and resizes the sidebar width dynamically.
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)
### Main Toolbar
- **Toggle Sidebar**
- **Location**: Top-left icon (when sidebar is hidden).
- **Component**: `DownloadTable.tsx` / `Sidebar.tsx`
- **Store Action**: `useSettingsStore.toggleSidebar()`
- **Add Download**
- **Location**: '+' Button.
- **Component**: `DownloadTable.tsx`
- **Store Action**: `useDownloadStore.toggleAddModal(true)`
- **Resume All / Pause All**
- **Location**: Toolbar Play/Pause icons.
- **Component**: `DownloadTable.tsx`
- **Store Action**: `handleResume` -> `resumeDownload`, `handlePause` -> IPC `pause_download`
- **Rust**: `commands::pause_download`
- **Expected**: Iterates through visible items and pauses/resumes them.
---
## 2. Download Table
## 1. Main Window
- **Table Column Resize**
- **Location**: Table headers drag handle.
- **Component**: `DownloadTable.tsx` (`startColumnResize`)
- **Expected**: Modifies local state grid template widths.
- **Row Double Click**
- **Location**: Table row.
- **Component**: `DownloadTable.tsx` (`handleDownloadDoubleClick`)
- **Expected**: If completed, opens file (IPC `open_downloaded_file`). Otherwise, opens properties (`useDownloadStore.setSelectedPropertiesDownloadId`).
- **Row Context Menu (Right Click)**
- **Location**: Table row.
- **Component**: `DownloadTable.tsx`
- **Actions**:
- **Open**: IPC `open_downloaded_file` -> Rust `commands::open_downloaded_file`
- **Show in Finder**: IPC `reveal_in_file_manager` -> Rust `commands::reveal_in_file_manager`
- **Pause**: IPC `pause_download` -> Rust `commands::pause_download`
- **Resume**: `useDownloadStore.resumeDownload()` -> IPC `resume_download` -> Rust `commands::resume_download`
- **Redownload**: `useDownloadStore.redownload()` -> IPC `remove_download` -> Rust `commands::enqueue_download`
- **Copy Address**: `navigator.clipboard.writeText(url)`
- **Copy File Path**: `navigator.clipboard.writeText(fullPath)`
- **Remove**: `useDownloadStore.openDeleteModal()`
- **Properties**: `useDownloadStore.setSelectedPropertiesDownloadId()`
### 1.1 Title bar / Toolbar (`DownloadTable.tsx`)
## 3. Add Downloads Window
| # | 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 190260px | Clamped in handler |
- **Input URLs**
- **Location**: Main textarea.
- **Component**: `AddDownloadsModal.tsx`
- **Expected**: Parses text, fetches metadata (IPC `fetch_metadata` or `fetchMediaMetadataDeduped`), and populates preview list.
- **Refresh Metadata**
- **Location**: "Refresh Metadata" button.
- **Component**: `AddDownloadsModal.tsx`
- **Expected**: Re-triggers metadata fetch for URLs.
- **Preview List Selection**
- **Location**: Preview items list.
- **Component**: `AddDownloadsModal.tsx` (`setSelectedItemIndex`)
- **Expected**: Updates the right-side form to reflect the selected item's specific options.
- **Media Format Selection**
- **Location**: Format list (if media).
- **Component**: `AddDownloadsModal.tsx` (`selectMediaFormat`)
- **Expected**: Updates the selected format, updating estimated sizes and file extensions.
- **Browse Save Location**
- **Location**: "Select" button next to save location.
- **Component**: `AddDownloadsModal.tsx` (`handleBrowse`)
- **Expected**: Opens Tauri native directory picker.
- **Options Toggles & Inputs**
- **Location**: Right-side pane.
- **Actions**: Change connections, toggle speed limit, set username/password, expand advanced, toggle checksum, edit headers/cookies/mirrors.
- **Expected**: Mutates local component state for the download configuration.
- **Start / Add to Queue**
- **Location**: Bottom right buttons.
- **Component**: `AddDownloadsModal.tsx` (`handleStart`)
- **Store Action**: `useDownloadStore.addDownload()`
- **IPC Command**: `enqueue_download` (called by store)
- **Rust**: `commands::enqueue_download`
- **Validation**: Checks for duplicates on disk (IPC `check_file_exists`) and in store, potentially opening `DuplicateResolutionModal`.
⚠️ **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`).
## 4. Download Properties Window
### 1.2 Download row actions (`DownloadItem.tsx`, `DownloadTable.tsx`)
- **Browse Save Location**
- **Location**: "Select" button.
- **Component**: `PropertiesModal.tsx`
- **Expected**: Opens Tauri native directory picker.
- **Form Inputs**
- **Location**: Text inputs and selects.
- **Component**: `PropertiesModal.tsx`
- **Expected**: Adjusts URL, filename, connections, speed limits, auth (matches/custom/none), advanced settings. Disabled if the download is locked/active.
- **Save Changes**
- **Location**: "Save" button.
- **Component**: `PropertiesModal.tsx`
- **Store Action**: `useDownloadStore.updateDownload()`
- **Expected**: Validates inputs, saves to store, syncs to DB.
| # | 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'` |
## 5. Settings Window
### 1.3 Row context menu (`DownloadTable.tsx`, floating menu)
- **Tab Navigation**
- **Location**: Top horizontal tabs.
- **Store Action**: `useSettingsStore.setActiveSettingsTab()`
- **Downloads Settings**
- **Location**: "Downloads" tab.
- **Actions**: Change default connections, parallel downloads (IPC `set_concurrent_limit`), global speed limit (IPC `set_global_speed_limit`), max retries, notification toggles.
- **Look and feel**
- **Location**: "Look and feel" tab.
- **Actions**: Theme switch, font size, density, dock badge toggle (IPC `update_dock_badge`), menu bar icon (IPC `toggle_tray_icon`).
- **Network**
- **Location**: "Network" tab.
- **Actions**: Change proxy mode/host/port, custom user agent.
- **Locations**
- **Location**: "Locations" tab.
- **Actions**:
- Browse Default Path
- Toggle "Ask where to save"
- Browse "All Categories Base" -> Tauri Picker -> IPC `create_category_directories` -> Rust `commands::create_category_directories`
- Browse specific category paths.
- Reset Defaults: `useSettingsStore.resetCategoryDirectories()`
- **Site Logins**
- **Location**: "Site Logins" tab.
- **Actions**:
- Add Login: IPC `set_keychain_password` -> `useSettingsStore.addSiteLogin()`
- Delete Login (Trash icon): IPC `delete_keychain_password` -> `useSettingsStore.removeSiteLogin()`
- **Engine**
- **Location**: "Engine" tab (when mounted).
- **Component**: `SettingsView.tsx` (`runEngineChecks`)
- **IPC**: `get_aria2_engine_status`, `get_ytdlp_engine_status`, `get_ffmpeg_engine_status`, `get_deno_engine_status`.
- **Expected**: Polls backend for binary status and updates UI. Re-triggered with "Refresh" button.
| # | 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 | — |
## 6. Other Modals & Actions
### 1.4 Column resize
- **Delete Confirmation Modal**
- **Location**: Prompt when removing a download.
- **Component**: `DeleteConfirmationModal.tsx`
- **Actions**:
- Remove from list: `useDownloadStore.removeDownload()` -> IPC `db_delete_download`
- Delete file & remove: `useDownloadStore.removeDownload()` + IPC `trash_download_assets` -> Rust `commands::trash_download_assets`
- **Duplicate Resolution Modal**
- **Location**: Triggered when adding a download that already exists.
- **Component**: `DuplicateResolutionModal.tsx`
- **Actions**: Select "Rename", "Replace", or "Skip".
- **Expected**: Modifies the batch of downloads added via `executeAddDownloads`.
- **Quality Modal**
- **Location**: Triggered when an active media download requires format resolution.
- **Component**: `QualityModal.tsx`
- **Expected**: Approves formats via local store or cancels metadata request.
- **Global Keyboard Shortcuts**
- **Location**: `App.tsx` (paste event listener)
- **Expected**: Pasting URLs anywhere outside inputs triggers `useDownloadStore.openAddModalWithUrls(text)`.
- **System Sleep / Power**
- **Location**: "Power" tab.
- **Expected**: Interacts with IPC `set_prevent_sleep` -> Rust `commands::set_prevent_sleep`.
| # | 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 |
## Likely Validation Methods
- **UI State**: Checking Zustand store (`useDownloadStore`, `useSettingsStore`) and React DevTools.
- **Rust Backend**: Monitoring standard output/stderr for `Invoke command` errors, and inspecting `src-tauri/src/commands.rs`.
- **File System**: Checking disk for queue persistence and newly created directories (e.g. `create_category_directories`).
### 1.5 Sidebar (`Sidebar.tsx`)
| # | 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:<id>')` | 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 | — |
### 1.6 Global handlers (App-level `useEffect` listeners)
| # | 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` |
---
## 2. Add Download Window (`AddDownloadsModal.tsx`)
| # | 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` | — | — | — | 116 (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 | — |
**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)`.
| # | 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 |
⚠️ **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.
---
## 3. Download Properties Window (`PropertiesModal.tsx`)
| # | 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` (116) | — | — | — | 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 | — |
`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).
⚠️ **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.
---
## 4. Settings Window (`SettingsView.tsx`, tabbed)
| # | Action | Tab | Store setter | IPC command | Rust fn | Validation |
|---|--------|-----|--------------|-------------|---------|------------|
| T1 | Tab select | — | `setActiveSettingsTab` | — | — | enum |
| **Downloads** | | | | | | |
| SD1 | Default connections (116) | downloads | `setPerServerConnections` | — | — | onBlur clamp 116 |
| SD2 | Parallel downloads (112) | downloads | `setMaxConcurrentDownloads` | `set_concurrent_limit` (App effect) | `set_concurrent_limit` | onBlur clamp 112 |
| SD3 | Global speed limit | downloads | `setGlobalSpeedLimit` | `set_global_speed_limit` (App effect) | `set_global_speed_limit` | free text |
| SD4 | Automatic retries (010) | downloads | `setMaxAutomaticRetries` | — | — | onBlur clamp 010 |
| 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 (165535) | 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 |
---
## 5. Tools Views
### 5.1 Scheduler (`SchedulerView.tsx`)
| # | 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 |
### 5.2 Speed Limiter (`SpeedLimiterView.tsx`)
| # | 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→'' |
### 5.3 Diagnostics (`DiagnosticsView.tsx`)
| # | 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 |
---
## 6. Menus / Tray
### 6.1 Tray menu (built twice — see risk note)
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).
---
## 7. Delete Confirmation (`DeleteConfirmationModal.tsx`)
| # | 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 | — |
---
## 8. IPC Command → Rust function map
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` |
---
## 9. Highest-Risk Areas
Ranked by likelihood × impact:
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.**
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.**
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).
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.
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`.**
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.
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.
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.
+18 -4
View File
@@ -119,12 +119,26 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
destinations.push(destination);
}
let category_destination = settings
.as_ref()
.and_then(|settings| settings.download_directories.get(&category).cloned());
let category_destination = settings.as_ref().map(|settings| {
settings
.category_directory_overrides
.get(&category)
.cloned()
.unwrap_or_else(|| {
let subfolder = settings
.category_subfolders
.get(&category)
.cloned()
.unwrap_or_else(|| category.clone());
std::path::PathBuf::from(&settings.base_download_folder)
.join(subfolder)
.to_string_lossy()
.to_string()
})
});
let default_destination = settings
.as_ref()
.map(|settings| settings.default_download_path.clone());
.map(|settings| settings.base_download_folder.clone());
if destinations.is_empty() {
let fallback_destination = category_destination.clone().or(default_destination.clone());
+3 -2
View File
@@ -227,7 +227,9 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
pub default_download_path: String,
pub base_download_folder: String,
pub category_subfolders: HashMap<String, String>,
pub category_directory_overrides: HashMap<String, String>,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
pub is_sidebar_visible: bool,
@@ -251,7 +253,6 @@ pub struct PersistedSettings {
pub ask_where_to_save_each_file: bool,
pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub download_directories: HashMap<String, String>,
pub site_logins: Vec<SiteLogin>,
// Note: `extension_pairing_token` is intentionally NOT persisted here. It
// is an HMAC shared secret and is stored in the OS keychain by the
+26 -9
View File
@@ -141,21 +141,38 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
}
#[tauri::command]
pub async fn create_category_directories(app_handle: tauri::AppHandle, paths: Vec<String>) -> Result<(), String> {
use tauri::Manager;
for path in paths {
let mut expanded = std::path::PathBuf::from(&path);
if let Some(stripped) = path.strip_prefix("~/") {
if let Ok(home) = app_handle.path().home_dir() {
expanded = home.join(stripped);
}
pub async fn create_category_directories(
app_handle: tauri::AppHandle,
base_folder: String,
subfolders: std::collections::HashMap<String, String>,
) -> Result<(), String> {
let base = crate::resolve_path(&base_folder, &app_handle);
for subfolder in subfolders.values() {
let normalized = subfolder.replace('\\', "/");
let relative = std::path::Path::new(&normalized);
if relative.is_absolute()
|| normalized
.split('/')
.any(|part| part == ".." || part.ends_with(':'))
{
return Err(format!(
"Category subfolder must be a relative path: {subfolder}"
));
}
let expanded = base.join(relative);
if !expanded.exists() {
if let Err(e) = tokio::fs::create_dir_all(&expanded).await {
eprintln!("Failed to create directory {}: {}", path, e);
log::warn!(
"Failed to create category directory '{}': {}",
expanded.display(),
e
);
}
}
}
Ok(())
}
+174 -16
View File
@@ -16,10 +16,11 @@ pub fn load_settings(app_handle: &AppHandle) -> Result<PersistedSettings, String
pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, String> {
let document = decode_document(stored)?;
let state = settings_state(&document)?;
let mut state = settings_state(&document)?.clone();
migrate_location_settings(&mut state)?;
let mut merged = serde_json::to_value(default_settings())
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
merge_json(&mut merged, state);
merge_json(&mut merged, &state);
let mut settings: PersistedSettings = serde_json::from_value(merged)
.map_err(|error| format!("invalid persisted settings: {error}"))?;
@@ -99,23 +100,138 @@ fn validate_settings(settings: &mut PersistedSettings) {
}
}
fn default_settings() -> PersistedSettings {
let download_directories = [
("Musics", "~/Downloads/Musics"),
("Movies", "~/Downloads/Movies"),
("Compressed", "~/Downloads/Compressed"),
("Documents", "~/Downloads/Documents"),
("Pictures", "~/Downloads/Pictures"),
("Applications", "~/Downloads/Applications"),
("Other", "~/Downloads/Other"),
fn default_category_subfolders() -> HashMap<String, String> {
[
("Musics", "Musics"),
("Movies", "Movies"),
("Compressed", "Compressed"),
("Documents", "Documents"),
("Pictures", "Pictures"),
("Applications", "Applications"),
("Other", "Other"),
]
.into_iter()
.map(|(category, path)| (category.to_string(), path.to_string()))
.collect::<HashMap<_, _>>();
.map(|(category, folder)| (category.to_string(), folder.to_string()))
.collect()
}
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
let parts = value
.split(['/', '\\'])
.filter(|part| {
!part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':')
})
.collect::<Vec<_>>();
if parts.is_empty() {
fallback.to_string()
} else {
parts.join("/")
}
}
fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
let state = state
.as_object_mut()
.ok_or_else(|| "persisted settings state must be an object".to_string())?;
let base = state
.get("baseDownloadFolder")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.or_else(|| {
state
.get("defaultDownloadPath")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
})
.unwrap_or("~/Downloads")
.to_string();
let mut subfolders = default_category_subfolders();
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
for (category, value) in persisted {
if let Some(folder) = value.as_str().filter(|folder| !folder.trim().is_empty()) {
let fallback = subfolders
.get(category)
.cloned()
.unwrap_or_else(|| category.clone());
subfolders.insert(
category.clone(),
normalize_category_subfolder(folder, &fallback),
);
}
}
}
let mut overrides = state
.get("categoryDirectoryOverrides")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if let Some(legacy) = state.get("downloadDirectories").and_then(Value::as_object) {
let aliases = [
("Musics", "Audio"),
("Movies", "Video"),
("Compressed", "Archives"),
("Documents", "Documents"),
("Pictures", "Images"),
("Applications", "Apps"),
("Other", "Other"),
];
for (category, alias) in aliases {
if overrides.contains_key(category) {
continue;
}
let Some(path) = legacy
.get(category)
.or_else(|| legacy.get(alias))
.and_then(Value::as_str)
.filter(|path| !path.trim().is_empty())
else {
continue;
};
let subfolder = subfolders
.get(category)
.map(String::as_str)
.unwrap_or(category);
if normalize_location_path(path) != derived_location_path(&base, subfolder) {
overrides.insert(category.to_string(), Value::String(path.to_string()));
}
}
}
state.insert("baseDownloadFolder".to_string(), Value::String(base));
state.insert(
"categorySubfolders".to_string(),
serde_json::to_value(subfolders)
.map_err(|error| format!("failed to migrate category subfolders: {error}"))?,
);
state.insert(
"categoryDirectoryOverrides".to_string(),
Value::Object(overrides),
);
state.remove("defaultDownloadPath");
state.remove("downloadDirectories");
Ok(())
}
fn normalize_location_path(path: &str) -> String {
path.replace('\\', "/").trim_end_matches('/').to_string()
}
fn derived_location_path(base: &str, subfolder: &str) -> String {
format!(
"{}/{}",
normalize_location_path(base),
subfolder.trim_matches(|character| character == '/' || character == '\\')
)
}
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
default_download_path: "~/Downloads".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders: default_category_subfolders(),
category_directory_overrides: HashMap::new(),
max_concurrent_downloads: 3,
global_speed_limit: String::new(),
is_sidebar_visible: true,
@@ -147,7 +263,6 @@ fn default_settings() -> PersistedSettings {
ask_where_to_save_each_file: false,
prevents_sleep_while_downloading: true,
media_cookie_source: MediaCookieSource::None,
download_directories,
site_logins: Vec::new(),
auto_check_updates: true,
}
@@ -184,7 +299,7 @@ mod tests {
assert!(settings.scheduler.enabled);
assert_eq!(settings.scheduler.start_time, "06:30");
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
assert_eq!(settings.default_download_path, "~/Downloads");
assert_eq!(settings.base_download_folder, "~/Downloads");
}
#[test]
@@ -201,6 +316,49 @@ mod tests {
assert!(!settings.scheduler.enabled);
}
#[test]
fn migrates_legacy_location_settings_and_preserves_custom_overrides() {
let stored = json!({
"state": {
"defaultDownloadPath": "/Users/test/Downloads",
"downloadDirectories": {
"Movies": "/Users/test/Downloads/Movies",
"Documents": "/Volumes/Archive/Documents"
}
},
"version": 1
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
assert_eq!(settings.category_subfolders["Movies"], "Movies");
assert!(!settings.category_directory_overrides.contains_key("Movies"));
assert_eq!(
settings.category_directory_overrides["Documents"],
"/Volumes/Archive/Documents"
);
}
#[test]
fn normalizes_category_subfolders_as_relative_paths() {
let stored = json!({
"state": {
"baseDownloadFolder": "/Users/test/Downloads",
"categorySubfolders": {
"Movies": "../Media/./Movies",
"Documents": "../../"
}
},
"version": 2
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.category_subfolders["Movies"], "Media/Movies");
assert_eq!(settings.category_subfolders["Documents"], "Documents");
}
#[test]
fn replaces_zero_concurrency_with_the_safe_default() {
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, defaultDownloadPath: string, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, downloadDirectories: { [key in string]: string }, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
+90 -35
View File
@@ -12,6 +12,10 @@ import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
interface RawMediaFormat {
format_id?: string;
@@ -271,7 +275,7 @@ export const AddDownloadsModal = () => {
addDownload,
queues
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const { baseDownloadFolder } = useSettingsStore();
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
@@ -289,7 +293,7 @@ export const AddDownloadsModal = () => {
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
@@ -310,7 +314,7 @@ export const AddDownloadsModal = () => {
useEffect(() => {
if (isAddModalOpen) {
setSaveLocation(defaultDownloadPath);
setSaveLocation(baseDownloadFolder);
setIsSaveLocationManual(false);
setUrls(pendingAddUrls || '');
setParsedItems([]);
@@ -338,7 +342,7 @@ export const AddDownloadsModal = () => {
pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
defaultDownloadPath,
baseDownloadFolder,
queues
]);
@@ -477,14 +481,20 @@ export const AddDownloadsModal = () => {
if (active && firstReadyIndex !== null && !isSaveLocationManual) {
setSelectedItemIndex(firstReadyIndex);
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const settingsStore = useSettingsStore.getState();
const categoryDir = (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) || settingsStore.defaultDownloadPath || '~/Downloads';
setSaveLocation(categoryDir);
if (lines.length > 1) {
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
} else {
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const categoryDir = await resolveCategoryDestination(
useSettingsStore.getState(),
category
);
if (active) setSaveLocation(categoryDir);
}
}
}
}, 400);
return () => {
@@ -513,10 +523,7 @@ export const AddDownloadsModal = () => {
const categoryLocationForFile = (fileName: string) => {
const category = categoryForFileName(fileName);
const settingsStore = useSettingsStore.getState();
return (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) ||
settingsStore.defaultDownloadPath ||
'~/Downloads';
return resolveCategoryDestination(useSettingsStore.getState(), category);
};
const handleAction = async (action: AddDownloadAction) => {
@@ -554,21 +561,33 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
} else {
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsInStore = false;
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(settings, download.category);
if (
destination === itemLocation &&
download.fileName === finalFile &&
download.status !== 'failed'
) {
fileExistsInStore = true;
break;
}
}
let fileExistsOnDisk = false;
try {
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
fileExistsOnDisk = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
} catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) {
@@ -607,8 +626,9 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -618,12 +638,26 @@ export const AddDownloadsModal = () => {
while (exists && count < 1000) {
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === newName && d.status !== 'failed';
});
let storeHas = false;
const currentSettings = useSettingsStore.getState();
for (const download of useDownloadStore.getState().downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
destination === itemLocation &&
download.fileName === newName &&
download.status !== 'failed'
) {
storeHas = true;
break;
}
}
let diskHas = false;
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
try {
diskHas = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, newName)
});
} catch(e) {}
exists = storeHas || diskHas;
count++;
}
@@ -640,15 +674,26 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === itemLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
let existingItem;
const currentSettings = useSettingsStore.getState();
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
(download.url === item.url ||
(destination === itemLocation && download.fileName === finalFile)) &&
download.status !== 'failed'
) {
existingItem = download;
break;
}
}
if (existingItem) {
await store.removeDownload(existingItem.id);
@@ -941,6 +986,16 @@ export const AddDownloadsModal = () => {
Browse
</button>
</div>
{parsedItems.length > 1 && !isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
Files will be organized into category folders.
</p>
)}
{isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
All selected downloads will use this folder.
</p>
)}
</section>
{/* Transfer Settings */}
+6 -17
View File
@@ -5,7 +5,10 @@ import { SidebarFilter } from './Sidebar';
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
import { invokeCommand as invoke } from '../ipc';
import { homeDir } from '@tauri-apps/api/path';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
interface DownloadTableProps {
filter: SidebarFilter;
@@ -57,18 +60,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
return () => window.clearTimeout(timeout);
}, [interactionError]);
const resolvePath = async (dir: string, file: string) => {
let resolvedDir = dir;
if (dir.startsWith('~/')) {
const home = await homeDir();
resolvedDir = home + '/' + dir.slice(2);
} else if (dir === '~') {
resolvedDir = await homeDir();
}
const separator = resolvedDir.endsWith('/') ? '' : '/';
return resolvedDir + separator + file;
};
const showInteractionError = (message: string, error: unknown) => {
const detail = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error);
setInteractionError(`${message}: ${detail}`);
@@ -79,10 +70,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (!fileName) return null;
const settings = useSettingsStore.getState();
const destination = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
return resolvePath(destination, fileName);
await resolveCategoryDestination(settings, item.category);
return resolveDownloadFilePath(destination, fileName);
};
const openProperties = (id: string) => {
+12 -4
View File
@@ -3,6 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
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';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -15,7 +16,7 @@ export const PropertiesModal = () => {
: null
);
const { defaultDownloadPath, perServerConnections } = useSettingsStore();
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
// Form states
const [url, setUrl] = useState('');
@@ -46,7 +47,14 @@ export const PropertiesModal = () => {
if (activeItem) {
setUrl(activeItem.url);
setFileName(activeItem.fileName);
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
if (activeItem.destination) {
setSaveLocation(activeItem.destination);
} else {
void resolveCategoryDestination(
useSettingsStore.getState(),
activeItem.category
).then(setSaveLocation);
}
setConnections(activeItem.connections || 16);
if (activeItem.speedLimit) {
@@ -85,7 +93,7 @@ export const PropertiesModal = () => {
setSelectedPropertiesDownloadId(null);
}
}
}, [selectedPropertiesDownloadId, defaultDownloadPath, setSelectedPropertiesDownloadId]);
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
if (!selectedPropertiesDownloadId || !item) return null;
@@ -179,7 +187,7 @@ export const PropertiesModal = () => {
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
</div>
</div>
+4 -3
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { categoryForFileName } from '../utils/downloads';
import { resolveCategoryDestination } from '../utils/downloadLocations';
const formatBytes = (bytes: number) => {
if (bytes === 0) return 'Unknown size';
@@ -23,7 +24,7 @@ export const QualityModal = React.memo(() => {
if (!isParsing && !activeMetadata && !parsingError) return null;
const handleConfirm = () => {
const handleConfirm = async () => {
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
@@ -34,7 +35,7 @@ export const QualityModal = React.memo(() => {
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
const category = categoryForFileName(filename);
const destination = (settings.downloadDirectories && settings.downloadDirectories[category]) || settings.defaultDownloadPath || '~/Downloads';
const destination = await resolveCategoryDestination(settings, category);
const downloadItem = {
id,
@@ -51,7 +52,7 @@ export const QualityModal = React.memo(() => {
mediaFormatSelector: format.format_id
};
void addDownload(downloadItem, { type: 'start-now' });
await addDownload(downloadItem, { type: 'start-now' });
clearMetadata();
};
+64 -55
View File
@@ -5,7 +5,6 @@ import {
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import { useDirectoryPicker } from '../hooks/useDirectoryPicker';
import {
Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
@@ -16,6 +15,11 @@ import { invokeCommand as invoke } from '../ipc';
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
DOWNLOAD_CATEGORIES,
normalizeCategorySubfolder
} from '../utils/downloadLocations';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
@@ -97,7 +101,6 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
export default function SettingsView() {
const settings = useSettingsStore();
const { pickDirectory } = useDirectoryPicker();
const activeTab = settings.activeSettingsTab;
// Local state for engine diagnostics
@@ -245,7 +248,7 @@ runEngineChecks(false);
};
const handleBrowseCategory = async (category: string) => {
const currentPath = (settings.downloadDirectories || {})[category] || '';
const currentPath = settings.categoryDirectoryOverrides[category] || settings.baseDownloadFolder;
try {
const selected = await open({
directory: true,
@@ -253,46 +256,42 @@ runEngineChecks(false);
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
settings.setCategoryDirectory(category, selected);
settings.setCategoryDirectoryOverride(category, selected);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
}
};
const handleBrowseBulk = async () => {
const handleBrowseBase = async () => {
try {
const base = await open({
directory: true,
multiple: false
multiple: false,
defaultPath: settings.baseDownloadFolder.startsWith('~')
? undefined
: settings.baseDownloadFolder
});
if (base && typeof base === 'string') {
const cleanBase = base.replace(/\/$/, '');
const paths = [
`${cleanBase}/Musics`,
`${cleanBase}/Movies`,
`${cleanBase}/Compressed`,
`${cleanBase}/Documents`,
`${cleanBase}/Pictures`,
`${cleanBase}/Applications`,
`${cleanBase}/Other`
];
settings.setCategoryDirectory('Musics', paths[0]);
settings.setCategoryDirectory('Movies', paths[1]);
settings.setCategoryDirectory('Compressed', paths[2]);
settings.setCategoryDirectory('Documents', paths[3]);
settings.setCategoryDirectory('Pictures', paths[4]);
settings.setCategoryDirectory('Applications', paths[5]);
settings.setCategoryDirectory('Other', paths[6]);
settings.setBaseDownloadFolder(base);
try {
await invoke('create_category_directories', { paths });
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
settings.categorySubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
await invoke('create_category_directories', {
baseFolder: base,
subfolders: safeSubfolders
});
} catch (e) {
console.error("Failed to create directories on disk:", e);
}
showToast("Updated and created all category folders at base");
showToast("Base download folder updated");
}
} catch (e) {
console.error("Failed to browse base path:", e);
@@ -670,20 +669,20 @@ runEngineChecks(false);
<div className="settings-pane max-w-[760px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] font-semibold text-text-primary">Default Download Path</span>
<div>
<span className="text-[13px] font-semibold text-text-primary">Base Download Folder</span>
<p className="mt-0.5 text-[11px] text-text-muted">Automatic category folders are created inside this folder.</p>
</div>
<div className="flex gap-2">
<input
type="text"
value={settings.defaultDownloadPath || ''}
onChange={(e) => settings.setDefaultDownloadPath(e.target.value)}
value={settings.baseDownloadFolder}
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
className="app-control w-64 text-[11px] px-2"
placeholder="~/Downloads"
/>
<button
onClick={async () => {
const path = await pickDirectory(settings.defaultDownloadPath);
if (path) settings.setDefaultDownloadPath(path);
}}
onClick={handleBrowseBase}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
@@ -706,46 +705,56 @@ runEngineChecks(false);
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
<div className="flex gap-2">
<input
type="text" readOnly placeholder="Choose base folder..."
className="app-control w-64 text-text-muted text-[11px] px-2"
/>
<button
onClick={handleBrowseBulk}
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
>
Browse
</button>
</div>
<span className="text-[13px] font-semibold text-text-primary">Category Subfolders</span>
<span className="text-[11px] text-text-muted">Relative to the base folder</span>
</div>
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
{DOWNLOAD_CATEGORIES.map((category) => (
<div key={category} className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">{category}</span>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<input
type="text"
value={(settings.downloadDirectories || {})[category] || ''}
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
value={settings.categorySubfolders[category] || ''}
onChange={(e) => settings.setCategorySubfolder(category, e.target.value)}
onBlur={(e) => settings.setCategorySubfolder(
category,
normalizeCategorySubfolder(
e.target.value,
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
)}
className="app-control w-64 text-[11px] px-2"
aria-label={`${category} subfolder`}
/>
<button
onClick={() => handleBrowseCategory(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
Custom folder
</button>
{settings.categoryDirectoryOverrides[category] && (
<button
onClick={() => settings.setCategoryDirectoryOverride(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Use automatic
</button>
)}
</div>
{settings.categoryDirectoryOverrides[category] && (
<p className="col-span-full pl-4 text-[10px] text-text-muted">
Override: {settings.categoryDirectoryOverrides[category]}
</p>
)}
</div>
))}
<div className="mac-settings-row justify-end border-t-0">
<button
onClick={() => {
settings.resetCategoryDirectories();
showToast("Reset directories to default");
settings.resetCategoryLocations();
showToast("Reset category locations to default");
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
>
+4 -1
View File
@@ -101,7 +101,10 @@ type CommandMap = {
db_replace_downloads: { args: { data: string }; result: void };
db_get_all_queues: { args: undefined; result: string[] };
db_replace_queues: { args: { data: string }; result: void };
create_category_directories: { args: { paths: string[] }; result: void };
create_category_directories: {
args: { baseFolder: string; subfolders: Record<string, string> };
result: void;
};
export_logs: { args: { destPath: string }; result: string };
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string };
+20
View File
@@ -12,6 +12,15 @@ vi.mock('@tauri-apps/plugin-log', () => ({
error: vi.fn(),
}));
vi.mock('@tauri-apps/api/path', () => ({
homeDir: vi.fn().mockResolvedValue('/Users/test'),
join: vi.fn(async (...parts: string[]) =>
parts
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
.join('/')
),
}));
vi.mock('./useSettingsStore', () => ({
useSettingsStore: {
getState: vi.fn(() => ({
@@ -22,6 +31,17 @@ vi.mock('./useSettingsStore', () => ({
customUserAgent: '',
maxAutomaticRetries: 3,
mediaCookieSource: 'none',
baseDownloadFolder: '~/Downloads',
categorySubfolders: {
Musics: 'Musics',
Movies: 'Movies',
Compressed: 'Compressed',
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Other: 'Other',
},
categoryDirectoryOverrides: {},
})),
}
}));
+13 -16
View File
@@ -11,6 +11,10 @@ import type { MediaMetadata } from '../bindings/MediaMetadata';
import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
export type { DownloadCategory } from '../utils/downloads';
@@ -124,21 +128,18 @@ const syncSystemIntegrations = () => {
const resolveDownloadPath = async (destination: string, fileName: string) => {
let resolvedDestination = destination;
if (destination.startsWith('~/')) {
resolvedDestination = `${await homeDir()}/${destination.slice(2)}`;
resolvedDestination = await resolveDownloadFilePath(await homeDir(), destination.slice(2));
} else if (destination === '~') {
resolvedDestination = await homeDir();
}
const separator = resolvedDestination.endsWith('/') ? '' : '/';
return `${resolvedDestination}${separator}${fileName}`;
return resolveDownloadFilePath(resolvedDestination, fileName);
};
const effectiveDestinationForItem = (
const effectiveDestinationForItem = async (
item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
) => item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category);
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -312,7 +313,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings);
const destPath = await effectiveDestinationForItem(item, settings);
const ownedItem: DownloadItem = {
...item,
destination: destPath,
@@ -453,9 +454,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const settings = useSettingsStore.getState();
const destPath = targetItem.destination ||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
await resolveCategoryDestination(settings, targetItem.category);
if (!destPath.trim()) {
throw new Error('Cannot redownload: destination folder is missing.');
@@ -639,10 +638,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const destPath = item.destination ||
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
url: item.url,
+50 -51
View File
@@ -13,6 +13,10 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
let settingsSave = Promise.resolve();
@@ -66,7 +70,9 @@ export type {
export interface SettingsState {
theme: Theme;
defaultDownloadPath: string;
baseDownloadFolder: string;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSidebarVisible: boolean;
@@ -94,13 +100,12 @@ export interface SettingsState {
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource;
downloadDirectories: Record<string, string>;
siteLogins: SiteLogin[];
extensionPairingToken: string;
autoCheckUpdates: boolean;
setTheme: (theme: Theme) => void;
setDefaultDownloadPath: (path: string) => void;
setBaseDownloadFolder: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setActiveView: (view: ActiveView) => void;
@@ -127,8 +132,9 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setCategoryDirectory: (category: string, path: string) => void;
resetCategoryDirectories: () => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
resetCategoryLocations: () => void;
addSiteLogin: (login: SiteLogin) => void;
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => void;
@@ -136,40 +142,6 @@ export interface SettingsState {
hydratePairingToken: () => Promise<boolean>;
}
const defaultDirectories = {
Musics: '~/Downloads/Musics',
Movies: '~/Downloads/Movies',
Compressed: '~/Downloads/Compressed',
Documents: '~/Downloads/Documents',
Pictures: '~/Downloads/Pictures',
Applications: '~/Downloads/Applications',
Other: '~/Downloads/Other'
};
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
if (!directories || typeof directories !== 'object') {
return { ...defaultDirectories };
}
const values = directories as Record<string, unknown>;
const directory = (current: string, legacy?: string) => {
const value = values[current] ?? (legacy ? values[legacy] : undefined);
return typeof value === 'string' && value.length > 0
? value
: defaultDirectories[current as keyof typeof defaultDirectories];
};
return {
Musics: directory('Musics', 'Audio'),
Movies: directory('Movies', 'Video'),
Compressed: directory('Compressed', 'Archives'),
Documents: directory('Documents'),
Pictures: directory('Pictures', 'Images'),
Applications: directory('Applications', 'Apps'),
Other: directory('Other')
};
};
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined'
@@ -200,7 +172,9 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
defaultDownloadPath: '~/Downloads',
baseDownloadFolder: '~/Downloads',
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
activeView: 'downloads',
@@ -236,13 +210,15 @@ export const useSettingsStore = create<SettingsState>()(
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
mediaCookieSource: 'none',
downloadDirectories: { ...defaultDirectories },
siteLogins: [],
extensionPairingToken: '',
autoCheckUpdates: true,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setDefaultDownloadPath: (path) => { info('Settings updated: defaultDownloadPath'); set({ defaultDownloadPath: path }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
},
setMaxConcurrentDownloads: (max) => {
info('Settings updated: maxConcurrentDownloads');
set({ maxConcurrentDownloads: max });
@@ -282,13 +258,28 @@ export const useSettingsStore = create<SettingsState>()(
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setCategoryDirectory: (category, path) => {
info(`Settings updated: category directory ${category}`);
setCategorySubfolder: (category, subfolder) => {
info(`Settings updated: category subfolder ${category}`);
set((state) => ({
downloadDirectories: { ...state.downloadDirectories, [category]: path }
categorySubfolders: { ...state.categorySubfolders, [category]: subfolder }
}));
},
resetCategoryDirectories: () => { info('Settings updated: resetCategoryDirectories'); set({ downloadDirectories: { ...defaultDirectories } }); },
setCategoryDirectoryOverride: (category, path) => {
info(`Settings updated: category directory override ${category}`);
set((state) => {
const next = { ...state.categoryDirectoryOverrides };
if (path?.trim()) next[category] = path.trim();
else delete next[category];
return { categoryDirectoryOverrides: next };
});
},
resetCategoryLocations: () => {
info('Settings updated: resetCategoryLocations');
set({
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {}
});
},
addSiteLogin: (login) => set((state) => ({
siteLogins: [...state.siteLogins, login]
})),
@@ -312,21 +303,29 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 1,
version: 2,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
}
const persisted = persistedState as Partial<SettingsState>;
const locations = normalizeDownloadLocationSettings(
persisted as Partial<SettingsState> & {
defaultDownloadPath?: unknown;
downloadDirectories?: unknown;
}
);
return {
...persisted,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
...locations,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
@@ -351,7 +350,6 @@ export const useSettingsStore = create<SettingsState>()(
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource,
downloadDirectories: state.downloadDirectories,
siteLogins: state.siteLogins,
autoCheckUpdates: state.autoCheckUpdates
}),
@@ -359,12 +357,13 @@ export const useSettingsStore = create<SettingsState>()(
const persisted = persistedState && typeof persistedState === 'object'
? persistedState as Partial<SettingsState>
: {};
const locations = normalizeDownloadLocationSettings(persisted);
return ({
...currentState,
...persisted,
...locations,
appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
siteLogins: Array.isArray(persisted.siteLogins)
? persisted.siteLogins
: currentState.siteLogins
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@tauri-apps/api/path', () => ({
join: vi.fn(async (...parts: string[]) =>
parts
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
.join('/')
)
}));
import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeCategorySubfolder,
normalizeDownloadLocationSettings,
resolveCategoryDestination
} from './downloadLocations';
describe('download locations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('migrates legacy derived directories without creating overrides', () => {
const settings = normalizeDownloadLocationSettings({
defaultDownloadPath: '/Users/test/Downloads',
downloadDirectories: {
Movies: '/Users/test/Downloads/Movies',
Documents: '/Users/test/Downloads/Documents'
}
});
expect(settings.baseDownloadFolder).toBe('/Users/test/Downloads');
expect(settings.categorySubfolders).toEqual(DEFAULT_CATEGORY_SUBFOLDERS);
expect(settings.categoryDirectoryOverrides).toEqual({});
});
it('preserves legacy custom category directories as overrides', () => {
const settings = normalizeDownloadLocationSettings({
defaultDownloadPath: '/Users/test/Downloads',
downloadDirectories: {
Video: '/Volumes/Media/Movies'
}
});
expect(settings.categoryDirectoryOverrides.Movies).toBe('/Volumes/Media/Movies');
});
it('resolves automatic and overridden category destinations', async () => {
const automatic = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads',
categorySubfolders: { Movies: 'Video Files' }
});
expect(await resolveCategoryDestination(automatic, 'Movies'))
.toBe('/Users/test/Downloads/Video Files');
automatic.categoryDirectoryOverrides.Movies = '/Volumes/Media';
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
});
it('keeps category subfolders relative and permits nested folders', () => {
expect(normalizeCategorySubfolder('../Media/./Movies', 'Movies')).toBe('Media/Movies');
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
expect(normalizeCategorySubfolder('../../', 'Movies')).toBe('Movies');
});
});
+130
View File
@@ -0,0 +1,130 @@
import { join } from '@tauri-apps/api/path';
import type { DownloadCategory } from '../bindings/DownloadCategory';
export const DOWNLOAD_CATEGORIES: DownloadCategory[] = [
'Musics',
'Movies',
'Compressed',
'Documents',
'Pictures',
'Applications',
'Other'
];
export const DEFAULT_CATEGORY_SUBFOLDERS: Record<DownloadCategory, string> = {
Musics: 'Musics',
Movies: 'Movies',
Compressed: 'Compressed',
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Other: 'Other'
};
export interface DownloadLocationSettings {
baseDownloadFolder: string;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
}
interface LegacyDownloadLocationSettings {
baseDownloadFolder?: unknown;
categorySubfolders?: unknown;
categoryDirectoryOverrides?: unknown;
defaultDownloadPath?: unknown;
downloadDirectories?: unknown;
}
const stringRecord = (value: unknown): Record<string, string> => {
if (!value || typeof value !== 'object') return {};
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
.map(([key, path]) => [key, path.trim()])
);
};
const normalizedForComparison = (value: string): string =>
value.replace(/\\/g, '/').replace(/\/+$/, '');
const legacyDerivedPath = (base: string, subfolder: string): string =>
`${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`;
export const normalizeCategorySubfolder = (
value: string,
fallback: string
): string => {
const parts = value
.trim()
.replace(/\\/g, '/')
.split('/')
.filter(part => part && part !== '.' && part !== '..' && !part.endsWith(':'));
return parts.join('/') || fallback;
};
export const normalizeDownloadLocationSettings = (
value: LegacyDownloadLocationSettings
): DownloadLocationSettings => {
const baseDownloadFolder =
(typeof value.baseDownloadFolder === 'string' && value.baseDownloadFolder.trim()) ||
(typeof value.defaultDownloadPath === 'string' && value.defaultDownloadPath.trim()) ||
'~/Downloads';
const persistedSubfolders = stringRecord(value.categorySubfolders);
const categorySubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
persistedSubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
const categoryDirectoryOverrides = stringRecord(value.categoryDirectoryOverrides);
const legacyDirectories = stringRecord(value.downloadDirectories);
const legacyAliases: Record<DownloadCategory, string> = {
Musics: 'Audio',
Movies: 'Video',
Compressed: 'Archives',
Documents: 'Documents',
Pictures: 'Images',
Applications: 'Apps',
Other: 'Other'
};
for (const category of DOWNLOAD_CATEGORIES) {
const legacyDirectory =
legacyDirectories[category] || legacyDirectories[legacyAliases[category]];
if (categoryDirectoryOverrides[category] || !legacyDirectory) continue;
const expected = legacyDerivedPath(baseDownloadFolder, categorySubfolders[category]);
if (normalizedForComparison(legacyDirectory) !== expected) {
categoryDirectoryOverrides[category] = legacyDirectory;
}
}
return {
baseDownloadFolder,
categorySubfolders,
categoryDirectoryOverrides
};
};
export const resolveCategoryDestination = async (
settings: DownloadLocationSettings,
category: DownloadCategory
): Promise<string> => {
const override = settings.categoryDirectoryOverrides[category]?.trim();
if (override) return override;
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
const subfolder =
normalizeCategorySubfolder(
settings.categorySubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
);
return join(base, subfolder);
};
export const resolveDownloadFilePath = async (
destination: string,
fileName: string
): Promise<string> => join(destination, fileName);