- Add backendRegisteredIds and backendDispatchPromises for single dispatch enforcement
- Add detach_download_for_reconfigure to safely modify properties of active downloads
- Add applyProperties logic handling completed, failed, paused, and active queues
- Add extractValidDownloadUrls and fix multi-url paste handling
- Setup vitest and add useDownloadStore unit tests for backend registration lifecycle
- Strip password, cookies, and headers from download_queue before writing
to store.bin; secrets remain in-memory for the active session only.
- Move extension pairing token from PersistedSettings to the OS keychain,
rotating it on upgrade from versions that persisted it as plaintext.
- Add ignores_legacy_extension_pairing_token_field test to confirm serde
silently drops the old field so existing installs migrate cleanly.
- Document intentional retention of URLs (signed params are the download
source and cannot be redacted without breaking resume/retry).
- Fix double tray icon by using correct tray id 'main' instead of 'main_startup'
- Fix yt-dlp version check to accept standalone binaries without _internal/
- Fix SIGABRT crash in did_finish_launching by replacing tokio::process::Command
with std::process::Command + spawn_blocking to avoid tokio Child lifecycle issues
yt-dlp's --downloader aria2c searched PATH for the bare name, but
Firelink's bundled binaries use target-triple suffixed names
(e.g. aria2c-aarch64-apple-darwin) — so yt-dlp could never find them.
Add resolve_bundled_binary_path() helper that locates sidecar binaries
in production (.app bundle) and dev mode. Before spawning yt-dlp, create
a temp directory with bare-name symlinks to the suffixed binaries, set
PATH=tempdir:/usr/bin:/bin, and pass --ffmpeg-location pointing there.
Existing Settings → Engine checks (test_ffmpeg, test_aria2c via RPC,
test_ytdlp) continue using app_handle.shell().sidecar() and are
unaffected.
The yt-dlp binary was a PyInstaller onedir build requiring an adjacent
_internal/ directory with the Python runtime. In the Tauri .app bundle,
sidecar binaries are placed in Contents/MacOS/ while the _internal/
resource ends up in Contents/Resources/binaries/, so the bootloader
could not find Python at runtime ([PYI-...:ERROR] Failed to load
Python shared library).
Replace it with the official yt-dlp_macos standalone onefile binary
from the yt-dlp release (v2026.06.09, universal2). This is fully
self-contained — it extracts its runtime to a temp directory at
invocation — and works regardless of Tauri's bundle layout.
Removes the entire _internal/ directory (~70 MB of Python framework,
native .so/.dylib files, etc.) and the stale .gitignore entry.
aria2c was spawned without a readiness check, causing the frontend to
always see 'error sending request for url' even when the process was
merely starting slowly. If spawn or sidecar creation failed, the error
was only logged and never surfaced to the user.
- Add 5-second readiness loop (poll aria2.getVersion every 100ms) after
spawning aria2c during app setup, blocking setup until RPC is available
or timeout expires.
- Store spawn/readiness errors in Aria2DaemonGuard.startup_error and
check it in test_aria2c before attempting RPC, so the real error
reason reaches the frontend.
- Keep child process lifecycle in Aria2DaemonGuard (killed on Drop).
- Add --status-queued and --status-retrying CSS vars to all theme blocks
- Add .download-status-queued/.download-status-retrying CSS classes
- Add .download-progress-fill.queued/.retrying with pulse animation for retrying
- Wire retrying status into hover action buttons and context menu (pause/resume/redownload)
- Show 'Processing…'/'Muxing…' text in speed/ETA columns during processing state
- Fix light theme: add missing --status-* vars to :root block
- Reset items with 'downloading' or 'processing' status to 'queued'
before re-enqueue on startup to prevent phantom active state
- Include 'processing' status in recovery filter (was a zombie path)
- Remove redundant LazyStore('store.bin') from settingsStore.ts;
import shared instance from useDownloadStore.ts instead
Transient network drops and Wi-Fi timeouts no longer promote a download
straight to a hard Failed state. A shared retry engine classifies the
error, emits a transient `Retrying` state, and runs a 3-strike
exponential backoff (2s / 5s / 10s) while preserving the active download
allocation (semaphore permit / worker slot). Resumability primitives are
reused on every retry so no downloaded bytes are discarded.
Engine core (src-tauri/src/retry.rs, new):
- BACKOFF_SCHEDULE = [2s, 5s, 10s], MAX_RETRIES = 3
- backoff_for(strike): schedule index with graceful clamp beyond range
- is_transient_network_error(msg): string classifier covering reqwest,
yt-dlp, and aria2c phrasing; permanent conditions (HTTP 401/403/404/
410/451, not-found, permission denied, out-of-disk) checked first so
they always fail fast
- backoff_and_emit / backoff_and_emit_cancel: cancel-safe sleep helpers
driving a Retrying state emit before each delay
- 9 unit tests covering schedule math, clamping, transient vs permanent
classification, and permanent-wins-over-transient precedence
State model (src-tauri/src/ipc.rs):
- DownloadStatus::Retrying variant (serialized "retrying")
- DownloadStateEvent::retrying(id, reason) constructor
- Regenerated TypeScript binding: src/bindings/DownloadStatus.ts
Native reqwest backend (src-tauri/src/download.rs):
- DownloadEvent::Retrying variant (headless mirror)
- CoordinatorEventSink::emit_retrying() drives the production
download-state channel with status "retrying"
- download_file retry core rewritten: transient -> emit_retrying ->
backoff_for sleep inside a control_rx select (pause/cancel honored
mid-backoff) -> re-issue Range header; permanent errors or strike
exhaustion advance to the next URL then hard Failed
yt-dlp media backend (src-tauri/src/lib.rs):
- child spawn+stream loop wrapped in a 3-strike re-spawn loop; stderr
tail classified and, if transient, the process is re-spawned after
backoff (--continue resumes); --retry-wait=2 added to aria2c downloader
aria2c backend (src-tauri/src/queue.rs):
- retry-wait=2 option so aria2's internal retries are not rapid-fire
- handle_aria2_download_error: intercepts transient onDownloadError,
backs off, re-issues addUri, and rotates the stale gid -> id mapping
via rotate_aria2_gid (fresh addUri mints a new gid; not rotating would
detach subsequent WS events and leak the semaphore permit permanently)
- aria2_payloads / aria2_retry_strikes tracking with cleanup on terminal
outcomes
Verification: cargo build clean; cargo test --lib 37 passed / 0 failed.
This commit replaces the frontend-imperative download dispatcher with a centralized backend `QueueManager`. It acts as the sole concurrency gatekeeper using a single `tokio::sync::Semaphore` across all download paths (aria2 RPC, native HTTP, yt-dlp media).
Backend Changes:
- **queue**: Added `QueueManager` to manage an ordered `VecDeque` of tasks, semaphore permits, and retirement debt (CAS resize).
- **commands**: Replaced direct start commands with `enqueue_download`, `enqueue_many`, `move_in_queue`, and `remove_from_queue`.
- **ipc**: Exported `DownloadStateEvent` and `QueueDirection` to the frontend.
- **tests**: Added 11 integration tests covering idle-parking, idempotent releases, CAS underflow prevention, and gid-completion races.
Frontend Changes:
- **store**: Made `useDownloadStore` reactive to the backend via the `download-state` event.
- **store**: Removed `processQueue` and introduced `pendingOrder` to track the accurate sequence of queued items.
- **ui**: Updated `DownloadItem` with queue visuals (clock icon, position badge).
- **ui**: Added Move Up/Down controls to interact with the backend queue reordering API.
Backend QueueManager as sole concurrency gatekeeper with Arc<Semaphore>,
uniform permit parking for aria2/media/native, CAS-based lazy semaphore
shrink, idle-park dispatcher, and reactive frontend store.
Design surfaced and fixes four correctness bugs:
- idle CPU spin on empty queue (peek-then-Notify park)
- fetch_sub underflow to usize::MAX (CAS retirement loop)
- aria2 permit leak on early RPC return (permit parking in active_permits)
- gid completion race before aria2_gids store (pending_completion buffer)