Vortex v0.3.0-beta.1
Pre-releaseHighlights
- CI is now the merge gate: a three-OS matrix (tests, lint, audits, Tauri
packaging), secrets scans, a blocking desktop E2E smoke test, and
reproducible plugin CI with checksummed release artifacts. - Premium accounts work end to end: real validation, per-service selection,
rotation on quota or cooldown, credentials confined to the OS keyring. - MediaFire, PixelDrain and Gofile pages are resolved by their hoster plugins
before transfer — HTML pages can no longer be saved as "successful" files. - Galleries expand into selectable per-image rows in Link Grabber.
- Settings, plugin catalog and docs now match real capabilities: every visible
control either works or is explicitly marked as planned.
Known limitations
- No CAPTCHA solving yet: 1fichier free downloads that require a CAPTCHA are
not supported. - MEGA downloads are refused with an explicit error until host-side decryption
ships; the plugin only parses and validates links. - No remote access: the REST API, WebSocket and Web UI do not exist yet; the
Remote Access settings section shows a planned-feature notice. - Speed limit, disk pre-allocation and DNS-over-HTTPS settings are disabled
("Coming soon"). - macOS and Windows binaries ship unsigned; Gatekeeper/SmartScreen warnings
are expected.
Upgrade notes (from v0.2.0-beta)
- SQLite migrations run automatically on first launch (adds the
premium-accounts wiring migration); existing downloads, packages, history
and accounts are preserved. config.tomlkeeps the same format; an invalid hand-editeduser_agent
now falls back to the default instead of aborting startup.- Installed plugins keep working: every registry entry declares
min_vortex_version≤ 0.2.0, which this release satisfies. Updating
plugins from the Plugins view is still recommended to pick up the fixes
below.
Security
- MAT-132 transient premium URLs: link analysis, IPC, queued downloads, and
resume sidecars now retain only the stable source URL plus opaque account id.
The engine resolves the direct capability immediately before connecting,
pins its public DNS answers, requires credential-free HTTPS, and rejects
redirects/private networks. Sensitive network diagnostics and all plugin
logs emitted while a credential is scoped are redacted. - MAT-132 credential boundary hardening: account extraction now calls the
exact selected plugin instead of resolving the URL a second time, credential
slots are isolated per loaded plugin generation across hot reloads, hoster
JSON is parsed at the adapter boundary, contradictory validation states fail
closed, and untrusted plugin diagnostics cannot reach events or IPC. - Updated compatible Rust and frontend dependencies, including the patched
quinn-protorelease. Disabled unused SeaORM migration defaults so the
MySQL/PostgreSQL drivers and vulnerablersacrate are no longer included.
Restored strict wildcard dependency checks after returning Extism to crates.io.
cargo auditstill reports RUSTSEC-2026-0194 and RUSTSEC-2026-0195 through
wayland-scanner 0.31.10; retain the documented exception only until the
upstream fix is released. - Typed yt-dlp broker (MAT-131): replaced the plugin-facing generic
run_subprocess(binary, args, timeout)capability with a closed
run_ytdlpcontract. Vortex now selects an approved executable, builds all
arguments, ignores external yt-dlp configuration/plugins/remote components,
uses fixed timeouts and a cleared environment, confines download output to
the Vortex temporary directory, and caps stdout/stderr. A strict legacy shim
keeps the exact profiles of already-published official plugins loadable
during the migration; arbitrary subprocess capabilities are no longer
registered. The Tauri metadata fallback uses the same broker. - Official plugin provenance (MAT-131): yt-dlp host functions are now
granted only to Store-installed official plugins whose manifest and WASM
still match freshly fetched registry checksums. The grant is persisted
outside plugin directories and revalidated on every load; local installs,
hot-reloaded modifications, unsafe executable paths, oversized process
output, and failed partial downloads all fail closed. Provenance paths are
resolved before containment checks, state updates use an atomic synced
replacement, committed record sync errors no longer abort verified installs,
and revocation sync errors remain blocking before local replacement. - Lot 1 review hardening (MAT-129, MAT-131): reusable plugin CI now drops
checkout credentials, verifies the real WASM export table, builds release
assets in a clean directory, and rejects duplicate registry entries. Plugin
trust paths are canonicalized against the real filesystem, yt-dlp accepts
HTTPS only and tolerates concurrent private-directory creation.
Fixed
- MAT-136 registry coherence: the soundcloud entry's
min_vortex_version
is raised to 0.2.0 (the released 1.2.1 build uses 0.2.0-only host
functions), the MEGA and 1fichier descriptions no longer overpromise
unimplemented features, and a newregistry_coherenceintegration test
validates every registry entry in CI — required fields, semver formats
(leading-zero segments rejected), checksum shape, known category including
utility, unique names;min_vortex_versionstays optional as documented
in the registry template. - MAT-136 documentation paths: README, ARCHI.md, and CLAUDE.md now point
at the real Tauri app-data location for the database,config.toml, and
plugins directory (~/.local/share/dev.vortex.app/on Linux, with macOS and
Windows equivalents) instead of the never-used~/.config/vortex/. - MAT-133 cleanup observability: engine tests now record artifact deletion
requests so pre-download failures cannot silently exercise a cleanup path. - MAT-133 review portability: resume metadata locks preserve read-only
sidecars, huge-file segment limits cannot wrap, and interruption coverage is
synchronized with persisted progress. - MAT-133 final review hardening: protected ranged responses are inspected
independently, interrupted segments resume from persisted offsets, and
metadata cleanup cannot race tombstone recovery. - MAT-133 final PR feedback: cleanup tombstones survive restarts, credentialed
capabilities always use the restricted HTTP policy, XML-declared HTML is
rejected, and unknown-size retries cannot retain stale trailing bytes. - MAT-133 XML response validation: non-HTML doctypes are consumed before
inspecting the document root, so XML-declared HTML error pages are rejected. - MAT-133 hoster download planning: MediaFire, PixelDrain, and Gofile page
URLs are resolved through their hoster plugins before transfer; stable source
URLs and plugin metadata reach the queue while ephemeral direct URLs and
request headers stay backend-only and are refreshed on retry. Unexpected HTML
responses and typed hoster failures no longer appear as successful files. - MAT-133 review hardening: built-in HTTP downloads keep their normal online
probe, while current hoster plugin failures map to safe typed errors without
exposing upstream diagnostics. - MAT-133 review follow-up: protected capabilities now refresh once during
an active failed transfer, preserve plugin headers and file metadata, reject
ambiguous HTML and size mismatches, and clean up only Vortex-owned artifacts. - MAT-133 adversarial coverage: added regressions for hostile stable URLs,
blank capabilities, response fan-out, disguised HTML, destination collisions,
exact error classification, and recoverable online probes. - MAT-133 protected-source hardening: plugin payloads and per-file metadata
are bounded, mono-file hosters retain the user-supplied stable URL, Gofile
child identifiers stay on validated official origins, and protected transfers
now reject disguised HTML without deleting or overwriting unowned destinations. - MAT-132 PR review hardening: premium selection now excludes free
accounts, serializes persisted cooldowns with rotation, revalidates download
associations on every JIT resolution, and lets cancellation win without late
writes. Plugin calls enforce enablement at credential injection, retained
credential logs and bearer URL diagnostics stay redacted, plugin HTTP is
HTTPS-only, legacy numeric account IDs remain unassociated while UUID refs are
indexed, validation worker failures roll back the new account and secret, and
WASM calls no longer pin registry shards. - MAT-132 final concurrency and quota fixes: JIT rotation now updates only
the existing download's account reference, preserving concurrent state and
never recreating a removed row. Cooldown and quota exhaustion remain distinct
when no backup exists, zero remaining traffic triggers typed rotation, and
account badges wake at subscription expiry without a backend event. - MAT-132 final acceptance coverage: added contracts for strict
JIT-only credential use, runtime account rotation with association updates,
referenced-account deletion safety, observable missing credentials, cooldown
badge expiry, and operator-prefix NAT64 SSRF rejection. - MAT-132 final review hardening: link analysis now selects only an opaque
account id without reading credentials or creating a one-shot token; the CQRS
resolver rotates accounts only when the engine requests the direct source.
Account failures persist before typed events, referenced accounts cannot be
deleted, cooldown badges wake at their deadline, and restricted HTTP clients
ignore system proxies while rejecting special IPv6 and discovered NAT64
mappings to private IPv4. The registry now pins the published 1fichier
v1.1.0 release assets. - MAT-132 review regressions: added coverage for fail-closed account state
persistence, premium account lifecycle races, account-event refreshes, lock
reclamation, and IPv6/proxy SSRF boundaries. - MAT-132 account-state hardening: validation now derives validity from its
typed status, clears stale subscription expiries, bounds temporary failures,
and invalidates the rotator cache on recovery. Per-account async locks prevent
slow plugin calls from undoing deletes or edits; password rotation writes the
keyring first and restores the prior secret on SQLite failure. Cooldown checks
use an injected clock, and exhausted premium accounts no longer fall back to
anonymous extraction. - Lot 1 install race fixes (MAT-131): adaptive downloads atomically reserve
unique destination files before copying, and failed Store installs clean
their staging directory before propagating loader or task errors. - Lot 0 maintenance baseline (MAT-126–MAT-128): restored CI by allowing non-secret
.npmrcconfiguration through the shared redacted scanner, updated compatible npm and Rust dependencies to clear security audits, pinned CI actions on their Node 24 releases, kept the codebase clean under the current Rust stable Clippy, and added multi-OS Tauri build diagnostics. - Task 40 follow-up — PR #153 review fixes round 6 (scope
download, sprint task 40, PR #153 review):chatgpt-codex-connector[bot]flagged a cancellation race in the failover loop. AfterAttemptOutcome::Failed, the loop bumpedmirror_idx, ran the file/meta cleanup, and unconditionally publishedMirrorSwitched(whichprogress_bridgepersists tocurrent_mirror_index). A user-cancel that landed in the window between the failed attempt returning and the next attempt starting — including whiletokio::task::spawn_blockingwas running the cleanup — would still bump the persisted cursor through the bridge before the next iteration'srun_mirror_attemptobservedcancel_token.is_cancelled()and reportedAttemptOutcome::Cancelled. The retry would then resume from a slot the user never asked for. Twocancel_token.is_cancelled()guards added inside the failover branch: one before bumpingmirror_idx(catches a cancel that arrived between the attempt finishing and the loop re-entering), one after the cleanup completes (catches a cancel that arrived during the spawn_blocking). Both paths emitDomainEvent::DownloadCancelledand break out, so noMirrorSwitchedever fires for an aborted switch. - Task 40 follow-up — PR #153 review fixes round 5 (scope
download, sprint task 40, PR #153 review):coderabbitai[bot]flagged that the round-2 cursor-reset path mapped everyDomainEvent::DownloadFailedtoMirrorCursorReset, butextract_archive::extract_archive_handler(and the domainDownload::fail()max-retries path) also publishDownloadFailedfor post-download errors. A successful download from mirror N followed by an extract / verify failure would zero the cursor and lose the last-known-good slot, so a manual re-download would walk the full mirror list from the top instead of going straight back to the mirror that already produced the bytes. New domain eventDomainEvent::AllMirrorsExhausted { id }published by the engine's failover loop right before the existingDownloadFailed;progress_bridgenow keys the cursor reset onAllMirrorsExhaustedand ignores genericDownloadFailed. Wired throughtauri_bridge(mirrors-exhaustedevent name,{ "id": <id> }payload) and the download log bridge (silent — no log line). Three tests:test_event_to_message_maps_all_mirrors_exhausted_to_cursor_reset,test_event_to_message_does_not_reset_cursor_on_generic_download_failed(assertsevent_to_message(&DownloadFailed)returnsNone),test_all_mirrors_exhausted_resets_persisted_mirror_cursor_to_zero(write-through). The previous round-2 tests were renamed in place. - Task 40 follow-up — PR #153 review fixes round 4 (scope
download, sprint task 40, PR #153 review):chatgpt-codex-connector[bot]flagged that the round-3MirrorSwitchedhandler clears the cursor + segment rows but leavesdownloads.downloaded_bytes/total_bytespinned at the failed mirror's last value. Becauseupdate_download_progressenforcesdownloaded_bytes = MAX(downloaded_bytes, ?)so concurrent state-transition saves cannot regress the counter, the new mirror's progress events would be silently swallowed until the new attempt crossed the failed mirror's high-water mark — the UI would show a frozen / inflated progress bar (and ETA) until then.progress_bridgenow adds areset_download_bytesstep inside theMirrorSwitchedhandler that runsUPDATE downloads SET downloaded_bytes = 0, total_bytes = NULL WHERE id = ?between the cursor write and the segments purge. The next attempt'sDownloadProgressevent repopulatestotal_bytesthrough the existingCOALESCE(NULLIF(...))guard, anddownloaded_bytesrises monotonically from 0 under the sameMAXguard. Two regression tests:test_reset_download_bytes_zeroes_downloaded_and_clears_total(write-through) andtest_reset_download_bytes_then_progress_repopulates_total(the round-trip with a follow-upupdate_download_progresscall). - Task 40 follow-up — PR #153 review fixes round 3 (scope
download, sprint task 40, PR #153 review): two more findings caught after the round-2 push. (1)chatgpt-codex-connector[bot]flagged that the failover loop wipes the data file and.vortex-metasidecar but never deletes the matchingdownload_segmentsrows — the next mirror's segment plan can differ (no range support, fewer splits), so stale rows from the failed attempt linger and the read model surfaces phantom /Errorsegments through both the detail panel and the list segment counts.progress_bridgenow extends itsMirrorSwitchedhandler with aDELETE FROM download_segments WHERE download_id = ?step so each switch leaves the segments table empty for the next attempt'sSegmentStartedevents to repopulate. Newclear_segments_for_downloadhelper plus regression testtest_clear_segments_for_download_drops_all_rows_for_id(verifies both the purge and the cross-id non-interference). (2)cubic-dev-ai[bot]flagged a race indownload_repo::persist_download— the round-1 fix addedCurrentMirrorIndexto theupdate_columnslist, so a genericsave()carrying a stale in-memory cursor would race with the event-driven write fromprogress_bridgeand clobber the live failover position with the cursor as it was when the aggregate was loaded.CurrentMirrorIndexis now excluded fromupdate_columns(alongsideSpeedBytesPerSecandDownloadedBytes, which already follow the same "owned by the bridge" rule) — the persisted cursor is exclusively written through the event channel. - Task 40 follow-up — PR #153 review fixes round 2 (scope
download, sprint task 40, PR #153 review): regression introduced by the round-1 cursor-persistence fix. After all mirrors fail,MirrorSwitchedadvances the persistedcurrent_mirror_indexto the last slot;DownloadFailedthen leaves it parked there, so the next manual or automatic retry resumed from the bottom-priority mirror and never re-tested the higher-priority entries that may have recovered.progress_bridgenow translatesDomainEvent::DownloadFailedinto aBridgeMessage::MirrorCursorResetand writescurrent_mirror_index = 0through the same single-task channel — retries always walk the mirror list from the top. Two regression tests:test_event_to_message_maps_download_failed_to_cursor_reset(event mapping),test_download_failed_resets_persisted_mirror_cursor_to_zero(write-through). - Task 40 follow-up — PR #153 review fixes (scope
download, sprint task 40, PR #153 review): three issues caught bychatgpt-codex-connector[bot](P1+P2) andcoderabbitai[bot]. (1) Mirror cursor was only mutated locally inside the engine's failover loop and never persisted todownloads.current_mirror_index, sodownload_read_repokept emitting slot 0 as active even after a runtime switch and the details panel surfaced the wrong mirror.progress_bridge.rsnow subscribes toDomainEvent::MirrorSwitchedand runsUPDATE downloads SET current_mirror_index = ? WHERE id = ?through the same single-worker queue that handles segment/progress writes, so cursor updates land in event-bus order alongside the rest of the row state. (2) The mirror retry path could fail at pre-allocation when an earlier attempt left a.vortex-metafile next to a partial download — the meta-aware branch inrun_mirror_attemptkeeps the file, thenstorage.create_file(which usescreate_new(true)) collides with it and the next mirror is never tried. The failover loop instart()now wipes both.vortex-metaand the partial file viatokio::task::spawn_blockingbefore thecontinue, so each new mirror attempt starts on a clean disk slot — bytes from mirror N are not safe to splice with mirror N+1 anyway. (3)download_read_repo::find_download_detailclampsmodel.current_mirror_indexagainstmirrors.len() - 1(or0when the list is empty) before constructing theDownloadDetailView, so a row withmirrors_jsonshrunk by a manual edit or future migration cannot surface an out-of-range slot. New regression tests:progress_bridge::test_update_mirror_cursor_persists_new_index(write-through),progress_bridge::test_event_to_message_maps_mirror_switched(event → bridge mapping). - Task 40 follow-up —
DownloadsViewtest fixture missing mirrors (scopedownload, sprint task 40): inlinedownload_detailmock insrc/views/DownloadsView/__tests__/DownloadsView.test.tsxnow returnsmirrors: []+currentMirrorIndex: 0so the simplify-pass removal of redundantmirrors?.lengthnull-guards inDownloadDetailsPanel.tsxno longer blows up the three downstream tests withTypeError: Cannot read properties of undefined (reading 'length'). Caught by the lefthook pre-pushts-testhook before the branch reached origin. - Task 39 follow-up — PR #151 review fixes round 2 (scope
download):download_removeIPC now runshandle_remove_downloadfirst and only invokeswait_manager.cancel_waiton success, so a fallible remove no longer strands the download inWaitingwith no timer to expire it (same pattern already in place fordownload_cancel).WaitManagertags every scheduled timer with a monotonicgenerationtoken alongside itsJoinHandle;expire_wait(id, generation)now compares the recorded generation against the live entry under the same lock and bails out on mismatch. This closes the reschedule race where a stale timer that already passed its.await(soJoinHandle::abort()is a cooperative no-op) couldremove(&id)matching byDownloadIdalone, evict the freshly-inserted handle and resume the aggregate against the elapsed deadline. New regression teststale_timer_does_not_evict_replaced_handlereproduces the race deterministically by advancing past the first deadline before the reschedule call and asserts the second timer survives + the aggregate staysWaitinginstead of resuming early. toErrorhardened against circular-reference payloads (scopeapi, issue #142, PR #146 review round 1):src/api/client.ts::toErrornow wrapsJSON.stringify(err)in atry/catchand falls back toString(err)when the rejected payload contains a circular reference. Previously a circularerr(possible when an enriched Rust error round-trips through Tauri IPC and re-references itself) would throwTypeError: Converting circular structure to JSONfrom inside thetauriInvokecatch block, masking the original failure with a normalization error. TheJSON.stringifyresult is also?? String(err)-coalesced before being passed tonew Error(...)so a payload that legitimately serializes toundefined(a bareundefinedrejection, a function, or asymbol—JSON.stringifyreturnsundefinedfor those without throwing) no longer constructs anErrorwith a blankmessage. Five regression tests cover circular objects, plain-object stringification, string rejections,undefinedrejections, and function/symbol rejections.- Task 33 — PR review fixes (scope
link, PR #145):url_normalizer::detect_schemenow reads viastr::get(..)accessors instead of byte-index slicing, so a non-ASCII-leading URL (e.g."😊https://…") no longer panics on a multi-byte UTF-8 boundary. Authority canonicalisation lowercases only the host portion — userinfo (user:pass@) is preserved byte-for-byte to avoid silently merging distinct credentials per RFC 3986.LinkGrabberViewadds a monotonicdetectBatchRefso a latelink_detect_duplicatesresponse from a previous resolve cannot clobber the duplicate state of a newer batch. WhileSkip duplicatesis on,isStartablenow returnsfalsefor rows whose duplicate probe hasn't returned yet (link.duplicate === undefined), closing the race where a fast Start click before detection completed would still launch flagged downloads.dispatchDuplicateDetectionalso wires anonErrorhandler so an IPC failure marks each batch row asduplicate: nullinstead of leaving them stuck in the loading sentinel —isStartablethen unblocks bulk start instead of silently rejecting every online row. NewgetDuplicateKey(link)helper unifies the URL identity used by dedupe andstartDownload(link.resolvedUrl ?? link.originalUrl), so a redirected link whose canonical form already exists in active/history can no longer slip past the duplicate gate. NewstartLinks(links)helper collapses bulk starts by canonical URL whenSkip duplicatesis on, so two rows pointing to the same target (mirror sites, redirects to the same file) no longer launch two parallel downloads from a single paste batch.dispatchDuplicateDetectionderives canonical probe URLs per-row through aSetinstead of an intermediateMap<originalUrl, …>that could silently drop a row when two rows shared anoriginalUrl. Duplicate-badge labels and tooltip route throughreact-i18next(linkGrabber.duplicate.active/…history/…tooltipWithFilename) instead of hardcoded English. Two new normalizer unit tests cover the UTF-8 panic + userinfo preservation paths.
Changed
-
MAT-136 settings drive real behavior:
max_segments_per_downloadand
max_retriesare applied to each new download instead of hardcoded domain
defaults,retry_delay_secondsfeeds the retry backoff live via the
settings bridge, and proxy/user-agent/connection-timeout build the HTTP
client at startup (the Network section now says changes apply at next
launch); an invaliduser_agentin a hand-edited config falls back to the
default instead of aborting startup, and the invalid-proxy warning no
longer logs the raw proxy URL, which can embed credentials. Options with no engine wiring yet — speed limit, pre-allocate
space, DNS over HTTPS — are disabled with a "Coming soon" badge, and the
Remote Access section is replaced by a planned-feature notice so
REST/WebSocket/Web UI can never look active while no server exists. -
MAT-136 MEGA plugin 1.1.0:
extract_linksandresolve_stream_url
now refuse with an explicit "downloads are not supported yet" error instead
of handing the engine an encrypted CDN URL whose bytes downloaded
"successfully" as unreadable AES ciphertext. The resolution and crypto code
stays in the plugin library, ready to rewire once host-side decryption
ships. -
MAT-132 account status contract: account validation responses now expose
their exact typed status to the UI, rejected-validation toasts use its
localized label instead of plugin diagnostics, elapsed quota/cooldown markers
render as active, and account read models no longer expose backend keyring
references. -
Task 40 follow-up —
/simplifycleanup (scopedownload, sprint task 40): extractedgetHostname+getProtocoltosrc/lib/url.tsand replaced the inlined copies inMirrorsSection.tsx+SourceInfoSection.tsxwith the shared helpers.run_mirror_attemptswapped its 13 positional arguments for aMirrorAttemptParamsstruct mirroring the adjacentSegmentParamspattern; the#[allow(clippy::too_many_arguments)]suppression is gone.entities/download.rs+download_read_repo.rsusesafe_u32(model.current_mirror_index as i64)instead of the ad-hocu32::try_from(...).unwrap_or(0)so the cast goes through the SQLite adapter's standard helper. NewMAX_MIRRORS_PER_DOWNLOAD = 64constant indomain/model/download.rs;Download::set_mirrorstruncates after sorting so a malformed.metalinkwith thousands of entries cannot bloatmirrors_jsonor stall the failover loop. Doc comments onDownload::mirrors/current_mirror_indexand the entity'smirrors_json/current_mirror_indexcolumns trimmed to the WHY clauses (cap rationale, future-extension hook); the persisted cursor's actual behaviour is now spelled out — the engine still drives failover with its in-task cursor, so a crash mid-failover restarts from slot 0, and the column is retained as a hook for the future call site that will mark a failing slot at the domain level. Frontend gates simplified:DownloadDetailsPanel.tsxdrops the redundantmirrors && …(non-optional field in TS),MirrorsSection.tsxdrops the redundant!download.mirrorsshort-circuit (dead code under the parent gate). -
Task 39 follow-up — PR #151 review fixes (scope
download):WaitManager::schedule_waitnow aborts the previousJoinHandlereturned byguard.insert(id, handle)instead of just dropping it (dropping aJoinHandleonly detaches the task —abort()is what actually stops the timer), so callingschedule_waittwice for the sameDownloadIdno longer leaves a stale task that could fireexpire_waitagainst an outdated deadline.expire_waitnow short-circuits when the handle has already been removed by a peercancel_wait/skip_wait(the abort is cooperative: once the sleep wakes and the task is past its last.await, it runs to completion regardless ofabort()), preventing a spuriousresume_aggregaterace against the cancel flow.download_cancelIPC now runs theCancelDownloadCommandfirst and only invokeswait_manager.cancel_waiton success, so a fallible cancel command no longer strands the download inWaitingwith no timer to resume it.download_log_bridgerecordsDownloadWaitingEnded { expired_naturally: false }as"ended early"instead of"skipped"(the same branch fires for cancel-driven endings, where "skipped" was misleading).useCountdownshort-circuits before schedulingsetIntervalwhenuntilUnixMsis already in the past, dropping one no-op render + interval wakeup cycle for already-expired rows. New regression testrescheduling_same_id_aborts_previous_timerproves only the latest timer can fire after a reschedule + newuseCountdowntest assertssetIntervalis not called for past deadlines. -
Task 39 follow-up —
/simplifycleanup (scopedownload):WaitManagerrecovers from a poisoned mutex via a privatehandles()accessor instead of.expect(...)-panicking every subsequentcancel_wait/skip_waitcall; thetokio::spawnfor the timer now runs under the same mutex guard that inserts theJoinHandle, closing the spawn-before-insert race that left an orphan entry when the timer fired (ortokio::time::advancetriggered in tests) before the parent inserted the handle; the previously silentlet _ = me.expire_wait(id)is now logged viatracing::debug!on a dropped error;Download::wait()/resume_from_wait()shed redundant.map_err(AppError::from)calls;active_countis#[cfg(test)]-gated;download_removeIPC invokeswait_manager.cancel_waitso deleting a Waiting download aborts the spawned timer instead of leaving it alive for the full hoster cooldown. Frontend:useDownloadEventsno longer double-invalidates ondownload-waiting-started/download-waiting-ended(the accompanyingdownload-waitinganddownload-resumed-from-waitevents already invalidate);useCountdownclears itssetIntervalonce the deadline is reached so a parked row that has expired stops firing 1 Hz no-op re-renders. -
Task 33 follow-up — review-driven cleanup (scope
link, sprint task 33): extractedapplication/services/history_paginate::for_each_history_page+list_full_historyso the bounded-page loop is no longer copy-pasted betweenexport_historyanddetect_duplicates. HoistedInMemoryDownloadReadRepo+make_history_and_downloads_query_busintoapplication/test_support(deleted ~120 lines of stub fixtures from the new query test module). Replaced the stringly-typedDuplicateCheckDto.source: Option<&'static str>with a proper serde enumDuplicateSourceDto.detect_duplicateshandler now stores existing-ids asu64in the index and stringifies only on confirmed hits, plus acontains_keypre-check on the history loop avoids allocating a tuple for entries already shadowed by an active match.url_normalizerdetects the scheme viaeq_ignore_ascii_caseinstead of allocating a lowercase copy of the whole URL, and trims WHAT-style block comments down to the load-bearing WHY notes.LinkGrabberViewextracts anisStartable/startLinkpredicate pair sohandleStartSelectedandhandleStartAllOnlineno longer carry the duplicated status / duplicate gates;duplicateCountisuseMemo-wrapped, and thedetectDuplicatesonSuccessshort-circuits via===checks against the previousduplicatereference so an all-unique batch does not retrigger downstream re-renders.LinkRowswaps the duplicate-source ternary chain for aduplicateLabelMaplookup anddata-duplicatenow uses"none"(was"no"). Test fixtures deduplicated through amakeDuplicatehelper.
Added
-
Blocking desktop E2E smoke test (MAT-135): a new
e2e-smokeCI job
builds the real desktop binary (debug profile, tauri-plugin-pilot embedded),
launches it under xvfb with an empty temporary profile, and drives the
critical path through the actual UI and Tauri IPC: paste a link served by a
local deterministic fixture server (HEAD + Range/206), resolve it in the
Link Grabber, start the download, and wait for completion in the Downloads
view. The downloaded file is checked against its expected name, size, and
SHA-256, and the app is restarted to verify the completed download persists
through SQLite. On failure the job uploads a screenshot, page HTML, console
and app logs, and the temp profile data as diagnostic artifacts. -
Gallery end-to-end integration (MAT-134): pasting a supported gallery
URL (Imgur, Flickr, generic pages) into the Link Grabber now expands it into
one selectable row per image, preserving gallery order, filenames, and the
imagemedia type. Per-image failures surface as individual error rows
instead of cancelling the whole gallery, and an empty gallery reports a
clear "no downloadable images" error. Selected images start downloads
through the existing Tauri commands. yt-dlp-backed crawlers (YouTube,
Vimeo, SoundCloud) keep their cheap probe path and are never expanded.
A garbled plugin response reports a plugin error instead of probing the
gallery page, image URLs must parse as http(s) with a host, and expansion
is capped at the resolve output limit so an oversized gallery truncates
instead of failing the whole request. -
Premium account runtime wiring (MAT-132): validate configured accounts
through hoster plugins, keep selected credentials scoped to the plugin call,
persist typed account availability, rotate on account failures, and associate
premium downloads with their account without exposing secrets to SQLite,
logs, or frontend events. SQLite stores only typed status, cooldown deadline,
and the opaque account UUID reference; legacy downloads keep their unused
numeric account column while new associations useaccount_reftext. Each
plugin call receives an isolated, automatically cleared credential slot;
existing service-level credentials remain a fallback for legacy plugins.
Contract tests pin the exact service/credential handoff and typed plugin
authentication failures; the Extism bridge now maps stable account error
codes and validation metrics without placing secrets in the plugin ABI.
Command-level tests require add/update/explicit validation to persist the
resulting typed state, including after password rotation; rejected accounts
stay configured but are excluded from premium selection by that state.
Selector/rotator tests require persisted cooldowns to survive a restart and
distinguish unavailable credentials from temporarily exhausted accounts;
selection and rotation now enforce those persisted states.
Resolution/start contract tests carry only the stable source URL, selected
account UUID, and plugin name, and require JIT rotation to a second account.
JIT hoster resolution uses the configured selector/rotator, scopes each
keyring secret to its plugin call, persists typed account failures, retries
an eligible fallback account, and validates the opaque association before
persisting a download.
Read-model and Link Grabber contract tests require typed account states,
cooldown deadlines, and opaque premium associations across IPC.
Account read models now expose non-secret validation state and cooldown
deadlines; Accounts renders typed badges, while Link Grabber forwards the
selected plugin/account pair when starting the resolved direct URL.
Production wiring now composes the Extism-backed account validator with the
SQLite selector and persistent quota rotator using one system clock; the
AppState integration test verifies both command and query account paths.
Premium resolution integration tests now cover missing keyring entries plus
invalid, quota, and cooldown failures before rotation to a second account. -
Task 42 — Link Grabber container import UI (scope
link, sprint task 42, PRD-v2 §P1.23 / PRD §6.2.1): drag-and-drop.dlc/.ccf/.rsdf/.metalink/.meta4files into the Link Grabber paste zone now decrypts the container through the loadedvortex-mod-containersplugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC commandlink_import_container(file_name, file_bytes)wrapsImportContainerCommand(application/commands/import_container.rs) — validates the extension against an allowlist, caps the payload atMAX_CONTAINER_BYTES = 1 MiB(defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the newPluginLoader::decrypt_container(bytes) -> JSONport, parses the plugin response, and creates aPackage { source_type: Container, name: <file_name> }so the imported batch is visible as one unit in the Packages view. The default trait impl returnsDomainError::NotFoundso trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabledContainer-category plugin that exportsdecrypt, calls it via the newPluginRegistry::call_plugin_bytes(name, "decrypt", &[u8])helper (containers are binary blobs — the existingcall_pluginwould have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded"NotFounderror that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast.PasteZone.tsxnow exportsCONTAINER_EXTENSIONS+ anisContainerFile(File)predicate and forwards container drops through a dedicatedonContainerFiles(File[])callback instead of synthesising fakecontainer:<name>URLs thatLinkGrabberViewthen dropped on the floor (the originalLinkGrabberView.tsx:67TODO).LinkGrabberView::handleContainerFilesreads eachFileviaarrayBuffer(), ships the bytes as anumber[], surfaces an "Imported {N} links from {filename}" success toast (i18n keyslinkGrabber.toast.containerImported_one/_otherinfr.json+en.json), then reuses the existingresolveLinks({ urls })mutation so containers and pasted text follow the exact same online-check + dedupe + start path. Container password protection is wired-up in spec but vacuously satisfied today —vortex-mod-containersv1.0 uses fixed historic AES keys per ADR-001 and the four supported formats (DLC v1, CCF v1, RSDF, Metalink) have no per-file password layer; CCF v2 keys + DLC v3 service-fetch are explicitly deferred to v1.1, so nopassword_requiredstate can flow throughdecrypt_containeruntil the plugin gains the capability. 16 new tests: 8 backend (import_container::tests— golden path with a Metalink response, blank/extension/empty/oversize validation rejections, pluginNotFoundpropagation, zero-link response, malformed JSON), 1 port default (plugin_loader::tests::test_decrypt_container_default_returns_not_found), 1 adapter (extism_loader::tests::test_decrypt_container_returns_not_found_when_no_plugin_loaded), 4 frontendPasteZone.test.tsxcases (drop forwards files viaonContainerFiles, ignored when callback missing, text-only drops keep extracting URLs,isContainerFileaccepts every supported extension + uppercase + rejects unrelated), 2 frontendLinkGrabberView.test.tsxcases (drop triggerslink_import_containerwith the byte array + chains intolink_resolveon success, IPC failure surfacestoast.errorand skipslink_resolve).cargo test --workspace: 1493 pass / 7 ignored.cargo clippy --workspace -- -D warnings+cargo fmt --checkclean.vitest run: 702 pass.oxlint+tsc -bclean. -
Plugin
vortex-mod-containersv1.0.0 (scopeplugin, sprint task 41, PRD-v2 §P1.22 / PRD §4.4): new official Vortex plugin in a sibling repovortex-mod-containers/. Container category, nohttpcapability — pure transformationbytes → Vec<ContainerLink>, the host routes the resulting URLs through the regular hoster pipeline. Decrypts the four legacy link-container formats: DLC v1 (JDownloadercb99b5cbc24db398AES-128-CBC key +9bc24cb995cb98b3IV, base64-wrapped XML carrying a base64-encrypted<content>whose plaintext is<files><file><url>BASE64URL</url><filename>BASE64NAME</filename><size>NNN</size></file>…), CCF v1 (Cryptload-styleCCF1\nmagic prefix + base64 AES-128-CBC ciphertext over a<package><file>…XML payload, Vortex-specific embedded key documented for round-trip until a Cryptload v1/v2 corpus is captured), RSDF (RapidShare legacy8C 35 19 2D 96 4D C3 18 2C 6F 84 F3 25 22 39 EBkey/IV pair, hex line per encrypted URL), and Metalink (RFC 5854 v4 + community v3 — namespaced<metalink>root,<file name>carrying<size>,<hash type="sha-256|sha1|md5">plus dashed-form variants normalised to the sameChecksumAlgoenum, and one or more<url>resolutions; first URL becomesContainerLink.url, the rest stack intoContainerLink.mirrors). Format detection (dispatch::detect) is structural: CCF magic first (unambiguous), then<metalink>substring, then base64 → DLC outer XML probe, then hex-line-shape RSDF probe; non-matching bytes returnOption::Noneso the host can show "Unsupported container variant" instead of guessing wrong. Errors map to a typedPluginError(UnsupportedFormat,Malformed(String),Decrypt(String),Xml(String),Base64(String),Hex(String),Utf8(String),MissingField(&'static str)) withFromimpls for every upstream error type — no.unwrap()outside tests, all upstream parsers are funnelled through?. The crypto core (src/crypto.rs) wrapscbc::Encryptor<aes::Aes128>/Decryptoragainst the in-placeencrypt_padded_mut::<Pkcs7>/decrypt_padded_mut::<Pkcs7>API (pre-sizedVec<u8>buffer, noalloc-feature dependency oncbc/cipher); decryption rejects non-% 16ciphertext lengths up-front so a misaligned blob fails fast. Plugin contract exposes three exports throughextism-pdk's#[plugin_fn]:can_decrypt(bytes) -> "true" | "false"(magic-byte + structural detection),detect(bytes) -> JSON DetectResponse(explicit format report includingnullon no match), anddecrypt(bytes) -> JSON DecryptResponse { format, links: [{url, filename?, sizeBytes?, mirrors[], checksums[]}] }— all DTOs#[serde(rename_all = "camelCase")]for the Tauri IPC bridge. Privacy hard-line documented indocs/ADR-001-container-keys.md: the plugin never reaches out toservice.jdownloader.org/dlcrypt.php(the JD service path used for DLC v3 per-container keys), so the WASM module declareshttp = falseand modern DLC v3 captures fail cleanly withPluginError::Decrypt(...)rather than silently leaking the container hash to a third party. WASM artefact weighs 206 KB under--release(well below the ≤500 KB acceptance budget) thanks to the existingopt-level = "z"+lto = true+codegen-units = 1+strip = trueprofile. 54 native unit tests acrosscrypto(AES round-trip, wrong-key rejection, block-size invariants, empty-plaintext padding, misaligned-input rejection),metalink(v3 + v4 + namespace + dashed hash type + multi-file + missing-<url>rejection + zero-files rejection + magic-hint detection),rsdf(encode/decode round-trip, magic detection, garbage rejection, short-hex rejection, empty-input rejection, CRLF tolerance, blank-line tolerance, all-empty-decrypt rejection),dlc(encode/decode round-trip, magic detection, plain-XML rejection, random-base64 rejection, short-blob rejection, invalid-base64-outer rejection, missing-<content>rejection, zero-files rejection, Unicode filename support),ccf(encode/decode round-trip, magic prefix detection, missing-magic rejection, invalid-base64 rejection, zero-files-after-decrypt rejection, XML-escape correctness for ampersand/angle-bracket URLs),dispatch(per-format detection routing + priority for CCF over Metalink + None on unknown), andlib(top-levelcan_decrypt/detect/decryptsmoke tests per format +UnsupportedFormaton garbage) + 8 integration tests intests/synthetic_corpus.rsexercising a 20-container corpus (5 DLC + 5 CCF + 5 RSDF + 5 Metalink — the corpus generator covers single-file, multi-file packs of up to 10 volumes, Unicode filenames, ampersand-laden URLs, mirror-rich Metalink, and dashed-form<hash type="sha-256">variants) round-tripped through the publicdecrypt()API; specificallycorpus_has_at_least_twenty_containers_across_four_formatsenforces the ≥20 / ≥5-per-format invariant,every_corpus_entry_decrypts_correctlyasserts format + link count + first-URL identity for each entry,metalink_corpus_carries_checksumsproves every Metalink fixture surfaces at least one checksum,metalink_corpus_recognises_sha256_when_presentproves dashed/upper SHA-256 attribute variants normalise to theSha256algo enum, the per-format suites prove the magic-prefix and order-preservation invariants, andunknown_blobs_are_rejectedprovescan_decrypt+decryptreject empty/HTML/binary inputs symmetrically. Test corpus is synthetic rather than redistributing JDownloader-era proprietary fixtures (rationale recorded in the ADR — historic captures point to dead hosters and many reference copyrighted material; future releases can drop real captures into a guardedtests/fixtures/real-world/if needed).cargo clippy --all-targets -- -D warningsclean (collapsible-if + manual-is_multiple_ofclippy hits fixed up-front, complex-type aliasEncodeCaseextracted in the integration suite).cargo fmt --checkclean. Registered invortex/registry/registry.tomlwithchecksum_sha256 = 28ba16ce…12ede6(wasm) andchecksum_sha256_toml = 6d7e0152…758dd6(manifest); categorycontainer, official,min_vortex_version = "0.1.0". Acceptance criteria status (5/5 verified) and per-criterion verification methods recorded in.claude/output/sprints/prd-v2-roadmap/tasks/41-plugin-containers.md. Unblocks task 42 (Link Grabber drop-zone container import UI). -
Metalink mirrors fallback in download engine (scope
download, sprint task 40, PRD-v2 §P1.21 / PRD §7.1): newdomain/model/mirror::Mirroraggregate (Url+ priority 1..=100 + optional ISO-3166 alpha-2 country) withsort_by_priorityordering highest-first and ties broken by URL string for deterministic re-tries;Downloadgainedmirrors: Vec<Mirror>+current_mirror_index: u32plus the builderwith_mirrors, the cursor mutatorsadvance_mirror/reset_mirror_cursor, and theactive_url()getter that resolves tomirrors[current_mirror_index]and falls back to the canonicalurlfield when the list is empty (single-source downloads see no behaviour change). Engine restructured:start()snapshots the mirror URL list and the persisted cursor, then a tokio loop drivesrun_mirror_attempt(...)per candidate. Each attempt gets a freshattempt_token = cancel_token.child_token()so an internal segment-failure teardown cancels peers without raising the user-cancel signal —user_cancel_token.is_cancelled()(theActiveDownloadtoken) remains the only wayAttemptOutcome::Cancelledis reported. OnAttemptOutcome::Failed(_)the loop publishesDomainEvent::MirrorSwitched { id, new_mirror_index, new_url }and retries the next slot; on full exhaustion publishesDownloadFailed. New SQLite migrationm20260505_000009_add_mirrorsaddsmirrors_json TEXT NULL+current_mirror_index INTEGER NOT NULL DEFAULT 0todownloads; the entity layer ships an adapter-sideMirrorJsonDtoso the domain stays serde-free, withserialize_mirrors/deserialize_mirrorsround-tripping the list — the read repo expands the JSON into a newMirrorViewper entry and the IPC DTO (DownloadDetailViewDto) carriesmirrors: Vec<MirrorViewDto>+currentMirrorIndexin camelCase. Frontend gained aMirrorViewTS interface and aMirrorsSectionpanel that renders the active mirror (host + priority + country) plus a list of alternatives with the active row highlighted viaaria-current="true";DownloadDetailsPanelonly inserts the section whenmirrors.length > 0so non-Metalink downloads keep their compact layout.DomainEvent::MirrorSwitchedis wired throughtauri_bridge(event namemirror-switched, camelCase payloadid/newMirrorIndex/newUrl) and the download log bridge ignores it (no log line spam on every failover). Coverage: 8Mirrorunit tests (validation + sort), 7Downloadmirror-handling tests (active URL, advance/exhaust/reset, sorted getter), 3 engine wiremock tests proving the three acceptance criteria —test_three_mirrors_first_404_triggers_failover_to_second(HEAD 404 on mirror 1 → switch to mirror 2 → DownloadCompleted, exactly one MirrorSwitched event),test_all_mirrors_fail_publishes_download_failed(all mirrors return 5xx → DownloadFailed, exactly one switch between the two slots, no DownloadCompleted),test_priority_respected_highest_first(mirrors inserted as low/high/mid → engine sorts and trieshighfirst, switches once tomid, never reacheslow) — plus 2 SQLite round-trip tests (test_save_round_trips_mirrors_with_priority_and_country,test_save_round_trips_current_mirror_index_after_advance), 1 detail-DTO camelCase serialisation test, and 3 Vitest tests forMirrorsSection(no render when empty, active highlight + alternatives count, priority/country tags). Acceptance criteria status (4/4 verified) recorded in.claude/output/sprints/prd-v2-roadmap/tasks/40-metalink-mirrors-fallback.md. -
Plugin
vortex-mod-1fichierv1.0.0 (scopeplugin, sprint task 38, PRD-v2 §P1.19 / PRD §4.1, §4.2): new official Vortex plugin in a sibling repovortex-mod-1fichier/. Hoster category,httpcapability only — no subprocess, no captcha solver in v1. Recogniseshttps://(www\.)?1fichier\.com/?<id>URLs (alphanumeric<id>of 6+ characters; rejected when the path contains anything other than/, when the id is shorter than 6 chars, or when extra query tokens appear before the id). Operates in two modes selected automatically: premium when a 1fichier API key is configured in the host credential store under the plugin's own service name (vortex-mod-1fichier), in which case the plugin posts tohttps://api.1fichier.com/v1/download/get_token.cgiwithAuthorization: Bearer <key>and returns the one-shot direct CDN URL plus optionaltraffic_used/traffic_totalmonitoring; free otherwise (or as a fallback when the API rejects the key as invalid / expired), in which case the plugin scrapes the public landing page and surfaceswait_seconds(read fromdata-wait="…",class="countdown", orvar c = …— covers the three layouts 1fichier ships) plus arequires_captchaflag (detected viag-recaptcha/h-captcha/class="captcha") so the host'sWaitManager(task 39) can park the download inWaitingstate for the advertised duration before the future captcha solver pipeline (task 43+) takes over. Freeresolve_stream_urltherefore surfacesPluginError::CaptchaRequireduntil the captcha pipeline ships — the metadata path (extract_links) still succeeds so the UI can show filename / size / wait countdown. The premium response parser maps the{"status":"KO","message":"…"}envelope onto typed errors:Invalid key→InvalidCredentials,Subscription expired/not premium→AccountExpired(both trigger free-mode auto-fallback insideextract_links),Flood detected/ rate / too many →RateLimited,not found/offline→Offline, anything else →InvalidApiResponse. Landing-page parser also detects thethe file you are trying to access is no longer available/requested file could not be found/fichier introuvablestrings and surfacesPluginError::Offlineso the engine flags the link as removed instead of retrying. Body size capped atMAX_BODY_BYTES = 1 MiBagainst memory abuse from a malicious server (real 1fichier landing pages weigh well under 100 KB).FileLink.resumableis reported astrueso the host's segmented engine engages multi-connection ranged downloads. The IPC envelope addsmode: "free" | "premium",wait_seconds: Option<u32>,requires_captcha: bool, and optionaltraffic_used_bytes/traffic_total_bytesalongside the standardid/url/filename/size_bytes/direct_url/resumableshape. 53 native unit tests (URL matcher × 16, free landing parser × 13 incl. recaptcha detection / offline / no-metadata /var ccountdown, premium API parser × 14 covering OK / OK+traffic / KO classification / malformed JSON, credential parser × 4, response builders × 5, routing helpers × 5) + 10 fixture-driven cases overtests/fixtures/*.htmlandtests/fixtures/*.json(basic landing, captcha landing, JS countdown landing, offline, no-metadata, premium OK, premium OK+traffic, premium invalid-key, premium expired, premium rate-limit) + 3 WASM-loaded smoke tests (Extism with stubhttp_request+ stubget_credentialthat signals "no credential" — exercises thecan_handle/supports_playlistexports against the compiledwasm32-wasip1artefact). Registered invortex/registry/registry.tomlwithchecksum_sha256 = 02035a49…3232c(wasm) andchecksum_sha256_toml = c16cb7d4…87c091(manifest). -
Hoster wait-time flow (scope
download, sprint task 39, PRD-v2 §P1.20 / PRD §6.1.2): newadapters/driven/network/wait_manager.rsservice owns onetokio::time::sleepper parked download and drives theWaiting↔Downloadingcycle that hoster cooldowns require.schedule_wait(id, total_seconds, reason)loads the aggregate, transitions it viaDownload::wait(), persists, then publishes both the legacyDownloadWaiting(state-transition signal) and the new richDownloadWaitingStarted { id, until_unix_ms, total_seconds, reason }(front-end countdown payload). Natural expiry callsexpire_wait(id)which transitions back toDownloadingviaresume_from_wait, persists, and publishesDownloadWaitingEnded { expired_naturally: true }plusDownloadResumedFromWait.skip_wait(id)(premium "skip-the-queue" path) aborts the timer and resumes immediately withexpired_naturally: false.cancel_wait(id)aborts the timer cleanly and emits the ended event without transitioning the aggregate (used by the cancel flow beforehandle_cancel_downloadruns). Multi-concurrent waits run in parallel — each download owns its ownJoinHandleinMutex<HashMap<DownloadId, JoinHandle>>so a 10s wait, a 30s wait, and a 60s wait all expire independently. New IPCdownload_skip_wait(id)exposed; existingdownload_cancelnow callswait_manager.cancel_waitfirst to avoid a spurious resume racing with the cancellation.Clockport grew a defaultnow_unix_ms()(default impl multipliesnow_unix_secs()by 1 000;SystemClockoverrides for full ms precision). NewDomainEvent::DownloadWaitingStarted/::DownloadWaitingEndedvariants wired throughtauri_bridge(event namesdownload-waiting-started/download-waiting-ended) and the download log bridge. Frontend additions:useCountdown(untilUnixMs)hook ticks once per second and returns{ remainingSeconds, label, expired }withMM:SS(orHH:MM:SSpast one hour) formatting, clamping the remainder to[0, ∞)and treating anulldeadline as a no-op;downloadStoregainedwaitMap+setWait/clearWaitpopulated from the new events;WaitCountdownCellreplacesEtaCellin the table whenever a row'sstate === "Waiting", rendering the live label plus aSkipForwardicon button that invokesdownload_skip_wait. 7wait_managerasync tests (paused tokio runtime, deterministicFakeClock) cover schedule + Started payload, natural expiry → resume + Ended(true), cancel before expiry → Ended(false)+ no resume + state untouched, skip → Ended(false)+ immediate resume, unknown-id skip returnsAppError::NotFound, three concurrent waits expire independently with one Started + one Ended each, andcancel_waiton an unknown id is a silent no-op. 6useCountdowntests + 5downloadStorewait-map tests + 3WaitCountdownCelltests + 2 newDomainEventpayload tests round out the coverage.tokiodev-dependency gained thetest-utilfeature so#[tokio::test(start_paused = true)]+tokio::time::advancework for deterministic timer tests. -
Plugin
vortex-mod-megav1.0.0 (scopeplugin, sprint task 37 ✅, PRD-v2 §P1.18 / PRD §4.1): new official Vortex plugin in a sibling repovortex-mod-mega/(commitse949c7bfoundation,5e25cadfolder enumeration). Hoster category,httpcapability only — no wait, no captcha, no subprocess. Recognises both modern (https://mega.nz/file/<id>#<key>/https://mega.nz/folder/<id>#<key>) and legacy (https://mega.co.nz/#!<id>!<key>/#F!<id>!<key>) URL shapes; classification is anchored on strict id (6–12 chars base64url) and key length (43 chars for files / 22 chars for folders) so a truncated URL never reaches the JSON-RPC layer. File key parsing decodes the 32-byte blob (8 × u32 BE) and XOR-folds words 0..3 with words 4..7 to recover the AES-128 key, then exposes the raw IV (top 8 bytes) andmetaMac(bottom 8 bytes) verbatim. Folder key parsing decodes the 16-byte master key directly. Resolution uses one HTTP round-trip per file via thegcommand (POST https://g.api.mega.co.nz/cs?id=<seq>body[{"a":"g","g":1,"p":"<fileId>"}]→[{"g":"<encryptedCdnUrl>","s":<size>,"at":"<encAttrs>","msd":1}]); folder URLs trigger thefcommand ([{"a":"f","c":1,"r":1}]) to enumerate children. Theidquery param uses an atomic seq counter (AtomicU32::fetch_add) so two concurrent invocations never race for the same id. MEGA error vocabulary maps onto the host's plugin-error type: code-9→PluginError::Offline, codes-3and-6→PluginError::RateLimited, any other negative code →PluginError::ApiError(code); HTTP statuses 429 and 509 also collapse toRateLimitedso the engine's exponential backoff kicks in instead of failing the link. The IPC envelope extends the standardFileLinkshape with a non-optionalencryption: EncryptionInfo { scheme: "mega-aes128-ctr", aes_key_hex, iv_hex, meta_mac_hex }so any host that treats the bytes fromdirect_urlas plaintext crashes loudly rather than writing ciphertext to disk;direct_urlitself isOption<String>—Some(<cdnUrl>)for the file-URL path,Nonefor folder children which the host re-resolves viaresolve_stream_urlper child. The crypto core (src/crypto.rs) ships three pieces: (1)MegaDecryptorfor CTR streaming (process/encrypt_only/absorb_plaintext/finalize/verify_against), (2)aes128_ecb_decrypt(key, &mut [u8])for unwrapping per-node 32-byte key blobs with the folder master key, and (3)aes128_cbc_decrypt(key, iv, &mut [u8])for decrypting the AES-128-CBC node attribute blob (IV = 0₁₂₈, "MEGA{json}" envelope). The chunk schedule is the canonical MEGA one — 128 KiB, 256 KiB, 384 KiB, 512 KiB, 640 KiB, 768 KiB, 896 KiB, 1024 KiB, then 1 MiB forever — implemented aschunk_size_at(usize) -> u64so it can be unit-tested independently. Memory bounds: caller buffer is whatever the host hands in (test exercises 4 KiB caller buffers over a 16 MiB stream), and decryptor state grows only withchunk_macs.len()(≈ 16 entries × 16 B for 16 MiB, ≈ 4 K entries × 16 B = 64 KB for a hypothetical 4 GB stream — bounded extrapolation rather than 4 GB allocation in CI). Folder enumeration (src/node_parser.rs::parse_folder_listing) walks thefresponse, filters file children (t == 0), unwraps each encrypted key blob via AES-128-ECB with the folder master key, XOR-folds the recovered 32-byte raw key into the sameMegaFileKeyshape as a URL key, and decrypts theafield via AES-128-CBC to recover the filename; non-decryptable attributes degrade gracefully (filename =None) so the host can fall back to the handle. Each child is emitted with its raw 32-byte key reserialised into a syntheticmega.nz/file/<handle>#<base64url(key)>URL, so the host can re-feed every entry through the regular file-URL pipeline. 61 native unit tests acrosserror/url_matcher/key_parser/crypto(incl. ECB / CBC round-trips + alignment checks) /api_client/node_parser(incl. fixture-driven decrypt, non-file skip, short-key skip, corrupted attrs fallback, garbage-body rejection,split_key_blobcases) /lib(incl. folder builder + synthetic-URL round-trip viaurl_matcher) + 4 WASM-loaded smoke tests forcan_handle/supports_playlist+ 1 end-to-end folder-decryption test (tests/folder_e2e.rs::wasm_extract_links_decrypts_folder_listing_with_one_childloads the WASM artefact via Extism, stubshttp_requestto return a syntheticfresponse built with real AES-128-ECB key wrapping + AES-128-CBC attribute encryption, then asserts the decrypted child has handle "ChildHnd", filename "holiday.mp4", size 12345, syntheticmega.nz/file/ChildHnd#<key>URL, and nulldirect_url); clippy clean (-D warnings) andcargo fmtclean, 1 JSON fixture for thegresponse shape. Registered invortex/registry/registry.tomlwithchecksum_sha256 = ee4fd771…290f07(wasm) andchecksum_sha256_toml = c9420281…ef805(manifest). Acceptance criteria status (6/6 verified) and per-criterion verification methods are recorded in.claude/output/sprints/prd-v2-roadmap/tasks/37-plugin-mega.md. -
Plugin
vortex-mod-gofilev1.0.0 (scopeplugin, sprint task 36, PRD-v2 §P1.17 / PRD §4.1): new official Vortex plugin in a sibling repovortex-mod-gofile/. Hoster category,httpcapability only — no wait, no captcha, no subprocess. Recogniseshttps://(www\.)?gofile\.io/d/<folder>URLs (alphanumeric<folder>of 6+ characters) plus a synthesised per-file shapehttps://gofile.io/d/<folder>/<file>returned byextract_linksso the host'sresolve_stream_urlcontract — "url string in, single CDN url out" — has something to disambiguate against in multi-file folders. Resolution takes two HTTP round-trips:GET https://api.gofile.io/createAccountfor a guest token, thenGET https://api.gofile.io/getContent?contentId=<id>&token=<token>for folder metadata + per-file CDN links. Folder root is enforced (type == "folder") and only direct file children are emitted asFileLinkentries — nested sub-folders are filtered out so the host always sees a flat list, files with emptylinkfields are skipped defensively, and entries are sorted by id for deterministic ordering across runs. The status envelope is mapped onto the host's plugin-error vocabulary:"error-notFound"/"error-passwordRequired"(and other not-found variants) →PluginError::Offlineso the engine surfaces the link as offline rather than retrying, any other"error-..."→PluginError::ApiError. The per-file URL token is percent-encoded defensively for+///=so a future token shape with non-alphanumeric characters won't break the query string.MAX_BODY_BYTES = 1 MBcaps both responses against memory abuse from a malicious server.FileLink.resumableis reported astrueso the host's segmented engine engages multi-connection ranged downloads. Theextract_linksresponse collapses single-file folders tokind = "file"and exposeskind = "folder"for multi-file ones.resolve_stream_urlaccepts either the synthesised per-file URL (re-fetches the folder, returns the matching file's CDN link) or a folder URL whose folder happens to carry exactly one file; multi-file folder URLs return anApiErrordirecting the caller to use the per-file shape. 55 native unit tests + 13 fixture-driven JSON cases overtests/fixtures/*.json(createAccount happy path / error envelope, single- and multi-file folders, sub-folder filtering, unicode filenames, not-found and password-required, empty folder) + 7 WASM-loaded smoke tests (Extism with stubhttp_requestthat dispatches on URL substring to alternate token / folder responses, including end-to-endextract_linksandresolve_stream_urlround-trips). Registered invortex/registry/registry.tomlwithchecksum_sha256 = 97661c3f…349bd(wasm) andchecksum_sha256_toml = 389889b5…dd9d(manifest). -
Plugin
vortex-mod-pixeldrainv1.0.0 (scopeplugin, sprint task 35, PRD-v2 §P1.16 / PRD §4.1): new official Vortex plugin in a sibling repovortex-mod-pixeldrain/. Hoster category,httpcapability only — no wait, no captcha, no subprocess. Recogniseshttps://(www\.)?pixeldrain\.com/u/<id>URLs (alphanumeric<id>of 6+ characters; multi-file list URLspixeldrain.com/l/...are deliberately out of scope as a future crawler concern). Resolves metadata via the public JSON API endpointGET https://pixeldrain.com/api/file/{id}/infoand serves the binary fromhttps://pixeldrain.com/api/file/{id}directly — same host, no extra CDN hop. Pixeldrain's two error envelope shapes are mapped onto the host's plugin-error vocabulary:{"success": false, "value": "file_not_found"}(and"not_found") →PluginError::Offline, any other{"success": false, ...}(e.g."rate_limit") →PluginError::ApiError. TheFileInfoparser only consumesid/name/size/mime_typeand ignores extra payload fields (views, downloads, hashes, ad flags…) so future API additions don't break the plugin.FileLink.resumableis reported astrueso the host's segmented engine engages multi-connection ranged downloads. 50 native unit tests + 5 WASM-loaded smoke tests (Extism with stubhttp_request, including end-to-endextract_linksandresolve_stream_urlround-trips) + 9 fixture-driven JSON cases overtests/fixtures/*.json(simple zip, large file, unicode filename, missing mime type, minimal payload, zero-size, plus the twosuccess: falseenvelopes). Registered invortex/registry/registry.tomlwithchecksum_sha256 = d6b6af34…a143d(wasm) andchecksum_sha256_toml = 02bd992f…f8a24(manifest). -
Plugin
vortex-mod-mediafirev1.0.0 (scopeplugin, sprint task 34, PRD-v2 §P1.15 / PRD §4.1): new official Vortex plugin in a sibling repovortex-mod-mediafire/. Hoster category,httpcapability only — no wait, no captcha, no subprocess. Recogniseshttps://(www.|m.)?mediafire.com/file/<key>(/<filename>)?(/file)?URLs (folder URLs are deliberately out of scope). The plugin scrapes the public landing page, extracting the direct CDN URL via two strategies: the plainhref="https://download<n>.mediafire.com/..."button and the obfuscateddata-scrambled-url="<base64>"attribute. Decoded scrambled URLs are checked against a^download[0-9]*\.mediafire\.com$host allow-list before being returned, so an attacker controlling the scramble payload cannot redirect the host engine to an arbitrary target. Filename + size are parsed from<span class="dl-btn-label" title="...">and the "Download (X.YZ MB)" text; size units span B / KB / MB / GB / TB.FileLink.resumableis reported astrueso the host's segmented engine engages multi-connection ranged downloads. 55 native unit tests + 3 WASM-loaded smoke tests (Extism with stubhttp_request) + 9 fixture-driven parser cases overtests/fixtures/*.html. Registered invortex/registry/registry.tomlwithchecksum_sha256 = 5775871b…f942(wasm) andchecksum_sha256_toml = 88f1e844…42c0(manifest). -
Link Grabber duplicate detection (scope
link, sprint task 33, PRD-v2 §P1.14 / PRD §6.2.2): newapplication/services/url_normalizer.rsproduces a canonical lexical form for an HTTP(S)/FTP URL — trims whitespace, lowercases scheme + host, strips the fragment, drops the default port (:80/:443/:21), removes a curated tracker block-list (utm_*,fbclid,gclid,dclid,msclkid,yclid,twclid,ttclid,mc_cid,mc_eid,_hsenc,_hsmi,_hsfp,hsa_*,igshid,igsh,vero_id,vero_conv,mkt_tok,oly_anon_id,oly_enc_id,elqtrackid,elqtrack,icid, case-insensitive), and pads bare hosts with a/path sohttps://example.comandhttps://example.com/collapse to the same key. Path case is intentionally preserved so genuinely-distinct case-sensitive CDN paths do not collide. Non-HTTP schemes (e.g.magnet:) round-trip untouched and fall back to byte-for-byte equality. Newapplication/queries/detect_duplicates.rsquery handler indexes every active download (download_read_repo.find_downloads) plus the full history (paginated throughMAX_HISTORY_PAGE_SIZE) by normalized URL, then resolves each input URL against that map; active matches win over history matches so a redownload still in the queue surfaces asActive. Capped atMAX_DETECT_DUPLICATES_URLS = 500per call to mirrorlink_resolve/link_check_online. Newlink_detect_duplicatesTauri IPC returns{ url, isDuplicate, source: "active" | "history" | null, existingId, existingFilename }per input. FrontendLinkGrabberViewfires duplicate detection in parallel withlink_check_onlineafter each resolve, theLinkRowrenders an orange "Already in active/history" badge with a tooltip exposing the existing filename, and a newActionsBarcheckboxSkip duplicates(default on) gates theStart Selected/Start All Onlineflows so flagged rows are excluded from the bulk download. 21 normalizer unit tests + 8 query handler tests + 5LinkRowbadge tests + 3 newActionsBartoggle tests cover the contract. New i18n keylinkGrabber.actions.skipDuplicates(en + fr). -
Auto-grouping split archives → package (scope
link, sprint task 31, PRD-v2 §P1.12 / PRD §6.3): newapplication/services/split_archive_grouper.rsclusters resolved Link-Grabber URLs that match split-archive patterns (*.partNN.rar,*.rNNplus the legacy terminal.rarheader,*.7z.NNN,*.zip.NNN,*.tar.{gz,bz2,xz}.NNN) by base name + format and creates onePackageper cluster withsource_type = SplitArchiveandexternal_id = "split-archive:{format_tag}:{base}"(format-namespaced so a RAR set and a ZIP set sharing a base name produce two distinct packages). NewGroupSplitArchivesCommandhandler +link_group_split_archivesTauri IPC mirror the playlist grouper flow, capped atMAX_LINKS = 500per call to mirrorMAX_URLSinresolve_linksand bound the cluster-state allocation;MAX_PART_INDEX = 10_000rejects absurd part suffixes socompute_missing_partscannot be coerced into a multi-billion-step iteration. The minimum-parts gate counts distinctpart_nums rather than raw link count, so duplicate mirrors of one volume cannot satisfy the singleton guard. Gaps in the part numbering emitDomainEvent::SplitArchiveIncomplete { package_id, base_name, missing_parts }(forwarded to the frontend assplit-archive-incomplete) so the UI can warn the user before extraction blocks; legacy RAR completeness now treats the terminal.rarheader as part 0 so a missing header is reported instead of silently dropped. FrontendSplitArchiveLinkInput/SplitArchiveGroupResulttypes added insrc/types/media.ts. 31 service unit tests (matcher fixtures + grouping integration + DoS caps + legacy-header coverage + distinct-parts gate) + 3 handler tests cover the contract. -
Shared grouper lock (scope
core): newapplication/services/group_lockmodule factors out the OnceLock + poisoned-mutex-recovery pair thatPlaylistGrouperandSplitArchiveGrouperwere each rolling locally. Both groupers now scope the lock to the find-then-save window and release it before publishingPackageCreated/SplitArchiveIncompleteso synchronous event-bus subscribers cannot block other concurrent grouping calls. -
CodSpeed performance benchmarks (CI): new
domain_benchmarksCriterion harness insrc-tauri/benches/domain_benchmarks.rsexercising the puredomain::model::confighelpers (apply_patch,normalize_link_check_parallelism,normalize_max_concurrent). Wired through a new.github/workflows/codspeed.ymlworkflow that runs the benches under CodSpeed on every PR, providing automated perf-regression tracking for the domain layer.criterion+codspeed-criterion-compatadded as dev-dependencies;[[bench]]target declared withharness = falseso Criterion drives the run. -
CI hardening (scope
ci): new GitHub Actions jobssecrets-scan(rejects.env/.pem/.key/etc. tracked files plusAKIA*/sk-ant-*/ghp_*/AIza*API key patterns in tracked content),forbidden-tools(rejectspnpm-lock.yaml/yarn.lock,.eslintrc*/biome.json*/.prettierrc*configs, and any#[allow(dead_code|unused|...)]/@ts-ignore/@ts-expect-error/oxlint-disablecomment), andchangelog-check(PR-only — fails when*.rs/*.ts/*.tsxchange without a matchingCHANGELOG.mdedit). Existingcargo auditswapped forcargo deny checkcovering advisories + licenses + bans + sources via the newdeny.toml. Frontend job now runsoxfmt --check,knip --reporter compact, and uploadscoverage/as an artifact. Newmutants.ymlworkflow runscargo mutants --in-diffon PRs touchingsrc-tauri/**and a 4-shard nightly sweep onmain. -
Release verification (scope
ci): newverify-tagjob inrelease.ymlblocks the build matrix whenCargo.toml,package.json, the pushed tag, and theCHANGELOG.mdsection are out of sync. -
Hook hardening (scope
ci): pre-commit gainsoxfmt --check(scoped via new.oxfmtignoreto keepsrc-tauri/gen/,contrib/,registry/,*.mduntouched),scripts/no-secrets.sh(file-pattern + diff-content scan),scripts/no-manual-deps.sh(rejects manualCargo.toml/package.jsondep edits without an updated lock — enforcescargo add/npm install), andchangelog-updated. Pre-push gainscargo deny check licenses bans advisoriesandknip --reporter compact. Newknip.jsonkeepsfiles/dependencies/unlisted/binaries/unresolvedaserrorand demotesexports/types/nsExports/nsTypes/duplicates/enumMemberstowarnfor backwards-compatible adoption.src-tauri/.cargo/mutants.tomlexcludes the binary entrypoint,build.rs,tests/**, and the SQLite migrations from cargo-mutants and bumpstimeout_multiplierto 2.0 /minimum_test_timeoutto 60s to absorb Tauri/sea-orm/extism cold-build latency.package.jsonaddsknip ^6.11andoxfmt ^0.47as devDependencies.
Removed
- Dead barrel (scope
ui):src/stores/index.ts(5-line re-export with zero importers — flagged by knip; consumers already import from the individualstores/<name>files directly).
Fixed
-
PR #140 review round 9 (scope
ci): cubic round-8 follow-up.scripts/forbidden-rust-allow.pyblock-comment branch instrip_comments_preserving_linesnow tracks Rust's nestable/* /* … */ … */form via a depth counter. The previoustext.find("*/", i + 2)returned the first*/, so an outer comment that wrapped an inner block was only partially stripped — any#[allow(...)]text that lived between the inner close and the outer close could leak through unstripped. Verified with/* outer /* inner */ #[allow(dead_code)] still outer */→ not flagged;/* /* nested */ */ #[allow(dead_code)]on the following line → flagged.
-
PR #140 review round 8 (scope
ci): cubic + chatgpt-codex round-7 follow-up.scripts/forbidden-rust-allow.pycomment stripper rewritten as a small state machine that walks the source tracking string-literal ("..."with\\escapes) and char-literal ('.') state. The previous regex (/\*[^"]*?\*/) failed on a block comment that legitimately contained a", e.g./* note: "this code is dead" */, so the comment wasn't stripped and a literal#[allow(dead_code)]mention inside it tripped the gate. Now both directions hold: a/*inside a string literal does not open a comment, and a"inside a comment does not open a string literal..github/workflows/ci.ymlchangelog-checkand.github/workflows/mutants.ymlincrementaljobs both switched from a two-dot endpoint diff (git diff $BASE $HEAD/origin/${base_ref}..) to a three-dot merge-base range (${BASE}...${HEAD}/origin/${base_ref}...HEAD). Without this, upstream churn on the base branch since the PR diverged was being attributed to the PR — a docs-only PR could be flagged as code-changed, andcargo-mutantscould exercise files outside the PR's scope.
-
PR #140 review round 7 (scope
ci): chatgpt-codex + cubic round-6 follow-up..github/workflows/release.ymlverify-tagjob now runs an explicit semver shape check (^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$) before any other validation. The trigger glob is necessarily permissive (on.push.tagsdoesn't accept regex quantifiers), so a malformed tag likev1foo.2.3would still fire the workflow and burn CI capacity downstream. The early bash regex aborts the run with a clear error before the build matrix starts.scripts/forbidden-rust-allow.pyblock-comment stripper switched from/\*.*?\*/to/\*[^"]*?\*/. A Rust string literal containing/*no longer tricks the scanner into stripping real#[allow(...)]attributes that happen to live between the opening and closing quotes. Verified with a syntheticlet s = "/* …"; #[allow(dead_code)] fn dead() {}; let t = "*/"-shaped fixture: the#[allow(dead_code)]is now caught instead of silently swept up.
-
PR #140 review round 6 (scope
ci): cubic + chatgpt-codex round-5 follow-up..github/workflows/ci.ymlforbidden-toolsno longer swallows scanner failures with|| true. Captures both stdout and exit status:0= clean,1= hits found (output is the report), any other code = real failure that aborts the step.scripts/forbidden-rust-allow.pyregex now matches both outer#[allow(...)]and inner#![allow(...)]attributes (#!?\[\s*allow\s*\((.*?)\)\s*\]). Lazy body(.*?)instead of[^)]*so allow lists containing parens (e.g.clippy::needless_pass_by_value()) don't terminate the match early.- Scanner strips line and block comments before matching (preserving line numbers via newline-padded substitution) so a TODO note that mentions
#[allow(dead_code)]literally no longer trips the gate. Adds a documented escape hatch: a suppression is tolerated when the immediately preceding non-blank line is a// TODO(task-N): ...comment. Silent suppressions still fail. forbidden-toolslockfile rejection switched from root-only[ -f pnpm-lock.yaml ]to a tracked-pathsgit ls-files | grep -E '(^|/)(pnpm-lock\.yaml|yarn\.lock)$'scan so a nestedtools/yarn.lockcannot bypass the gate.
-
PR #140 review round 5 (scope
ci): closed coderabbit / cubic / chatgpt-codex flags from round 4, including a critical YAML parse failure introduced by the round-4 inline Python heredoc.- Critical:
.github/workflows/ci.ymlPython heredoc body lacked the indentation required by therun: |block scalar, breaking YAML parse and preventing theforbidden-toolsjob from loading. Extracted the scanner toscripts/forbidden-rust-allow.pyand call it aspython3 scripts/forbidden-rust-allow.py. The script slurps each*.rsfile and walks every#[allow(...)]group withre.DOTALL, exits 1 with hits on stdout when any are found. secrets-scanAPI key job no longer echoes the matched secret value into CI logs.git grep -nreturnsfile:line:matched_text, so the report now passes throughawk -F: '{print $1 ":" $2}'to keep onlyfile:line.secrets-scanfile pattern now matches nested.npmrc/.pypirc(e.g.tools/.npmrc,ci/.pypirc) via(^|/)\.npmrc$|(^|/)\.pypirc$.scripts/no-secrets.shSECRET_PATTERNS aligned to the same anchoring.forbidden-toolsstep dropped the blanketset +e. Replaced with a targeted|| trueon thegit grepcall (the only command that exits non-zero on the success path) so unexpected failures (Python crashes, missing tools, permissions) still abort the job.scripts/no-secrets.shcontent filter switched back to^\+plus an explicitgrep -vE '^\+\+\+ [ab]/'invert step. The earlier^\+[^+]filter rejected real added lines whose content started with+, opening a credential-bypass crack.
- Critical:
-
PR #140 review round 4 (scope
ci): closed coderabbit / chatgpt-codex flags from the round-3 push.scripts/no-secrets.sh^\+filter now anchored as^\+[^+]so the diff metadata line+++ b/<path>cannot trip the API key content scan when a tracked filename happens to share a key prefix..github/workflows/ci.ymlforbidden-toolsRust-suppression scanner replaced with a Python slurp-and-regex pass so multiline forms like#[allow(\n clippy::xxx,\n dead_code,\n)]are caught.git grepis line-oriented and missed those.chatgpt-codexflagged theoxfmt --check 'src/**/*.{ts,tsx,css,json}' …invocation as a literal-path bug; not applicable.oxfmt --helpdocuments glob support andoxfmt --version 0.47.0 --checkagainst the same quoted glob locally returns "Finished in … on 260 files", so the pattern is expanded by oxfmt itself.
-
PR #140 review round 3 (scope
ci): closed the third pass of coderabbit / cubic flags..github/workflows/ci.ymlforbidden-toolsconfig-rejection step previously only checked root dotfiles vials -1. Now scans every tracked path withgit ls-files | grep -E '(^|/)(\.eslintrc.*|eslint\.config\..+|biome\.json.*|biome\.jsonc.*|\.prettierrc.*|prettier\.config\..+)$'so flat-config files (eslint.config.ts,prettier.config.js) and nested manifests are caught.forbidden-toolsRust suppression regex was anchored to the first argument inside#[allow(...)]. Replaced with#\[allow\([^]]*\b(dead_code|unused|unused_variables|unused_imports)\bso a suppression like#[allow(clippy::module_name_repetitions, dead_code)]no longer slips through.secrets-scanAPI key pattern set addsASIA[0-9A-Z]{16}for AWS temporary STS credentials (the long-livedAKIA…form was already matched).scripts/no-manual-deps.shcargo_dep_section_rangesnow wraps thegit showcall in{ ... || true; }so a missing HEAD blob (new manifest, freshly added file) emits empty awk input instead of trippingset -euo pipefail. Smoke-tested with a brand-newCargo.toml+ dep + no lockfile → still blocked correctly.
-
PR #140 review round 2 (scope
ci): closed the remaining gaps surfaced by the second pass of chatgpt-codex / cubic / coderabbit findings.scripts/no-manual-deps.shnow also flags Cargo dependency deletions, not just additions. A newremoved_line_numbershelper feeds the same range-overlap check, but againstcargo_dep_section_rangesof the HEAD blob — a manualcargo remove(or sed/^foo = /d) that omits an updated lockfile is rejected. New error message listscargo remove/npm uninstallalongside the add commands.pkg_dep_change_detectednow fails closed whenjqis absent. Arequire_jqhelper prints install instructions for Linux / macOS / Windows and exits 1 instead of returning "no change" — the policy is non-negotiable, so a missing tool blocks the commit instead of silently disabling the gate..github/workflows/ci.ymlserver-side API key scan no longer excludespackage.jsonandCargo.toml(the patterns are distinctive prefixes —AKIA*,sk-ant-*, etc. — so manifest scanning is safe). Pattern list aligned withscripts/no-secrets.shto addsk-[A-Za-z0-9]{32,}(OpenAI-style) andglpat-[A-Za-z0-9_-]{20}(GitLab PATs) so defence-in-depth is consistent across pre-commit and CI.
-
PR #140 review (scope
ci): tightened the new CI/hooks layer in response to chatgpt-codex / cubic / coderabbit findings.release.ymltag trigger was regex-style (v[0-9]+.[0-9]+.[0-9]+*) — won't matchv1.2.3since GitHub Actionson.push.tagsis glob, not regex. Replaced withv[0-9]*.[0-9]*.[0-9]*.release.ymlCHANGELOG check now usesgrep -Fagainst## [<version>]/## <version>instead of unescaped regex, so version dots match literally (1.2.3no longer accepts1X2X3).scripts/no-manual-deps.shrewritten to scope dependency-edit detection to actual TOML / JSON dep tables: an awk pass over the staged Cargo.toml computes the line ranges occupied by[dependencies]/[dev-dependencies]/[build-dependencies](plusworkspace.andtarget.*.variants), then only added lines falling in those ranges count.package.jsonnow diffs the relevant sections viajq(dependencies,devDependencies,peerDependencies,optionalDependencies) againstHEAD. Pure version / metadata bumps no longer trigger the lockfile requirement.scripts/no-manual-deps.shrejectspnpm-lock.yaml/yarn.lockunconditionally (was nested inside thepackage.jsonbranch — a lockfile-only commit slipped through). Hook entry no longer scopes viaglobso the script always runs.scripts/no-secrets.shfilename pattern now matchessecrets/directories without a leading dot ((^|/)(\.env|\.secrets|secrets)/)..github/workflows/ci.ymlserver-side scan extended with.netrcandawsplus the samesecrets/fix.lefthook.ymlchangelog-updatedhook now treats*.js/*.jsxas code (wasrs|ts|tsxonly).lefthook.ymlpre-pushcargo-denynow runslicenses bans advisories sources(thesourcescheck was missing on push, even though CI runs the full check).deny.tomlunknown-gitflipped fromwarntodenynow thatallow-gitlists every legitimate git source — unlisted git deps are rejected.
Changed
-
Frontend formatting baseline (scope
ci): mass-formatted 188 files undersrc/**/*.{ts,tsx,css,json}to satisfy the newoxfmt --checkgate. No behavioural change — pure formatting. The hook globsrc/**/*.{ts,tsx,css,json}and the CI invocation explicitly target the same scope so README, CHANGELOG, registry, and Tauri-generated schemas are never reformatted. -
Link Grabber check online (PRD §6.2.2, PRD-v2 §P1.13, task 32): new
link_check_online(urls)Tauri command streams alink-status-updatedevent per URL through the existingEventBus → tauri_bridgepipeline. The handler bounds in-flight probes by the newAppConfig::link_check_parallelism(default 8, clamp 1–64) and caps each probe withAppConfig::link_check_timeout_secs(default 10). Probes are dispatched throughtokio::task::spawn_blockingso the syncHttpClient::headcannot stall the runtime, thentokio::time::timeoutcancels stragglers and downgrades them toLinkStatus::Unknown. The domain enum now carries the full PRD set (Checking | Online | Offline | PremiumOnly | Unknown); the built-inHttpModule::check_linkmaps HTTP401/402toPremiumOnlyand keeps404/410 → Offline. Frontend wiring: a new ZustandlinkGrabberStore(statusesmap keyed by URL withsetStatus/setManyStatuses/reset) is fed by the newuseLinkStatusEventshook subscribed tolink-status-updated;LinkRowreads the live status, renders a coloured badge per row (blue spinner / green check / red X / orange lock / gray help) and surfaces aRotateCcwretry button only when the terminal status isunknownso a transient timeout can be re-issued without re-pasting the whole batch; theOnlinefilter and theapplyFilterhelper now prefer the live status over the static one so a row that flips fromcheckingtoofflinemid-flight is excluded from theStart Online onlyflow. NewAppConfig/ConfigPatch/SettingsDto/ConfigDtoplumbing carries the two new fields end-to-end (TOML round-trip + IPC camelCase + apply-patch). 14 new tests cover every acceptance criterion: handler tests against an in-memoryScriptedHttpcoverOnlinemetadata extraction,404 → Offline,401/402 → PremiumOnly, scheme rejection (ftp / magnet) without firing a HEAD, timeout →Unknown, batch validation cap, and an explicit semaphore-backed concurrency cap (12 URLs againstlink_check_parallelism = 3proves peak ≤ 3); 2 newtauri_bridgetests pin the camelCase JSON payload shape (kind: "online" | "offline" | "premiumOnly" | "unknown" | "checking"); 2http_moduletests pin the new401/402mapping; 6 frontend tests cover the store API plus the live-status filter override and the retry button render/click. cargo test 1357 passing, vitest 659 passing, clippy + fmt + oxlint + tsc clean. -
Playlist banner reuse-aware (issue #136, PR #135 follow-up): the
MediaGrabberDialognow predicts whether re-resolving a playlist will reuse an existing package or create a new one, soPlaylistPackageBannershows "Reusing playlist package {name}" instead of always falling back to "Will create …". A new read-only IPC querypackage_find_by_external_idis wired through the CQRS read side:PackageReadRepositorygainsfind_package_by_external_id(external_id) -> Option<PackageView>, the SQLite adapter mirrors the existingfind_package_by_idSQL path filtering on the existingidx_packages_external_idUNIQUE index, and a newFindPackageByExternalIdQueryhandler maps the result to a minimalPackageSummaryDto { packageId, packageName }(camelCase) so the UI never receives the full aggregate. The new typed React hookusePackageByExternalId(externalId?)short-circuits via TanStack Query'senabled: !!externalIdflag — when the dialog is opened on a non-playlist URL, no IPC call is made. The dialog computesplaylistKey = canonicalPlaylistKey(link.originalUrl)only whenmetadata.isPlaylistis true, calls the hook unconditionally withplaylistKey | undefinedto preserve hook order, deriveswillReuseExisting = !!existingPackage, and prefersexistingPackage.packageNameover the default-derived name when populating the banner. The "loading → false" default ensures the banner is never blocked by the round-trip; the banner momentarily reports "will create" while the IPC is in flight, which is the safe fallback per AC #3. Seven new Rust tests (PackageSummaryDtoround-trip + camelCase serialization, three handler tests againstInMemoryPackageReadRepo, two SQLite tests round-trippingexternal_idmatches and misses) and six new TypeScript tests (four hook tests covering invoke args / response / skip / null match, two dialog tests covering the will-create vs will-reuse banner branches) cover every acceptance criterion. cargo test 1337 passing, vitest 650 passing, clippy + tsc clean. -
Keyboard-accessible move-download-between-packages (issue #134, PR #133 follow-up): the pointer-only HTML5 drag-and-drop introduced for moving downloads between packages now has a fully keyboard-driven and screen-reader-friendly alternative. Each
PackageDownloadRowexposes a single toggle button (Move…↔Cancel move) that marks the download as pending-move; the button carriesaria-pressedso screen readers announce the toggle state, replacing the deprecatedaria-grabbednamed in the original issue. Once a download is selected, every otherPackageRow(the source package is intentionally excluded) surfaces aMove selected hereaction with an explicitaria-label="Move selected download to {package}"; the deprecatedaria-dropeffectis intentionally not used because the descriptive button label plus the live region cover the same intent without the deprecated attribute. Activating the target reuses the samepackage_remove_download+package_add_downloadIPC pair as the drag-and-drop path (now factored into a sharedmoveDownloadBetweenPackageshelper so the rollback logic stays in one place), surfaces start / success / cancel / error transitions through a new visually-hiddenrole="status" aria-live="polite"live region (packages-move-live-region), and matches the pointer path's visual feedback by raisingtoast.success(packages.toast.moveDownloadSuccess)on success andtoast.error(packages.toast.moveDownloadError)in the catch (toast.error(packages.toast.moveDownloadRollbackError)still fires when both the move and the rollback fail). TheexecuteMovefinally block guards against an in-flight move clobbering a newer pending selection:setPendingMoveis cleared only when the current state still references the move that just completed. The drag-and-drop path is unchanged. New i18n keyspackages.move.{start,startAriaLabel,cancel,cancelAriaLabel,target,targetAriaLabel,liveRegionLabel,announce.{selected,cancelled,started,success,error}}ship in EN + FR. Six new Vitest tests cover the four issue acceptance criteria: keyboard initiation without a pointer (Movebutton →aria-pressedflips), live region announcements (selected / success / cancelled / error wording), unchanged pointer drag-and-drop (existing test still green), error-path coverage of the "target add fails, rollback succeeds" branch with explicit call-order assertions, and a race-condition guard that proves a staleexecuteMovedoes not erase a newer selection when itspackage_remove_downloadresolves late. -
Auto-grouping playlist → package (PRD §6.3, PRD-v2 §P1.11, task 30): when the Link Grabber resolves a YouTube or SoundCloud playlist URL, every selected item is now auto-attached to a single
Packageper playlist. NewPlaylistGrouperapplication service (src-tauri/src/application/services/playlist_grouper.rs) finds-or-creates aPackagekeyed by the newexternal_idnatural key (the playlist URL acts as the natural id), so re-resolving the same playlist reuses the existing package instead of creating a duplicate. NewGroupPlaylistsCommandhandler (group_playlists.rs) routes the request through the grouper; newlink_group_playlistsTauri IPC command (PlaylistGroupInputDto→Vec<PlaylistGroupResultDto>) lets the frontend orchestrate the group-then-attach flow. Migrationm20260430_000008_add_package_external_idaddspackages.external_id TEXT NULLplus theidx_packages_external_idindex; the column is intentionally nullable (manual packages keepNULL) and the application layer enforces uniqueness throughfind_by_external_idrather than a DB UNIQUE constraint that would forbid more than one manual row. DomainPackageaggregate gains anexternal_id: Option<String>field withset_external_idandexternal_id()accessors;PackageRepository::find_by_external_idis added to the trait, returning the oldest row by(created_at, id)so repeated reuses pick the same package deterministically. TheMediaGrabberDialognow shows aPlaylistPackageBanner("Will create package: {name} with {N} items" / "Will reuse existing package …") above the playlist item list oncemetadata.isPlaylistis true, with EN + FR plural translations undermediaGrabber.playlistBanner.*.LinkGrabberView.handleMediaGrabberConfirmnow callslink_group_playlistsfirst when the user has selected playlist items, thendownload_media_start, then attaches every returneddownloadIdto the package viapackage_add_download(failures on attach are non-fatal — downloads still run, only the grouping is missed). Visibility ofapplication::commands::tests_supportis bumped topub(crate)so the newplaylist_grouperandgroup_playliststests can reuse the existingInMemoryPackageRepo/CapturingEventBusmocks. 13 new tests cover the four acceptance criteria: 7 unit tests onPlaylistGrouper(create / reuse / blank id / fallback name / batch / partial failure / trim), 5 SQLite tests onfind_by_external_id(round-trip / match / not-found / no NULL match / oldest-on-duplicates / clear-on-upsert), 3 handler tests, 4 frontend tests onPlaylistPackageBanner. cargo test 1327 passing, vitest 626 passing, clippy + fmt clean. Unblocks the Link Grabber's playlist UX. -
Packages view (PRD §6.3, PRD-v2 §P1.10, task 29): full Packages management UI replacing the previous
PlaceholderView. Newsrc/views/PackagesView/folder withPackagesView(root),PackageToolbar(filter chipsAll / Container / Playlist / Manual / Split archive+ debounced search input + "New package" trigger),PackageTree(empty state + list ofPackageRow),PackageRow(chevron toggle, inline rename trigger, source-type badge, file count via_one/_otherplural keys, total bytes viaformatBytes, aggregatedProgressbar, folder browse button,Keypassword trigger,Switchfor auto-extract, native<select>1-10 priority, Pause-all / Start-all / Delete buttons),PackageDownloadRow(HTML5 native draggable child row with state badge + size + speed + ETA + per-row progress) andPackageDialogs(AddPackageDialog,RenamePackageDialog,PasswordDialogwithtype="password",FolderDialogwithtauri-plugin-dialogdirectory picker,DeletePackageDialogwith optional "also delete child downloads" checkbox). Newsrc/types/package.tsmirrorsPackageViewDto(camelCase, no password field) plusPackagePatch/PackageListFilter/CreatePackageInput/PackageMoveOutcome. Newsrc/hooks/usePackagesQuery.tsexposesusePackagesQuery(filter?)(TanStack Query, 30 sstaleTime, forwardssourceType+nameQtopackage_list) andusePackageDownloadsQuery(packageId | null)(lazy viaenabled, 10 sstaleTime, callspackage_list_downloads). NewpackageQueriescache-key factory insrc/api/queries.tswithlists()/list(filter)/details()/detail(id)/downloads(id)so mutations can target the right slice. The view wires every command from task 27:package_create,package_update(rename),package_set_password(keyring-only — UI never echoes the stored secret back),package_set_priority,package_move_to_folder(toast announces the count of moved children fromPackageMoveOutcome.moved.length),package_toggle_auto_extract,package_delete(confirmation dialog withdeleteDownloadsboolean),package_add_downloadandpackage_remove_download(drag-and-drop pairing). Drag-and-drop uses nativedataTransfer(no external lib):PackageDownloadRowsetsapplication/x-vortex-download(id) +application/x-vortex-source-package(origin id) ondragstart;PackageRowregisters itself as a drop zone viadata-testid="package-row-{id}-dropzone"and the View'sdropDownloadhandler short-circuits whenfrom === to, parses the numeric id, callspackage_remove_downloadthenpackage_add_download, surfacesmoveDownloadSuccess/moveDownloadErrortoasts, and invalidates the package cache. Bulk Pause-all / Start-all fans out the existingdownload_pause/download_resumeIPC overPromise.allSettledfor every member returned bypackage_list_downloads, then surfaces a single success toast orbulkActionErrorif any leg failed. Filter chips and the 300 ms debounced search (useDebouncedValue) re-key theusePackagesQueryso the round-trip happens server-side viapackage_list { sourceType?, nameQ? }; an empty filter object is collapsed toundefinedso the SQL path takes the no-filter branch. Component boundary stays at 2 levels (View → Tree → Row) to honour the project's prop-drilling rule — dialogs are mounted at the View level and receive only the active target via state. New i18n namespacepackages.*adds 60+ keys covering title / loading / empty / search placeholder / filter labels / row controls / dialog copy (Add / Rename / Password / Folder / Delete) / drag aria-labels / toast messages, with mirrored EN + FR translations and_one/_otherplural variants for the file-count badge.useTauriMutation'sinvalidateKeysarray invalidatespackageQueries.all()on every mutation; commands that touch downloads (package_delete,package_move_to_folder) additionally invalidatedownloadQueries.all()so the main downloads list reflects the cascade. The legacysrc/views/PackagesView.tsxplaceholder file becomes a single-line re-export of the new folder, preserving every existing import path. The french translations test (issue30-ui-fr.test.tsx) is updated:PackagesViewis no longer asserted as a placeholder; a new dedicated case asserts the FR header (Paquets) and search placeholder (Rechercher des paquets) render correctly with a realQueryClientProvider. 16 new Vitest tests cover the six acceptance criteria (tree expand/collapse, auto-extract toggle, masked password dialog, drag-and-drop FK update, ≥80 % coverage, ≤2-level prop drilling) plus filter chips, debounced search, dialog flows, fan-out bulk actions and the error state. Coverage onsrc/views/PackagesView/: 87.28 % statements / 90.07 % lines / 79.59 % functions — above the 80 % frontend threshold. -
Packages queries (PRD §6.3, PRD-v2 §P1.9, task 28): three CQRS query handlers (
list_packages,get_package,list_package_downloads) wired through theQueryBusbuilder via a newwith_package_read_reposetter. New driven portPackageReadRepository(find_packages/find_package_by_id/find_package_downloads) andSqlitePackageReadRepoadapter compute every package statistic (downloads_count,total_bytes,downloaded_bytes,progress_percent,all_completed) in a singleLEFT JOIN packages → downloadswithGROUP BY p.idso listing N packages costs one round-trip instead ofN+1.PackageFilter { source_type?, name_q? }AND-combines filters:source_typeis an exact match against the lowercase wire form (container/playlist/manual/split_archive) and is delegated to the SQLWHEREclause, whilename_qis a case-insensitive substring (LOWER(p.name) LIKE %?%) so the UI can fuzzy-search package titles. Blank / whitespace-onlyname_qis treated as "no filter" so the UI can blindly forward an empty input. Aggregate progress mirrors the per-download formula (Completedalways reports 100 even whendownloaded < total, unknown total reports 0, otherwisedownloaded / total * 100rounded to 1 dp);all_completedflips totrueonly when the package has at least one member and every member is in theCompletedstate. New read modelPackageViewDto(#[serde(rename_all = "camelCase")]) re-exposes the aggregatedPackageViewto the frontend with no password / credential reference field, by construction.list_package_downloads(id)reuses the existingDownloadViewso the React layer can render member rows with the same component as the main downloads list. Three Tauri IPC commands (package_list,package_get,package_list_downloads) registered ininvoke_handler!and re-exported fromlib.rs;package_listvalidates an unknownsource_typeargument up-front so callers see "invalid package source type" instead of an empty result. The runtime now wiresSqlitePackageReadRepoto theQueryBusviawith_package_read_repo. Twenty-three new unit + integration tests cover the three acceptance criteria (SQL-side stats with no N+1, fuzzyname_q, in-memory SQLite fixtures): aggregate vs empty package, mixed-state aggregation, all-completed flip, unknown total treated as zero, deterministic ordering by(created_at, id), exactsource_typefilter, case-insensitive substringname_q, AND combination, blankname_qignored, missing-idNone, member ordering byqueue_positionthenid, no leak across packages, validation errors when the read repo is missing, and DTO camelCase + no-password serialization assertions. Unblocks task 29 (Vue Packages React). -
Packages commands (PRD §6.3, PRD-v2 §P1.8, task 27): nine command handlers wired to the new
PackageRepositoryand the existingCredentialStorevia awith_package_repobuilder on theCommandBus.create_package(name, source_type, folder_path?)generates a UUID v4 id, validates the trimmed name is non-empty, persists the aggregate and emitsDomainEvent::PackageCreated.update_package(id, PackagePatch)applies a partial mutation (rename / folder / priority / auto_extract) —folder_pathacceptsSome(Some(path))to set,Some(None)to clear,Noneto leave untouched, so the frontend can distinguish "set to empty" from "unchanged".delete_package(id, delete_downloads)runs in two cascade modes:false(default) detaches every member viaPackageRepository::detach_downloadso the downloads survive as standalone rows,trueremoves each member through the existingRemoveDownloadCommand(deletes engine state, files, and the SQLite row) before dropping the package row; the keyring entry undervortex.package.<id>is best-effort cleaned in both cases.set_package_password(id, Option<String>)stores the secret in the OS keyring viaCredentialStore::store("vortex.package.<id>", …)and only writes the keyring service key (never the plaintext) onto thepackages.passwordSQLite column as a marker; passingNoneclears both the keyring entry and the marker idempotently, and an explicit empty string is rejected as a validation error so callers cannot ambiguously "clear by emptying".set_package_priority(id, priority)validates the value through the domainPriorityaggregate up-front (so a bad input never produces partial cascade state), persists the new value on the package row and then loops through every member returned bylist_downloadsto update each download'spriorityand emit aDownloadPrioritySetevent per child — dangling FK members (download row missing) are skipped with a debug log instead of aborting the cascade.move_package_to_folder(id, new_folder)updates the package row'sfolder_pathand re-uses task 13'sChangeDirectoryCommandfor each member; per-child failures are collected into aPackageMoveOutcome { moved, failed }and surfaced to the frontend so partial failures don't roll back the package update.toggle_package_auto_extract(id)flips the flag and returns the new state.add_download_to_package(package_id, download_id)andremove_download_from_package(package_id, download_id)set / clear the FK ondownloads.package_idvia the newattach_download/detach_downloadtrait methods; both validate the package exists first so the IPC layer surfaces a cleanNotFoundfor stale callers, andattach_downloadalso requires the download to exist (re-attaching is idempotent). ThePackageRepositorytrait gainsattach_download(&PackageId, DownloadId) -> Result<(), DomainError>(returnsNotFoundwhen the download row is missing) anddetach_download(DownloadId) -> Result<(), DomainError>(idempotent, no-op on missing row); theSqlitePackageRepoadapter implements both via rawUPDATE downloads SET package_id = ? WHERE id = ?so the FK singleton semantics match the existingON DELETE SET NULLmigration. Two newDomainEventvariants —PackageUpdated { id }(rename / folder / priority / password / auto_extract / membership change) andPackageDeleted { id, delete_downloads }(the flag mirrors the command so subscribers distinguish "package detached, downloads kept" from "everything gone" without re-reading the repo) — are forwarded by the Tauri bridge aspackage-updatedandpackage-deleted(camelCasedeleteDownloads). Nine Tauri IPC commands (package_create,package_update,package_delete,package_set_password,package_set_priority,package_move_to_folder,package_toggle_auto_extract,package_add_download,package_remove_download) registered ininvoke_handler!and re-exported fromlib.rs, with a newPackagePatchDtodeserialiser whosefolder_path: Option<Option<String>>round-trips the three-state semantics from the frontend. The runtime now wiresSqlitePackageRepointo theCommandBusviawith_package_repo. Forty-three new unit tests againstInMemoryPackageRepo/InMemoryDownloadRepo/InMemoryCredentialStoremocks cover every acceptance criterion: CRUD round-trip, cascade-delete vs detach, keyring-only password storage (thepackages.passwordcolumn never holds the plaintext), per-childDownloadPrioritySetcascade with an explicit count assertion, partial-failure outcome shape on bulk move, idempotent attach/detach, dangling-FK skip on the priority cascade, and validation paths for blank names, empty-string passwords, invalid priorities, missing repos, and unknown ids. Adapter coverage hovers at 95-99 % per file (well above the 85 % threshold). Five SQLite-level tests pin the new attach/detach semantics on a real in-memory DB. Unblocks task 29 (Vue Packages React). -
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.