Skip to content

Vortex v0.3.0-beta.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 19 Jul 11:17
b31b559

Highlights

  • 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.toml keeps the same format; an invalid hand-edited user_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-proto release. Disabled unused SeaORM migration defaults so the
    MySQL/PostgreSQL drivers and vulnerable rsa crate are no longer included.
    Restored strict wildcard dependency checks after returning Extism to crates.io.
    cargo audit still 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_ytdlp contract. 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 new registry_coherence integration 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_version stays 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 .npmrc configuration 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. After AttemptOutcome::Failed, the loop bumped mirror_idx, ran the file/meta cleanup, and unconditionally published MirrorSwitched (which progress_bridge persists to current_mirror_index). A user-cancel that landed in the window between the failed attempt returning and the next attempt starting — including while tokio::task::spawn_blocking was running the cleanup — would still bump the persisted cursor through the bridge before the next iteration's run_mirror_attempt observed cancel_token.is_cancelled() and reported AttemptOutcome::Cancelled. The retry would then resume from a slot the user never asked for. Two cancel_token.is_cancelled() guards added inside the failover branch: one before bumping mirror_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 emit DomainEvent::DownloadCancelled and break out, so no MirrorSwitched ever 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 every DomainEvent::DownloadFailed to MirrorCursorReset, but extract_archive::extract_archive_handler (and the domain Download::fail() max-retries path) also publish DownloadFailed for 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 event DomainEvent::AllMirrorsExhausted { id } published by the engine's failover loop right before the existing DownloadFailed; progress_bridge now keys the cursor reset on AllMirrorsExhausted and ignores generic DownloadFailed. Wired through tauri_bridge (mirrors-exhausted event 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 (asserts event_to_message(&DownloadFailed) returns None), 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-3 MirrorSwitched handler clears the cursor + segment rows but leaves downloads.downloaded_bytes / total_bytes pinned at the failed mirror's last value. Because update_download_progress enforces downloaded_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_bridge now adds a reset_download_bytes step inside the MirrorSwitched handler that runs UPDATE downloads SET downloaded_bytes = 0, total_bytes = NULL WHERE id = ? between the cursor write and the segments purge. The next attempt's DownloadProgress event repopulates total_bytes through the existing COALESCE(NULLIF(...)) guard, and downloaded_bytes rises monotonically from 0 under the same MAX guard. Two regression tests: test_reset_download_bytes_zeroes_downloaded_and_clears_total (write-through) and test_reset_download_bytes_then_progress_repopulates_total (the round-trip with a follow-up update_download_progress call).
  • 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-meta sidecar but never deletes the matching download_segments rows — 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 / Error segments through both the detail panel and the list segment counts. progress_bridge now extends its MirrorSwitched handler with a DELETE FROM download_segments WHERE download_id = ? step so each switch leaves the segments table empty for the next attempt's SegmentStarted events to repopulate. New clear_segments_for_download helper plus regression test test_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 in download_repo::persist_download — the round-1 fix added CurrentMirrorIndex to the update_columns list, so a generic save() carrying a stale in-memory cursor would race with the event-driven write from progress_bridge and clobber the live failover position with the cursor as it was when the aggregate was loaded. CurrentMirrorIndex is now excluded from update_columns (alongside SpeedBytesPerSec and DownloadedBytes, 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, MirrorSwitched advances the persisted current_mirror_index to the last slot; DownloadFailed then 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_bridge now translates DomainEvent::DownloadFailed into a BridgeMessage::MirrorCursorReset and writes current_mirror_index = 0 through 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 by chatgpt-codex-connector[bot] (P1+P2) and coderabbitai[bot]. (1) Mirror cursor was only mutated locally inside the engine's failover loop and never persisted to downloads.current_mirror_index, so download_read_repo kept emitting slot 0 as active even after a runtime switch and the details panel surfaced the wrong mirror. progress_bridge.rs now subscribes to DomainEvent::MirrorSwitched and runs UPDATE 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-meta file next to a partial download — the meta-aware branch in run_mirror_attempt keeps the file, then storage.create_file (which uses create_new(true)) collides with it and the next mirror is never tried. The failover loop in start() now wipes both .vortex-meta and the partial file via tokio::task::spawn_blocking before the continue, 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_detail clamps model.current_mirror_index against mirrors.len() - 1 (or 0 when the list is empty) before constructing the DownloadDetailView, so a row with mirrors_json shrunk 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 — DownloadsView test fixture missing mirrors (scope download, sprint task 40): inline download_detail mock in src/views/DownloadsView/__tests__/DownloadsView.test.tsx now returns mirrors: [] + currentMirrorIndex: 0 so the simplify-pass removal of redundant mirrors?.length null-guards in DownloadDetailsPanel.tsx no longer blows up the three downstream tests with TypeError: Cannot read properties of undefined (reading 'length'). Caught by the lefthook pre-push ts-test hook before the branch reached origin.
  • Task 39 follow-up — PR #151 review fixes round 2 (scope download): download_remove IPC now runs handle_remove_download first and only invokes wait_manager.cancel_wait on success, so a fallible remove no longer strands the download in Waiting with no timer to expire it (same pattern already in place for download_cancel). WaitManager tags every scheduled timer with a monotonic generation token alongside its JoinHandle; 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 (so JoinHandle::abort() is a cooperative no-op) could remove(&id) matching by DownloadId alone, evict the freshly-inserted handle and resume the aggregate against the elapsed deadline. New regression test stale_timer_does_not_evict_replaced_handle reproduces the race deterministically by advancing past the first deadline before the reschedule call and asserts the second timer survives + the aggregate stays Waiting instead of resuming early.
  • toError hardened against circular-reference payloads (scope api, issue #142, PR #146 review round 1): src/api/client.ts::toError now wraps JSON.stringify(err) in a try/catch and falls back to String(err) when the rejected payload contains a circular reference. Previously a circular err (possible when an enriched Rust error round-trips through Tauri IPC and re-references itself) would throw TypeError: Converting circular structure to JSON from inside the tauriInvoke catch block, masking the original failure with a normalization error. The JSON.stringify result is also ?? String(err)-coalesced before being passed to new Error(...) so a payload that legitimately serializes to undefined (a bare undefined rejection, a function, or a symbolJSON.stringify returns undefined for those without throwing) no longer constructs an Error with a blank message. Five regression tests cover circular objects, plain-object stringification, string rejections, undefined rejections, and function/symbol rejections.
  • Task 33 — PR review fixes (scope link, PR #145): url_normalizer::detect_scheme now reads via str::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. LinkGrabberView adds a monotonic detectBatchRef so a late link_detect_duplicates response from a previous resolve cannot clobber the duplicate state of a newer batch. While Skip duplicates is on, isStartable now returns false for 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. dispatchDuplicateDetection also wires an onError handler so an IPC failure marks each batch row as duplicate: null instead of leaving them stuck in the loading sentinel — isStartable then unblocks bulk start instead of silently rejecting every online row. New getDuplicateKey(link) helper unifies the URL identity used by dedupe and startDownload (link.resolvedUrl ?? link.originalUrl), so a redirected link whose canonical form already exists in active/history can no longer slip past the duplicate gate. New startLinks(links) helper collapses bulk starts by canonical URL when Skip duplicates is 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. dispatchDuplicateDetection derives canonical probe URLs per-row through a Set instead of an intermediate Map<originalUrl, …> that could silently drop a row when two rows shared an originalUrl. Duplicate-badge labels and tooltip route through react-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_download and
    max_retries are applied to each new download instead of hardcoded domain
    defaults, retry_delay_seconds feeds 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 invalid user_agent in 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_links and resolve_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 — /simplify cleanup (scope download, sprint task 40): extracted getHostname + getProtocol to src/lib/url.ts and replaced the inlined copies in MirrorsSection.tsx + SourceInfoSection.tsx with the shared helpers. run_mirror_attempt swapped its 13 positional arguments for a MirrorAttemptParams struct mirroring the adjacent SegmentParams pattern; the #[allow(clippy::too_many_arguments)] suppression is gone. entities/download.rs + download_read_repo.rs use safe_u32(model.current_mirror_index as i64) instead of the ad-hoc u32::try_from(...).unwrap_or(0) so the cast goes through the SQLite adapter's standard helper. New MAX_MIRRORS_PER_DOWNLOAD = 64 constant in domain/model/download.rs; Download::set_mirrors truncates after sorting so a malformed .metalink with thousands of entries cannot bloat mirrors_json or stall the failover loop. Doc comments on Download::mirrors / current_mirror_index and the entity's mirrors_json / current_mirror_index columns 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.tsx drops the redundant mirrors && … (non-optional field in TS), MirrorsSection.tsx drops the redundant !download.mirrors short-circuit (dead code under the parent gate).

  • Task 39 follow-up — PR #151 review fixes (scope download): WaitManager::schedule_wait now aborts the previous JoinHandle returned by guard.insert(id, handle) instead of just dropping it (dropping a JoinHandle only detaches the task — abort() is what actually stops the timer), so calling schedule_wait twice for the same DownloadId no longer leaves a stale task that could fire expire_wait against an outdated deadline. expire_wait now short-circuits when the handle has already been removed by a peer cancel_wait / skip_wait (the abort is cooperative: once the sleep wakes and the task is past its last .await, it runs to completion regardless of abort()), preventing a spurious resume_aggregate race against the cancel flow. download_cancel IPC now runs the CancelDownloadCommand first and only invokes wait_manager.cancel_wait on success, so a fallible cancel command no longer strands the download in Waiting with no timer to resume it. download_log_bridge records DownloadWaitingEnded { expired_naturally: false } as "ended early" instead of "skipped" (the same branch fires for cancel-driven endings, where "skipped" was misleading). useCountdown short-circuits before scheduling setInterval when untilUnixMs is already in the past, dropping one no-op render + interval wakeup cycle for already-expired rows. New regression test rescheduling_same_id_aborts_previous_timer proves only the latest timer can fire after a reschedule + new useCountdown test asserts setInterval is not called for past deadlines.

  • Task 39 follow-up — /simplify cleanup (scope download): WaitManager recovers from a poisoned mutex via a private handles() accessor instead of .expect(...)-panicking every subsequent cancel_wait / skip_wait call; the tokio::spawn for the timer now runs under the same mutex guard that inserts the JoinHandle, closing the spawn-before-insert race that left an orphan entry when the timer fired (or tokio::time::advance triggered in tests) before the parent inserted the handle; the previously silent let _ = me.expire_wait(id) is now logged via tracing::debug! on a dropped error; Download::wait() / resume_from_wait() shed redundant .map_err(AppError::from) calls; active_count is #[cfg(test)]-gated; download_remove IPC invokes wait_manager.cancel_wait so deleting a Waiting download aborts the spawned timer instead of leaving it alive for the full hoster cooldown. Frontend: useDownloadEvents no longer double-invalidates on download-waiting-started / download-waiting-ended (the accompanying download-waiting and download-resumed-from-wait events already invalidate); useCountdown clears its setInterval once 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): extracted application/services/history_paginate::for_each_history_page + list_full_history so the bounded-page loop is no longer copy-pasted between export_history and detect_duplicates. Hoisted InMemoryDownloadReadRepo + make_history_and_downloads_query_bus into application/test_support (deleted ~120 lines of stub fixtures from the new query test module). Replaced the stringly-typed DuplicateCheckDto.source: Option<&'static str> with a proper serde enum DuplicateSourceDto. detect_duplicates handler now stores existing-ids as u64 in the index and stringifies only on confirmed hits, plus a contains_key pre-check on the history loop avoids allocating a tuple for entries already shadowed by an active match. url_normalizer detects the scheme via eq_ignore_ascii_case instead of allocating a lowercase copy of the whole URL, and trims WHAT-style block comments down to the load-bearing WHY notes. LinkGrabberView extracts an isStartable / startLink predicate pair so handleStartSelected and handleStartAllOnline no longer carry the duplicated status / duplicate gates; duplicateCount is useMemo-wrapped, and the detectDuplicates onSuccess short-circuits via === checks against the previous duplicate reference so an all-unique batch does not retrigger downstream re-renders. LinkRow swaps the duplicate-source ternary chain for a duplicateLabelMap lookup and data-duplicate now uses "none" (was "no"). Test fixtures deduplicated through a makeDuplicate helper.

Added

  • Blocking desktop E2E smoke test (MAT-135): a new e2e-smoke CI 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
    image media 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 use account_ref text. 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 / .meta4 files into the Link Grabber paste zone now decrypts the container through the loaded vortex-mod-containers plugin (task 41) and feeds the extracted URLs back into the regular resolve / online-check / duplicate-detect / start pipeline. New IPC command link_import_container(file_name, file_bytes) wraps ImportContainerCommand (application/commands/import_container.rs) — validates the extension against an allowlist, caps the payload at MAX_CONTAINER_BYTES = 1 MiB (defensive cap mirrored in the Tauri handler so an oversized buffer is rejected before crossing the IPC bridge), calls the new PluginLoader::decrypt_container(bytes) -> JSON port, parses the plugin response, and creates a Package { source_type: Container, name: <file_name> } so the imported batch is visible as one unit in the Packages view. The default trait impl returns DomainError::NotFound so trait-only test loaders stay compatible. The Extism adapter scans the registry for the first enabled Container-category plugin that exports decrypt, calls it via the new PluginRegistry::call_plugin_bytes(name, "decrypt", &[u8]) helper (containers are binary blobs — the existing call_plugin would have silently corrupted non-UTF-8 bytes), and surfaces a "no container plugin loaded" NotFound error that the IPC layer rewrites into a user-friendly "Install vortex-mod-containers to import .dlc/.ccf/.rsdf/.metalink files" toast. PasteZone.tsx now exports CONTAINER_EXTENSIONS + an isContainerFile(File) predicate and forwards container drops through a dedicated onContainerFiles(File[]) callback instead of synthesising fake container:<name> URLs that LinkGrabberView then dropped on the floor (the original LinkGrabberView.tsx:67 TODO). LinkGrabberView::handleContainerFiles reads each File via arrayBuffer(), ships the bytes as a number[], surfaces an "Imported {N} links from {filename}" success toast (i18n keys linkGrabber.toast.containerImported_one/_other in fr.json + en.json), then reuses the existing resolveLinks({ 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-containers v1.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 no password_required state can flow through decrypt_container until 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, plugin NotFound propagation, 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 frontend PasteZone.test.tsx cases (drop forwards files via onContainerFiles, ignored when callback missing, text-only drops keep extracting URLs, isContainerFile accepts every supported extension + uppercase + rejects unrelated), 2 frontend LinkGrabberView.test.tsx cases (drop triggers link_import_container with the byte array + chains into link_resolve on success, IPC failure surfaces toast.error and skips link_resolve). cargo test --workspace: 1493 pass / 7 ignored. cargo clippy --workspace -- -D warnings + cargo fmt --check clean. vitest run: 702 pass. oxlint + tsc -b clean.

  • Plugin vortex-mod-containers v1.0.0 (scope plugin, sprint task 41, PRD-v2 §P1.22 / PRD §4.4): new official Vortex plugin in a sibling repo vortex-mod-containers/. Container category, no http capability — pure transformation bytes → Vec<ContainerLink>, the host routes the resulting URLs through the regular hoster pipeline. Decrypts the four legacy link-container formats: DLC v1 (JDownloader cb99b5cbc24db398 AES-128-CBC key + 9bc24cb995cb98b3 IV, 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-style CCF1\n magic 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 legacy 8C 35 19 2D 96 4D C3 18 2C 6F 84 F3 25 22 39 EB key/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 same ChecksumAlgo enum, and one or more <url> resolutions; first URL becomes ContainerLink.url, the rest stack into ContainerLink.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 return Option::None so the host can show "Unsupported container variant" instead of guessing wrong. Errors map to a typed PluginError (UnsupportedFormat, Malformed(String), Decrypt(String), Xml(String), Base64(String), Hex(String), Utf8(String), MissingField(&'static str)) with From impls for every upstream error type — no .unwrap() outside tests, all upstream parsers are funnelled through ?. The crypto core (src/crypto.rs) wraps cbc::Encryptor<aes::Aes128> / Decryptor against the in-place encrypt_padded_mut::<Pkcs7> / decrypt_padded_mut::<Pkcs7> API (pre-sized Vec<u8> buffer, no alloc-feature dependency on cbc/cipher); decryption rejects non-% 16 ciphertext lengths up-front so a misaligned blob fails fast. Plugin contract exposes three exports through extism-pdk's #[plugin_fn]: can_decrypt(bytes) -> "true" | "false" (magic-byte + structural detection), detect(bytes) -> JSON DetectResponse (explicit format report including null on no match), and decrypt(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 in docs/ADR-001-container-keys.md: the plugin never reaches out to service.jdownloader.org/dlcrypt.php (the JD service path used for DLC v3 per-container keys), so the WASM module declares http = false and modern DLC v3 captures fail cleanly with PluginError::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 existing opt-level = "z" + lto = true + codegen-units = 1 + strip = true profile. 54 native unit tests across crypto (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), and lib (top-level can_decrypt / detect / decrypt smoke tests per format + UnsupportedFormat on garbage) + 8 integration tests in tests/synthetic_corpus.rs exercising 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 public decrypt() API; specifically corpus_has_at_least_twenty_containers_across_four_formats enforces the ≥20 / ≥5-per-format invariant, every_corpus_entry_decrypts_correctly asserts format + link count + first-URL identity for each entry, metalink_corpus_carries_checksums proves every Metalink fixture surfaces at least one checksum, metalink_corpus_recognises_sha256_when_present proves dashed/upper SHA-256 attribute variants normalise to the Sha256 algo enum, the per-format suites prove the magic-prefix and order-preservation invariants, and unknown_blobs_are_rejected proves can_decrypt + decrypt reject 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 guarded tests/fixtures/real-world/ if needed). cargo clippy --all-targets -- -D warnings clean (collapsible-if + manual-is_multiple_of clippy hits fixed up-front, complex-type alias EncodeCase extracted in the integration suite). cargo fmt --check clean. Registered in vortex/registry/registry.toml with checksum_sha256 = 28ba16ce…12ede6 (wasm) and checksum_sha256_toml = 6d7e0152…758dd6 (manifest); category container, 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): new domain/model/mirror::Mirror aggregate (Url + priority 1..=100 + optional ISO-3166 alpha-2 country) with sort_by_priority ordering highest-first and ties broken by URL string for deterministic re-tries; Download gained mirrors: Vec<Mirror> + current_mirror_index: u32 plus the builder with_mirrors, the cursor mutators advance_mirror / reset_mirror_cursor, and the active_url() getter that resolves to mirrors[current_mirror_index] and falls back to the canonical url field 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 drives run_mirror_attempt(...) per candidate. Each attempt gets a fresh attempt_token = cancel_token.child_token() so an internal segment-failure teardown cancels peers without raising the user-cancel signal — user_cancel_token.is_cancelled() (the ActiveDownload token) remains the only way AttemptOutcome::Cancelled is reported. On AttemptOutcome::Failed(_) the loop publishes DomainEvent::MirrorSwitched { id, new_mirror_index, new_url } and retries the next slot; on full exhaustion publishes DownloadFailed. New SQLite migration m20260505_000009_add_mirrors adds mirrors_json TEXT NULL + current_mirror_index INTEGER NOT NULL DEFAULT 0 to downloads; the entity layer ships an adapter-side MirrorJsonDto so the domain stays serde-free, with serialize_mirrors / deserialize_mirrors round-tripping the list — the read repo expands the JSON into a new MirrorView per entry and the IPC DTO (DownloadDetailViewDto) carries mirrors: Vec<MirrorViewDto> + currentMirrorIndex in camelCase. Frontend gained a MirrorView TS interface and a MirrorsSection panel that renders the active mirror (host + priority + country) plus a list of alternatives with the active row highlighted via aria-current="true"; DownloadDetailsPanel only inserts the section when mirrors.length > 0 so non-Metalink downloads keep their compact layout. DomainEvent::MirrorSwitched is wired through tauri_bridge (event name mirror-switched, camelCase payload id / newMirrorIndex / newUrl) and the download log bridge ignores it (no log line spam on every failover). Coverage: 8 Mirror unit tests (validation + sort), 7 Download mirror-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 tries high first, switches once to mid, never reaches low) — 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 for MirrorsSection (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-1fichier v1.0.0 (scope plugin, sprint task 38, PRD-v2 §P1.19 / PRD §4.1, §4.2): new official Vortex plugin in a sibling repo vortex-mod-1fichier/. Hoster category, http capability only — no subprocess, no captcha solver in v1. Recognises https://(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 to https://api.1fichier.com/v1/download/get_token.cgi with Authorization: Bearer <key> and returns the one-shot direct CDN URL plus optional traffic_used / traffic_total monitoring; 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 surfaces wait_seconds (read from data-wait="…", class="countdown", or var c = … — covers the three layouts 1fichier ships) plus a requires_captcha flag (detected via g-recaptcha / h-captcha / class="captcha") so the host's WaitManager (task 39) can park the download in Waiting state for the advertised duration before the future captcha solver pipeline (task 43+) takes over. Free resolve_stream_url therefore surfaces PluginError::CaptchaRequired until 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 keyInvalidCredentials, Subscription expired / not premiumAccountExpired (both trigger free-mode auto-fallback inside extract_links), Flood detected / rate / too many → RateLimited, not found / offlineOffline, anything else → InvalidApiResponse. Landing-page parser also detects the the file you are trying to access is no longer available / requested file could not be found / fichier introuvable strings and surfaces PluginError::Offline so the engine flags the link as removed instead of retrying. Body size capped at MAX_BODY_BYTES = 1 MiB against memory abuse from a malicious server (real 1fichier landing pages weigh well under 100 KB). FileLink.resumable is reported as true so the host's segmented engine engages multi-connection ranged downloads. The IPC envelope adds mode: "free" | "premium", wait_seconds: Option<u32>, requires_captcha: bool, and optional traffic_used_bytes / traffic_total_bytes alongside the standard id / url / filename / size_bytes / direct_url / resumable shape. 53 native unit tests (URL matcher × 16, free landing parser × 13 incl. recaptcha detection / offline / no-metadata / var c countdown, 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 over tests/fixtures/*.html and tests/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 stub http_request + stub get_credential that signals "no credential" — exercises the can_handle / supports_playlist exports against the compiled wasm32-wasip1 artefact). Registered in vortex/registry/registry.toml with checksum_sha256 = 02035a49…3232c (wasm) and checksum_sha256_toml = c16cb7d4…87c091 (manifest).

  • Hoster wait-time flow (scope download, sprint task 39, PRD-v2 §P1.20 / PRD §6.1.2): new adapters/driven/network/wait_manager.rs service owns one tokio::time::sleep per parked download and drives the WaitingDownloading cycle that hoster cooldowns require. schedule_wait(id, total_seconds, reason) loads the aggregate, transitions it via Download::wait(), persists, then publishes both the legacy DownloadWaiting (state-transition signal) and the new rich DownloadWaitingStarted { id, until_unix_ms, total_seconds, reason } (front-end countdown payload). Natural expiry calls expire_wait(id) which transitions back to Downloading via resume_from_wait, persists, and publishes DownloadWaitingEnded { expired_naturally: true } plus DownloadResumedFromWait. skip_wait(id) (premium "skip-the-queue" path) aborts the timer and resumes immediately with expired_naturally: false. cancel_wait(id) aborts the timer cleanly and emits the ended event without transitioning the aggregate (used by the cancel flow before handle_cancel_download runs). Multi-concurrent waits run in parallel — each download owns its own JoinHandle in Mutex<HashMap<DownloadId, JoinHandle>> so a 10s wait, a 30s wait, and a 60s wait all expire independently. New IPC download_skip_wait(id) exposed; existing download_cancel now calls wait_manager.cancel_wait first to avoid a spurious resume racing with the cancellation. Clock port grew a default now_unix_ms() (default impl multiplies now_unix_secs() by 1 000; SystemClock overrides for full ms precision). New DomainEvent::DownloadWaitingStarted / ::DownloadWaitingEnded variants wired through tauri_bridge (event names download-waiting-started / download-waiting-ended) and the download log bridge. Frontend additions: useCountdown(untilUnixMs) hook ticks once per second and returns { remainingSeconds, label, expired } with MM:SS (or HH:MM:SS past one hour) formatting, clamping the remainder to [0, ∞) and treating a null deadline as a no-op; downloadStore gained waitMap + setWait / clearWait populated from the new events; WaitCountdownCell replaces EtaCell in the table whenever a row's state === "Waiting", rendering the live label plus a SkipForward icon button that invokes download_skip_wait. 7 wait_manager async tests (paused tokio runtime, deterministic FakeClock) 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 returns AppError::NotFound, three concurrent waits expire independently with one Started + one Ended each, and cancel_wait on an unknown id is a silent no-op. 6 useCountdown tests + 5 downloadStore wait-map tests + 3 WaitCountdownCell tests + 2 new DomainEvent payload tests round out the coverage. tokio dev-dependency gained the test-util feature so #[tokio::test(start_paused = true)] + tokio::time::advance work for deterministic timer tests.

  • Plugin vortex-mod-mega v1.0.0 (scope plugin, sprint task 37 ✅, PRD-v2 §P1.18 / PRD §4.1): new official Vortex plugin in a sibling repo vortex-mod-mega/ (commits e949c7b foundation, 5e25cad folder enumeration). Hoster category, http capability 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) and metaMac (bottom 8 bytes) verbatim. Folder key parsing decodes the 16-byte master key directly. Resolution uses one HTTP round-trip per file via the g command (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 the f command ([{"a":"f","c":1,"r":1}]) to enumerate children. The id query 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 -9PluginError::Offline, codes -3 and -6PluginError::RateLimited, any other negative code → PluginError::ApiError(code); HTTP statuses 429 and 509 also collapse to RateLimited so the engine's exponential backoff kicks in instead of failing the link. The IPC envelope extends the standard FileLink shape with a non-optional encryption: EncryptionInfo { scheme: "mega-aes128-ctr", aes_key_hex, iv_hex, meta_mac_hex } so any host that treats the bytes from direct_url as plaintext crashes loudly rather than writing ciphertext to disk; direct_url itself is Option<String>Some(<cdnUrl>) for the file-URL path, None for folder children which the host re-resolves via resolve_stream_url per child. The crypto core (src/crypto.rs) ships three pieces: (1) MegaDecryptor for 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 as chunk_size_at(usize) -> u64 so 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 with chunk_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 the f response, 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 same MegaFileKey shape as a URL key, and decrypts the a field 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 synthetic mega.nz/file/<handle>#<base64url(key)> URL, so the host can re-feed every entry through the regular file-URL pipeline. 61 native unit tests across error / 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_blob cases) / lib (incl. folder builder + synthetic-URL round-trip via url_matcher) + 4 WASM-loaded smoke tests for can_handle / supports_playlist + 1 end-to-end folder-decryption test (tests/folder_e2e.rs::wasm_extract_links_decrypts_folder_listing_with_one_child loads the WASM artefact via Extism, stubs http_request to return a synthetic f response 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, synthetic mega.nz/file/ChildHnd#<key> URL, and null direct_url); clippy clean (-D warnings) and cargo fmt clean, 1 JSON fixture for the g response shape. Registered in vortex/registry/registry.toml with checksum_sha256 = ee4fd771…290f07 (wasm) and checksum_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-gofile v1.0.0 (scope plugin, sprint task 36, PRD-v2 §P1.17 / PRD §4.1): new official Vortex plugin in a sibling repo vortex-mod-gofile/. Hoster category, http capability only — no wait, no captcha, no subprocess. Recognises https://(www\.)?gofile\.io/d/<folder> URLs (alphanumeric <folder> of 6+ characters) plus a synthesised per-file shape https://gofile.io/d/<folder>/<file> returned by extract_links so the host's resolve_stream_url contract — "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/createAccount for a guest token, then GET 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 as FileLink entries — nested sub-folders are filtered out so the host always sees a flat list, files with empty link fields 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::Offline so 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 MB caps both responses against memory abuse from a malicious server. FileLink.resumable is reported as true so the host's segmented engine engages multi-connection ranged downloads. The extract_links response collapses single-file folders to kind = "file" and exposes kind = "folder" for multi-file ones. resolve_stream_url accepts 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 an ApiError directing the caller to use the per-file shape. 55 native unit tests + 13 fixture-driven JSON cases over tests/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 stub http_request that dispatches on URL substring to alternate token / folder responses, including end-to-end extract_links and resolve_stream_url round-trips). Registered in vortex/registry/registry.toml with checksum_sha256 = 97661c3f…349bd (wasm) and checksum_sha256_toml = 389889b5…dd9d (manifest).

  • Plugin vortex-mod-pixeldrain v1.0.0 (scope plugin, sprint task 35, PRD-v2 §P1.16 / PRD §4.1): new official Vortex plugin in a sibling repo vortex-mod-pixeldrain/. Hoster category, http capability only — no wait, no captcha, no subprocess. Recognises https://(www\.)?pixeldrain\.com/u/<id> URLs (alphanumeric <id> of 6+ characters; multi-file list URLs pixeldrain.com/l/... are deliberately out of scope as a future crawler concern). Resolves metadata via the public JSON API endpoint GET https://pixeldrain.com/api/file/{id}/info and serves the binary from https://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. The FileInfo parser only consumes id / name / size / mime_type and ignores extra payload fields (views, downloads, hashes, ad flags…) so future API additions don't break the plugin. FileLink.resumable is reported as true so the host's segmented engine engages multi-connection ranged downloads. 50 native unit tests + 5 WASM-loaded smoke tests (Extism with stub http_request, including end-to-end extract_links and resolve_stream_url round-trips) + 9 fixture-driven JSON cases over tests/fixtures/*.json (simple zip, large file, unicode filename, missing mime type, minimal payload, zero-size, plus the two success: false envelopes). Registered in vortex/registry/registry.toml with checksum_sha256 = d6b6af34…a143d (wasm) and checksum_sha256_toml = 02bd992f…f8a24 (manifest).

  • Plugin vortex-mod-mediafire v1.0.0 (scope plugin, sprint task 34, PRD-v2 §P1.15 / PRD §4.1): new official Vortex plugin in a sibling repo vortex-mod-mediafire/. Hoster category, http capability only — no wait, no captcha, no subprocess. Recognises https://(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 plain href="https://download<n>.mediafire.com/..." button and the obfuscated data-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.resumable is reported as true so the host's segmented engine engages multi-connection ranged downloads. 55 native unit tests + 3 WASM-loaded smoke tests (Extism with stub http_request) + 9 fixture-driven parser cases over tests/fixtures/*.html. Registered in vortex/registry/registry.toml with checksum_sha256 = 5775871b…f942 (wasm) and checksum_sha256_toml = 88f1e844…42c0 (manifest).

  • Link Grabber duplicate detection (scope link, sprint task 33, PRD-v2 §P1.14 / PRD §6.2.2): new application/services/url_normalizer.rs produces 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 so https://example.com and https://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. New application/queries/detect_duplicates.rs query handler indexes every active download (download_read_repo.find_downloads) plus the full history (paginated through MAX_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 as Active. Capped at MAX_DETECT_DUPLICATES_URLS = 500 per call to mirror link_resolve / link_check_online. New link_detect_duplicates Tauri IPC returns { url, isDuplicate, source: "active" | "history" | null, existingId, existingFilename } per input. Frontend LinkGrabberView fires duplicate detection in parallel with link_check_online after each resolve, the LinkRow renders an orange "Already in active/history" badge with a tooltip exposing the existing filename, and a new ActionsBar checkbox Skip duplicates (default on) gates the Start Selected / Start All Online flows so flagged rows are excluded from the bulk download. 21 normalizer unit tests + 8 query handler tests + 5 LinkRow badge tests + 3 new ActionsBar toggle tests cover the contract. New i18n key linkGrabber.actions.skipDuplicates (en + fr).

  • Auto-grouping split archives → package (scope link, sprint task 31, PRD-v2 §P1.12 / PRD §6.3): new application/services/split_archive_grouper.rs clusters resolved Link-Grabber URLs that match split-archive patterns (*.partNN.rar, *.rNN plus the legacy terminal .rar header, *.7z.NNN, *.zip.NNN, *.tar.{gz,bz2,xz}.NNN) by base name + format and creates one Package per cluster with source_type = SplitArchive and external_id = "split-archive:{format_tag}:{base}" (format-namespaced so a RAR set and a ZIP set sharing a base name produce two distinct packages). New GroupSplitArchivesCommand handler + link_group_split_archives Tauri IPC mirror the playlist grouper flow, capped at MAX_LINKS = 500 per call to mirror MAX_URLS in resolve_links and bound the cluster-state allocation; MAX_PART_INDEX = 10_000 rejects absurd part suffixes so compute_missing_parts cannot be coerced into a multi-billion-step iteration. The minimum-parts gate counts distinct part_nums rather than raw link count, so duplicate mirrors of one volume cannot satisfy the singleton guard. Gaps in the part numbering emit DomainEvent::SplitArchiveIncomplete { package_id, base_name, missing_parts } (forwarded to the frontend as split-archive-incomplete) so the UI can warn the user before extraction blocks; legacy RAR completeness now treats the terminal .rar header as part 0 so a missing header is reported instead of silently dropped. Frontend SplitArchiveLinkInput / SplitArchiveGroupResult types added in src/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): new application/services/group_lock module factors out the OnceLock + poisoned-mutex-recovery pair that PlaylistGrouper and SplitArchiveGrouper were each rolling locally. Both groupers now scope the lock to the find-then-save window and release it before publishing PackageCreated / SplitArchiveIncomplete so synchronous event-bus subscribers cannot block other concurrent grouping calls.

  • CodSpeed performance benchmarks (CI): new domain_benchmarks Criterion harness in src-tauri/benches/domain_benchmarks.rs exercising the pure domain::model::config helpers (apply_patch, normalize_link_check_parallelism, normalize_max_concurrent). Wired through a new .github/workflows/codspeed.yml workflow that runs the benches under CodSpeed on every PR, providing automated perf-regression tracking for the domain layer. criterion + codspeed-criterion-compat added as dev-dependencies; [[bench]] target declared with harness = false so Criterion drives the run.

  • CI hardening (scope ci): new GitHub Actions jobs secrets-scan (rejects .env/.pem/.key/etc. tracked files plus AKIA*/sk-ant-*/ghp_*/AIza* API key patterns in tracked content), forbidden-tools (rejects pnpm-lock.yaml/yarn.lock, .eslintrc*/biome.json*/.prettierrc* configs, and any #[allow(dead_code|unused|...)] / @ts-ignore / @ts-expect-error / oxlint-disable comment), and changelog-check (PR-only — fails when *.rs / *.ts / *.tsx change without a matching CHANGELOG.md edit). Existing cargo audit swapped for cargo deny check covering advisories + licenses + bans + sources via the new deny.toml. Frontend job now runs oxfmt --check, knip --reporter compact, and uploads coverage/ as an artifact. New mutants.yml workflow runs cargo mutants --in-diff on PRs touching src-tauri/** and a 4-shard nightly sweep on main.

  • Release verification (scope ci): new verify-tag job in release.yml blocks the build matrix when Cargo.toml, package.json, the pushed tag, and the CHANGELOG.md section are out of sync.

  • Hook hardening (scope ci): pre-commit gains oxfmt --check (scoped via new .oxfmtignore to keep src-tauri/gen/, contrib/, registry/, *.md untouched), scripts/no-secrets.sh (file-pattern + diff-content scan), scripts/no-manual-deps.sh (rejects manual Cargo.toml / package.json dep edits without an updated lock — enforces cargo add / npm install), and changelog-updated. Pre-push gains cargo deny check licenses bans advisories and knip --reporter compact. New knip.json keeps files / dependencies / unlisted / binaries / unresolved as error and demotes exports / types / nsExports / nsTypes / duplicates / enumMembers to warn for backwards-compatible adoption. src-tauri/.cargo/mutants.toml excludes the binary entrypoint, build.rs, tests/**, and the SQLite migrations from cargo-mutants and bumps timeout_multiplier to 2.0 / minimum_test_timeout to 60s to absorb Tauri/sea-orm/extism cold-build latency. package.json adds knip ^6.11 and oxfmt ^0.47 as 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 individual stores/<name> files directly).

Fixed

  • PR #140 review round 9 (scope ci): cubic round-8 follow-up.

    • scripts/forbidden-rust-allow.py block-comment branch in strip_comments_preserving_lines now tracks Rust's nestable /* /* … */ … */ form via a depth counter. The previous text.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.py comment 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.yml changelog-check and .github/workflows/mutants.yml incremental jobs 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, and cargo-mutants could exercise files outside the PR's scope.
  • PR #140 review round 7 (scope ci): chatgpt-codex + cubic round-6 follow-up.

    • .github/workflows/release.yml verify-tag job 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.tags doesn't accept regex quantifiers), so a malformed tag like v1foo.2.3 would 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.py block-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 synthetic let 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.yml forbidden-tools no 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.py regex 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-tools lockfile rejection switched from root-only [ -f pnpm-lock.yaml ] to a tracked-paths git ls-files | grep -E '(^|/)(pnpm-lock\.yaml|yarn\.lock)$' scan so a nested tools/yarn.lock cannot 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.yml Python heredoc body lacked the indentation required by the run: | block scalar, breaking YAML parse and preventing the forbidden-tools job from loading. Extracted the scanner to scripts/forbidden-rust-allow.py and call it as python3 scripts/forbidden-rust-allow.py. The script slurps each *.rs file and walks every #[allow(...)] group with re.DOTALL, exits 1 with hits on stdout when any are found.
    • secrets-scan API key job no longer echoes the matched secret value into CI logs. git grep -n returns file:line:matched_text, so the report now passes through awk -F: '{print $1 ":" $2}' to keep only file:line.
    • secrets-scan file pattern now matches nested .npmrc / .pypirc (e.g. tools/.npmrc, ci/.pypirc) via (^|/)\.npmrc$|(^|/)\.pypirc$. scripts/no-secrets.sh SECRET_PATTERNS aligned to the same anchoring.
    • forbidden-tools step dropped the blanket set +e. Replaced with a targeted || true on the git grep call (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.sh content filter switched back to ^\+ plus an explicit grep -vE '^\+\+\+ [ab]/' invert step. The earlier ^\+[^+] filter rejected real added lines whose content started with +, opening a credential-bypass crack.
  • 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.yml forbidden-tools Rust-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 grep is line-oriented and missed those.
    • chatgpt-codex flagged the oxfmt --check 'src/**/*.{ts,tsx,css,json}' … invocation as a literal-path bug; not applicable. oxfmt --help documents glob support and oxfmt --version 0.47.0 --check against 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.yml forbidden-tools config-rejection step previously only checked root dotfiles via ls -1. Now scans every tracked path with git 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-tools Rust suppression regex was anchored to the first argument inside #[allow(...)]. Replaced with #\[allow\([^]]*\b(dead_code|unused|unused_variables|unused_imports)\b so a suppression like #[allow(clippy::module_name_repetitions, dead_code)] no longer slips through.
    • secrets-scan API key pattern set adds ASIA[0-9A-Z]{16} for AWS temporary STS credentials (the long-lived AKIA… form was already matched).
    • scripts/no-manual-deps.sh cargo_dep_section_ranges now wraps the git show call in { ... || true; } so a missing HEAD blob (new manifest, freshly added file) emits empty awk input instead of tripping set -euo pipefail. Smoke-tested with a brand-new Cargo.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.sh now also flags Cargo dependency deletions, not just additions. A new removed_line_numbers helper feeds the same range-overlap check, but against cargo_dep_section_ranges of the HEAD blob — a manual cargo remove (or sed /^foo = /d) that omits an updated lockfile is rejected. New error message lists cargo remove / npm uninstall alongside the add commands.
    • pkg_dep_change_detected now fails closed when jq is absent. A require_jq helper 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.yml server-side API key scan no longer excludes package.json and Cargo.toml (the patterns are distinctive prefixes — AKIA*, sk-ant-*, etc. — so manifest scanning is safe). Pattern list aligned with scripts/no-secrets.sh to add sk-[A-Za-z0-9]{32,} (OpenAI-style) and glpat-[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.yml tag trigger was regex-style (v[0-9]+.[0-9]+.[0-9]+*) — won't match v1.2.3 since GitHub Actions on.push.tags is glob, not regex. Replaced with v[0-9]*.[0-9]*.[0-9]*.
    • release.yml CHANGELOG check now uses grep -F against ## [<version>] / ## <version> instead of unescaped regex, so version dots match literally (1.2.3 no longer accepts 1X2X3).
    • scripts/no-manual-deps.sh rewritten 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] (plus workspace. and target.*. variants), then only added lines falling in those ranges count. package.json now diffs the relevant sections via jq (dependencies, devDependencies, peerDependencies, optionalDependencies) against HEAD. Pure version / metadata bumps no longer trigger the lockfile requirement.
    • scripts/no-manual-deps.sh rejects pnpm-lock.yaml / yarn.lock unconditionally (was nested inside the package.json branch — a lockfile-only commit slipped through). Hook entry no longer scopes via glob so the script always runs.
    • scripts/no-secrets.sh filename pattern now matches secrets/ directories without a leading dot ((^|/)(\.env|\.secrets|secrets)/). .github/workflows/ci.yml server-side scan extended with .netrc and aws plus the same secrets/ fix.
    • lefthook.yml changelog-updated hook now treats *.js / *.jsx as code (was rs|ts|tsx only).
    • lefthook.yml pre-push cargo-deny now runs licenses bans advisories sources (the sources check was missing on push, even though CI runs the full check).
    • deny.toml unknown-git flipped from warn to deny now that allow-git lists every legitimate git source — unlisted git deps are rejected.

Changed

  • Frontend formatting baseline (scope ci): mass-formatted 188 files under src/**/*.{ts,tsx,css,json} to satisfy the new oxfmt --check gate. No behavioural change — pure formatting. The hook glob src/**/*.{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 a link-status-updated event per URL through the existing EventBus → tauri_bridge pipeline. The handler bounds in-flight probes by the new AppConfig::link_check_parallelism (default 8, clamp 1–64) and caps each probe with AppConfig::link_check_timeout_secs (default 10). Probes are dispatched through tokio::task::spawn_blocking so the sync HttpClient::head cannot stall the runtime, then tokio::time::timeout cancels stragglers and downgrades them to LinkStatus::Unknown. The domain enum now carries the full PRD set (Checking | Online | Offline | PremiumOnly | Unknown); the built-in HttpModule::check_link maps HTTP 401/402 to PremiumOnly and keeps 404/410 → Offline. Frontend wiring: a new Zustand linkGrabberStore (statuses map keyed by URL with setStatus / setManyStatuses / reset) is fed by the new useLinkStatusEvents hook subscribed to link-status-updated; LinkRow reads the live status, renders a coloured badge per row (blue spinner / green check / red X / orange lock / gray help) and surfaces a RotateCcw retry button only when the terminal status is unknown so a transient timeout can be re-issued without re-pasting the whole batch; the Online filter and the applyFilter helper now prefer the live status over the static one so a row that flips from checking to offline mid-flight is excluded from the Start Online only flow. New AppConfig / ConfigPatch / SettingsDto / ConfigDto plumbing 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-memory ScriptedHttp cover Online metadata 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 against link_check_parallelism = 3 proves peak ≤ 3); 2 new tauri_bridge tests pin the camelCase JSON payload shape (kind: "online" | "offline" | "premiumOnly" | "unknown" | "checking"); 2 http_module tests pin the new 401/402 mapping; 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 MediaGrabberDialog now predicts whether re-resolving a playlist will reuse an existing package or create a new one, so PlaylistPackageBanner shows "Reusing playlist package {name}" instead of always falling back to "Will create …". A new read-only IPC query package_find_by_external_id is wired through the CQRS read side: PackageReadRepository gains find_package_by_external_id(external_id) -> Option<PackageView>, the SQLite adapter mirrors the existing find_package_by_id SQL path filtering on the existing idx_packages_external_id UNIQUE index, and a new FindPackageByExternalIdQuery handler maps the result to a minimal PackageSummaryDto { packageId, packageName } (camelCase) so the UI never receives the full aggregate. The new typed React hook usePackageByExternalId(externalId?) short-circuits via TanStack Query's enabled: !!externalId flag — when the dialog is opened on a non-playlist URL, no IPC call is made. The dialog computes playlistKey = canonicalPlaylistKey(link.originalUrl) only when metadata.isPlaylist is true, calls the hook unconditionally with playlistKey | undefined to preserve hook order, derives willReuseExisting = !!existingPackage, and prefers existingPackage.packageName over 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 (PackageSummaryDto round-trip + camelCase serialization, three handler tests against InMemoryPackageReadRepo, two SQLite tests round-tripping external_id matches 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 PackageDownloadRow exposes a single toggle button (Move…Cancel move) that marks the download as pending-move; the button carries aria-pressed so screen readers announce the toggle state, replacing the deprecated aria-grabbed named in the original issue. Once a download is selected, every other PackageRow (the source package is intentionally excluded) surfaces a Move selected here action with an explicit aria-label="Move selected download to {package}"; the deprecated aria-dropeffect is 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 same package_remove_download + package_add_download IPC pair as the drag-and-drop path (now factored into a shared moveDownloadBetweenPackages helper so the rollback logic stays in one place), surfaces start / success / cancel / error transitions through a new visually-hidden role="status" aria-live="polite" live region (packages-move-live-region), and matches the pointer path's visual feedback by raising toast.success(packages.toast.moveDownloadSuccess) on success and toast.error(packages.toast.moveDownloadError) in the catch (toast.error(packages.toast.moveDownloadRollbackError) still fires when both the move and the rollback fail). The executeMove finally block guards against an in-flight move clobbering a newer pending selection: setPendingMove is cleared only when the current state still references the move that just completed. The drag-and-drop path is unchanged. New i18n keys packages.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 (Move button → aria-pressed flips), 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 stale executeMove does not erase a newer selection when its package_remove_download resolves 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 Package per playlist. New PlaylistGrouper application service (src-tauri/src/application/services/playlist_grouper.rs) finds-or-creates a Package keyed by the new external_id natural key (the playlist URL acts as the natural id), so re-resolving the same playlist reuses the existing package instead of creating a duplicate. New GroupPlaylistsCommand handler (group_playlists.rs) routes the request through the grouper; new link_group_playlists Tauri IPC command (PlaylistGroupInputDtoVec<PlaylistGroupResultDto>) lets the frontend orchestrate the group-then-attach flow. Migration m20260430_000008_add_package_external_id adds packages.external_id TEXT NULL plus the idx_packages_external_id index; the column is intentionally nullable (manual packages keep NULL) and the application layer enforces uniqueness through find_by_external_id rather than a DB UNIQUE constraint that would forbid more than one manual row. Domain Package aggregate gains an external_id: Option<String> field with set_external_id and external_id() accessors; PackageRepository::find_by_external_id is added to the trait, returning the oldest row by (created_at, id) so repeated reuses pick the same package deterministically. The MediaGrabberDialog now shows a PlaylistPackageBanner ("Will create package: {name} with {N} items" / "Will reuse existing package …") above the playlist item list once metadata.isPlaylist is true, with EN + FR plural translations under mediaGrabber.playlistBanner.*. LinkGrabberView.handleMediaGrabberConfirm now calls link_group_playlists first when the user has selected playlist items, then download_media_start, then attaches every returned downloadId to the package via package_add_download (failures on attach are non-fatal — downloads still run, only the grouping is missed). Visibility of application::commands::tests_support is bumped to pub(crate) so the new playlist_grouper and group_playlists tests can reuse the existing InMemoryPackageRepo / CapturingEventBus mocks. 13 new tests cover the four acceptance criteria: 7 unit tests on PlaylistGrouper (create / reuse / blank id / fallback name / batch / partial failure / trim), 5 SQLite tests on find_by_external_id (round-trip / match / not-found / no NULL match / oldest-on-duplicates / clear-on-upsert), 3 handler tests, 4 frontend tests on PlaylistPackageBanner. 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. New src/views/PackagesView/ folder with PackagesView (root), PackageToolbar (filter chips All / Container / Playlist / Manual / Split archive + debounced search input + "New package" trigger), PackageTree (empty state + list of PackageRow), PackageRow (chevron toggle, inline rename trigger, source-type badge, file count via _one/_other plural keys, total bytes via formatBytes, aggregated Progress bar, folder browse button, Key password trigger, Switch for 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) and PackageDialogs (AddPackageDialog, RenamePackageDialog, PasswordDialog with type="password", FolderDialog with tauri-plugin-dialog directory picker, DeletePackageDialog with optional "also delete child downloads" checkbox). New src/types/package.ts mirrors PackageViewDto (camelCase, no password field) plus PackagePatch / PackageListFilter / CreatePackageInput / PackageMoveOutcome. New src/hooks/usePackagesQuery.ts exposes usePackagesQuery(filter?) (TanStack Query, 30 s staleTime, forwards sourceType + nameQ to package_list) and usePackageDownloadsQuery(packageId | null) (lazy via enabled, 10 s staleTime, calls package_list_downloads). New packageQueries cache-key factory in src/api/queries.ts with lists() / 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 from PackageMoveOutcome.moved.length), package_toggle_auto_extract, package_delete (confirmation dialog with deleteDownloads boolean), package_add_download and package_remove_download (drag-and-drop pairing). Drag-and-drop uses native dataTransfer (no external lib): PackageDownloadRow sets application/x-vortex-download (id) + application/x-vortex-source-package (origin id) on dragstart; PackageRow registers itself as a drop zone via data-testid="package-row-{id}-dropzone" and the View's dropDownload handler short-circuits when from === to, parses the numeric id, calls package_remove_download then package_add_download, surfaces moveDownloadSuccess / moveDownloadError toasts, and invalidates the package cache. Bulk Pause-all / Start-all fans out the existing download_pause / download_resume IPC over Promise.allSettled for every member returned by package_list_downloads, then surfaces a single success toast or bulkActionError if any leg failed. Filter chips and the 300 ms debounced search (useDebouncedValue) re-key the usePackagesQuery so the round-trip happens server-side via package_list { sourceType?, nameQ? }; an empty filter object is collapsed to undefined so 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 namespace packages.* 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 / _other plural variants for the file-count badge. useTauriMutation's invalidateKeys array invalidates packageQueries.all() on every mutation; commands that touch downloads (package_delete, package_move_to_folder) additionally invalidate downloadQueries.all() so the main downloads list reflects the cascade. The legacy src/views/PackagesView.tsx placeholder 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: PackagesView is 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 real QueryClientProvider. 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 on src/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 the QueryBus builder via a new with_package_read_repo setter. New driven port PackageReadRepository (find_packages / find_package_by_id / find_package_downloads) and SqlitePackageReadRepo adapter compute every package statistic (downloads_count, total_bytes, downloaded_bytes, progress_percent, all_completed) in a single LEFT JOIN packages → downloads with GROUP BY p.id so listing N packages costs one round-trip instead of N+1. PackageFilter { source_type?, name_q? } AND-combines filters: source_type is an exact match against the lowercase wire form (container / playlist / manual / split_archive) and is delegated to the SQL WHERE clause, while name_q is a case-insensitive substring (LOWER(p.name) LIKE %?%) so the UI can fuzzy-search package titles. Blank / whitespace-only name_q is treated as "no filter" so the UI can blindly forward an empty input. Aggregate progress mirrors the per-download formula (Completed always reports 100 even when downloaded < total, unknown total reports 0, otherwise downloaded / total * 100 rounded to 1 dp); all_completed flips to true only when the package has at least one member and every member is in the Completed state. New read model PackageViewDto (#[serde(rename_all = "camelCase")]) re-exposes the aggregated PackageView to the frontend with no password / credential reference field, by construction. list_package_downloads(id) reuses the existing DownloadView so 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 in invoke_handler! and re-exported from lib.rs; package_list validates an unknown source_type argument up-front so callers see "invalid package source type" instead of an empty result. The runtime now wires SqlitePackageReadRepo to the QueryBus via with_package_read_repo. Twenty-three new unit + integration tests cover the three acceptance criteria (SQL-side stats with no N+1, fuzzy name_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), exact source_type filter, case-insensitive substring name_q, AND combination, blank name_q ignored, missing-id None, member ordering by queue_position then id, 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 PackageRepository and the existing CredentialStore via a with_package_repo builder on the CommandBus. create_package(name, source_type, folder_path?) generates a UUID v4 id, validates the trimmed name is non-empty, persists the aggregate and emits DomainEvent::PackageCreated. update_package(id, PackagePatch) applies a partial mutation (rename / folder / priority / auto_extract) — folder_path accepts Some(Some(path)) to set, Some(None) to clear, None to 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 via PackageRepository::detach_download so the downloads survive as standalone rows, true removes each member through the existing RemoveDownloadCommand (deletes engine state, files, and the SQLite row) before dropping the package row; the keyring entry under vortex.package.<id> is best-effort cleaned in both cases. set_package_password(id, Option<String>) stores the secret in the OS keyring via CredentialStore::store("vortex.package.<id>", …) and only writes the keyring service key (never the plaintext) onto the packages.password SQLite column as a marker; passing None clears 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 domain Priority aggregate 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 by list_downloads to update each download's priority and emit a DownloadPrioritySet event 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's folder_path and re-uses task 13's ChangeDirectoryCommand for each member; per-child failures are collected into a PackageMoveOutcome { 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) and remove_download_from_package(package_id, download_id) set / clear the FK on downloads.package_id via the new attach_download / detach_download trait methods; both validate the package exists first so the IPC layer surfaces a clean NotFound for stale callers, and attach_download also requires the download to exist (re-attaching is idempotent). The PackageRepository trait gains attach_download(&PackageId, DownloadId) -> Result<(), DomainError> (returns NotFound when the download row is missing) and detach_download(DownloadId) -> Result<(), DomainError> (idempotent, no-op on missing row); the SqlitePackageRepo adapter implements both via raw UPDATE downloads SET package_id = ? WHERE id = ? so the FK singleton semantics match the existing ON DELETE SET NULL migration. Two new DomainEvent variants — PackageUpdated { id } (rename / folder / priority / password / auto_extract / membership change) and PackageDeleted { 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 as package-updated and package-deleted (camelCase deleteDownloads). 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 in invoke_handler! and re-exported from lib.rs, with a new PackagePatchDto deserialiser whose folder_path: Option<Option<String>> round-trips the three-state semantics from the frontend. The runtime now wires SqlitePackageRepo into the CommandBus via with_package_repo. Forty-three new unit tests against InMemoryPackageRepo / InMemoryDownloadRepo / InMemoryCredentialStore mocks cover every acceptance criterion: CRUD round-trip, cascade-delete vs detach, keyring-only password storage (the packages.password column never holds the plaintext), per-child DownloadPrioritySet cascade 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 packages table (migration m20260429_000007) with the schema mandated by PRD-v2 §8 P1 — id TEXT PRIMARY KEY, name, source_type (container / playlist / manual / split_archive), nullable folder_path, nullable password (keyring ref), auto_extract (default 1), priority (default 5), created_at. The legacy stub packages table from migration 1 (BIGINT id, name only, never wired) is dropped and recreated. The migration also adds downloads.package_id TEXT REFERENCES packages(id) ON DELETE SET NULL plus the idx_downloads_package index, so deleting a package detaches its members without losing the rows. New PackageRepository driven port (save / find_by_id / list / delete / list_downloads) and SqlitePackageRepo adapter with sea-orm entity + from_domain / into_domain converters. Upserts preserve the original created_at so list ordering stays stable across re-saves; list orders by (created_at asc, id asc); list_downloads orders by queue_position asc, id asc so the caller surfaces members in scheduling order. Domain Package aggregate gained the new persisted fields plus a PackageId(String) typed wrapper and a PackageSourceType enum (round-trips via Display / FromStr); download_ids stays in-memory (the FK on downloads.package_id is the source of truth on disk). DomainEvent::PackageCreated.id switches from u64 to PackageId to match. Twenty-one new unit tests cover the four acceptance criteria (fresh + existing-DB migration, FK ON DELETE SET NULL semantics, full-field round-trip, ≥85 % adapter coverage), plus error paths (unknown source_type, priority overflow, created_at overflow), source-type round-trip per variant, optional fields persisting as NULL, list_downloads filtering and ordering, and the InMemoryPackageRepository mock 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 AccountRotator application service detects quota exhaustion (HTTP 429 or traffic_left below a caller-supplied threshold via is_quota_signal), pulls the offending account out of rotation for a hoster-specific cooldown via mark_exhausted(account_id, service_name, ttl_secs), and asks the existing AccountSelector for the next best candidate via next_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), and AllExhausted { next_eligible_at_ms } (every eligible account is on cooldown — stall the download in Waiting until 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 to Download.error stay uniform across hosters. Cooldown lifecycle: record_traffic_refresh(account_id, traffic_left, threshold) clears the marker only when the upstream confirms traffic_left >= threshold (a None observation or below-threshold value leaves the marker in place so a hoster without a traffic counter cannot silently undo every mark_exhausted); clear_exhausted(account_id) is the explicit reset path, idempotent for unknown ids; expired entries are pruned lazily on the next next_account call so no background sweeper is needed. The exhaustion map sits behind a std::sync::Mutex in AccountRotator (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 as AppError::Validation("exhausted accounts mutex poisoned") so callers can distinguish "no candidate" from "internal state corrupted", matching AccountSelector::pick_round_robin's contract. The AllExhausted deadline 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. New AccountSelector::select_best_excluding(service, strategy, exclude_ids) extends the existing select_best with an exclude list (no caching, no behaviour change for empty exclude); the prior signature is now a thin wrapper. New DomainEvent::AccountExhausted { id, service_name, exhausted_until_ms } forwarded by the Tauri bridge as account-exhausted (camelCase exhaustedUntilMs). New transient Account::exhausted_until: Option<u64> field with mark_exhausted / clear_exhausted / is_exhausted(now_ms) / exhausted_until() methods — the field is reset to None by Account::reconstruct so the rotator's in-memory map remains the single source of truth even though SQLite roundtrips drop the marker. New CommandBus::with_account_rotator / account_rotator() builder & accessor wires the rotator alongside the existing AccountSelector. 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 / 500 ignored by is_quota_signal, threshold-equality below-but-not-above, idempotent clear_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 AccountSelector application service picks the best Account per service for the live AppConfig::account_selection_strategy. Three strategies: BestTraffic (default, ranks enabled → not expired → most traffic_left → most recent last_validated → smallest id with Unlimited traffic ranking above any finite value), RoundRobin (per-service cursor over enabled non-expired candidates ordered by id; a poisoned cursor mutex now surfaces as AppError::Validation("round-robin cursor mutex poisoned") so it stays distinguishable from "no eligible account"), and Manual (fallback alias of BestTraffic until pinning UI lands). The selector reads AccountRepository::list_by_service on every call instead of caching: the previous event-driven invalidation could read stale rows when select_best landed between bus.publish(AccountUpdated) and the spawned TokioEventBus subscriber firing. New CommandBus::resolve_account_for(service_name) exposes the selector to download / link-grabber flows; failures from ConfigStore::get_config() propagate via ? instead of being swallowed by a default-strategy fallback. New DomainEvent::NoAccountAvailable { service_name } (emitted when no candidate passes the filter) and DomainEvent::AccountSelected { id, service_name, strategy } (emitted whenever a pick is made), both forwarded by the Tauri bridge as no-account-available / account-selected. New account_selection_strategy field on AppConfig / ConfigPatch / apply_patch plus the matching IPC and TOML serialisation paths (snake_case "best_traffic" | "round_robin" | "manual"). The IPC layer rejects unknown strategy values: ConfigPatchDtoConfigPatch is TryFrom and settings_update surfaces invalid account selection strategy: … instead of silently ignoring a typo. The TOML store mirrors the rule: ConfigDtoAppConfig is also TryFrom, so a hand-edited config.toml carrying an unknown strategy value now fails fast with StorageError("invalid config: …") instead of silently coercing to best_traffic. Backward compat is preserved: a legacy config.toml written before this field existed deserializes the missing key as the empty string via #[serde(default)], and that empty case is treated as BestTraffic so 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-backed account_list query, 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 via formatBytes), valid_until and last_validated columns, an enable/disable Switch, an inline Validate button, and a kebab menu with Edit / Delete. The new AddAccountDialog validates non-empty service / username / password before submission. EditAccountDialog posts a partial AccountPatch (skips fields that did not change so the keyring rotation only fires when the password field is filled). The Delete action honours the existing settings.confirm_delete toggle: when enabled it pops the new DeleteAccountDialog (translated description naming the row), otherwise it deletes immediately. ImportAccountsDialog calls tauri-plugin-dialog's file-pick to anchor the encrypted bundle path, prompts for the passphrase, then calls account_import and invalidates the list cache so freshly-imported rows appear without a manual refresh; ExportAccountsDialog requires the user to confirm the passphrase, opens the native save dialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existing CommandBus / QueryBus handlers (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 in invoke_handler! and re-exported from lib.rs. The runtime now wires SqliteAccountRepo to both buses and provides the KeyringAccountStore + AesGcmPbkdf2Codec adapters to the CommandBus. Adds useAccountsQuery (TanStack Query, 30 s staleTime) and accountQueries cache key factory. New i18n namespace accounts.* covers titles, status badges, dialog copy and toast messages in en.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. AccountValidator is intentionally not wired in this commit — account_validate returns the configured Validation error 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 until history gains an account_id column.

  • Accounts queries (PRD §6.4, PRD-v2 §P1.3, task 22): three CQRS query handlers (list_accounts, get_account, get_account_traffic) wired through the QueryBus builder via a new with_account_repo setter. New read models AccountViewDto and AccountTrafficDto (#[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_name is delegated to the repo's list_by_service for SQL-level pruning, while account_type and enabled filter in memory. get_account_traffic returns the persisted counters; the upstream-refresh path is the existing account_validate command (task 21), keeping queries side-effect free per the project CQRS rule. 21 new unit tests against an InMemoryAccountRepoForQueries fixture cover filter combinations, missing-id 404s, missing-repo validation errors, camelCase serialization shape, and explicit "no password field" assertions on serde_json::to_value output. 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 the CommandBus builder. New driven ports AccountCredentialStore, AccountValidator (with ValidationOutcome) and PassphraseCodec keep handlers free of plugin / crypto dependencies. KeyringAccountStore adapter persists per-account passwords under vortex-account-{id} keyring entries; AesGcmPbkdf2Codec adapter 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 a VORTACC magic + version byte so tampered or downgraded bundles fail authentication. Domain events AccountAdded, AccountUpdated, AccountDeleted, AccountValidated, AccountValidationFailed, AccountsImported, AccountsExported published via EventBus and forwarded by the Tauri bridge as account-* 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 accounts table (migration m20260428_000006) with id / service_name / username / account_type / enabled / traffic_left / traffic_total / valid_until / last_validated / created_at columns and a UNIQUE (service_name, username) index. New AccountRepository driven port (save / find_by_id / list / list_by_service / delete) and SqliteAccountRepo adapter with sea-orm entity + from_domain / into_domain converters. UNIQUE violations surface as DomainError::AlreadyExists instead of leaking storage errors. Domain Account aggregate gained traffic_total, last_validated, created_at fields and switched its identifier to AccountId(String) so generated account ids match the spec's TEXT PRIMARY KEY. Account::credential_ref() returns a keyring://{service}/{username} URI exposing a logical reference suitable for diagnostics; passwords themselves are never persisted to SQLite — they live in the OS keychain via the AccountCredentialStore adapter (added in task 21, keyed by AccountId). Unblocks tasks 21-25, 38, 51-56, 75-76.