All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Packages persistence (PRD §6.3, PRD-v2 §P1.7, task 26): SQLite
packagestable (migrationm20260429_000007) with the schema mandated by PRD-v2 §8 P1 —id TEXT PRIMARY KEY,name,source_type(container/playlist/manual/split_archive), nullablefolder_path, nullablepassword(keyring ref),auto_extract(default1),priority(default5),created_at. The legacy stubpackagestable from migration 1 (BIGINT id, name only, never wired) is dropped and recreated. The migration also addsdownloads.package_id TEXT REFERENCES packages(id) ON DELETE SET NULLplus theidx_downloads_packageindex, so deleting a package detaches its members without losing the rows. NewPackageRepositorydriven port (save/find_by_id/list/delete/list_downloads) andSqlitePackageRepoadapter with sea-orm entity +from_domain/into_domainconverters. Upserts preserve the originalcreated_atso list ordering stays stable across re-saves;listorders by(created_at asc, id asc);list_downloadsorders byqueue_position asc, id ascso the caller surfaces members in scheduling order. DomainPackageaggregate gained the new persisted fields plus aPackageId(String)typed wrapper and aPackageSourceTypeenum (round-trips viaDisplay/FromStr);download_idsstays in-memory (the FK ondownloads.package_idis the source of truth on disk).DomainEvent::PackageCreated.idswitches fromu64toPackageIdto match. Twenty-one new unit tests cover the four acceptance criteria (fresh + existing-DB migration, FKON DELETE SET NULLsemantics, full-field round-trip, ≥85 % adapter coverage), plus error paths (unknownsource_type, priority overflow,created_atoverflow), source-type round-trip per variant, optional fields persisting asNULL,list_downloadsfiltering and ordering, and theInMemoryPackageRepositorymock used by future command / query handlers. Unblocks tasks 27 (Commands Packages), 28 (Queries Packages), 30 (auto-grouping playlist) and 31 (auto-grouping split archives). - Account rotation on quota (PRD §6.4, PRD-v2 §P1.6, task 25): new
AccountRotatorapplication service detects quota exhaustion (HTTP429ortraffic_leftbelow a caller-supplied threshold viais_quota_signal), pulls the offending account out of rotation for a hoster-specific cooldown viamark_exhausted(account_id, service_name, ttl_secs), and asks the existingAccountSelectorfor the next best candidate vianext_account(service, strategy) -> NextAccountOutcome. The outcome enum distinguishes three caller-actionable states:Picked(Account)(use the credential),NoneAvailable(no enabled / non-expired account configured — fall back to the free path or surface a UI hint), andAllExhausted { next_eligible_at_ms }(every eligible account is on cooldown — stall the download inWaitinguntil the earliest deadline so the scheduler can retry without busy-looping).NextAccountOutcome::error_message(service_name)returns the PRD §6.4 standard wording ("All accounts exhausted for {service}"/"No account available for {service}") so callers attaching the error toDownload.errorstay uniform across hosters. Cooldown lifecycle:record_traffic_refresh(account_id, traffic_left, threshold)clears the marker only when the upstream confirmstraffic_left >= threshold(aNoneobservation or below-threshold value leaves the marker in place so a hoster without a traffic counter cannot silently undo everymark_exhausted);clear_exhausted(account_id)is the explicit reset path, idempotent for unknown ids; expired entries are pruned lazily on the nextnext_accountcall so no background sweeper is needed. The exhaustion map sits behind astd::sync::MutexinAccountRotator(intentionally NOT persisted in SQLite — a process restart wipes the cooldown, which is the desired behaviour for the 5-to-15-minute hoster reset window); a poisoned mutex surfaces asAppError::Validation("exhausted accounts mutex poisoned")so callers can distinguish "no candidate" from "internal state corrupted", matchingAccountSelector::pick_round_robin's contract. TheAllExhausteddeadline restricts its scan to accounts that actually belong to the queried service so a parallel-service entry cannot leak its cooldown into an unrelated answer. NewAccountSelector::select_best_excluding(service, strategy, exclude_ids)extends the existingselect_bestwith an exclude list (no caching, no behaviour change for emptyexclude); the prior signature is now a thin wrapper. NewDomainEvent::AccountExhausted { id, service_name, exhausted_until_ms }forwarded by the Tauri bridge asaccount-exhausted(camelCaseexhaustedUntilMs). New transientAccount::exhausted_until: Option<u64>field withmark_exhausted/clear_exhausted/is_exhausted(now_ms)/exhausted_until()methods — the field is reset toNonebyAccount::reconstructso the rotator's in-memory map remains the single source of truth even though SQLite roundtrips drop the marker. NewCommandBus::with_account_rotator/account_rotator()builder & accessor wires the rotator alongside the existingAccountSelector. Twenty-two new unit tests cover the four acceptance criteria (429 → next account,all exhausted → AllExhausted with earliest deadline,traffic-refresh clears cooldown when above threshold, full rotator + selector-exclude integration), plus edge cases: zero-TTL no-op, deadline-exclusive equality, cross-service deadline isolation,None-traffic refresh keeps cooldown,404/500ignored byis_quota_signal, threshold-equality below-but-not-above, idempotentclear_exhausted, lazy cooldown expiry surfaces an account back into rotation. Unblocks task 38 (vortex-mod-1fichier free + premium) which is the first hoster to wire the rotation flow. - Account auto-selection (PRD §6.4, PRD-v2 §P1.5, task 24): new
AccountSelectorapplication service picks the bestAccountper service for the liveAppConfig::account_selection_strategy. Three strategies:BestTraffic(default, ranksenabled → not expired → most traffic_left → most recent last_validated → smallest idwithUnlimitedtraffic ranking above any finite value),RoundRobin(per-service cursor over enabled non-expired candidates ordered by id; a poisoned cursor mutex now surfaces asAppError::Validation("round-robin cursor mutex poisoned")so it stays distinguishable from "no eligible account"), andManual(fallback alias ofBestTrafficuntil pinning UI lands). The selector readsAccountRepository::list_by_serviceon every call instead of caching: the previous event-driven invalidation could read stale rows whenselect_bestlanded betweenbus.publish(AccountUpdated)and the spawnedTokioEventBussubscriber firing. NewCommandBus::resolve_account_for(service_name)exposes the selector to download / link-grabber flows; failures fromConfigStore::get_config()propagate via?instead of being swallowed by a default-strategy fallback. NewDomainEvent::NoAccountAvailable { service_name }(emitted when no candidate passes the filter) andDomainEvent::AccountSelected { id, service_name, strategy }(emitted whenever a pick is made), both forwarded by the Tauri bridge asno-account-available/account-selected. Newaccount_selection_strategyfield onAppConfig/ConfigPatch/apply_patchplus the matching IPC and TOML serialisation paths (snake_case"best_traffic" | "round_robin" | "manual"). The IPC layer rejects unknown strategy values:ConfigPatchDto→ConfigPatchisTryFromandsettings_updatesurfacesinvalid account selection strategy: …instead of silently ignoring a typo. The TOML store mirrors the rule:ConfigDto→AppConfigis alsoTryFrom, so a hand-editedconfig.tomlcarrying an unknown strategy value now fails fast withStorageError("invalid config: …")instead of silently coercing tobest_traffic. Backward compat is preserved: a legacyconfig.tomlwritten before this field existed deserializes the missing key as the empty string via#[serde(default)], and that empty case is treated asBestTrafficso an upgrade does not break startup. Eighteen unit tests cover the four acceptance criteria (3-account scenario, all-expired surface, comparative ranking table, round-robin alternance), repo-fresh selection, poisoned-cursor surfacing, IPC rejection of unknown strategies, TOML-store rejection of unknown persisted strategies, legacy-config (missing strategy field) backward compat, and config-error propagation. Unblocks task 25 (rotation auto sur quota). - Accounts view (PRD §6.4, PRD-v2 §P1.4, task 23): full Accounts management UI replacing the previous
PlaceholderView. Header tabs (All/Debrid/Premium/Free) drive a category filter on top of the SQLite-backedaccount_listquery, with the(filter, all)count rendered next to each label. Each row exposes the service, username, account type, derived status badge (Active/Expired/Disabled/Unverified), an aria-labelled traffic progress bar (used / total formatted viaformatBytes),valid_untilandlast_validatedcolumns, an enable/disableSwitch, an inlineValidatebutton, and a kebab menu withEdit/Delete. The newAddAccountDialogvalidates non-empty service / username / password before submission.EditAccountDialogposts a partialAccountPatch(skips fields that did not change so the keyring rotation only fires when the password field is filled). TheDeleteaction honours the existingsettings.confirm_deletetoggle: when enabled it pops the newDeleteAccountDialog(translated description naming the row), otherwise it deletes immediately.ImportAccountsDialogcallstauri-plugin-dialog's file-pick to anchor the encrypted bundle path, prompts for the passphrase, then callsaccount_importand invalidates the list cache so freshly-imported rows appear without a manual refresh;ExportAccountsDialogrequires the user to confirm the passphrase, opens the nativesavedialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existingCommandBus/QueryBushandlers (tasks 21, 22) to the frontend:account_add,account_update,account_delete,account_validate,account_export,account_import,account_list,account_get,account_traffic_get, all registered ininvoke_handler!and re-exported fromlib.rs. The runtime now wiresSqliteAccountRepoto both buses and provides theKeyringAccountStore+AesGcmPbkdf2Codecadapters to theCommandBus. AddsuseAccountsQuery(TanStack Query, 30 sstaleTime) andaccountQueriescache key factory. New i18n namespaceaccounts.*covers titles, status badges, dialog copy and toast messages inen.json+fr.json. 13 Vitest tests cover render, empty state, category filter, add → IPC → toast flow, delete → confirm → IPC, export trigger disabled when no accounts, export with passphrase, import with file picker.AccountValidatoris intentionally not wired in this commit —account_validatereturns the configuredValidationerror until the first hoster plugin lands (task 38), letting the UI render the failure toast without crashing. The "volume per account" stat from the requirements list is deferred untilhistorygains anaccount_idcolumn. - Accounts queries (PRD §6.4, PRD-v2 §P1.3, task 22): three CQRS query handlers (
list_accounts,get_account,get_account_traffic) wired through theQueryBusbuilder via a newwith_account_reposetter. New read modelsAccountViewDtoandAccountTrafficDto(#[serde(rename_all = "camelCase")]) expose every persisted field —id,service_name,username,account_type,enabled,traffic_left,traffic_total,valid_until,last_validated,created_at,credential_ref— and never carry a password or raw credential field, by construction.AccountFilter { service_name?, account_type?, enabled? }AND-combines filters:service_nameis delegated to the repo'slist_by_servicefor SQL-level pruning, whileaccount_typeandenabledfilter in memory.get_account_trafficreturns the persisted counters; the upstream-refresh path is the existingaccount_validatecommand (task 21), keeping queries side-effect free per the project CQRS rule. 21 new unit tests against anInMemoryAccountRepoForQueriesfixture cover filter combinations, missing-id 404s, missing-repo validation errors, camelCase serialization shape, and explicit "no password field" assertions onserde_json::to_valueoutput. Unblocks task 23 (Vue Accounts). - Accounts commands (PRD §6.4, PRD-v2 §P1.2, task 21): six application-layer command handlers (
add_account,update_account,delete_account,validate_account,export_accounts,import_accounts) wired through theCommandBusbuilder. New driven portsAccountCredentialStore,AccountValidator(withValidationOutcome) andPassphraseCodeckeep handlers free of plugin / crypto dependencies.KeyringAccountStoreadapter persists per-account passwords undervortex-account-{id}keyring entries;AesGcmPbkdf2Codecadapter implements the import / export bundle format using AES-256-GCM with a PBKDF2-HMAC-SHA256 200 000-iteration KDF, fresh per-call salt + nonce, header bound as AAD, and aVORTACCmagic + version byte so tampered or downgraded bundles fail authentication. Domain eventsAccountAdded,AccountUpdated,AccountDeleted,AccountValidated,AccountValidationFailed,AccountsImported,AccountsExportedpublished viaEventBusand forwarded by the Tauri bridge asaccount-*browser events. Add rolls back the SQLite row when the keyring write fails so credentials never end up orphaned; import validates every entry up-front and skips(service_name, username)pairs already present without inserting partial state. Unblocks task 23 (Vue Accounts). - Accounts persistence (PRD §6.4, PRD-v2 §P1.1, task 20): SQLite
accountstable (migrationm20260428_000006) withid/service_name/username/account_type/enabled/traffic_left/traffic_total/valid_until/last_validated/created_atcolumns and a UNIQUE(service_name, username)index. NewAccountRepositorydriven port (save/find_by_id/list/list_by_service/delete) andSqliteAccountRepoadapter with sea-orm entity +from_domain/into_domainconverters. UNIQUE violations surface asDomainError::AlreadyExistsinstead of leaking storage errors. DomainAccountaggregate gainedtraffic_total,last_validated,created_atfields and switched its identifier toAccountId(String)so generated account ids match the spec'sTEXT PRIMARY KEY.Account::credential_ref()returns akeyring://{service}/{username}URI exposing a logical reference suitable for diagnostics; passwords themselves are never persisted to SQLite — they live in the OS keychain via theAccountCredentialStoreadapter (added in task 21, keyed byAccountId). Unblocks tasks 21-25, 38, 51-56, 75-76.
First public beta of Vortex, completing Phase 0 of the v2 roadmap (PRD-v2 §P0). Every placeholder view in the v0.1 Tauri scaffold ships as a real, wired-to-backend feature, the queue/scheduler now respects the persisted max_concurrent_downloads value, completed downloads project into the history and statistics read models for KPI dashboards and re-download flows, and the integrity pipeline can verify SHA-256 / MD5 checksums end-to-end. The plugin store gains dynamic per-plugin configuration UIs and a "report broken" action; the system tray pulses while transfers are active; desktop notifications surface filename + size on completion and the failure reason on errors. Targeted at testers — REST API, browser extension and headless CLI are deferred to v0.3+.
- History view (PRD §6.8, task 03): live grouped list, debounced search, period filter tabs (All / Completed / Failed / Cancelled), per-row Re-download / Copy URL / Delete / Open folder actions, CSV / JSON export via native save dialog. Backed by a new
HistoryRepositorywithlist/search/find_by_id/delete_by_id/delete_all/delete_older_than. - Statistics view (PRD §6.9, task 04): seven KPI cards, four Recharts visualisations (daily volume, top hosts, type breakdown, average speed) and a Top-5 modules ranking, period-bound (7d / 30d / all-time). Sourced from
stats_get+stats_top_modules+history_list, with consistent period filtering. - Checksum integrity (PRD §7.1, task 06): post-download SHA-256 / MD5 verification (algorithm auto-detected from hash format) streams files in 8 MB chunks. Mismatch →
Errorstate withChecksumMismatchevent + persisted reason; match →Completedwithchecksum_computed+checksum_algorithmcolumns. Newdownload_verify_checksum(id)IPC re-runs validation on demand. - Dynamic segment splitting (PRD §7.1, task 17): when a parallel segment finishes early, the engine picks the slowest still-running segment whose remaining range exceeds
dynamic_split_min_remaining_mb(default 4 MiB) and shrinks it in place — a fresh worker takes the upper half. Atomic.vortex-metarewrite per split so a crash mid-split resumes consistently. Live config viasettings_update(no engine restart). - Queue reorder (PRD §6.1, §7.1, task 12): drag-and-drop reorder, Move-to-Top / Move-to-Bottom row actions, persistent
queue_position(migrationm20260425_000004). Priority + position trigger immediateQueueManagerrescheduling so a high-priority item starts as soon as a slot frees. - Change directory / Move download (PRD §6.1, task 13): per-row + bulk
Move to...action, same-FSfs::renamefirst then copy + size-verify + delete-source for cross-device, with.vortex-metasidecar relocation and post-move engine resume. - Re-download (PRD §6.1, §6.8, task 09): clones a
Downloadaggregate or aHistoryEntryinto a fresh row preserving URL / filename / segments / priority / module / account. Tagged-union response ({kind: "created"} | {kind: "fileExists"}) drives theOverwrite / Keep both / Canceldialog. - Open file / Open folder (PRD §6.1, task 08): two new IPC commands launch the OS default app or reveal in the file manager via a
FileOpenerport (xdg-open/open -R/explorer /select,). - Browse-folder dialog (PRD §6.10, task 07): General-settings Browse button now opens the native picker via
tauri-plugin-dialog. ReusableuseBrowseFolder/useBrowseFilehooks ready for package destinations and other path pickers. - Clipboard monitoring toggle (PRD §7.3, task 10): functional
Switchin the Link Grabber header, synchronised with the status-bar indicator via the existingclipboard-monitoring-changedevent. - Keyboard shortcuts (PRD §8, task 11): global
Ctrl/Cmd+Vpaste-to-link-grabber and a dedicated Keyboard shortcuts Settings tab listing the ten PRD §8 combos with platform-aware modifier (Cmdon macOS). - Plugin config UI (PRD §6.6, task 15): plugins declaring a
[config]block inplugin.tomlnow expose a typed configuration dialog (string / boolean / integer / float / url / enum) with min / max / regex / options validation. Newplugin_configstable,UpdatePluginConfigCommandandGetPluginConfigQuery. - Report broken plugin (PRD §2.3, task 16): kebab-menu action opens a pre-filled GitHub issue (plugin name + version, Vortex version, OS, last 50 log lines) on the plugin's repository. RFC 3986-compliant percent encoder, GitHub-only host validation.
- History retention (PRD §6.8, task 14):
history_retention_dayssetting (default 30, presets 7 / 30 / 90 / 365 /0 = unlimited) drives a dailyHistoryPurgeWorkerthat hard-deletes rows older than the cutoff. State sentinel survives restart so the schedule never drifts. - Tray animated icon (PRD §7.5, task 18): system tray pulses an orange dot whenever the active-download set is non-empty and reverts to the default static icon when transfers stop. Procedural 32×32 RGBA frames (no binary asset commit), zero timer wake-ups when idle.
- Enriched notifications (PRD §7.5, task 19): completion toast shows
{filename} · {size}, failure toast shows{filename} · Error: {reason}(capped at 200 chars). 5 s sliding-window grouper emits"N downloads completed"on bursts. Respects the Settingsnotifications_enabledtoggle on every event. max_concurrentconfig fix (PRD §7.1, task 05):QueueManagernow seeds from persistedconfig.max_concurrent_downloadsat startup and reacts toSettingsUpdatedfor live updates. Clamps0/ out-of-range values to 1–20.
download_logsIPC requiredlimitto be present on every call:tauri-pilot ipc download_logs --args '{"id":1}'returnedinvalid args 'limit' for command download_logs: command download_logs missing required key limit. The frontend'sLogsSection.tsxalways passeslimit: 20, so the contract was effectively undocumented for any non-React caller (QA tooling, future REST/WebSocket adapters, debug scripts). The argument is nowOption<usize>and falls back to a newDEFAULT_DOWNLOAD_LOG_LIMIT = 256constant — sized to match the per-download buffer cap configured inlib.rs::DownloadLogStore::new(256)so an unspecifiedlimitsurfaces every line currently retained, while explicit smaller limits keep their head-trimming behaviour. Two new unit tests pin the contract: one verifies the default returns the full retained buffer, the other verifies an explicitlimitsmaller than the buffer trims to the most-recent lines (issue: vortex-qa BUG #2).- History view stayed empty even after dozens of completed downloads:
HistoryRepository::recordhad no production caller, so thehistorytable was never written. The Statistics view (which reads thestatisticstable viaspawn_stats_recorder_bridge) showed the right numbers, but every history-driven feature — the History view's group/filter/search/export,download_redownload {sourceKind:"history"}(P0.9), and thehistory_purge_older_thanworker (P0.14) — had nothing to operate on. A newspawn_history_recorder_bridgemirrors the stats recorder pattern: it subscribes toDownloadCompletedPersistedon the event bus, projects the carriedHistoryEntrySnapshotinto aHistoryEntry(total_bytesprefersfile_sizeand falls back todownloaded_bytes;updated_at - created_atis converted from milliseconds to seconds with a1-second floor so an instant completion does not divide by zero inavg_speed;completed_atis theupdated_atepoch in seconds, matching the Unix-seconds contract documented onHistoryFilter::date_from/date_to) and callsrecord. The snapshot travels on the event itself instead of re-reading the repository so the recorder cannot race withclear/remove/change-directoryflows: any later mutation of the persisted row no longer rewrites the projection. A startup backfill inlib.rswalks everyCompleteddownload once at boot, skips entries already represented inhistory, and replays the same projection helper so users upgrading from broken builds get their existing completions persisted instead of an empty History view. The bridge swallows history-repo failures withtracing::warn!so a write glitch never propagates back into the queue/UI flow. Wired inlib.rsalongside the existing tauri/notification/download-log/progress/stats bridges (issue: vortex-qa BUG #1). - Keyboard accessibility: the app now renders a
SkipLink("Skip to main content" / "Aller au contenu principal") as the first focusable element inAppLayout, paired withid="main-content"+tabIndex={-1}on the<main>landmark. The link stays visually hidden viasr-onlyuntil focused, then reveals itself in the top-left corner; activating it programmatically focuses the main content (withevent.preventDefault()so the URL hash stays clean for SPA routing). This addresses the WCAG 2.1 "bypass blocks" success criterion and gives keyboard users a single-Tab shortcut over the 10-item Sidebar. Note: the QA report (#116) reproduced viatauri-pilot press Tabis a known false positive — syntheticKeyboardEventdispatched by the pilot does not trigger the webview's native focus-traversal behaviour, so the issue title's "first Tab keypress leaves focus on BODY" is a measurement artefact rather than a real-user bug. The skip-link is shipped anyway because it is a genuine a11y improvement (issue #116). plugin_report_brokenerrored on every official plugin because theirplugin.tomlmanifests do not declare arepositoryfield, while the handler inapplication/commands/report_broken_plugin.rsrequiresmanifest.repository_url()to beSometo build the GitHub issue URL. The handler now falls back to the local plugin store cache (PluginStoreEntry.repository, populated fromregistry.tomlviaplugin_store_refresh) when the loaded manifest is missing the field, so the feature works for the four official plugins without requiring a re-publish. The validation message now points at the actual TOML field ([plugin].repositoryinplugin.toml) instead of the internal Rust field namerepository_url, which only exists onPluginInfo(issue #115).- Statistics view KPIs derived from the
statisticstable (total files, daily volume, average and peak speed) stayed at zero even when completed downloads existed:StatsRepository::record_completedhad no production caller, so the daily rollup table was never written. A newspawn_stats_recorder_bridgesubscribes toDownloadCompletedPersistedon the event bus, looks up the persistedDownloadaggregate, derives(bytes, avg_speed)(file_sizefalling back todownloaded_bytes;updated_at - created_atis converted from milliseconds to seconds with a1-second floor so an instant completion does not divide by zero) and callsrecord_completed. The bridge swallows repo and stats failures withtracing::warn!so a stats glitch never propagates back into the queue/UI flow. Wired alongside the existingtauri/notification/download_log/progressbridges inlib.rs. KPIs sourced fromdownloads(success_rate,top_hosts) keep their existing read path so no migration is needed (issue #114). copy_then_delete_ionow treats aNotFounderror from the final source unlink as success: by that point the destination has already been written and size-verified, so discarding it would lose the only complete copy and breakmove_meta's "missing source = no-op" contract (coderabbit critical, PR #107).FsFileStorage::move_metanow probes the source sidecar when destination reservation fails withAlreadyExists: if the source is missing, the call returnsOk(())per the sidecar contract instead of surfacing "destination already exists" — that error would otherwise roll back an already-completed body move in the change-directory handler. When the source is present and the destination is occupied, the error still propagates so unrelated metadata isn't silently clobbered (cubic P2, PR #107).FsFileStorage::move_metano longer pre-checks the sidecar existence withtry_existsbefore attempting the move: the probe + move pair was a TOCTOU race where another actor deleting the sidecar between the two calls would cause the move to fail and roll back an already-successful body move in the change-directory handler. The function now attempts the move directly and treatsNotFoundon the source as the no-op theFileStoragecontract promises (coderabbit major, PR #107).- Change-directory cross-FS fallback no longer reopens the TOCTOU race it was meant to close: the reserved-destination placeholder is now kept in place when
fs::renamereturns EXDEV —fs::copytruncates and overwrites it with the source bytes — instead of being deleted before the copy runs (cubic P1, PR #107). - Move-to-folder partial-failure flow now updates
selectedDownloadIdtogether withselectedDownloadIds, so the details panel doesn't keep showing a download that just moved successfully when only failed rows remain selected (coderabbit minor + cubic P2, PR #107). - Change-directory handler no longer reports a successful move as failed when the post-persistence engine resume errors out: the
DownloadDirectoryChangedevent now publishes before the resume attempt and resume failures log a warning instead of propagating, so bulk callers no longer misclassify the row as failed and the frontend always invalidates its caches. Sidecar rollback failures during DB-save recovery are now logged loudly so metadata/body divergence is observable. The productionmove_filepath replaces its racyto.exists()check with acreate_newplaceholder reservation, closing the TOCTOU window where a concurrent process could squeeze a different file into the destination before our rename. TheFileStorageport'sfile_existsnow returnsResult<bool, DomainError>and usesPath::try_existsso I/O errors (permission denied, broken symlink loop) surface asErrinstead of being silently coerced intofalse. Themove_fileandmove_metadefaults now return an explicit "not implemented" error so a future adapter that forgets to override surfaces the gap loudly instead of silently succeeding while leaving the file behind. FrontendMoveDialognow receives the first selected download'sdestinationPathso the "current location" pill renders and the OS folder picker opens at the file's parent directory;deriveDefaultDirhandles root-level paths (/file.bin→/,C:\file.bin→C:\) instead of returningnull/C:. The bulk-move outcome's failed-rows handler now coerces the IPC's numeric ids back to strings before writing to the UI store, matching the store'sstring[]contract. (PR #107 review)
- Enriched desktop notifications (PRD-v2 P0.19, task 19): the
tauri-plugin-notificationbridge now reads the user'snotifications_enabledflag on every event (immediate respect of the Settings toggle), enriches theDownloadCompletedbody with{filename} · {size}derived from the read repository'sDownloadDetailView, and surfaces the failure reason onDownloadFailedas{filename} · Error: {error_message}(capped at 200 chars including the ellipsis to fit the OS toast and avoid leaking long URL/credential payloads). Average speed and total duration are deliberately omitted: the read model only exposescreated_at(queue admission), so any duration computed at notification time would inflate by the time the download spent queued or paused — the bridge will reintroduce both fields once the read model surfaces a transfer-start metric (e.g. via theHistoryEntryproduced on completion). Bursts of completions are debounced through a newdomain::notification::NotificationGrouper: a 5 s sliding window with threshold 3 emits a single aggregated "N downloads completed" notification on the third event and silently suppresses any further completions in the same window so the OS toast stack stays clean. The grouper also detects wall-clock backwards jumps (NTP correction, manual time change) and clears its window so stale "future" timestamps cannot bias subsequent decisions. Pure domain helpersformat_size/format_speed/format_duration(base 1024, one decimal, rounding-aware unit promotion so values like1024 * 1024 - 1render as1.0 MBinstead of1024.0 KB, dropped zero leading components) live underdomain/notification/format.rsto keep the formatting policy testable without an adapter and without pulling inhumansize. The bridge call site inlib.rsnow threadsArc<dyn ConfigStore>andArc<dyn DownloadReadRepository>so the gating + lookup share the same instances the IPC layer already mutates, no double-instantiation. Click-to-open and click-to-focus actions are blocked upstream —tauri-plugin-notification2.3.3 desktop API consumes theNotificationHandlereturned bynotify_rustinternally, so the click callback is unreachable; the limitation is documented innotification_bridge.rsand tracked for revisit when the plugin exposeson_eventor when a directnotify_rustintegration becomes worthwhile. (task 19, partial — click action deferred) - Animated tray icon while at least one download is active (PRD-v2 P0.18, task 18): the system tray now pulses an orange dot whenever the active-download set is non-empty and reverts to the default static icon as soon as the set goes back to zero. Backend ships a new
adapters/driven/tray/sub-module split into a domain-pureActivityTracker(aHashSet<DownloadId>consumingDownloadStarted/Resumed/ResumedFromWaitto add andPaused/Completed/CompletedPersisted/Failed/Cancelled/Removed/Waitingto remove, returningActivated/Deactivated/NoChangetransitions), a proceduralpulse_frames()generator that renders eight 32×32 RGBA frames in pure Rust (triangular-wave radius pulseMIN_RADIUS=3 → MAX_RADIUS=7 → MIN_RADIUS, no binary PNG assets to commit, full unit-test coverage of shape/colors), anIconSwappertrait (show_frame(usize)/show_static()) so the loop is unit-testable without a Tauri runtime, anAnimatorCorestate machine that wraps the tracker with a frame index and exposeshandle_event(returningStartAnimation/StopAnimation/NoOp) andtick, and aspawn_tray_animatorasync wiring that subscribes to theEventBus(filtering out high-frequencyDownloadProgress/ segment events at the source so they never reach the channel), forwards relevant events through an mpsc to atokio::select!loop that idles the interval arm withif core.is_animating()so a fully idle tray costs zero timer wake-ups, and callsswapper.show_static()once on shutdown. The Tauri-boundTauriIconSwapperowns the frames asImage::new_owned(so the underlying RGBA buffers outlive eachset_iconcall), guards on empty frame slices, and logsset_iconfailures viatracing::warninstead of unwrapping.setup_system_traynow returns theTrayIconhandle solib.rscan build the swapper and spawn the animator with the sameArc<dyn EventBus>the Tauri / notification bridges already share, with aDEFAULT_FRAME_INTERVALof 200 ms. The implementation is platform-agnostic (nocfg(target_os)in the adapter) and relies only on the cross-platform Tauri 2TrayIcon::set_icon(Option<Image>)API. (task 18) - Dynamic segment splitting (PRD-v2 P0.17, task 17): when a parallel segment finishes before its peers, the engine now re-evaluates the still-running segments, picks the slowest one whose remaining range exceeds
dynamic_split_min_remaining_mb(default 4 MiB) and shrinks it in place — a fresh worker takes the upper half so the tail of the download accelerates instead of stalling on a single slow connection. Backend ships a domain-pureSegment::split(at_byte, new_id)validation method (state must beDownloading, split point strictly inside the unfetched range, caller-provided id must differ from the original — IDs are allocated by the engine's monotonicnext_segment_idcounter, never invented inside the domain), a newDomainEvent::SegmentSplit { download_id, original_segment_id, new_segment_id, split_at }forwarded as thesegment-splitTauri event and logged in the per-download log store, two newAppConfig/ConfigPatch/SettingsDtofieldsdynamic_split_enabled(defaulttrue) anddynamic_split_min_remaining_mb(default4) wired through the toml config store, the Tauri IPCSettingsDto/ConfigPatchDto(so the frontend can both read and write them) and the newapplication::services::engine_config_bridgesubscriber so livesettings_updatecalls reconfigure already-running engines without a restart.SegmentedDownloadEnginestoresdynamic_split_enabled/dynamic_split_min_remaining_bytesinArc<AtomicBool>/Arc<AtomicU64>and exposes aset_dynamic_split(enabled, min_remaining_mb)setter consumed by the bridge. After a split, the engine updates the original slot'sinitial_endtosplit_atimmediately on successfulend_tx.send, so a subsequentpick_split_targetevaluation cannot expand the worker's range past the shrunk boundary andpersist_split_metarecords the post-split topology rather than the stale one (closes coderabbit P1 + greptile P1 race). Each segment task now returns(slot_idx, Result<u64>); on success the engine flips acompleted: boolflag on the slot —pick_split_targetskips completed slots so they cannot be re-picked, andpersist_split_metakeeps the entry withcompleted: trueand a full-rangedownloaded_bytesso a crash right after a split never loses the record of byte ranges already on disk.pick_split_targetalso gates on a 500 ms / non-zero-progress sample window: a fresh split child cannot be picked again until it has actually produced a throughput sample, preventing cascading fragmentation of the newest range. The segment worker accepts the upper bound through atokio::sync::watch::Receiver<u64>instead of a frozenu64, re-reads it before each chunk fetch and again after every successful network read so a mid-flight shrink clamps the next write to the new boundary; per-segment progress is exposed via anArc<AtomicU64>so the engine can pick the slowest candidate by throughput (downloaded / elapsed). After every split, the engine atomically rewrites.vortex-metawith the updated segment topology so resume after a crash mid-split sees a consistent state. (task 17, PR #111 review) - "Report broken plugin" action (PRD-v2 P0.16, task 16): plugins listed in Plugins → Plugin Store now expose a Report broken plugin item in their kebab menu. Clicking it opens the user's default browser at a pre-filled GitHub issue on the plugin's repository, with diagnostic metadata (plugin name + version, Vortex version, OS, optional URL under test, last 50 log lines) inlined into the issue body. Backend adds a
repository_urlfield todomain::model::plugin::PluginInfo(parsed from the new[plugin].repositorykey inplugin.toml), adomain::ports::driven::UrlOpenerport plus its platform-nativeSystemUrlOpeneradapter (xdg-open/open/cmd start,http(s)://only by validation), the std-onlydomain::model::plugin::build_report_broken_urlURL builder (RFC 3986 unreserved-set percent encoder, last 50 log lines, GitHub-only repository hosts, accepts.gitsuffix, rejects malformed URLs withDomainError::ValidationError), and aReportBrokenPluginCommandhandler that returnsAppError::Validationwhen a manifest carries norepository_url. New Tauri IPCplugin_report_broken(pluginName, logLines?, testedUrl?) → stringreturns the issue URL so the UI can fall back to clipboard copy if the launcher fails. i18n (en/fr):plugins.action.reportBroken,plugins.toast.reportBrokenSuccess,plugins.toast.reportBrokenError. (task 16) - Dynamic plugin configuration UI (PRD-v2 P0.15, task 15): plugins declaring a
[config]block in theirplugin.tomlnow expose their schema at runtime. Backend addsConfigField/ConfigFieldType/PluginConfigSchematodomain/model/plugin.rs(typed validation, enum options,min/maxbounds, regex via a std-only matcher — no external import in the domain), aPluginConfigStoreport (get_values/set_value/list_all/delete_all) implemented bySqlitePluginConfigRepobacked by the newplugin_configs (plugin_name, key, value)table (migrationm20260425_000005_create_plugin_configs, composite primary key). The manifest parser (adapters/driven/plugin/manifest.rs) now extractstype,default,options,description,min,max,regexon top of the existing defaults, and rejects defaults that fail their own field validation. CQRS gainsUpdatePluginConfigCommand(validates against the schema, applies the runtime first then persists, rolls back on failure) andGetPluginConfigQuery(returns the schema plus persisted values, dropping any persisted entry that no longer matches the current schema and falling back to manifest defaults).PluginLoaderis extended withget_manifest()andset_runtime_config();ExtismPluginLoaderimplements both by reading fromPluginRegistryand writing toSharedHostResources::plugin_configs, soget_config(key)calls from the WASM plugin observe the new value without a reload. At startup,lib.rsreplays persisted configs onto the in-memory map before plugins are loaded. Frontend adds two components:PluginConfigField.tsx(dispatcher renderer:string→ text input,boolean→ shadcn switch,integer/float→ numeric input with bounds,url→ url input,enum(andstringwith options) → shadcn select;aria-describedbyon the control points to the error message) andPluginConfigDialog.tsx(loads the schema viauseQuery, validates each field on the UI side (rejects empty floats, validates JSON arrays) before sending, persists changed values sequentially, guards the schema-reset effect while a save is in flight to avoid clobbering the draft, invalidates the query on success).PluginsViewqueriesplugin_config_getfor each installed plugin (keyed off the unfiltered installed list to avoid churn while typing in search) to decide whether the Configure button (Settings icon, next to the More menu) should render: a plugin without[config]exposes no button. New IPC commandsplugin_config_get(name) → PluginConfigViewandplugin_config_update(name, key, value). i18n (en/fr):plugins.action.configure,plugins.config.{title,description,loading,error,noFields,toast.{saveSuccess,validationFailed}}. (task 15) - History retention with automatic daily purge (PRD-v2 P0.14, task 14): new
history_retention_dayssetting (default 30, presets 7 / 30 / 90 / 365 /0 = unlimited) exposed in the General Settings tab as aSelectdropdown wired tosettings_update. Backend ships aClockdomain port (SystemClockadapter underadapters/driven/scheduler/) and aHistoryPurgeWorkerdaemon spawned during Tauri setup that hard-deleteshistoryrows wherecompleted_at < now - retention_days * 86_400. The worker persists its last run as a Unix-epoch timestamp inside<app_data_dir>/.history_purge_state(sentinel filenameHISTORY_PURGE_STATE_FILE). On startup, the daemon reads the sentinel and either runs immediately (missing/stale) or sleeps forSECS_PER_DAY - elapsedso the first post-launch purge stays anchored to the previous successful run instead of drifting up to ~47h after a restart; the recurring loop then ticks every 24h viatokio::time::intervalwithMissedTickBehavior::Skip.retention_days <= 0is a no-op that does not write the sentinel, so the next run re-fires the moment the user re-enables retention; corrupt sentinels are treated as "never ran" so a stuck file never blocks the scheduler. The worker shares the sameArc<dyn HistoryRepository>andArc<dyn ConfigStore>the IPC layer already mutates, so a settings change is observed without restart. Domain helpernormalize_history_retention_daysclamps negatives back to0and is now applied at every write boundary —apply_patch(so a craftedsettings_updatepayload cannot persist a negative) andFrom<ConfigDto> for AppConfig(so a hand-editedconfig.tomlis normalized at load) — plus the worker itself for defense-in-depth. (task 14) - Change-directory action that moves a download's on-disk file (and its
.vortex-metasidecar when present) into a new destination folder (PRD-v2 P0.13, task 13). New Tauri IPC commandsdownload_change_directory(id, newDestinationDir)anddownload_change_directory_bulk(ids, newDestinationDir)are backed byChangeDirectoryCommand/ChangeDirectoryBulkCommandin the application layer; the bulk variant returns a structured{ moved: number[], failed: { id, message }[] }outcome so the UI can keep failed rows selected for retry instead of swallowing partial errors. The handler pauses the download engine forDownloadingitems, relocates the body and the.vortex-metasidecar, persists the new path, then resumes — segments survive the move so the engine picks up exactly where it left off.ExtractingandCheckingdownloads are rejected because another worker is actively reading the file. TheFileStorageport growsmove_file,move_metaandfile_exists; the productionFsFileStorageadapter prefersfs::renamefor same-filesystem moves and falls back to copy + size-verify + delete-source for cross-device cases (EXDEV /ErrorKind::CrossesDevices), with rollback on any partial failure so the source file always stays intact. NewDomainEvent::DownloadDirectoryChanged { id, newDestinationPath }is forwarded to the frontend as thedownload-directory-changedevent. Frontend ships a reusable<MoveDialog>(folder picker viauseBrowseFolder, current path + selected path preview, confirm disabled until a folder is picked) and aMove to...action in the downloadsActionsBarselection toolbar that wires the bulk IPC, surfaces success / partial-failure / error toasts and clears or re-narrows the selection accordingly. New i18n keysdownloads.actions.moveSelected,downloads.moveDialog.*anddownloads.toast.{moveSucceeded,movePartial,moveError}(en/fr). (task 13) - Queue reordering via drag & drop and Move-to-Top / Move-to-Bottom (PRD-v2 P0.12, task 12): new Tauri IPC commands
download_move_to_top(id),download_move_to_bottom(id),download_reorder_queue(orderedIds)backed byMoveToTopCommand/MoveToBottomCommand/ReorderQueueCommandin the application layer. A newqueue_positioncolumn (migrationm20260425_000004_add_queue_position,BIGINT NOT NULL DEFAULT 0, indexidx_downloads_queue_position) persists the manual ordering so drag-reorders survive restart.QueueManagernow sorts candidates by priority desc →queue_positionasc →created_atasc, and also subscribes to two new domain events (DownloadPrioritySet,QueueReordered) so changing priority triggers immediate rescheduling — a high-priority item starts as soon as a slot is free. The defaultdownload_listsort usesqueue_positionASC →created_atDESC so fresh downloads (position 0) still appear newest-first while manually-moved rows stick. Frontend integration inDownloadsTableadds@dnd-kit/core+@dnd-kit/sortablewith a drag handle column (enabled only for Queued/Retry/Waiting rows), aSortableContextaround the virtualized rows, and acomputeReorderedIdshelper that filters non-reorderable IDs from the new order before invokingdownload_reorder_queue. Row dropdown menu gets Move to top / Move to bottom items for reorderable rows. New i18n keysdownloads.table.actions.moveToTop/moveToBottom(en/fr).DownloadView/DownloadViewDtonow exposepriority+queuePosition. (task 12) - Global
Ctrl/Cmd+Vpaste-to-link-grabber shortcut and a dedicated Keyboard shortcuts Settings tab (PRD-v2 P0.11, task 11): pressingCtrl/Cmd+Vanywhere outside a text field reads the system clipboard vianavigator.clipboard.readText, navigates to/link-grabberwithlocation.state = { focusPaste: true, pasteContent, pasteToken }, thenPasteZoneconsumespasteContentthrough a newinitialValueprop by pre-filling the textarea and auto-triggeringlink_resolveon the extracted URLs. Replay is keyed off a navigation-scopedpasteToken(a freshDate.now()+random string per shortcut press) instead of the raw clipboard text, so pressing the shortcut twice with identical clipboard contents still re-resolves;handleClear()also resets the guard. Focus is preserved by the existingdata-shortcut-target="link-grabber-paste"handler. TheAppLayout.isEditableTargetguard still short-circuits the shortcut when focus is on an<input>,<textarea>, orcontenteditable, so native paste keeps working inside editors; a.catchonreadText()surfaces alinkGrabber.toast.clipboardReadFailedtoast instead of an unhandled rejection when permission is denied. A newShortcutsSectioncomponent (SettingTab = 'shortcuts', Keyboard icon) renders the ten PRD §8 combos in a<kbd>table and substitutesCmdon macOS via the newsrc/lib/platform.tshelper (also used byAppLayout) so the displayed modifier always matches the actual handler. i18n undersettings.shortcuts.*(columns.shortcut/action,rows.pasteUrls/selectAll/pauseResume/deleteSelection/toggleClipboard/navigateViews/focusSearch/addUrlsDialog/openSettings/closePanel) translated for en/fr. Covered by new Vitest cases inAppLayout.test.tsx(Ctrl/Cmd+Vreads clipboard + navigates withpasteContent+pasteToken, clipboard read failure shows a toast,Ctrl+1ignored on textarea,Ctrl+Vnot intercepted on textarea,Ctrl+2..6nav),LinkGrabberView.test.tsx(textarea pre-filled +link_resolvecalled with the pasted URLs), andSettingsView.test.tsx(seven tabs with exact count, shortcuts tab lists ten rows). (task 11) - Clipboard monitoring toggle now live in the Link Grabber header (PRD-v2 P0.10, task 10): the
Switchis no longerdisabled,onCheckedChangeis wired through the existinguseClipboardMonitoringhook so a click invokes theclipboard_toggleIPC and also subscribes to theclipboard-monitoring-changedTauri event. Initial state is seeded fromsettingsStore.config.clipboardMonitoring, so the toggle matches the persisted config as soon as the store hydrates. A 7×7 status dot (success on, border off) sits between the label and the switch, and the wrappertitleswaps betweenstatusBar.clipboardActive("Clipboard monitoring active") andstatusBar.clipboardPaused("Clipboard monitoring paused") — the same copy used by the status-barClipboardIndicator, so both views stay in sync through the shared event. Backend persistence is untouched:handle_toggle_clipboardstill writes the new value viaConfigStore::update_configand rolls back if the observerstart/stopfails, so the state survives restart. The orphanlinkGrabber.clipboardComingSooni18n key was removed fromen.json/fr.json. (task 10) - Re-download action on completed downloads and history entries (PRD-v2 P0.9, task 09): new Tauri IPC command
download_redownload(sourceKind, sourceId, overwriteMode?)that clones either aDownloadaggregate or aHistoryEntryinto a brand-new Download with a freshDownloadId, preserving the URL, filename, destination, and — for theDownloadsource — segments count, priority, source hostname, module name and account id. Return type is a tagged union:{ kind: "created", id }on success or{ kind: "fileExists", originalPath, suggestedPath }when the destination already exists; the UI re-invokes withoverwriteMode: "overwrite"or"rename"(the latter resolves a non-collidingname (N).extvia the existingunique_destinationhelper). BackendRedownloadCommand+RedownloadSource(application layer) and new command handlerapplication/commands/redownload.rsthatload_templates the source before callingDownload::newwith the new id fromnext_download_id. DomainDownloadgainswith_segments_count,with_module_name,with_account_idbuilder methods so the handler can carry forward options the history row does not retain. Frontend ships a reusable<OverwriteDialog>(Overwrite / Keep both / Cancel) and auseRedownloadhook returning{ trigger, dialog, isPending }; bothDownloadsTable(Completed rows only) andHistoryViewrender the dialog and invalidatedownloads.lists,downloads.countByStateandhistory.listson success. New i18n keyscommon.overwriteDialog.*anddownloads.table.{actions.redownload,toast.redownload*}(en/fr). (task 09) - Open file / Open folder actions on completed downloads (PRD-v2 P0.8, task 08): two new Tauri IPC commands
download_open_file(id)anddownload_open_folder(id)launch the OS default app or reveal the file in the host file manager. Driven by a newFileOpenerport (domain/ports/driven/file_opener.rs) with aSystemFileOpeneradapter that dispatches per-OS:xdg-openon Linux,open/open -Ron macOS,explorer/explorer /select,<path>on Windows. Application handlersopen_download_fileandopen_download_folderlook up the download by id, refuse non-Completedstate withAppError::Validation, and surfaceDomainError::NotFoundwhen the destination file is gone — the frontenduseTauriMutationerrorMessagemapper translates that to a localized "File not found" toast (en/fr). UI exposes the actions in the row dropdown (Completed rows only) and as buttons in the detail panel's File info section.CommandBus::with_file_openerwires the adapter optionally (matching thewith_checksum_computerpattern) so existing test fixtures do not need new mocks. (task 08) - Browse-folder dialog in General settings (PRD-v2 P0.7, task 07): the
Browsebutton next to Download directory is now wired to a native OS folder picker viatauri-plugin-dialog, replacing the previouslydisabledplaceholder. Two async Tauri IPC commands back the UI:browse_folder(default_path?)andbrowse_file(filters?, default_path?)— both returnOption<String>so a cancelled dialog persists nothing and does not raise an error toast. The implementation bridges the plugin's callback-basedpick_folder/pick_fileto async with atokio::sync::oneshotchannel; the passeddefault_pathis validated (directory must exist) before being forwarded asset_directory, and forbrowse_filethe anchor falls back to the parent when a file path is provided.GeneralSectionnow consumes a new reusableuseBrowseFolder/useBrowseFilehook pair fromsrc/hooks/useBrowseFolder.ts, ready to be reused for package destinations, export paths and other future path pickers. Selected folder goes through the existingsettings_updatemutation so persistence and toast feedback stay on one path. (task 07) - Checksum integrity validation (PRD-v2 P0.6, task 06): post-download SHA-256 / MD5 verification driven by the
Downloading → Checking → Completed | Errorflow whenchecksum_expectedis set andverify_checksumsis on. Algorithm auto-detected from the hash format (32 hex chars → MD5, 64 → SHA-256).compute_file_checksumstreams files in 8 MB chunks viasha2+md-5to handle multi-GB downloads without memory pressure. Mismatches transition toErrorwith aChecksumMismatch { expected, computed, algorithm }event published on the bus and a descriptiveerror_messagepersisted alongside; matches transition toCompletedwithchecksum_computed+checksum_algorithmcolumns durable in SQLite (migrationm20260424_000003_add_checksum_columns). New IPC commanddownload_verify_checksum(id)re-runs validation on demand even for already-completed downloads. New domain portChecksumComputerwithStreamingChecksumComputeradapter; new application serviceChecksumValidatorServiceorchestrating validation + persistence + event publishing.IntegritySectionin the detail panel now shows the algorithm, expected hash, computed hash, match indicator (✓ / ✗) and a "Verify" button wired to the IPC. Settingverify_checksums = falsebypasses validation entirely so downloads complete directly. (task 06) - Statistics view (
#/statistics) now replaces the placeholder: period selector (7d/30d/all-time tablist), seven KPI cards (total volume, total files, avg/peak speed, success rate, cumulative download time, CAPTCHA placeholder), four Recharts visualizations (daily volume bar, top hosts donut, type breakdown horizontal bar, average speed line) plus a Top-5 modules ranking. NewuseStatsQuery(period)aggregatesstats_get,stats_top_modules(limit 5) andhistory_list; type breakdown and speed series are derived client-side from history (extension parsing + UTC-day grouping). Charts pull their primary color fromvar(--color-accent)so the user's accent setting is respected, fall back to a fixed palette for multi-series, exposerole="img"+ axislabelprops for screen readers, and render an empty hint when the period yields no data. Recharts dependency added (npm i recharts). Newi18nnamespacestatistics.*(en/fr). (task 04) - History view (
#/history) now replaces the placeholder: entries grouped by local day with sticky date headers and a proper<thead>row (Name, Host, Size, Duration, Completed, Status, Avg speed, Module, Account, Actions); filter tabs All/Completed/Failed/Cancelled with live counts (Failed/Cancelled resolve to 0 until the backend persists those states); debounced search (300 ms) that swapshistory_list→history_search; per-row actions Re-download (invokesdownload_startwith the original URL), Copy URL, Delete entry, Open folder (invokes the newreveal_in_folderIPC to reveal the destination in the OS file manager via xdg-open/open/explorer); Export CSV / JSON via a native save dialog (wrapped in try/catch with error toast) that pipes the chosen path throughhistory_export. NewuseHistoryQueryTanStack wrapper anduseDebouncedValuehook.HistoryViewDtoserializesentryId(u64) as a string so 64-bit IDs survive JavaScript number precision;history_delete_entry/history_get_by_idaccept a string id and parse it server-side.exportSuccesstoast uses i18next plural forms (_one/_other). Bundledtauri-plugin-dialog(Cargo + npm) with a scopeddialog:allow-savecapability; no open-dialog exposure. (task 03) - Statistics IPC surface:
stats_get(period)("7d" | "30d" | "all") returnsStatsViewDtowith period-bounded totals, daily volumes, success rate and top hosts;stats_top_modules(limit)returns the most-used resolving modules (name, download count, total bytes) capped at 50. Period filtering usesstatistics.date >= ?for the daily rollup anddownloads.created_at >= ?for success rate / top hosts so cutoffs line up with the data source. NewStatsPeriodenum andModuleStatsdomain view;StatsRepository::get_statsnow takes a period argument and gainedtop_modules(limit).ModuleStatsDtoserializesmoduleName/downloadCount/totalBytescamelCase for the frontend. (task 02) - History IPC surface: queries
history_list(dateFrom, dateTo, hostname, sortField, sortDirection, limit, offset),history_search(q),history_get_by_id(id)and commandshistory_export(format, path)(CSV RFC 4180 with spreadsheet-formula guard, or JSON pretty-printed),history_delete_entry(id),history_clear,history_purge_older_than(days)—days == 0is rejected to avoid a full-table wipe. Results are capped at 500 rows per request;listsupportsoffsetfor pagination.HistoryViewDtoexposes the primary key asentryIdso the frontend can target individual rows. TheHistoryRepositoryport gainedlist(with date range + exact hostname match against the URL host),search(case-insensitive over file name / URL / destination),find_by_id,delete_by_idanddelete_all, implemented bySqliteHistoryRepo. (task 01) useTauriMutationnow acceptssilentError(opt-out of the default toast) anderrorMessage(remap the error message before toasting) options. (#74)
- Plugins view refreshed to match the design mockup: a header with enabled/disabled counters and a "Check updates" action, a segmented category filter replacing the dropdown, grouped sections per category with uppercase labels, monogram icons with accent coloring for crawlers/extractors, a toggle for installed plugins, and a kebab menu hosting the destructive "Uninstall" action. Installable plugins keep a single
Installbutton; pending updates surface as an inline pill on the row. - Default settings values now match PRD §6.10 (fresh installs only — existing
config.tomlfiles are not migrated):autoExtracton,maxConcurrentDownloads=4,maxRetries=5,retryDelaySeconds=10,minFileSizeMb=1.0,verifyChecksumson,webInterfacePort=9876, REST API and WebSocket enabled by default.downloadDirnow resolves to the OS default Downloads directory on first launch, andapiKeyis generated as a random UUIDv4 so the REST/WS protocols never start with an empty credential. (#67) - Every IPC mutation now surfaces an error toast by default via
useTauriMutation; migrated all call sites (downloads, settings, plugins, link grabber, clipboard monitoring) to rely on this default. Inline error state removed from the link grabber. (#74)
QueueManagernow seedsmax_concurrentfrom persistedconfig.max_concurrent_downloadsat startup instead of the hardcoded4, and listens forSettingsUpdatedevents through a newqueue_config_bridgesubscriber so raising the limit in the UI takes effect immediately without a restart. Both paths route through a newdomain::model::config::normalize_max_concurrenthelper that clamps the rawu32to 1-20, so a manually-editedconfig.tomlwith0or an out-of-range value can no longer stall the scheduler. The// TODO: read max_concurrent from configinlib.rsis gone. (task 05)- Statistics view type breakdown and speed curve could silently truncate data:
history_listwas called without filters, so the backend's 500-row cap clipped the dataset for users with large histories while KPI cards (sourced fromstats_get) reflected the full DB.useStatsQuerynow passesdateFrommatching the selected period cutoff and an explicitlimit: 500, keeping KPI and chart data consistent within 7d/30d windows. Top-modules card title now reads "Top modules (all time)" to document that the backendstats_top_modulesranking is not period-bounded. Inline error banner surfaces partial failures instead of replacing the entire dashboard whenstats_getsucceeded buthistory_listorstats_top_modulesfailed.PeriodSelectortabs now expose propertabIndexsemantics (0on selected,-1otherwise).formatDurationFromSecondsreturns"< 1min"for positive sub-minute durations instead of"0min".useStatsQuery.refetchrethrows the first failure fromPromise.allso callers can react to refetch errors. - YouTube
get_media_metadatasurfaced 144p, 240p, and other non-canonical heights in the quality selector even thoughvortex-mod-youtubedoes not support them. Picking one produced a raw yt-dlp "Requested format is not available" error because the plugin only bypasses its pre-merged-HTTPS path for heights >=720 on the canonical ladder.parse_ytdlp_jsonnow filtersavailable_qualitiesagainst the plugin's supported set{360, 480, 720, 1080, 1440, 2160, 4320}, kept in sync withplugin.toml :: default_quality.options. The filter is scoped to YouTube sources (detected via yt-dlp'sextractor_key/webpage_url_domain) so Vimeo, SoundCloud and other providers keep their own ladders (e.g. Vimeo's 540p). - Completed downloads stayed stuck on
Downloadingin the UI until a manual reload:QueueManager::handle_download_completedpersistedstate = Completedto SQLite but never published theDownloadCompletedPersistedevent the Tauri bridge forwards asdownload-completed, souseDownloadEventsnever invalidated the TanStack Query cache. Now emitted after the save (and also for pre-persisted completions fromRegisterLocalFileCommand/ExtractArchive), gated on the repo state beingCompletedso late events after cancel/fail do not mislead the UI. - Frontend briefly showing stale state after a download completed:
DownloadCompletedfired beforeQueueManagerpersistedstate = Completedto SQLite, so a re-fetch triggered by the event could read the previous state. NewDownloadCompletedPersistedevent emitted after the save; the Tauri bridge maps it to the samedownload-completedfrontend event so existing invalidation logic is reused without changes. downloaded_bytesstayed at 0 in SQLite for downloads that finished in under 500 ms (theDownloadProgressthrottle window):segment_workernow emits one finalDownloadProgressright beforeSegmentCompletedsoprogress_bridgealways observes the real byte count.- State-transition saves could regress
downloaded_bytesback to a stale lower value when racing withprogress_bridgewrites.SqliteDownloadRepo.savenow excludesdownloaded_bytesfrom the UPSERT column list and usesMAX(excluded.downloaded_bytes, COALESCE(downloads.downloaded_bytes, 0))so only larger values win. DownloadView/DownloadDetailViewnow exposesource_hostnameso the UI can show the origin host (e.g.www.youtube.com) rather than the CDN host (rr1---sn-n4g-cvq6.googlevideo.com) that the download URL resolves to.- YouTube downloads silently downgrading to 360p when 1080p was requested but only DASH streams were available.
- YouTube
download_to_filereturningHTTP Error 403: Forbiddenon protected videos (VEVO music, age-gated content). Bumpedvortex-mod-youtubeto 1.2.3 which passes--extractor-args "youtube:player_client=default,web_safari,android_vr,tv",--retries 3,--fragment-retries 3, and--quietto yt-dlp. - Completed downloads showed ~96% progress instead of 100%: the last
DownloadProgressevent (throttled to 500ms) could arrive before the final chunk was written;compute_progress_percent()now forces 100.0 whenstate == "Completed" - Progress values showed excessive decimal places (e.g. "96.247262...%"); rounded to one decimal place using
(v * 10.0).round() / 10.0 - Downloads never transitioned to Completed state: queue_manager received DownloadCompleted events but never persisted the state change; added
handle_download_completed()analogous tohandle_download_failed()to load the aggregate, call.complete(), and save it progressPercentalways showed 0:DownloadProgressevents carrytotal_bytesbut the progress_bridge was discarding it; now writestotal_bytesto the downloads row on first progress event (COALESCE so existing values are never overwritten)- Downloads stalling indefinitely mid-transfer:
response.chunk().awaithad no idle timeout, so a server stalling mid-stream would block the segment task forever; added a 30-second idle timeout that triggersSegmentFailedand allows the engine to fail-fast and retry create_filefailed with "file exists" after app restart: engine now checks for orphaned download files (no.vortex-metasidecar) and removes them before callingcreate_new(true)- Default download destination was
./(current working directory, usually the Tauri binary dir); now usesconfig.download_dirordirs::download_dir()XDG fallback (fixes #59) - Download directory was not created automatically;
create_filenow callsstd::fs::create_dir_all(parent)before opening the file - Pause button was shown for Queued state downloads, causing a silent IPC error since the backend only allows Downloading → Paused; button now correctly only shows for Downloading state (fixes #58)
- Bulk toggle (Space shortcut) no longer attempts to pause Queued downloads, aligning with the domain state machine
- Orphaned downloads from previous session (stuck in Downloading/Waiting/Checking/Extracting state) are now recovered to Error on startup so the user can retry; Queued/Retry downloads are re-scheduled automatically (fixes #57)
maxConcurrentDownloadsvalidation now enforces the PRD §6.10 limit of 1–20 (was incorrectly accepting up to 100) in both backend validation and the settings UI input- Download engine was double-joining
file_nameontodestination_path, producing a path like/Downloads/file.bin/file.binand causing all downloads to fail silently with a "Not a directory" I/O error before any bytes were fetched (fixes #54) SegmentStartedevent now carriesstart_byteandend_byteso downstream consumers can identify which byte range a segment covers
- Clear completed / Clear failed downloads: two new toolbar buttons in the Downloads view, separated from the bulk actions by a vertical separator. Each opens a confirmation dialog with an optional "Also delete files from disk" checkbox gated behind a prominent red warning panel. Success and error outcomes are reported via toasts.
sonnertoast notifications (new dependency) mounted globally inApp.tsx, with a thinsrc/lib/toast.tswrapper so components do not depend on the library directly.- YouTube 1080p+ support: when
resolve_stream_urlreturnsAdaptiveStreamOnly,download_media_startnow falls back todownload_to_filewhich delegates the full DASH download + ffmpeg merge to yt-dlp. The merged file is moved to the downloads folder and registered as a completed download. download_media_startIPC command: resolves the direct CDN stream URL via the WASM plugin that claims the URL (resolve_stream_urlexport), then starts the download — fixes the retry loop where the engine received a YouTube/Vimeo/SoundCloud page URL instead of a downloadable CDN URLresolve_stream_urlmethod onPluginLoadertrait: delegates URL resolution to WASM plugins; implemented inExtismPluginLoaderviaregistry.call_plugin; default impl returnsNotFoundfor loaders that don't support itcommand_get_media_metadataIPC command: invokesyt-dlp --dump-single-json --flat-playlistand returns video title, thumbnail, duration, deduplicated quality options (sorted by height), video/audio container formats, subtitles (excluding live_chat), and playlist entries — fixes the "Failed to load media metadata" error in the Media Grabber Options dialog- Error message display: failed downloads now show the error reason in a popover tooltip on the Status column (Popover component from shadcn/ui)
error_messagecolumn added todownloadstable (migration m20260415_000002); exposed inDownloadViewread model and IPC responseDownloadRepository::save_failed(download, error)— persists Error state and error text atomically, replacing the previous pattern of callingsave()then updating separately- Plugin store: browse, refresh, and install official plugins from the built-in registry; plugins verified by SHA-256 checksum and
min_vortex_versionconstraint spawn_sqlite_progress_bridge— new event bridge that persists live download state to SQLite (downloads.downloaded_bytes,download_segmentsrows) so the read model reflects real progress instead of always showing 0%SqliteStatsRepo— persistent download statistics backed by SQLite (replaces in-memory stub)- Project scaffolding: Tauri 2 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
- Nix flake for reproducible development environment
- Hexagonal architecture folder structure for Rust backend
- CI pipeline with GitHub Actions (lint, test, build matrix)
- Lefthook pre-commit and pre-push hooks
- EditorConfig for cross-editor consistency
- Contributor documentation (CONTRIBUTING.md, issue/PR templates)
- Domain models: Download, Segment, Package, Account, Plugin entities with state machines
- Domain ports: repository traits, event bus, engine, storage, and credential ports
- CQRS infrastructure: CommandBus, QueryBus, AppError, read model DTOs
- SQLite persistence: sea-orm adapter with WAL mode, migrations, and 3 repository implementations
SqliteDownloadRepo(write: save, find_by_id, delete, find_by_state)SqliteDownloadReadRepo(read: filtered/sorted list, detail with segments, count by state)SqliteHistoryRepo(record, find_recent, find_by_download, delete_older_than)- Initial migration creating 6 tables with indexes and foreign keys
- Event system: TokioEventBus (broadcast), Tauri bridge, useTauriEvent React hook
- Segmented download engine: parallel HTTP Range downloads with pause/cancel
ReqwestHttpClientadapter implementing HttpClient port (HEAD, GET range, range detection)SegmentedDownloadEngineadapter implementing DownloadEngine port (start, pause, cancel)- Segment worker with streaming HTTP chunks, progress throttling (500ms), and CancellationToken
- Configurable segment count with 64KB minimum segment size
- Single-segment fallback when server doesn't support Range requests
- File storage adapter:
FsFileStorageimplementingFileStorageport- Sparse file pre-allocation via
set_len()(no disk space wasted) - Segment writes at arbitrary byte offsets with seek + write_all
.vortex-metabincode persistence for download resume state- Atomic meta writes (write-to-tmp + rename) to prevent corruption on crash
- Graceful handling of corrupted
.vortex-metafiles (log warning, restart download)
- Sparse file pre-allocation via
- Queue manager:
QueueManagerapplication service for download scheduling- Configurable max concurrent downloads with
AtomicUsizeslot tracking - Priority-based queue ordering (highest priority first, FIFO within same priority)
- Automatic next-download scheduling when a slot frees (completion, failure, pause)
- Exponential backoff retry: 10s, 20s, 40s, 80s, 160s (capped at 300s)
- Circuit breaker integration: respects
Download::retry()/MaxRetriesExceeded - Retry cancellation via
CancellationToken(e.g., when download is deleted) - EventBus sync-to-async bridge using bounded
mpsc::channel(1024)with lifecycle event filtering - Idempotent
on_slot_freed()viatokio::sync::Mutexscheduling lock - Event-driven scheduling for DownloadCreated, DownloadResumed, and DownloadRetrying events
- Configurable max concurrent downloads with
- Download command handlers: 9 CQRS command handlers on CommandBus
StartDownloadCommand: HEAD metadata, URL validation, Download entity creation, event-driven queue schedulingPauseDownloadCommand/ResumeDownloadCommand: state machine transitions with engine controlCancelDownloadCommand: engine cancellation, DB cleanup,.vortex-metaremovalRetryDownloadCommand: circuit breaker integration via domainretry()state machinePauseAllDownloadsCommand/ResumeAllDownloadsCommand: batch operations on active/paused downloadsSetPriorityCommand: priority update (1-10) for queue reorderingRemoveDownloadCommand: full cleanup with optional file deletion
- Tauri IPC driving adapter: 9
#[tauri::command]functions withAppStatewiring- Convention:
download_{action}naming (download_start,download_pause, etc.)
- Convention:
- Download query handlers: 3 CQRS query handlers on QueryBus
GetDownloadsQuery: filtered, sorted, paginated download list via read repositoryGetDownloadDetailQuery: full detail view with segments, NotFound handlingCountDownloadsByStateQuery: state-grouped counts for UI filter badges- Tauri IPC:
download_list(filter/sort/search/pagination),download_detail,download_count_by_state - String-based filter/sort parsing in IPC layer (DownloadState, SortField, SortDirection)
- Plugin infrastructure: WASM plugin system via Extism with hot-reload
plugin.tomlmanifest parser with category/capabilities/version validationPluginRegistrybacked by DashMap for concurrent in-memory trackingExtismPluginLoaderimplementingPluginLoaderport: load, unload, resolve_url, list- Hot-reload file watcher via
notifycrate with tokio integration InstallPluginCommand/UninstallPluginCommandCQRS handlers with domain eventsEnablePluginCommand/DisablePluginCommandhandlers (validation-only for MVP)ListPluginsQueryhandler returningPluginViewDtoread models- Tauri IPC:
plugin_install,plugin_uninstall,plugin_enable,plugin_disable,plugin_list - Path traversal protection on plugin_install IPC (canonicalize + prefix check)
- WASM file size limit (100 MB) to prevent OOM
- Atomic insert via DashMap entry API to prevent TOCTOU races
ContainerandNotifierplugin categories added to domain model- Plugin host functions: http_request, log, get_config/set_config, get_state/set_state, get_credential, run_subprocess
- Capability-based security for plugin host function access
- Downloads View: main table UI with TanStack Table + Virtual virtualization
- Virtualized table rendering 10k+ rows with
@tanstack/react-virtual(estimateSize: 48px, overscan: 10) - 9 columns: checkbox, state dot, filename (tooltip URL), type badge, host, progress bar, speed, ETA, actions
- Sortable columns via TanStack Table
getSortedRowModel(click header for asc/desc toggle) - FilterBar with state tabs: All | Active | Queued | Done | Failed (counts from
download_count_by_state) - SearchBar: case-insensitive search across filename, URL, and hostname
- Multi-select: Ctrl/Cmd+click toggles selection, single click selects for detail panel
- ActionsBar: Pause All / Resume All when no selection; Cancel Selected / Clear when items selected
- Per-row actions: Pause/Resume/Retry buttons + DropdownMenu (Set Priority, Remove)
- Real-time progress: ProgressCell, SpeedCell, EtaCell read from Zustand
downloadStore.progressMap - Speed color coding: green (>10 MB/s), blue (1-10 MB/s), muted (<1 MB/s)
- Format utilities:
formatEta,formatSpeed,formatBytesinsrc/lib/format.ts - uiStore extended with
selectedDownloadIdsfor multi-select state - Fixed
useTauriQueryto support customqueryKey(query cache invalidation now works correctly) - Fixed
useDownloadEventsto invalidatedownloadQueries.all()(covers list + count queries)
- Virtualized table rendering 10k+ rows with
- Download Details Panel: right sidebar showing detailed info for the selected download
- 8 sections: File Info, Metrics, Segments, Speed History, Source, Integrity, Module, Logs
- Real-time metrics from Zustand
downloadStore.progressMap(speed, ETA, downloaded/total) - Segment visualization with colored progress bars per segment
- Speed sparkline: SVG polyline chart with 2-minute history sampled every 2 seconds
- File info with MIME type detection, tooltips on long filenames/paths/URLs
- Integrity section with SHA-256 checksum status
- Scrollable logs section fetching last 20 log lines via IPC query
- Auto-opens when a download is selected, closeable via X button
useDownloadDetailhook wrapping TanStack Query with 500ms staleTimeuseSpeedHistoryhook sampling speed from store at 2s intervals
- Link Grabber View: paste zone, URL validation, link analysis pipeline, package grouping
- PasteZone with textarea + drag-and-drop URL input
- FilterBar: All | Online | Offline | Media filter tabs
- PackageGrouping: group by hostname, extension, or type
- ActionsBar: Select All, Start Selected, Start All Online, Clear
- LinkRow with status icons, filename/URL display, media badge
- ResolvedLinksSection with grouping, multi-select, and group toggle
- URL validation for http/https/ftp/magnet protocols (case-insensitive)
useTauriMutationforlink_resolveanddownload_startcommands
- Media Grabber UI: modal dialog for media download options (Task 21)
- MediaGrabberDialog: orchestrates quality/format/audio/subtitle/playlist selection
- QualitySelector: grid of video qualities (360p-4K) with resolution, fps, bitrate
- AudioOnlySection: toggle + audio format selection (M4A, MP3, OGG, WAV, OPUS)
- SubtitleSelector: multi-select checkbox list of available languages
- PlaylistSection: scrollable list with individual/bulk select for playlist items
- SizeEstimate: real-time download size estimation based on quality and duration
- MediaPreview: thumbnail + title display with broken image fallback
useMediaMetadatahook fetching metadata via Tauri IPC- Integration in LinkGrabberView via clickable media button in LinkRow
- shadcn/ui Dialog, Card, Skeleton components added to UI library
- Clipboard Observer & System Tray (Task 22)
- Clipboard monitoring adapter: polls system clipboard every 500ms via
tauri-plugin-clipboard-manager - URL extraction from clipboard text via regex (http, https, ftp, magnet protocols)
- Duplicate URL detection with seen-set deduplication
- Toggle clipboard monitoring via
clipboard_toggleIPC command with config persistence ClipboardUrlDetecteddomain event for frontend notification of detected URLs- System tray with menu: Pause All, Resume All, Clipboard Monitoring toggle, Open Window, Quit
- Tray icon click opens/focuses main window
- Desktop notification bridge: notifies on download completion and failure via
tauri-plugin-notification ClipboardIndicatorcomponent in StatusBar showing monitoring state with toggleuseClipboardMonitoringReact hook with server-confirmed state updates- Vitest test infrastructure: jsdom environment, setup file, Tauri API mocks
- Frontend tests for ClipboardIndicator (4 tests) and useClipboardMonitoring (4 tests)
- Clipboard monitoring adapter: polls system clipboard every 500ms via
- Settings View (Task 23)
- Expanded
AppConfigdomain model from 9 to 32 fields across 6 categories ConfigPatchwithapply_patch()utility for partial config updatesTomlConfigStoreadapter: read/write~/.config/vortex/config.tomlwith atomic writes and auto-defaultsUpdateConfigCommandhandler with input validation (proxy_type, theme, port bounds, limits)SettingsUpdateddomain event with Tauri bridge forwarding- Settings IPC:
settings_getquery +settings_updatecommand with camelCase DTO serialization - SettingsView with 6-tab sidebar layout (General, Downloads, Network, Remote Access, Browser, Appearance)
- Shared
SettingToggleandSettingNumberInputfield components - GeneralSection: download directory, 7 toggle settings
- DownloadsSection: 5 numeric settings with MB/s speed limit conversion, 2 toggles
- NetworkSection: proxy type selector with conditional URL, user-agent, DoH, timeout
- RemoteAccessSection: security warning, web interface/REST API/WebSocket toggles, API key display with show/hide/copy/regenerate
- BrowserSection: min file size, excluded domains/extensions via comma-separated textarea
- AppearanceSection: theme selector, 6 accent color presets, compact mode, language selector
- Event-based cache invalidation for settings changes from external sources
- 35 frontend tests (SettingsView + 6 sections + settingsStore)
- Expanded
- YouTube WASM Plugin (Task 24)
- New
plugins/vortex-mod-youtube/crate targetingwasm32-wasip1viaextism-pdk1.4 plugin.tomlmanifest declaringsubprocess = ["yt-dlp"](least-privilege, no HTTP capability)url_matchermodule: regex-based classification of youtube.com / youtu.be / shorts / playlist / channel URLs with video and playlist ID extractionmetadatamodule: serde-based parsing ofyt-dlp --dump-json(single video) and--flat-playlist(JSONL + envelope formats) with automatic audio-only / video-only / muxed classification viavcodec/acodecinspectionquality_managermodule: format selection by target resolution (360p → 4320p +Best) with height-bucket fallback and user container preference (mp4/webm/mkv); audio-only picks highestabrextractormodule: pure helpers to build yt-dlp subprocess requests with--sentinel (defense-in-depth against option injection) and parseSubprocessResponseenvelopes with UTF-8-safe stderr truncationplugin_apimodule (WASM-only, gated behindcfg(target_family = "wasm")):#[plugin_fn]exports forcan_handle,supports_playlist,extract_links,get_media_variants,extract_playlist;#[host_fn] extern "ExtismHost"import ofrun_subprocesswith documented safety invariantsPluginErrorenum withthiserror:SerdeJson(#[from])preserves error source chain; dedicated variants for parse errors, subprocess failures, host response errors, unsupported URLs, and quality mismatches- 77 native unit tests covering all pure-logic modules (url_matcher, metadata, quality_manager, extractor, ipc handlers) — runs on
x86_64-unknown-linux-gnuwithout a WASM runtime - Release WASM binary: 1.2 MB stripped with LTO +
opt-level = "z"
- New
- SoundCloud, Vimeo, and Gallery WASM plugins (Task 25)
- New crates under
plugins/:vortex-mod-soundcloud,vortex-mod-vimeo,vortex-mod-gallery, all targetingwasm32-wasip1viaextism-pdk1.4 and delegating network I/O to the hosthttp_requestcapability - SoundCloud plugin:
/resolveAPI client (api-v2.soundcloud.com) with tagged enumResolveResponse(Track / Playlist / User / Unknown),classify_urlrouter coveringsoundcloud.com,m.soundcloud.com,on.soundcloud.com(single-segment short-links treated as Track), plussets/,likes,reposts,tracks,albumspaths. Fragment-safe path normalisation (#recentno longer misclassifies), artwork upgrade from-largeto-t500x500(handles.ext, extensionless, and query-string variants),client_idforwarded via hostget_config. Artist profiles are intentionally rejected bycan_handle/supports_playlist/ensure_soundcloud_urluntil artist pagination is implemented, avoiding a false-positive capability claim. 51 native unit tests. - Vimeo plugin: oEmbed JSON client (
vimeo.com/api/oembed.json) for metadata + player config client (player.vimeo.com/video/<id>/config) for quality variants (progressive MP4 + HLS). Balanced-brace HTML fallback with single- and double-quoted string tracking, plus a word-boundary marker (window.playerConfig/playerConfig =) so similarly named variables likewindow.playerConfigVersioncannot derail extraction. Deterministic HLS CDN fallback (lexicographic key order whendefault_cdnis missing).pick_variant_for_qualitywith2K → 1440/4K → 2160mapping,filter_audio_onlypreserving HLS, plusdefault_qualityconfig honoured by hoisting the matching variant to the head of the returned list. Private-share URLs (vimeo.com/<id>/<hash>) are preserved verbatim in the response so the auth token is not dropped. Showcase URLs are rejected bycan_handle/supports_playlist/extract_linksuntil token-gated showcase extraction lands. Anchored showcase/album regex rejects malformed trailing segments. 57 native unit tests. - Gallery plugin: 3 provider backends with dedicated JSON shapes — Imgur album API v3 (Authorization: Client-ID), Reddit submission JSON (native
is_gallery+ single-image preview fallback) with&unescaping and deterministic URL-sorted output (single-image fallback accepts.jpg/.png/… URLs with query strings and fragments). Flickrflickr.photosets.getPhotoshandles both numeric and stringwidth_o/height_o, and{"stat":"fail"}envelopes surface as aPluginError::HttpStatuswith the Flickr errorcode/messageinstead of a JSON parse failure. Generic<img src>HTML fallback behind a separateextract_genericexport; relative URLs now resolve against the page directory (preservinggallery/context), protocol-relative URLs inherit the page scheme (no forcedhttps:), andUrlContextstrips?/#when computing the origin and base directory.has_non_http_schemeguard blocksdata:/javascript:/mailto:/blob:from resolution. Fragment-stripping URL normaliser;extract_reddit_permalinkno longer double-appends.jsonwhen the input already ends in.json. Post-processing pipeline:dedupe_links→filter_by_min_resolution(now drops images with a single known dimension below the threshold, not just both-known cases) →auto_name(zero-padded<provider>_<album>_<idx>.<ext>with album-id sanitisation). CanonicalProviderenum lives inurl_matcher.rsand is re-exported fromlink.rs, eliminating the duplicated type surface. Runtimemin_resolutionfallback (800x600) now matches the manifest default. 49 native unit tests. - Shared host-function envelope pattern: every plugin models
HttpRequest/HttpResponseto mirrorsrc-tauri/src/adapters/driven/plugin/host_functions.rs, withHttpResponse::into_success_body()mapping 401/403 →PluginError::Privateand other non-2xx →PluginError::HttpStatus PluginErrorper crate viathiserrorwithSerdeJson(#[from]), no.unwrap()in production paths, no#[allow(dead_code)], nounsafeoutside documented#[host_fn]call sites- Release WASM binaries: SoundCloud ~250 KB, Vimeo ~1.12 MB, Gallery ~1.14 MB (all stripped with LTO +
opt-level = "z")
- New crates under
- Archive extractor module: native Rust extraction for ZIP, RAR, 7z, TAR (Task 26)
- Domain types:
ArchiveFormatenum (9 variants),ExtractSummary,ArchiveEntry,ExtractionConfig ArchiveExtractorport trait with detect, extract, list_contents, detect_segments- Format detection by magic bytes (ZIP, RAR v4/v5, 7z, TAR ustar) with extension fallback
- ZIP handler:
zipcrate with AES password support, path traversal protection viaenclosed_name() - TAR handler: plain TAR + GZ/BZ2/XZ/ZSTD compression via
flate2/bzip2/xz2/zstd - RAR handler:
unrarcrate for v4/v5 with password support and graceful error recovery - 7z handler:
sevenz-rust2(pure Rust) with password support and path safety validation - Split archive detection: RAR parts (.partNN.rar), 7z segments (.7z.NNN), ZIP spans
VortexArchiveExtractorcomposite: format routing, recursive extraction with configurable depthExtractArchiveCommandCQRS handler withspawn_blockingfor CPU-bound extractionListArchiveContentsQueryhandler for archive preview without extraction
- Domain types:
- i18n & advanced theming (Task 27)
react-i18next+i18next+i18next-browser-languagedetectorinstalled for internationalisationsrc/i18n/i18n.ts: i18next instance initialized withLanguageDetector(localStorage → navigator fallback)src/i18n/locales/en.jsonandsrc/i18n/locales/fr.json: complete English and French translations covering navigation, all settings sections, downloads search, and media grabber dialogssrc/hooks/useLanguage.ts:useLanguage()hook for language switching — callsi18n.changeLanguage()and persistslocaleto backend config viasettingsStore.updateConfig()src/hooks/useAppEffects.ts:useAppEffects()hook applying DOM side-effects on config changes — togglescompact-modeclass on<body>and sets--color-accentCSS variable on:root- All hardcoded UI strings replaced with
t('key')calls: navigation labels (Sidebar), settings tabs and all 6 settings sections, downloads search bar, media grabber dialog src/types/layout.ts:RouteConfig.labelrenamed tolabelKey(i18n translation key), Sidebar usest(route.labelKey)src/App.tsx:import './i18n/i18n'added as first import to ensure i18n is initialized before renderingsrc/layouts/AppLayout.tsx: loadssettings_geton mount, feeds result tosettingsStoreforuseAppEffectsto pick up initial compact mode and accent colorsrc/index.css:body.compact-modeselector with reduced font size, line height, and spacing overrides- Accent color runtime: changing accent color preset updates
--color-accentCSS variable immediately without reload UpdateConfigCommandlocale validation: rejects locales not in["en", "fr", "de", "es", "ja", "zh"]src/test-setup.ts: globalreact-i18nextmock returning English translation values via key lookup so all existing tests continue passing- 17 new frontend tests:
useLanguage(4),useAppEffects(5), translation key parity en↔fr (8) - 2 new Rust tests:
test_handle_update_config_rejects_invalid_locale,test_handle_update_config_accepts_valid_locale
- Release & distribution pipeline (Task 28)
.github/workflows/release.yml: triggered onv*.*.*tags, 6 jobscreate-release: extracts changelog body from CHANGELOG.md, creates GitHub Releasebuild-tauri-linux: builds .deb and .rpm, uploads to releasebuild-tauri-macos: builds .dmg with code signing + notarization via xcrun notarytool, uploads to releasebuild-tauri-windows: builds .msi with certificate import, uploads to releasepublish-flatpak: builds Flatpak bundle from manifest, uploads to releaseupdate-updater: generateslatest.jsonupdater manifest and uploads to release
- Tauri in-app updater configured in
tauri.conf.json(plugins.updater, endpoint → GitHub Releases) tauri-plugin-updateradded to Cargo.toml dependenciescontrib/vortex.service— systemd user unit for headless/autostart scenarioscontrib/vortex.desktop— Freedesktop .desktop entry (MimeType magnet + uri-list)contrib/flatpak/org.vortex.Vortex.yml— Flatpak manifest (runtime 23.08, Rust + Node 22 SDK)contrib/icons/README.md— icon generation instructions vianpx tauri iconcontrib/winget/Vortex.yaml— Winget manifest template (TODO placeholders for future submission)contrib/homebrew/vortex.rb— Homebrew cask template (TODO placeholders for future submission)
KeyringCredentialStorereplacesNoopCredentialStoreas the default credential adapter (#35)- Credentials now persist in the OS keychain (macOS Keychain, Linux Secret Service/keyutils, Windows Credential Manager)
NoopCredentialStoreremains available for tests
- HTML
langattribute now updates when the app locale changes — screen readers and browser features use the correct language pronunciation rules (#33) - Link Grabber now shows an inline error message when
link_resolvefails, instead of silently resetting after "Analyze Links" (#29) - Settings view now displays the actual backend error message when
settings_getfails, instead of only a generic "Failed to load settings" (#28) - CRITICAL: All 22 IPC commands now work —
AppStateis constructed and registered via.manage()in the Tauri setup closure (#27)- Database connection (SQLite WAL mode) with migrations run at startup
- All driven adapters wired: event bus, file storage, HTTP client, config store, clipboard observer, plugin loader, download engine, archive extractor
- CQRS buses (CommandBus + QueryBus) assembled from 15 driven ports
- Event bridges (Tauri webview + desktop notifications) connected to domain event bus
- Plugin hot-reload watcher started with tracing on failure
- Shared
reqwest::Clientbetween HTTP metadata port and download engine NoopCredentialStorestub for tests (replaced byKeyringCredentialStoreas default in #35)InMemoryStatsRepositorystub for unit tests (replaced bySqliteStatsRepoas default in #36)
- Status bar now shows real available disk space instead of
-- GB free— newstatus_bar_getTauri IPC command reads available bytes viastatvfs(Unix) orGetDiskFreeSpaceExW(Windows) from the configured download directory, with fallback to the system Downloads folder then the current directory (#32) - Status bar text now follows the UI language —
AppLayoutsyncssettings_get.localeinto i18next on startup so all status bar strings (statusBar.*) render in the active language; English and French translations are complete (#32)