mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-29 05:19:45 +00:00
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:
+423
-166
@@ -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 190–260px | 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` | — | — | — | 1–16 (disabled for media) | min/max |
|
||||
| A8 | Limit speed per file (checkbox+input) | `setSpeedLimitEnabled`/`setSpeedLimit` | — | — | — | Per-file KiB/s cap | numeric |
|
||||
| A9 | Use authorization (checkbox+user/pass) | `setUseAuth`/`setUsername`/`setPassword` | — | — | — | Sends basic auth | useAuth flag |
|
||||
| A10 | Advanced toggle | `setAdvancedExpanded` | — | — | — | Expands advanced fields | — |
|
||||
| A11 | Verify Checksum (algo+digest) | `setChecksumEnabled`/`setChecksumAlgo`/`setChecksumValue` | — | — | — | Sends `algo=digest` | checksumEnabled |
|
||||
| A12 | Headers / Cookies / Mirrors | `setHeaders`/`setCookies`/`setMirrors` | — | — | — | Raw strings forwarded | trim/empty |
|
||||
| A13 | Add to Queue | `handleStart(false)` → `executeAddDownloads(false,…)` | `addDownload` (status `'paused'`) | `enqueue_download` (via dispatchItem) | `enqueue_download` | Adds paused items | duplicate detection |
|
||||
| A14 | Start Downloads | `handleStart(true)` → `executeAddDownloads(true,…)` | `addDownload` (status `'queued'`) + immediate dispatch | `enqueue_download` | `enqueue_download` | Adds + starts items | duplicate detection |
|
||||
| A15 | Cancel | `toggleAddModal(false)` | `useDownloadStore.toggleAddModal` | — | — | Closes modal | — |
|
||||
|
||||
**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` (1–16) | — | — | — | per-file connections | disabled if transferLocked |
|
||||
| P5 | Limit speed (checkbox+value) | `setSpeedLimitEnabled`/`setSpeedLimitValue` | — | — | — | per-file KiB/s | disabled if transferLocked |
|
||||
| P6 | Login mode toggle (matching/custom/none) | `setLoginMode` | — | — | — | Credentials source | disabled if transferLocked |
|
||||
| P7 | Custom username/password | `setUsername`/`setPassword` | — | — | — | Override login | custom mode only |
|
||||
| P8 | Checksum (verify+algo+digest) | `setChecksumEnabled`/`setChecksumAlgorithm`/`setChecksumValue` | — | — | — | `algo=digest` | disabled if transferLocked |
|
||||
| P9 | Cookies / Headers / Mirrors | `setCookies`/`setHeaders`/`setMirrors` | — | — | — | Advanced fields | disabled if transferLocked |
|
||||
| P10 | Advanced toggle | `setAdvancedExpanded` | — | — | — | Expand/collapse | — |
|
||||
| P11 | Save | `handleSave` → `applyProperties(id, updates)` | `useDownloadStore.applyProperties` | `remove_from_queue`/`detach_download_for_reconfigure`/`enqueue_download` (re-dispatch) | `remove_from_queue`, `detach_download_for_reconfigure`, `enqueue_download` | Updates item; re-attaches backend | status guard (throws if active) |
|
||||
| P12 | Cancel | `setSelectedPropertiesDownloadId(null)` | as above | — | — | Closes modal | — |
|
||||
|
||||
`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 (1–16) | downloads | `setPerServerConnections` | — | — | onBlur clamp 1–16 |
|
||||
| SD2 | Parallel downloads (1–12) | downloads | `setMaxConcurrentDownloads` | `set_concurrent_limit` (App effect) | `set_concurrent_limit` | onBlur clamp 1–12 |
|
||||
| SD3 | Global speed limit | downloads | `setGlobalSpeedLimit` | `set_global_speed_limit` (App effect) | `set_global_speed_limit` | free text |
|
||||
| SD4 | Automatic retries (0–10) | downloads | `setMaxAutomaticRetries` | — | — | onBlur clamp 0–10 |
|
||||
| SD5 | Show notification on completion | downloads | `setShowNotifications` | — | — | toggle |
|
||||
| SD6 | Play sound on completion | downloads | `setPlayCompletionSound` | — | — | requires SD5 |
|
||||
| **Look & Feel** | | | | | | |
|
||||
| SL1 | Theme (system/light/dark/dracula/nord) | lookandfeel | `setTheme` | — | — | App effect applies classes |
|
||||
| SL2 | Font size | lookandfeel | `setAppFontSize` | — | — | `data-font-size` attr |
|
||||
| SL3 | List row density | lookandfeel | `setListRowDensity` | — | — | enum |
|
||||
| SL4 | Dock badge | lookandfeel | `setShowDockBadge` | `update_dock_badge` (setter + effect) | `update_dock_badge` | toggle; clears badge when off |
|
||||
| SL5 | Menu bar icon | lookandfeel | `setShowMenuBarIcon` | `toggle_tray_icon` (App effect) | `toggle_tray_icon` | toggle |
|
||||
| **Network** | | | | | | |
|
||||
| SN1 | Proxy mode (none/system/custom) | network | `setProxyMode` | — | — | radio |
|
||||
| SN2 | Proxy host | network | `setProxyHost` | — | — | shown if custom |
|
||||
| SN3 | Proxy port (1–65535) | network | `setProxyPort` | — | — | onBlur clamp |
|
||||
| SN4 | Custom User Agent | network | `setCustomUserAgent` | — | — | free text + datalist |
|
||||
| **Locations** | | | | | | |
|
||||
| SO1 | Default download path (text+Browse) | locations | `setDefaultDownloadPath` | — | — | `pickDirectory` |
|
||||
| SO2 | Ask where to save each file | locations | `setAskWhereToSaveEachFile` | — | — | toggle |
|
||||
| SO3 | All Categories Base Browse | locations | `setCategoryDirectory` ×7 | `create_category_directories` | `parity::create_category_directories` | creates dirs on disk |
|
||||
| SO4 | Per-category path (text+Browse) | locations | `setCategoryDirectory` | — | — | `handleBrowseCategory` |
|
||||
| SO5 | Reset Defaults | locations | `resetCategoryDirectories` | — | — | restores defaults |
|
||||
| **Site Logins** | | | | | | |
|
||||
| SLg1 | Delete login | sitelogins | `removeSiteLogin` | `delete_keychain_password` | `delete_keychain_password` | removes keychain entry |
|
||||
| SLg2 | Add login (pattern/user/pass) | sitelogins | `addSiteLogin` | `set_keychain_password` | `set_keychain_password` | pattern+user non-empty |
|
||||
| **Power** | | | | | | |
|
||||
| SP1 | Prevent sleep while downloading | power | `setPreventsSleepWhileDownloading` | `set_prevent_sleep` (syncSystemIntegrations + setter) | `set_prevent_sleep` | keepawake on/off |
|
||||
| **Engine** | | | | | | |
|
||||
| SE1 | Recheck engines | engine | — | `get_*_engine_status` ×4 | `check_*` | force re-run; cached otherwise |
|
||||
| SE2 | Show/hide technical details | engine | — (local) | — | — | expand card |
|
||||
| SE3 | Browser Cookies Source | engine | `setMediaCookieSource` | — | — | enum (none/safari/chrome/firefox/edge/brave) |
|
||||
| **Integrations** | | | | | | |
|
||||
| SI1 | Copy Token | integrations | — (clipboard) | — | — | copies pairing token |
|
||||
| SI2 | Regenerate token | integrations | `regeneratePairingToken` | `set_keychain_password` + `set_extension_pairing_token` (App effect) | both | mints new token |
|
||||
| SI3 | Get Extension links | integrations | — (anchor) | — | — | opens external URLs |
|
||||
| **About** | | | | | | |
|
||||
| SA1 | Check for Updates | about | — | `check_for_updates` | `parity::check_for_updates` | toast result |
|
||||
| SA2 | Auto-check updates toggle | about | `setAutoCheckUpdates` | — | — | switch |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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});
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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: {},
|
||||
})),
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user