From 038f31b98819277cb57d9e0c1fbf6079eded88c7 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 20 Jun 2026 19:22:20 +0330 Subject: [PATCH] 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 --- INTERACTION_REVIEW.md | 589 +++++++++++++++++++-------- src-tauri/src/download_ownership.rs | 22 +- src-tauri/src/ipc.rs | 5 +- src-tauri/src/parity.rs | 35 +- src-tauri/src/settings.rs | 190 ++++++++- src/bindings/PersistedSettings.ts | 2 +- src/components/AddDownloadsModal.tsx | 125 ++++-- src/components/DownloadTable.tsx | 23 +- src/components/PropertiesModal.tsx | 16 +- src/components/QualityModal.tsx | 7 +- src/components/SettingsView.tsx | 119 +++--- src/ipc.ts | 5 +- src/store/useDownloadStore.test.ts | 20 + src/store/useDownloadStore.ts | 29 +- src/store/useSettingsStore.ts | 101 +++-- src/utils/downloadLocations.test.ts | 65 +++ src/utils/downloadLocations.ts | 130 ++++++ 17 files changed, 1103 insertions(+), 380 deletions(-) create mode 100644 src/utils/downloadLocations.test.ts create mode 100644 src/utils/downloadLocations.ts diff --git a/INTERACTION_REVIEW.md b/INTERACTION_REVIEW.md index c3e3b06..a81c9de 100644 --- a/INTERACTION_REVIEW.md +++ b/INTERACTION_REVIEW.md @@ -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:')` | 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. diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 03825a4..b3fe15c 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -119,12 +119,26 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result, + pub category_directory_overrides: HashMap, 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, pub site_logins: Vec, // Note: `extension_pairing_token` is intentionally NOT persisted here. It // is an HMAC shared secret and is stored in the OS keychain by the diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index dfeb158..0a26c72 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -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) -> 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, +) -> 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(()) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 12a2e25..cb41cd2 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -16,10 +16,11 @@ pub fn load_settings(app_handle: &AppHandle) -> Result Result { 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 { + [ + ("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::>(); + .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::>(); + 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}); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 143af38..a848053 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, 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, autoCheckUpdates: boolean, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 29795a3..9454574 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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(MAIN_QUEUE_ID); @@ -289,7 +293,7 @@ export const AddDownloadsModal = () => { const actionMenuRef = useRef(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 + {parsedItems.length > 1 && !isSaveLocationManual && ( +

+ Files will be organized into category folders. +

+ )} + {isSaveLocationManual && ( +

+ All selected downloads will use this folder. +

+ )} {/* Transfer Settings */} diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 637f34b..6f34dd2 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -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 = ({ 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 = ({ 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) => { diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 6b09aae..da3b7e5 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -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 = () => {
Last try-
Date added{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
-
Destination{item.destination || defaultDownloadPath}
+
Destination{saveLocation || baseDownloadFolder}
diff --git a/src/components/QualityModal.tsx b/src/components/QualityModal.tsx index 305a8c9..f01199d 100644 --- a/src/components/QualityModal.tsx +++ b/src/components/QualityModal.tsx @@ -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(); }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 853435e..2dbd6ef 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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);
- Default Download Path +
+ Base Download Folder +

Automatic category folders are created inside this folder.

+
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" /> -
+ Category Subfolders + Relative to the base folder
- {['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => ( + {DOWNLOAD_CATEGORIES.map((category) => (
{category} -
+
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`} /> + {settings.categoryDirectoryOverrides[category] && ( + + )}
+ {settings.categoryDirectoryOverrides[category] && ( +

+ Override: {settings.categoryDirectoryOverrides[category]} +

+ )}
))}