Compare commits

..

456 Commits

Author SHA1 Message Date
NimBold 6d9ef68bb0 chore(release): prepare firelink 1.0.3 2026-07-09 04:07:34 +03:30
NimBold 51258b25bc fix(media): smooth transfer speed and eta 2026-07-09 03:40:14 +03:30
NimBold 1baadf6ea6 fix(media): force yt-dlp progress output (#8) 2026-07-09 03:16:44 +03:30
NimBold 60dad5703a fix(media): show live yt-dlp progress (#8)
Buffer yt-dlp output chunks before parsing progress events and update the download row from live progress state instead of imperative DOM writes.
2026-07-09 02:59:37 +03:30
NimBold b8ef712981 ci(release): publish tag builds by default 2026-07-08 22:43:23 +03:30
NimBold 3b9faa789c chore(release): prepare 1.0.2
Remove reduced yt-dlp info-json reuse so metadata fallback downloads re-extract from the original URL instead of crashing on incomplete extractor metadata.

Update README and changelog for the 1.0.2 desktop release and point the bundled extension submodule at Firelink Companion 2.0.2.
2026-07-08 22:19:15 +03:30
NimBold 161b0028f9 fix(downloads): harden add modal and retry state
Route explicit no-limit speed overrides without inheriting the global cap.

Preserve dotted media titles when switching formats and drain retry gid completions through remember_gid.
2026-07-08 21:57:12 +03:30
NimBold f84726a403 chore: refresh release dependencies 2026-07-08 17:52:38 +03:30
NimBold e52e885193 fix(ui): keep sidebar toggle accessible across views
Move the collapsed sidebar reveal control into the app shell so Settings, Scheduler, Speed Limiter, Logs, and Downloads all have a recovery path. Keep separate collapsed spacing for macOS and Windows/Linux window controls to avoid overlap.\n\nFixes #9
2026-07-08 16:50:06 +03:30
NimBold 5fc3c9f965 fix: harden metadata fetch release paths 2026-07-08 16:36:03 +03:30
NimBold 800b94f9c0 fix: harden media cleanup retries
Retry best-effort yt-dlp media artifact cleanup before giving up so Windows file-handle release timing does not leave stale partials behind.

Also adjusts the app sidebar panel contrast and border tokens.
2026-07-08 02:52:23 +03:30
NimBold 6b5384f261 fix(media): speed up extension metadata fetch 2026-07-08 01:32:18 +03:30
NimBold 607d193f8e fix(proxy): preserve system proxy schemes
Detect platform system proxy schemes before normalizing proxy values.

Fail normal aria2 downloads clearly when the selected proxy is SOCKS, since aria2 only supports HTTP-style all-proxy URLs.
2026-07-07 22:25:58 +03:30
NimBold f711c6b7a4 feat(media): support explicit fetch media intent 2026-07-07 12:44:19 +03:30
NimBold e30eff4d2e feat(media): support extension media fetch
Thread extension-provided headers and cookies into Firelink media metadata loading so authenticated page fetches show the correct formats.

Include those auth inputs in both metadata and yt-dlp info-json cache keys to prevent cross-session collisions, and advance the Firefox extension submodule to the published media-fetch commit.
2026-07-07 00:17:35 +03:30
NimBold 85ad9d18e8 fix(settings): add category subfolders toggle
Add a Settings > Locations toggle that disables automatic category subfolders while keeping the selected base download folder as the automatic destination.

Persist the setting across frontend and backend startup paths and disable the category subfolder controls when the feature is off.

Fixes #6
2026-07-06 23:37:03 +03:30
NimBold 036ff02dac chore(engines): refresh ffmpeg provider builds
Update Windows and Linux FFmpeg source locks to BtbN 8.1.2-22-g94138f6973 from autobuild-2026-07-06-14-19.

Replace the bundled macOS Apple Silicon FFmpeg binary with Martin Riedl snapshot N-125450-gfad2e0bc50 and record its verified checksum.

Teach the update checker to compare FFmpeg provider builds separately from upstream stable releases so future refreshes are not missed.

Keep yt-dlp pinned at the current official stable 2026.07.04 and update Vitest to 4.1.10 so the release update gate stays clean.
2026-07-06 20:43:21 +03:30
NimBold 99f73eaae2 fix(downloads): handle root category folders and cookie fallback
Allow explicit empty category subfolders to resolve to the base download folder across settings, UI, and backend ownership paths.

Retry yt-dlp metadata and media downloads without browser cookies when the selected browser cookie database cannot be copied, while preserving real auth failures.

Fixes #6

Fixes #7
2026-07-06 20:19:33 +03:30
NimBold 5ea46024e9 chore(windows): use Firelink icon for NSIS installer
Set the NSIS installer and uninstaller icon to the existing Firelink .ico asset so Windows setup files do not fall back to the default NSIS icon.
2026-07-06 16:44:08 +03:30
NimBold a2a3b08d4e chore(release): ad-hoc sign macOS builds
Configure Tauri to ad-hoc sign macOS app and DMG builds without Apple Developer credentials.

Add a macOS signing verifier that checks the built app, the DMG payload, Mach-O signatures, quarantine xattrs, and expected Gatekeeper behavior.

Tighten README copy while keeping the platform install pills and accurate non-notarized macOS guidance.
2026-07-06 16:34:20 +03:30
NimBold 5ef0b3b5f6 docs(readme): render windows badge locally
Use a checked-in Windows SVG badge because Shields does not render the Windows logo slug.

Keep the browser extension badges compact and inline.
2026-07-06 01:55:30 +03:30
NimBold 515ba611ce feat(integrations): surface chromium extension install
Link Chrome and Chromium users to the manual load-unpacked instructions from the README and Settings > Integrations.

Advance the bundled extension submodule to the Chromium package build commit.
2026-07-06 01:22:20 +03:30
NimBold 579174c5b5 fix(settings): restrict custom proxy to http
Remove the SOCKS5 example from the Network settings custom proxy field until normal file downloads support it end to end.

Reject non-HTTP custom proxy URLs in frontend normalization so Settings cannot emit unsupported proxy schemes to aria2-backed downloads.

Update proxy dispatch tests to preserve the HTTP path and cover rejected SOCKS5 and HTTPS custom inputs.
2026-07-06 00:55:03 +03:30
NimBold c13f993150 fix(proxy): harden system proxy metadata flow
Normalize system proxy values from sysproxy, Windows registry, and environment fallbacks before handing them to download engines.

Apply the resolved proxy to direct metadata probes and yt-dlp media metadata requests so preview and final downloads use the same network path.

Enable reqwest SOCKS support and key media metadata caches by proxy/cookie inputs to avoid stale cross-proxy reuse.
2026-07-06 00:41:34 +03:30
NimBold 017d9cd879 fix(engines): update lockfile checksum for aarch64 internal runtime 2026-07-05 21:17:28 +03:30
NimBold dabd252ab7 fix(ci): resolve clippy warnings 2026-07-05 20:42:09 +03:30
NimBold 3da73c623f fix(proxy): add robust system proxy fallback and log redaction
- Fallback to reading Windows Registry for ProxyServer if sysproxy crate fails parsing.
- Fallback to reading HTTP_PROXY and related environment variables.
- Redact user home directory in tauri_plugin_log to protect privacy.
- Improves yt-dlp compatibility for domains blocked by local ISPs.

Resolves #5
2026-07-05 20:31:34 +03:30
NimBold 757e313f71 fix(download): require aria2 for file transfers
Update yt-dlp to 2026.07.04 and refresh the bundled macOS runtime plus engine locks.

Route every non-media download through aria2 by removing the native HTTP fallback path, native GID handling, and the old direct-download harness.

Retry transient aria2 startup/RPC failures before failing, then show the real last error in the download row and Properties modal so Windows failures are diagnosable instead of silent.

Refresh docs and tests around the aria2-only file-download contract, and advance the Firefox extension submodule to its published wording cleanup.
2026-07-05 20:00:53 +03:30
NimBold 3aaf32f9ee fix(window): enable custom titlebar controls 2026-07-05 00:02:31 +03:30
NimBold 58bbf95761 fix(release): use platform names for assets 2026-07-04 23:36:59 +03:30
NimBold 5189146369 chore(release): prepare Firelink 1.0.0 2026-07-04 23:09:23 +03:30
NimBold a92d5689d7 fix(downloads): harden queue and media retry controls
Add aria2 control epochs so delayed resume workers cannot unpause a transfer after a newer pause, remove, or reconfigure command wins.

Thread captured cookies into yt-dlp media runs, sanitize yt-dlp config values, reject malformed media headers, and preserve resumable media artifacts across transient retry backoff.

Cover the new queue epoch and media helper behavior with focused regression tests.
2026-07-04 18:55:22 +03:30
NimBold e5348d3213 fix(queue): discard stale aria2 dispatches 2026-07-04 17:45:33 +03:30
NimBold f726b058f7 fix(scheduler): run post-completion system actions 2026-07-04 17:24:16 +03:30
NimBold 4a322196de feat: improve toast UI, add-modal cookie handling, and completion sounds
- Refine toast animations and durations
- Prevent extension-captured cookies from being sent across cross-origin redirects
- Clear stale extension metadata properly for single-link handoffs
- Disable completion sound by default and update settings labels
- Handle AudioContext suspension for completion chimes
2026-07-04 05:32:40 +03:30
NimBold 84a4ff4bfc feat(tools): harden scheduler speed limiter logs
Modernize the Tools release surfaces for the cross-platform re-release checklist.

- make Speed Limiter presets unit-aware and persist custom removable quick presets

- keep diagnostic logging opt-in on clean installs and honor the saved log state on startup

- stage Scheduler enablement edits without discarding unsaved draft changes

- extend persisted settings and bindings for log opt-in and speed preset state

Verification:

- npm run build

- npm test -- --run src/store/useDownloadStore.test.ts

- cargo test --lib

- cargo test settings --lib
2026-07-03 22:21:16 +03:30
NimBold ef877463da fix(engine): preserve ffmpeg snapshot versions
Parse FFmpeg version output with snapshot-aware handling so builds that report N-prefixed identifiers no longer collapse to a single N in Settings > Engine.

Reuse the parser for both the engine status card and the explicit FFmpeg test command, and cover snapshot and release-style outputs with unit tests.
2026-07-03 18:23:58 +03:30
NimBold 4dc607b964 chore(settings): clarify about credits copy
Rename the About footer tooling acknowledgement from download engines to credits so the label better reflects the aria2, yt-dlp, FFmpeg, and Deno attribution.
2026-07-03 17:04:10 +03:30
NimBold c1f9407adb fix(settings): polish integration and about actions
Use the Tauri opener plugin for Integration and About external actions so Firefox Add-ons, GitHub Releases, Source, and License controls work reliably in the desktop shell.

Replace manual update-check toasts with inline About-page state for checking, up-to-date, update-available, and error outcomes.

Modernize About copy by describing Firelink as a cross-platform download manager and removing non-clickable implementation-stack credits from the visible credits section.
2026-07-03 16:59:18 +03:30
NimBold 4026e5bc86 fix(downloads): correct extension metadata and list UI 2026-07-03 03:55:59 +03:30
NimBold 63abf23622 fix(engine): broaden media cookie source support 2026-07-02 23:11:47 +03:30
NimBold ae9f14ad6e fix(settings): harden locations and site login matching 2026-07-02 22:38:25 +03:30
NimBold 98be029203 fix(settings): keep user agent menu above rows 2026-07-02 17:27:38 +03:30
NimBold cfb5a5b6e0 fix(settings): harden network controls 2026-07-02 15:43:53 +03:30
NimBold d274e3af17 fix(settings): honor look and feel controls 2026-07-02 15:13:01 +03:30
NimBold 7cde15b4c1 fix(extension): advance companion capture
Point the bundled Firefox companion submodule at the published Firelink-Extension commit that launches Firelink for automatic captures when the desktop app is closed.

This keeps the parent app tree aligned with the source-of-truth extension repo and preserves the Add-window-first desktop handoff contract.
2026-07-02 01:59:26 +03:30
NimBold 3458df01d1 chore(engines): update Deno to 2.9.1
Refresh the official Deno sidecar pin to v2.9.1 after checking the upstream release notes.

Update Windows and Linux archive checksums in engine-sources.lock.json and replace the bundled macOS arm64 executable with the official v2.9.1 artifact.
2026-07-02 01:59:13 +03:30
NimBold 53982eaef1 fix(ui): show accent table affordances
Selected download rows now use a stronger accent-derived background and inset outline across themes.

Column resize hit targets keep a subtle visible divider before hover, then switch to the accent color while hovered or dragged so users can locate splitters predictably.
2026-07-02 01:58:58 +03:30
NimBold 276446a4dd fix(app): decouple completion chime
Play the completion sound through the app with Web Audio so it is not tied to whether desktop notifications are enabled or whether a platform-specific notification sound name is supported.

Keep the notification body focused on completion status and leave the settings toggle available even when notifications are disabled.
2026-07-02 01:58:43 +03:30
NimBold 1e61d05873 fix(download): honor retry and queue limits
Treat max_tries as the configured retry count instead of total attempts across native, media, aria2, and fallback paths.

Route queue slot acquisition through retirement-aware permit handling so shrinking concurrency does not dispatch new work while active transfers still meet the smaller limit.

Update retry-budget and queue-manager tests to cover the corrected attempt counts and paused aria2 resume behavior.
2026-07-02 01:58:30 +03:30
NimBold 6b7eaa1f66 fix(media): preserve yt-dlp output names 2026-07-01 14:45:55 +03:30
NimBold fdca17910a fix(ui): align cross-platform chrome 2026-07-01 14:29:22 +03:30
NimBold 21fc115d98 ci(release): pin macos runner image 2026-06-30 21:59:43 +03:30
NimBold 2042f5c251 fix(release): align app metadata and smoke checks 2026-06-30 21:40:37 +03:30
NimBold e412b7c72b fix(release): wait for packaged installer output 2026-06-30 21:22:30 +03:30
NimBold b6e82491f3 fix(release): stabilize packaged build validation 2026-06-30 21:04:46 +03:30
NimBold fcb7fc91f6 ci(release): harden packaged app builds 2026-06-30 20:39:52 +03:30
NimBold 74275013dc chore(release): refresh build dependencies
Update npm lockfile packages, compatible Rust lockfile entries, and GitHub Actions release workflow actions.

Bundle the macOS aria2 OpenSSL legacy provider and set OPENSSL_MODULES for aria2 version checks and daemon startup so packaged builds do not depend on Homebrew OpenSSL provider paths.

Keep the Rust Tauri crate family on the previously verified patch line and hold time at 0.3.49 because newer compatible lockfile candidates failed local cargo verification.
2026-06-30 18:04:44 +03:30
NimBold 35ccd5659e fix(windows): hide engine consoles
Disable the native Windows frame at startup and render in-app window controls so packaged builds use Firelink's own titlebar placement instead of the default OS chrome.

Apply CREATE_NO_WINDOW to raw child processes used by the aria2 daemon and shared engine version checks. Route ffmpeg and deno checks through the same hidden version-check helper so Settings > Engine does not flash console windows.

Normalize Windows extended-length paths before returning approved download roots to the UI, while keeping canonicalization for backend safety checks. Detect yt-dlp's embedded runtime per platform so Windows onedir builds look for python*.dll instead of macOS Python.framework.

Verification: npm run build; cargo check --manifest-path src-tauri/Cargo.toml; cargo test --manifest-path src-tauri/Cargo.toml --lib; node --check scripts/smoke-packaged-app.js; Ruby YAML parse for .github/workflows/release.yml; git diff --check.
2026-06-30 15:47:02 +03:30
NimBold 1d9b898e9c test(ci): increase smoke test timeout and fix WebKitGTK hang in xvfb 2026-06-30 06:21:34 +03:30
NimBold 84e8b325c0 fix(ci): completely hide engine-dist from linuxdeploy because patchelf corrupts static binaries 2026-06-30 06:07:58 +03:30
NimBold b33d7d9ac5 fix(ci): fix linuxdeploy by deleting _internal from staged engine-dist instead of provisioned-engines 2026-06-30 05:54:17 +03:30
NimBold 78ce0ed006 fix(ci): fix macos dmg bundle verification and linuxdeploy dep resolution 2026-06-30 05:50:44 +03:30
NimBold fc4eb6a3fb fix(linux): install libreadline6 directly via deb to fix linuxdeploy dependency resolution without LD_LIBRARY_PATH pollution 2026-06-30 05:41:46 +03:30
NimBold 9f88c27b83 fix(linux): set LD_LIBRARY_PATH to help linuxdeploy find yt-dlp dependencies 2026-06-30 05:38:05 +03:30
NimBold df311374fd Revert "fix(linux): use single-file yt-dlp binary to avoid linuxdeploy crashes"
This reverts commit f2f9da1489.
2026-06-30 05:37:40 +03:30
NimBold 29885aa8fd Revert "fix(linux): make yt-dlp provisioning robust against missing _internal"
This reverts commit 5b4fe6cf82.
2026-06-30 05:37:23 +03:30
NimBold 5b4fe6cf82 fix(linux): make yt-dlp provisioning robust against missing _internal 2026-06-30 05:36:02 +03:30
NimBold f2f9da1489 fix(linux): use single-file yt-dlp binary to avoid linuxdeploy crashes 2026-06-30 05:33:13 +03:30
NimBold d1863a76d8 chore: fix unused mut for tray 2026-06-30 05:06:46 +03:30
NimBold e957dd0b85 fix(aria2): handle broken range resumes 2026-06-29 23:00:45 +03:30
NimBold dbbfd5dc7c fix(download): automatically degrade to single connection when server rejects chunked range bounds
When a server (like fuckingfast.co) ignores the upper bound of a range request (returning bytes=start-end/total), aria2 aborts with 'Invalid range header'. This patch treats that error as transient and mutates the download payload to force connections=1 before re-enqueuing. This ensures aria2 issues an open-ended range request (bytes=start-) on the retry, seamlessly resuming the download.
2026-06-29 22:13:26 +03:30
NimBold 20cbacd785 fix(download): recognize aria2 HTTP status formats as transient errors
aria2 prefixes HTTP statuses with 'status=XXX' in its onDownloadError payload instead of 'HTTP XXX'. These were falling through the transient check and causing instant, permanent failure instead of triggering the escalating backoff schedule.
2026-06-29 21:46:49 +03:30
NimBold 7e6706b3cf fix(ui): remove conflicting nth-child rule for download row stripes 2026-06-29 21:46:24 +03:30
NimBold 220290f6f0 fix(ui): apply initial progress state on mount to prevent 0% blink 2026-06-29 21:30:30 +03:30
NimBold cf0af00887 fix(download): handle dynamic server rate-limits and metadata
Fallback to GET Range for metadata when HEAD fails or misses Content-Length. Fetch aria2 WS error payload via tellStatus. Escalate 429 retries with specific 60/120/300s backoff. Persist download fraction for paused tasks.
2026-06-29 21:26:21 +03:30
NimBold 0a77e15f86 fix(ui): define headers and cookies missing from previous commit for fetch_metadata 2026-06-29 17:44:20 +03:30
NimBold 4448895a6b fix: pass capture cookies and headers securely to metadata fetcher
- Pass captured cookies and headers from UI state into the backend `fetch_metadata` command.
- Implement secure domain-matching redirect policy in the backend to prevent leaking cookies, headers, and Basic Auth credentials to third-party domains upon redirect.
- Ensure metadata fetches correctly identify authenticated files (e.g. from Gofile) without being redirected to login/error pages.
2026-06-29 17:36:43 +03:30
NimBold 2ff7a1be44 fix(ci): run engine payload tests with node 2026-06-29 17:18:12 +03:30
NimBold 01ca6e604c fix(security): harden extension and engine trust 2026-06-29 17:11:19 +03:30
NimBold e79d32af74 fix(ui): keep animationParent mounted to prevent auto-animate ghost node bugs 2026-06-29 11:19:48 +03:30
NimBold d5e0caac33 style: center-align text in table columns 2026-06-29 11:01:36 +03:30
NimBold 672565e274 fix(ci): update yt-dlp parse tests with new eta and struct fields 2026-06-29 10:49:53 +03:30
NimBold 97b3489004 fix(backend): recursively kill yt-dlp child processes to prevent zombies 2026-06-29 10:46:14 +03:30
NimBold 34f4174213 feat(ui): add bulk reordering in queue with auto-animate 2026-06-29 10:41:29 +03:30
NimBold 2783460d50 fix(ci): define get_metadata_cache missing from previous commit and fix unused variables warnings 2026-06-29 10:37:52 +03:30
NimBold 4688c941e8 fix(ui): keep table header visible when list is empty, improve multiselect standard OS behaviors, and fix multi-select context menu actions 2026-06-29 10:22:29 +03:30
NimBold f666b2e8f2 fix(ytdlp): fix info.json temporary file leak using a Drop guard to ensure cleanup on all exit paths 2026-06-29 10:18:17 +03:30
NimBold 9edf0a570b fix(ytdlp): cache info.json to bypass 7s startup delay and fix speed/ETA blinking by parsing size and using media_progress_speed smoothing 2026-06-29 10:15:01 +03:30
NimBold 19b4ab8a84 style(ui): center align download list columns for better spacing 2026-06-28 23:47:44 +03:30
NimBold 64b98282d8 fix(ui): instantly persist scheduler enabled toggle to avoid losing state on restart without save 2026-06-28 22:58:16 +03:30
NimBold f7487c8ef6 feat(ui): set date added descending as default sort order for clean installs 2026-06-28 22:37:07 +03:30
NimBold 1fd4a18d17 feat(ui): add interactive column sorting and optimize bulk start operations in main window 2026-06-28 15:01:15 +03:30
NimBold 4a81945f9f fix(media): enforce strict separation by decoupling yt-dlp from aria2c 2026-06-28 14:29:26 +03:30
NimBold 73f894c154 fix: update macOS deno binary and correct hash 2026-06-28 00:24:59 +03:30
NimBold 191a08e817 build: update deno, rusqlite, and enforce standalone tls 2026-06-28 00:18:01 +03:30
NimBold 77b940df60 feat: add resumability detection and pause warnings
- Update 'fetch_metadata' to detect 'Accept-Ranges' HTTP header
- Persist 'resumable' flag in DownloadItem across backend and frontend
- Add confirmation dialog when pausing non-resumable downloads (single and bulk actions)
- Improve UI for start/pause/options buttons by adopting the application accent color on hover
2026-06-28 00:03:12 +03:30
NimBold 1cba5ee556 fix: resolve queue rendering and sorting desyncs
- Ensure `initDB` sorts active items by `queuePosition` before dispatching to preserve expected backend execution order on startup.
- Update `resumeDownload` to explicitly move items to the end of the queue visually by assigning a new max `queuePosition`, synchronizing frontend representation with backend queue behavior.
- Change `queuePosition` calculations to use `Math.max` via `reduce` rather than array `.length`, preventing duplicate queue position assignments upon deletions and avoiding call stack limit exceptions for massive queues.
- Modify `DownloadTable.tsx` sorting to enforce active downloads appearing at the top of the queue view, deferring to `queuePosition` only for pending items.
- Exclude active items from `moveInQueue` logic and UI to prevent visual corruption and silent queue scrambling.
2026-06-27 19:09:26 +03:30
NimBold 8206d1d5e4 fix: ensure aria2c daemon terminates when app closes
- Pass '--stop-with-process' to the aria2c command using the app's Process ID.
- Prevents aria2c from becoming an orphaned process and continuing downloads invisibly in the background after the app is forcefully closed or terminated.
2026-06-27 18:41:07 +03:30
NimBold b2b370161a fix: resolve download state, toast UI, and checksum cleanup loopholes
- Prevent backward state transitions for downloads in frontend store.
- Add Downloading event emission upon aria2 unpause.
- Fix toast container visual bleed on exit by adding overflow-hidden.
- Add retry backoff for deleted files to prevent Windows file lock errors.
- Lowercase checksum hash types before passing to aria2.
- Clean up lingering files upon checksum verification failure.
- Convert Tauri AppHandle arguments to support generic runtimes.
2026-06-27 18:25:36 +03:30
NimBold a22a26f4ce fix(downloads): enforce Add Modal for extension captures 2026-06-27 02:46:54 +03:30
NimBold e1e1061511 fix(macos): resolve build warnings, memory leaks, and thread safety
- Remove deprecated `cocoa` crate in favor of direct `objc` usage to fix future-incompatibilities warning from `block` dependency.
- Fix irrefutable `if let` pattern warning in macOS `SleepPreventer::Drop`.
- Fix memory leaks by properly releasing `alloc`-ed `NSString` and `NSAppleScript` objects.
- Fix AppKit thread safety by routing dock badge updates to the main thread via `run_on_main_thread`.
2026-06-26 21:18:58 +03:30
NimBold eb7be82e71 fix: revert removeDownload to reject on failure to satisfy CI tests and handle UI cleanup manually in fallback 2026-06-26 13:17:50 +03:30
NimBold e1507a6c2c fix: resolve main window UI and state sync bugs
- Fix "Date added" showing "Invalid date" on redownload by standardizing to ISO string format
- Update handleExtensionDownload to fallback to AddDownloadsModal on silent download dispatch failure
- Fix macOS sleep prevention by utilizing IOKit assertions (PreventSystemSleep and NetworkClientActive) to maintain network connection when display sleeps
- Improve resumeDownload to eagerly update UI state to "Queued" to fix progress bar freezing on resume
- Ensure robust error handling for removeDownload to maintain consistent frontend store state
2026-06-26 13:15:00 +03:30
Nima d406cd327f Update README.md 2026-06-25 14:41:39 +03:30
NimBold 9fdb6f1647 fix: treat paused as stopped state in wait_for_aria2_stopped 2026-06-25 14:34:24 +03:30
NimBold 069c1b6788 fix: resolve deadlock, redownload failure, and UI issues
- Fix re-entrant deadlock in `legacy_download_queue_paths` by scoping DB lock
- Ensure `release_registered_id` runs in `remove_download` even if cleanup fails
- Add fallback to `std::fs::remove_file` if `trash::delete` fails
- Ignore 'cannot be paused now' error in `detach_download_for_reconfigure`
- Apply `break-all` and `whitespace-pre-wrap` to Toast messages to prevent UI overflow
2026-06-25 14:27:38 +03:30
NimBold b4ff14805b fix: resolve double-click and redownload issues
- Use `e.detail === 2` for double clicks in `DownloadTable.tsx`
- Add `user-select: none` to list items in `index.css`
- Delete existing assets before enqueueing in `redownload` action
- Remove redundant `onDoubleClick` props from `DownloadItem.tsx`
2026-06-25 14:01:42 +03:30
NimBold 8eb1a55e72 fix: eliminate OS keychain prompt on startup by persisting pairing token in DB
The root cause was hydrate_extension_pairing_token accessing the
keychain when keychainAccessGranted was true in the DB.  Any keychain
access on macOS triggers the system prompt when the binary signature
changes after an update.

Architecture change: two-store model.
- The SQLite settings DB is now the primary store for the token.
  hydrate_extension_pairing_token reads from it exclusively -- it
  never touches the OS keychain.  No system prompt on startup.
- The OS keychain remains defence-in-depth: grant_keychain_access
  still writes the token there, but it is only reached from the
  explicit Grant Access button, so any system prompt is user-initiated.

DB helpers: load_pairing_token_from_settings / save_pairing_token_to_settings
hydrate_extension_pairing_token: reads from DB, skips keychain entirely
grant_keychain_access: syncs token to DB after keychain access
Frontend: extensionPairingToken included in partialize for auto-persist
2026-06-25 03:09:50 +03:30
NimBold 09f103ea04 fix: stop keychain modal on every launch and fix grant-access loop
- Revert hydrate_extension_pairing_token to consult the DB's
  keychainAccessGranted flag instead of unconditionally skipping the
  keychain.  When the flag is true the backend tries the keychain; if
  macOS trusts the current binary (no code-signature change) the read
  succeeds silently and the modal is suppressed.  When the flag is
  false the keychain is skipped and the modal is shown exactly once.
- Fix the Grant Access button in the KeychainPermissionModal by
  writing the result (token, persistent flag) directly into the store
  instead of calling hydratePairingToken() again.  The re-hydration
  raced with Zustand's async persist middleware, which could read
  keychainAccessGranted=false from the DB and flip persistent back
  to false, re‑showing the modal in an endless loop.
- The scheduler defaults (enabled: false) were already correct in
  both the Rust default_settings() and the frontend Zustand store;
  no code change needed.
2026-06-25 02:38:40 +03:30
NimBold 0a647cd612 fix: default logs off, suppress webview context menu, sort queue rows, defer keychain access to user action
- Disable log capture by default in both backend (LOG_PAUSED=true) and
  frontend (isPaused=true) to avoid unnecessary disk I/O and battery drain.
- Add a global contextmenu listener that prevents the webview's default
  right-click menu (Reload, etc.) so the app behaves like a native macOS
  window. Custom context menus in DownloadItem, LogsView, and Sidebar
  still work because their handlers preventDefault() before the document
  listener fires.
- Sort the download table by queuePosition when viewing a specific queue
  so the move-up/down controls produce a visible reorder instead of a
  silent position swap with no UI feedback.
- Rework the keychain access flow to eliminate the OS credential prompt
  that appeared before the app's own KeychainPermissionModal after every
  binary update. hydrate_extension_pairing_token now always skips the
  keychain; only the explicit Grant Access button (grant_keychain_access)
  triggers the OS prompt, which is user-initiated and therefore acceptable
  even when macOS trust resets after a code-signature change.
2026-06-25 02:03:19 +03:30
NimBold 47373b4565 fix(test): unwrap Option<OwnedSemaphorePermit> in queue manager tests 2026-06-25 00:54:34 +03:30
NimBold e03a3b5dbb style: run cargo fmt to resolve CI failure 2026-06-25 00:46:21 +03:30
NimBold b3f4074d67 fix: resolve low severity vulnerabilities from deepseek audit 2026-06-25 00:41:43 +03:30
NimBold 68607d6909 chore: fix clippy warnings 2026-06-25 00:29:02 +03:30
NimBold 8a258981ce fix(security): resolve all medium severity vulnerabilities
- Ensure yt-dlp partial files are cleaned up on error exits.
- Handle Semaphore closure gracefully without panicking.
- Replace busy-polling in wait_permit_released with Notify.
- Fix extension server silent failure with retry loop and logging.
- Ignore malformed existing scheduler settings instead of failing.
- Add final symlink checks before revealing/opening downloads.
- Delay listener registration in App.tsx until core is ready.
- Sanitize Referer header to prevent injection.
2026-06-25 00:19:23 +03:30
NimBold 187d70f348 fix: resolve bugs introduced during high severity vulnerability remediation 2026-06-24 23:59:38 +03:30
NimBold 00e84d6935 fix: resolve 12 HIGH severity vulnerabilities from audit 2026-06-24 23:48:35 +03:30
NimBold b147ec5f78 fix: resolve 7 critical security vulnerabilities from audit 2026-06-24 23:36:21 +03:30
NimBold 1a687d0081 chore(backend): remove unused parameter from db initialization
Removed the unused 'force_disable_keychain' parameter since keychain initialization is now deferred entirely to the frontend.
2026-06-24 22:19:24 +03:30
NimBold fec20db57c fix(backend): absolutely eliminate keychain access on startup
This entirely removes 'sanitize_current_settings_and_restore_token' from 'db::init' and removes the keychain hydration from 'setup()'. The backend will now never access the keychain during bootstrapping, relying purely on the frontend to explicitly trigger token hydration and migration via Tauri IPC when the UI mounts. This guarantees the macOS system prompt will never block the app from opening, and will only appear after the window is fully visible.
2026-06-24 22:14:08 +03:30
NimBold e1f62c949d fix(backend): hydrate session token without triggering keychain prompts
This fixes a severe issue where 'hydrate_pairing_token' was hardcoded to fetch from the keychain on startup and via the 'hydrate_extension_pairing_token' command. This caused multiple macOS keychain prompts to appear back-to-back before the app even loaded. We now correctly check the user's 'keychainAccessGranted' preference from the DB payload and skip keychain operations entirely if they haven't explicitly granted access yet.
2026-06-24 22:00:12 +03:30
NimBold 7213606182 fix(backend): defer keychain migration until manual user grant
This fixes the issue where the app forces a macOS system prompt for credential store access on first launch/update. Instead, the legacy token is safely preserved in the persistent JSON document and is now only forcefully migrated to the macOS keychain when the user manually hits 'Grant Access' in the UI modal.
2026-06-24 21:54:20 +03:30
NimBold 2fe1683f4b fix(extension): harden desktop handoff 2026-06-24 21:06:20 +03:30
NimBold 6f426c1a18 fix(keychain): persist access state, update granted UI, and resolve build errors 2026-06-24 15:45:21 +03:30
NimBold af2c101ca8 feat(settings): add keychain access permission flow 2026-06-24 15:32:43 +03:30
NimBold 8d23f0752f fix: resolve E0521 by cloning specific fields instead of borrowing AppState 2026-06-24 14:36:38 +03:30
NimBold 29193cca74 fix: resolve compile error by using correct app_handle variable for emit 2026-06-24 14:34:38 +03:30
NimBold d720308f30 fix: resolve UI hangs and contrast issues, improve concurrency
- Prevent UI hang by processing aria2 unpause actions asynchronously
- Bypass internal aria2 limits to honor Firelink's max concurrent setting
- Improve visibility of the 'Recheck engines' button in Settings
- Refactor Speed Limiter checkbox into a toggle and fix text selection
- Update Add window font and placeholder to match modern design standards
2026-06-24 14:31:40 +03:30
NimBold d05f4fb251 docs: revamp README layout and add application screenshots 2026-06-24 13:14:53 +03:30
NimBold 9043ebe429 chore: update deps and local cleanup
- update npm dependencies, including Vite 8.1.0 and TypeScript 6.0.3

- update bundled Deno engine locks and macOS arm64 binary to 2.8.3

- add npm run check:updates for npm and engine freshness checks

- ignore local agent/planning notes and remove tracked internal docs
2026-06-24 12:24:39 +03:30
NimBold 941d05112b fix(ci): avoid broken windows tauri lib harness 2026-06-23 22:36:50 +03:30
NimBold b726d3a310 fix(ci): run backend tests before engine provisioning 2026-06-23 22:27:16 +03:30
NimBold 7ed7bceacb fix(ci): isolate backend tests from engine staging 2026-06-23 22:17:06 +03:30
NimBold 66252473d5 fix(ci): align tauri feature allowlist 2026-06-23 22:05:39 +03:30
NimBold 61bd79969c fix(release): pin engine source payloads 2026-06-23 21:57:33 +03:30
NimBold 82ed38d55a fix(release): harden cross-platform gates 2026-06-23 21:43:11 +03:30
NimBold f603b74a99 feat(release): add cross-platform packaging
Add target-aware engine provisioning, platform package configs, and CI/release verification for macOS arm64, Windows x64, and Linux AppImage.
2026-06-23 21:26:51 +03:30
NimBold 0e3118ec2c fix(ui): implement robust CSS transitions for toast animations
Migrate from CSS keyframes to a React state-machine using useLayoutEffect and CSS transitions. Fixes animation interruption jumps, double-timeout memory leaks, layout snapping, and correctly handles duration: 0.
2026-06-23 11:26:08 +03:30
NimBold eae3cf6180 feat(logging): default to paused state and prevent performance hit via frontend wrapper
- Default LOG_PAUSED to true in backend.
- Combine tauri_plugin_log filters to drop backend logs when paused.
- Create frontend logger wrapper to intercept and drop logs before IPC when paused.
- Sync LogsView state directly with logger wrapper and handle initialization races.
2026-06-23 04:33:24 +03:30
NimBold ddd4662898 fix(network): explicitly disable proxy fallbacks for none and system modes
Disabled implicit system/environment variable proxy fallbacks in reqwest, yt-dlp, and aria2c when no proxy is explicitly provided. Updated useDownloadStore to strictly return 'none' when 'none' is selected or when 'system' proxy is empty, matching IDM/FDM behavior and ignoring http_proxy environment variables.
2026-06-23 04:21:25 +03:30
NimBold 6301bf9fd0 style: update logs context menu to match app-modal glassmorphic styling 2026-06-22 22:04:32 +03:30
NimBold a802c947ec fix: remove global user-select none to allow text selection in logs 2026-06-22 21:58:06 +03:30
NimBold 34f25ff3ea fix: force DOM flush for speed and eta when download completes 2026-06-22 21:52:05 +03:30
NimBold 58fb0155b7 feat: enhance log system with true deletion, noise filtering, and error boundaries
- Implemented `clear_logs` Tauri command to physically wipe local log files.
- Added a global `ErrorBoundary` to cleanly intercept React render loops (e.g. Error #185) and provide readable stack traces.
- Hardened `tauri_plugin_log` to filter out `webview`, `hyper`, `reqwest`, `rustls` log noise.
- Replaced the default browser right-click menu in LogsView with a custom native React menu for copying text securely.
2026-06-22 21:44:05 +03:30
NimBold 25a58c8356 fix: resolve keyboard shortcut and log state edge cases, add privacy note
- Disabled Backspace/Delete keyboard shortcuts when any app modal is active
- Synced frontend LogsView pause state with the backend on initial mount
- Added a privacy and telemetry note in the Logs view to inform users
- Removed legacy polling dead code from LogsView
2026-06-22 21:26:36 +03:30
NimBold c5ee3ec3cd feat: optimize log viewer, add global item deletion, and system telemetry
- Replaced LogViewer setInterval polling with reactive attachLogger
- Added backend LOG_PAUSED toggle to fully halt logs and save I/O
- Added native text selection to logs console
- Implemented Backspace/Delete keyboard shortcuts across DownloadTable and Sidebar with modal-aware safeties
- Integrated cross-platform sysinfo logging for telemetry
2026-06-22 21:20:52 +03:30
NimBold 7c835b022f fix: harden scheduler, permissions, and download safety
- Implement scheduler hydration barrier to prevent premature triggers
- Track scheduler exact runs using keys to avoid false stops
- Use 'System Events' for accurate macOS automation permissions
- Prevent system-sleep via proper idle assertions
- Ensure download pauses use channel acknowledgements (PauseWithAck)
- Require Firelink ownership before replacing files in add/conflict UI
- Retain partial download assets when removing entries without deletion
- Clear progress state in store when downloads complete or pause to reduce churn
- Handle empty/invalid queue selections gracefully
2026-06-22 18:05:04 +03:30
NimBold 6b3753949b fix(downloads): support offline fallback drafts
- launch Firefox handoffs through authenticated reconnect
- preserve usable drafts when metadata lookup fails
- reject stale metadata results and retry failed rows only
2026-06-22 17:22:21 +03:30
Nima f469c5d7eb Delete INTERACTION_REVIEW.md 2026-06-22 12:41:42 +03:30
NimBold db4adc7f89 fix(extension): align desktop handoff protocol 2026-06-22 11:55:58 +03:30
NimBold f1ee3812ed fix(downloads): stabilize queue item selector 2026-06-22 11:28:08 +03:30
NimBold 25595d7ebd chore(docs): exclude youtube media handoff 2026-06-22 11:19:36 +03:30
NimBold 06b14df307 fix(core): harden download lifecycle and scheduling 2026-06-22 11:19:18 +03:30
NimBold d535bdac8f fix: resolve queue management and metadata fetch bugs
- Reset isRemoving state in DeleteConfirmationModal on open and close to prevent UI freeze
- Filter sidebar queue count to exclude completed downloads
- Return fully resolved URL from backend metadata fetch to avoid 403s on one-time tokens
2026-06-21 14:57:21 +03:30
NimBold 3660ceb47a fix: resolve scheduler infinite loop and frozen delete modal bugs 2026-06-21 14:28:21 +03:30
NimBold de1318deb6 fix(download): wait for media process to stop and clean up partial files on remove 2026-06-21 13:48:42 +03:30
NimBold bf28029d2e feat(ui): merge 'Add to list' into 'Add to queue' and fix display bugs
- Remove 'Add to list' function from UI and state store
- Replace 'Add to...' button with 'Add to queue' menu in AddDownloadsModal
- Prevent completed items from displaying in queue views
- Fix right-click context menu overflow clipping the queue submenu
- Gracefully discard completed items when batch-assigning to queues
2026-06-21 13:35:18 +03:30
NimBold 9e8c4aacf7 feat(ui): modernize desktop interactions 2026-06-21 12:53:02 +03:30
NimBold cd0397ea00 feat(ui): implement multi-select and fix finder reveal
- Fix 'Show in finder' for unfinished entries to open destination folder
- Add 'Add to queue' submenu for unfinished items
- Support Shift/Cmd modifiers for multi-selection in download list
- Implement mass-action context menu for multi-selected downloads
- Enhance DeleteConfirmationModal to handle mass deletions gracefully
2026-06-21 00:52:43 +03:30
NimBold 226c7913f5 fix(media): correct HLS progress tracking
Use fragment progress for HLS downloads, prevent false multi-track completion, require exact resolution matches, and remove the ambiguous Best format option and obsolete frontend parser.
2026-06-21 00:21:59 +03:30
NimBold 340ef09efa fix(media): correct YouTube size estimates
Estimate missing stream sizes from bitrate and duration, bind displayed options to exact yt-dlp stream IDs, and propagate approximate totals consistently to download rows.
2026-06-21 00:07:29 +03:30
NimBold a8b792074f fix(media): harden YouTube metadata loading
Bundle the self-contained yt-dlp onedir runtime to avoid per-request extraction latency, explicitly use bundled Deno, and guard the packaging layout. Also fix Add window menu/reset behavior and harden diagnostics and toast lifecycle handling.
2026-06-20 23:57:13 +03:30
NimBold 7ec855efd4 fix: resolve Add window UI and transfer settings bugs
- Remove redundant 'Queue for Add to Queue' dropdown in Transfer Settings
- Fix text cursor appearing on hover over the connections slider
- Fix disabled connections slider dropping the accent color when media format is active
2026-06-20 23:28:12 +03:30
NimBold 262633f441 fix: resolve media download bugs and Add window UI issues
- Fix 'Unknown size' for YouTube videos by improving media size estimation fallback
- Optimize yt-dlp metadata fetch by isolating PATH to bundled binaries
- Fix dull slider color in Add window using Tailwind accent class
- Fix Add window failing to fetch metadata automatically upon re-opening via paste
2026-06-20 23:14:18 +03:30
NimBold c59db4e032 fix(ui): resolve diagnostics console log listener and add level filter 2026-06-20 22:56:19 +03:30
NimBold 15ab6520a2 fix(ui): correct SettingsView syntax and typescript typing errors 2026-06-20 22:48:14 +03:30
NimBold bb7387dbd1 feat(ui): display full category paths in locations settings and remove override message 2026-06-20 22:45:13 +03:30
NimBold 52a7ce11eb style(ui): redesign toast notifications with modern aesthetics 2026-06-20 22:33:32 +03:30
NimBold a7cb84d4b6 fix(downloads): expand tilde to absolute path in download locations 2026-06-20 22:33:32 +03:30
NimBold 070c210d85 feat(ui): modernize toasts, enhance button feedback, and restructure layouts
- Add global scale-down animation to button active states for tactile feedback
- Reposition toast notifications to bottom-center with updated modern styling
- Split 'Start Downloads' and 'Add to...' actions in AddDownloadsModal
- Overhaul Category Subfolders layout in SettingsView using flexbox for better alignment
2026-06-20 19:57:19 +03:30
NimBold 2cc00e433e refactor(ui): unify and modernize in-app toast notifications
- Created root-level ToastProvider and useToast hook
- Replaced scattered ad-hoc toast states with unified provider
- Updated Settings, Scheduler, Speed Limiter, App, and DownloadTable to use ToastContext
- Added variant support (success, info, warning, error) with distinct styling
- Refined toast animations and styling for modern aesthetics
- Adjusted auto-dismiss behavior to ignore actionable or important errors
2026-06-20 19:28:29 +03:30
NimBold 038f31b988 feat(settings): modernize download location settings
- Replace split 'Default Download Path' and 'All Categories Base' with a canonical baseDownloadFolder and categorySubfolders model
- Retain absolute category overrides only where required
- Update Add window to accurately reflect intended destination:
  - Single URL: specific category path
  - Multiple URLs: base folder with explanatory text
  - Manual Browse override: selected folder
- Centralize path resolution logic shared by Rust backend and UI
2026-06-20 19:22:20 +03:30
NimBold 79b579790d fix(persistence): preserve user data across updates 2026-06-20 19:08:48 +03:30
NimBold 487e1fea43 fix(downloads): correct add window actions 2026-06-20 18:51:38 +03:30
NimBold 894c238bb7 feat(queue): implement concurrent deduplication and safe backend detachment
- 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
2026-06-20 11:41:55 +03:30
NimBold 1660dd112d fix(ui): refine add download dark themes 2026-06-19 18:54:30 +03:30
NimBold d921876b72 feat(ui): modernize add download window 2026-06-19 18:39:28 +03:30
NimBold 6db21b7f14 fix(release): harden packaged engine startup 2026-06-19 18:18:21 +03:30
NimBold 3ca126e5db fix: repair download interaction flows 2026-06-19 17:25:40 +03:30
NimBold 85df857678 fix(ytdlp): correct media size estimates 2026-06-19 16:41:33 +03:30
NimBold a2d7b689f3 fix(download): redownload creates fresh queued task 2026-06-19 16:18:51 +03:30
NimBold 70581e8524 fix: persist download path ownership 2026-06-19 08:22:55 +03:30
NimBold 28df7ca809 fix(ui): align download list and live properties 2026-06-19 07:42:59 +03:30
NimBold 8a1619f056 fix: align download path ownership with category destinations 2026-06-19 07:36:02 +03:30
NimBold 1ae2fe7703 fix: repair download open and reveal interactions 2026-06-18 20:56:44 +03:30
NimBold de4d81ecd0 feat(settings): stream engine check results 2026-06-18 20:20:10 +03:30
NimBold 62945a5516 fix(ui): truncate download table cells correctly 2026-06-18 19:53:33 +03:30
NimBold 645d566540 fix(youtube): correct speed and media format options 2026-06-18 19:49:31 +03:30
NimBold 34fcdcc04a fix(extension): make bridge health check reliable 2026-06-18 18:07:55 +03:30
NimBold 94628b865f fix(scheduler): clarify macOS permission flow 2026-06-18 14:57:39 +03:30
NimBold 91d3b2cd67 fix(ui): prevent download table column overflow 2026-06-18 14:44:49 +03:30
NimBold c90ae8bcc3 perf(media): optimize YouTube metadata loading 2026-06-18 14:35:34 +03:30
NimBold c548cb4fe0 fix(media): filter yt-dlp storyboards and clean format rows 2026-06-18 14:14:33 +03:30
NimBold 43e21bfc1e fix(media): restore live download progress 2026-06-18 13:48:34 +03:30
NimBold 2130412e84 fix(macos): unify bundled yt-dlp detection 2026-06-18 13:34:34 +03:30
NimBold 3a76c6f5d7 fix(security): redact secrets from plaintext persistence
- 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).
2026-06-18 08:20:22 +03:30
NimBold e2dd387a8c fix(integration): decode persisted settings and align ports 2026-06-18 07:52:48 +03:30
NimBold 5200c1e453 fix(security): restrict frontend filesystem access 2026-06-18 07:44:18 +03:30
NimBold 97802adb70 fix(downloads): manage aria2 lifecycle correctly 2026-06-18 07:37:30 +03:30
NimBold 605992439d fix(queue): preserve media terminal states 2026-06-18 07:26:40 +03:30
NimBold 18de275d42 refactor(ffmpeg): use regex for robust version parsing 2026-06-18 06:52:47 +03:30
NimBold db99e10238 fix(core): add engine timeouts and symlink deno for yt-dlp 2026-06-17 20:20:21 +03:30
NimBold 2d27b14513 fix: resolve startup crash, double tray icon, and engine version detection
- 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
2026-06-17 19:48:00 +03:30
NimBold a300e440e0 feat(release): add macOS pre-release gate verifying bundled engines
- Enhance scripts/verify-binaries.js with 9 engine checks:
  sidecar existence, executable permission, file(1) identification,
  otool -L linkage scan (forbids /opt/homebrew, /usr/local/Cellar),
  yt-dlp packaging sanity, version self-tests, aria2 RPC smoke test,
  and stderr scanning for Library not loaded / image not found
- Add release.yml workflow triggered on v* tags:
  engine-verification job runs verification, builds .app, uploads
  bundle; create-release job publishes GitHub Release
- Add Verify bundled engines step to ci.yml for PR/main coverage
- Create RELEASE.md documenting the full release process
2026-06-17 19:18:39 +03:30
NimBold 0a1d367a99 feat(engine): replace vague error strings with structured get_engine_status diagnostics
Add a single Rust command returning per-engine structured status with
resolved path, version, error context, stderr tail, and remediation hints.

- Aria2: binary check, daemon startup error, RPC readiness, live stderr capture
- yt-dlp: binary check, onedir layout validation (_internal/Python.framework)
- FFmpeg / Deno: binary check, version parse
- UI: Ready/Error badge + expandable technical detail panel
- Old test_* commands kept for backward compatibility
2026-06-17 18:45:11 +03:30
NimBold 292240cb71 fix(media-download): resolve bundled aria2c and ffmpeg via restricted PATH instead of relying on system binaries
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.
2026-06-17 18:32:56 +03:30
NimBold 7d1d3e1f9b fix(yt-dlp): replace onedir PyInstaller build with standalone macOS binary
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.
2026-06-17 18:19:28 +03:30
NimBold b8b4f9e437 fix(aria2): add readiness loop and surface real spawn errors to frontend
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).
2026-06-17 18:11:47 +03:30
NimBold 18063dc488 Remove unused and temporary files 2026-06-17 11:07:15 +03:30
Nima 0b4dace54c Remove Legacy SwiftUI app reference from README
Removed mention of archived Legacy SwiftUI app from README.
2026-06-17 11:03:52 +03:30
NimBold e638926598 Remove legacy swift folder 2026-06-17 11:02:18 +03:30
NimBold cb39113117 feat(observability): add centralized diagnostics logging engine and native log viewer panel
- Configure tauri-plugin-log with 10MB rotation and 3-file retention
- Add high-frequency format filter to strip [download]% progress ticks
- Hook yt-dlp, aria2c, and native reqwest runners with lifecycle log macros
- Add export_logs Tauri command for async log file export via save dialog
- Create DiagnosticsView React component with streaming monospace console
- Apply severity highlighting: ERROR=red, WARN=yellow, INFO=grey, DEBUG=dim
- Wire Diagnostics tab into sidebar navigation and ActiveView routing
- Log native download failures to log::error! in download.rs
2026-06-17 10:57:32 +03:30
NimBold 69a7192fda fix(permissions): widen sidecar scope to allow-spawn and unrestricted args for runtime reliability 2026-06-17 10:44:24 +03:30
NimBold 7252e43e68 fix(ui): add missing styling for queued/retrying states and processing speed labels
- 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
2026-06-17 10:30:27 +03:30
NimBold 1f150d78b1 fix(persistence): reset interrupted downloads to queued on startup and deduplicate LazyStore instance
- 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
2026-06-17 10:17:37 +03:30
NimBold 6521457cfe fix(infra): harden loopback server lifecycle 2026-06-17 10:07:57 +03:30
NimBold 374b861246 fix(lifecycle): focus primary window on second launch 2026-06-17 09:53:59 +03:30
NimBold 58f4a8a14d fix(security): harden sidecar and media processing lifecycles 2026-06-17 09:45:31 +03:30
NimBold 7c7317adc9 fix(retry): honor attempt caps per download URL 2026-06-17 09:27:17 +03:30
NimBold f6851682dd feat(retry): add connection-aware exponential backoff across all download backends
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.
2026-06-17 09:16:17 +03:30
NimBold bb618aef7d feat(queue): implement backend-driven download queue coordinator
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.
2026-06-16 17:52:20 +03:30
NimBold 1ec4d2aa17 docs: add download queue coordinator implementation plan
12 TDD tasks covering: QueueManager scaffold, dispatcher with idle-park
and CAS resize, uniform permit parking, gid-race handling, reordering,
AppState wiring, aria2 WS poller + completion listener, frontend store
refactor, queued UI visuals, deprecated command cleanup, smoke tests.
2026-06-16 17:02:22 +03:30
NimBold 4b7c00dae3 docs: add download queue coordinator design spec
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)
2026-06-16 16:48:05 +03:30
NimBold 2727d07d62 feat: implement native OS file management and trash utilities 2026-06-16 15:45:22 +03:30
NimBold e41e761b33 feat: implement pre-download media parsing engine and quality selection modal 2026-06-16 15:39:14 +03:30
NimBold 4027ac39ab feat: implement persistent rolling log system with tauri-plugin-log 2026-06-16 15:19:57 +03:30
NimBold 85cdabd72b feat: implement settings engine with directory picker and aria2 configuration sync 2026-06-16 15:10:29 +03:30
NimBold 275eb6e5c4 feat: implement native system tray, background notifications, and download resumability via tauri-plugin-store 2026-06-16 11:38:02 +03:30
NimBold b6678493d3 fix(backend): resolve clippy warnings breaking CI pipeline 2026-06-16 11:21:26 +03:30
NimBold 113f5d3943 perf(frontend): architect transient react progress state with zustand 2026-06-16 11:21:20 +03:30
NimBold a40e6cfef8 refactor(backend): migrate to tauri v2 sidecars and resolve pipe deadlocks
- Replace tokio::process with tauri_plugin_shell sidecar API for native cross-compilation bundle execution.
- Implement non-blocking stdout/stderr multiplexing in yt-dlp spawn to prevent OS pipe buffer deadlocks.
- Refactor capabilities to restrict execution explicitly to sidecars instead of wildcards.
- Rename local binaries to strictly adhere to target-triple architecture suffixes.
- Drop manual get_binary_name path resolutions in favor of Tauri's externalBin bundler.
2026-06-16 11:08:26 +03:30
NimBold fde5eaacba fix: resolve UI bugs and pasting behavior
- Add global Cmd+V keyboard paste support for downloading links
- Restore striped background styling for alternate rows in download list
- Add ghost rows to seamlessly fill empty download space
- Map correctly formatted item size into the queue immediately
- Remove unused Tauri manager imports from backend
2026-06-15 19:57:20 +03:30
NimBold b4b3104c67 fix(yt-dlp): correct js-runtimes syntax to prevent runtime warnings 2026-06-15 19:29:35 +03:30
NimBold ea6ede8a7f fix(yt-dlp): remove buggy ios,tv player client spoofing
YouTube seems to have enforced DRM checks specifically for the ios,tv player clients, which caused yt-dlp to return 'This video is DRM protected' for all standard videos when using that extractor arg. Removing the arg allows yt-dlp to fallback to web/android which still bypasses bot detection successfully.
2026-06-15 19:10:55 +03:30
NimBold e2744526d8 fix(yt-dlp): remove invalid --js-runtime-path flag
The previous refactor introduced a hallucinated yt-dlp flag from the review report, which caused yt-dlp to immediately fail with 'no such option: --js-runtime-path'. This commit fixes the syntax to correctly format the runtime path into the --js-runtimes argument.
2026-06-15 19:07:15 +03:30
NimBold f4b74898fd refactor(yt-dlp): modernize bot evasion and progress parsing
- Fix config location typo to enable authentication
- Wire up bundled Deno to handle YouTube PoW challenges
- Implement iOS/TV player spoofing to bypass web blocks
- Replace fragile regex progress parsing with structured templates
- Add auto-reconnect logic to Aria2 WebSocket
- Deduplicate media domains and fetch dynamically on frontend
2026-06-15 19:02:12 +03:30
NimBold 44c089fadf refactor(aria2): modernize architecture with websockets and state mapping
- Replace polling loops with global tokio_tungstenite WebSocket listener
- Map Download IDs to Aria2 GIDs in AppState
- Route all standard downloads to Aria2 daemon, mapping all config options
- Refactor path resolution into a DRY helper function
- Add missing terabyte multiplier to frontend speed parser
2026-06-15 18:42:03 +03:30
NimBold 12761caf10 feat: enhance locations and download categorization
- Fix system proxy fetching logic in download queue
- Expand recognized file formats across Windows, Mac, and Linux
- Automatically assign matching category folder for new downloads
- Add backend command to instantly create category directories on bulk base path selection
2026-06-15 18:21:44 +03:30
NimBold d15e537916 fix: disable aria2 system proxy fallback and fix download-failed event payload 2026-06-15 17:57:26 +03:30
NimBold 601b04c4cb fix: implement aria2 polling to prevent UI from sticking on queued 2026-06-15 17:51:32 +03:30
NimBold 58f0f0dfb1 fix(security): remediate SSRF, path traversal, and command injection vulnerabilities 2026-06-15 14:38:02 +03:30
NimBold 1b04860c94 fix: resolve P3 audit findings 2026-06-15 14:24:34 +03:30
NimBold 789a8161b2 fix: resolve P2 and P3 audit findings 2026-06-15 14:18:16 +03:30
NimBold 285ec0a81d fix: resolve P0 and P1 audit findings
- Fix state corruption on deleted queues in frontend
- Prevent concurrent dispatch of duplicate downloads
- Fix file paths when deleting native downloads
- Ensure pausing and resuming items persist state to database
- Remove duplicate IPC invocations for speed limits
- Stop PropertiesModal from resetting inputs during download progress
- Switch aria2 secret to use secure v4 UUID
- Fix SSRF vulnerability in fetch_metadata via DNS rebinding block
- Resolve race condition when reading media download logs
- Gracefully handle panics when acquiring prevent sleep lock
- Rate limit native downloads respecting global settings
- Enforce TLS certificate validation in backend requests
2026-06-15 14:10:45 +03:30
NimBold 22d59e3c11 chore: fix cargo warnings and remove unused import 2026-06-15 13:41:53 +03:30
NimBold 503e69a499 fix: remove unused imports causing CI failure 2026-06-15 13:35:58 +03:30
NimBold 9e26be5b2c fix: app nap ipc drop and implement delete modal 2026-06-15 13:29:20 +03:30
NimBold d046c95c28 fix(security): add notification plugin permissions to capabilities 2026-06-15 12:33:14 +03:30
NimBold 6cf360bce0 fix: address codebase review issues
- refactor(backend): use tokio::sync::Mutex for DbState and update commands to async
- fix(backend): remove unconditional post-queue system action in scheduler
- refactor(backend): remove dead WebSocket aria2 progress loop
- fix(backend): use character count for deep link payload length check
- fix(backend): implement dynamic port fallback for extension server
- build(backend): apply macos codesigning step for release builds
- security(backend): add explicitly defined Content-Security-Policy
- fix(frontend): replace pause_download API call with remove_download for file cleanup
- fix(frontend): resolve bug ignoring 0% progress reporting
- fix(frontend): append instead of overwrite deep link URLs when Add Modal is open
- style(frontend): append standard .dark class for dark mode themes
- style(frontend): remove ghost row layout hack from download table
- build: decouple typescript binding generation from build step
2026-06-15 11:53:24 +03:30
NimBold 2ddcd2d99a fix(scheduler): wire post queue action on stop time and remove unused field warning 2026-06-15 11:13:11 +03:30
NimBold e7608dc8b4 fix(ci): support clean checkout builds 2026-06-15 10:43:31 +03:30
NimBold 6593f9e76a refactor(repo): promote tauri app to repository root 2026-06-15 10:33:40 +03:30
NimBold ab7550d39e test(desktop): add headless download integration harness 2026-06-15 00:57:19 +03:30
NimBold 20dcb69ae2 feat(desktop): integrate native deep links and tray lifecycle 2026-06-15 00:50:42 +03:30
NimBold f806fd2cbf feat(desktop): modernize download core and type IPC
Replace shared download locking with a Tokio coordinator actor and async streamed file writes.

Generate TypeScript IPC payload types from Rust and route frontend commands and events through typed wrappers.
2026-06-14 17:38:16 +03:30
NimBold 77fdc30b80 style(ui): polish desktop interface design and interactions
- Replace abrupt sidebar toggle with modern negative-margin swipe animation
- Match stripe styling to original macOS native application with pill-shaped rows and proper faint opacity
- Remove borders between rows and background stripes
- Add dynamic flex-1 CSS clipping trick to perfectly fill viewport with stripes without causing infinite scroll
- Enhance empty state text with physical-looking shortcut keycaps, Lucide icons, OS detection, and accent colors
- Fix background color seam behind sidebar panel in Dark theme
- Restore bright accent colors for active navigation items
2026-06-14 13:11:41 +03:30
NimBold b1b17b7ad8 fix(desktop): secure extension ping handler & fix UI alignment in settings 2026-06-14 12:26:17 +03:30
NimBold 4cfd59b45b fix(settings): wire UI settings to backend and ensure cross-platform binary paths 2026-06-14 12:10:05 +03:30
NimBold 34f51884bb fix(ui): restore theme variants and selector layout 2026-06-14 10:21:19 +03:30
NimBold 58cbd0567d feat(ui): align settings with SwiftUI 2026-06-14 10:04:53 +03:30
NimBold 0e588fa360 fix(engine): restore bundled aria2 daemon 2026-06-14 09:40:57 +03:30
NimBold c82757fc2e feat(ui): match Swift download workspace 2026-06-14 09:40:57 +03:30
NimBold 7ab592b3d4 fix(ui): align macOS window chrome 2026-06-14 09:03:11 +03:30
NimBold 796d00d527 style(ui): redesign app layout to native macOS density and refine visual theme
- Adjusted overall layout scale to perfectly match original native macOS density.

- Refined sidebar into a floating rounded slab with proper padding, border, and toggle button placement.

- Fixed drag region overlap preventing button interactions in the title bar.

- Removed artificial shadow lines and scrollbars from the sidebar.

- Updated Tauri backend configuration, dependencies, and scheduler functions for improved stability.
2026-06-14 08:36:53 +03:30
NimBold 4ec1c4fdf6 fix(core): resolve aria2c daemon leak and tokio io blocking
This commit patches a critical resource leak by managing the aria2c child process using an idiomatic RAII Drop guard inside Tauri's managed state. It also purges the legacy `open` crate from Cargo.toml in favor of tauri-plugin-opener, and fully migrates all asynchronous file system operations to tokio::fs to unblock the Tokio async runtime.
2026-06-13 22:49:37 +03:30
NimBold ae19f69e7b feat(core): phase 2 native modernization
This commit removes the remaining osascript processes and implements native bindings using cocoa and objc crates for macOS dock badges and TCC automation prompts. It also migrates synchronous integration tests to tokio::process::Command.
2026-06-13 22:44:40 +03:30
NimBold d6fa8b6865 feat(core): modernize backend with rusqlite and tokio scheduler
This commit migrates Firelink to use native Rust implementations for state persistence (SQLite) and background scheduling (tokio::spawn + chrono). React's interval pollers are completely removed.
2026-06-13 22:40:19 +03:30
NimBold a2b64aa6d1 refactor(frontend): replace manual react queue state throttling with native tokio concurrency 2026-06-13 22:13:23 +03:30
NimBold 240f62bc6e refactor(backend): replace legacy manual proxy scraping with native sysproxy crate 2026-06-13 22:07:35 +03:30
NimBold 79cad4213b refactor(backend): replace legacy manual queue with native tokio semaphore concurrency 2026-06-13 22:03:34 +03:30
NimBold 6284a0a8d6 refactor(backend): modernize extension server to use axum web framework 2026-06-13 21:55:11 +03:30
NimBold f0859cb4cd refactor(backend): modernize download architecture to use global aria2c daemon with rpc 2026-06-13 21:51:33 +03:30
NimBold 19e366089e feat(desktop): modernize dark theme and update flow 2026-06-13 18:08:42 +03:30
NimBold ba37d78cba style(ui): overhaul background and themes for modern minimalist aesthetics 2026-06-13 14:45:59 +03:30
NimBold f6c7a0b8d2 feat(backend): implement cross-platform proxy resolution
- Update get_system_proxy to support Windows (via registry) and Linux (via env vars and gsettings) without external dependencies
2026-06-13 12:41:57 +03:30
NimBold 88b97862c3 feat(backend): add final phase 5 parity integration handlers
- Add get_system_proxy to resolve macOS system proxy via scutil
- Port FileClassifier to get_file_category mapping extensions accurately
- Implement check_for_updates fetching GitHub API releases with semver checking
2026-06-13 12:39:41 +03:30
NimBold c79dcfa74d feat(backend): implement download queue and resolve legacy parity gaps
- Add SSRF protection and migrate sensitive credentials to tempfiles
- Refactor aria2c process management to use ephemeral JSON-RPC
- Implement sysinfo recursive process termination for graceful stopping
- Add DownloadManager and MPSC queue for strict concurrency limits
- Configure yt-dlp to delegate download chunking to aria2c
2026-06-13 12:19:05 +03:30
NimBold f8c89e2f88 feat(desktop): modernize browser extension integration 2026-06-13 12:00:28 +03:30
NimBold 9119cafdcd feat(desktop): implement advanced transfer, tray icon, media cookies, and extension tcp server 2026-06-13 11:24:41 +03:30
NimBold 6de0cd268c feat: implement duplicate download resolution 2026-06-13 00:09:51 +03:30
NimBold dc9289fece feat: refine sidebar UI and update download categories
- Restructured layout so the sidebar acts as an edge-to-edge curved layer with a shadow
- Replaced old download categories with Musics, Movies, Compressed, Documents, Pictures, Applications, and Other
- Updated Settings > Locations tab to handle the new categories
- Added extensive predefined file extensions for smart auto-categorization of downloads
2026-06-13 00:02:07 +03:30
NimBold 610eda203e feat: implement native OS keyring for site logins
- Replaced plaintext local storage password fields with keyring v3 OS native integration.
- Tauri backend securely sets, gets, and deletes credentials via system keychains.
- Dynamic password injection occurs seamlessly on download start and metadata parsing.
2026-06-12 23:27:57 +03:30
NimBold 8cd6c1972d feat: implement site logins, queues parity, and engine settings updates
- Implemented Site Logins parity by integrating auth credentials to Rust backend (Aria2 and yt-dlp) and fetching metadata.
- Implemented Queues functionality including context menu actions (start, pause, rename, delete).
- Updated Engine settings UI to include Deno and render clean version output without extra trademark info.
2026-06-12 23:19:21 +03:30
NimBold f378fc0d8d feat(desktop): implement scheduler and speed limiter 2026-06-12 21:17:34 +03:30
NimBold 4c21a36083 feat(desktop): align UI with SwiftUI 2026-06-12 20:59:49 +03:30
NimBold a5022fbf8a feat(desktop): modernize download management 2026-06-12 20:33:17 +03:30
NimBold 3074438147 chore: remove tauri build workflow 2026-06-12 19:13:57 +03:30
NimBold c3ad5c3c75 feat(desktop): implement media extraction engine, sys integrations, and UI polish
- Implement get_free_space and set_prevent_sleep in Rust backend
- Add robust media extraction UI and format parser in AddDownloadsModal.tsx
- Wire application settings to Zustand store
- Add global user-select: none for native text feel
- Fix window dragging in Sidebar.tsx
- Add GitHub Actions CI workflow for Tauri cross-platform build
2026-06-12 19:12:34 +03:30
Nima 90b5397a46 Rename project to 'Firelink Reborn' and update description 2026-06-12 16:32:09 +03:30
NimBold 59a1fa5cd5 docs: add cross-platform evolution progress section to README 2026-06-12 16:28:00 +03:30
NimBold d6690c7769 feat: initial tauri desktop app rewrite with ui and settings wired 2026-06-12 16:21:16 +03:30
NimBold e6ab997b00 chore: clean up scratch files 2026-06-11 19:05:01 +03:30
NimBold 32c6dcc2d6 chore(release): prepare 0.7.3 2026-06-11 18:26:41 +03:30
NimBold 79f4c8f0e9 fix: pad overall progress total for first track to prevent 100% snapback
- When yt-dlp underestimates the total bytes (which often excludes audio), the first track download would prematurely hit 100% and then snap back to a lower percentage when the second track started.
- Added a 20% padding to the overall expected size during the first file download to leave room for potential secondary tracks.
- Also added a safeguard to ensure the visually reported overall progress fraction never decreases during a download.
2026-06-11 14:38:29 +03:30
NimBold 2ab3325f30 feat: accumulate track sizes to present a unified overall progress bar
- Removed track-specific status messages in favor of a simpler generic message.
- Passed initial `sizeBytes` estimation into the progress parser.
- YTDLPProgressParser now accumulates sizes across multiple sequential downloads (like video then audio).
- The progress fraction and displayed size are correctly computed relative to the overall expected size, preventing the progress bar from resetting to 0% mid-download.
2026-06-11 14:31:29 +03:30
NimBold 4856b3f3b2 fix: emit distinct status messages for individual tracks during download
- yt-dlp downloads video and audio streams sequentially, causing the progress bar to reset when switching streams.
- The status message was previously hardcoded to 'Downloading Media' and overwrote track change indicators.
- Now, when yt-dlp starts a new stream, the status message clearly reads 'Downloading Video Track', 'Downloading Audio Track', etc., explaining the progress reset to the user.
2026-06-11 14:19:59 +03:30
NimBold 99500254a1 feat: enhance speed and ETA display logic during pause and drop
- Keep the ETA visible even when the download is paused so the user knows how much time is left.
- When the connection drops, show the last known speed for 3 seconds. If the connection isn't restored, show '0 KiB/s' instead of 'Unknown'.
- Clear speed and ETA fields when a download is completed, queued, or canceled.
2026-06-11 14:12:16 +03:30
NimBold aa9b3aad2a fix: resolve unknown speed flickering and ultra-wide high-resolution detection
- Ignore 'Unknown' or '-' speed/ETA updates if a valid speed is already known to prevent UI flickering.
- Improve high-resolution video detection by checking 'format_note', parsing the 'resolution' string, and using more relaxed height tolerances to correctly identify ultra-wide formats (e.g., 2160p with a 1920 height).
2026-06-11 14:04:57 +03:30
NimBold 1c8f189b54 feat(ui): add Deno to about credits and engines list 2026-06-11 13:39:53 +03:30
NimBold 11b030f66a chore(release): prepare 0.7.2 2026-06-11 13:30:06 +03:30
NimBold e165366820 fix(media): stabilize bundled download engines 2026-06-11 13:15:19 +03:30
NimBold 7b3d620efa fix(build): revert to one-file yt-dlp binary to bypass macOS Gatekeeper Library Validation
Gatekeeper enforces Library Validation and prevents `dlopen` of quarantined ad-hoc signed dynamic libraries on ARM64. Because the `_internal` folder was shipped inside the DMG, the user's system flagged it with the com.apple.quarantine attribute, causing the Python shared library to fail to load inside `yt-dlp`.

By reverting back to the single-file PyInstaller binary (which is now properly code-signable without corruption on macOS), PyInstaller extracts the `_internal` folder at runtime to `/var/folders/...`. These extracted files do not inherit the quarantine flag, allowing AMFI to successfully load them and completely bypassing the dlopen system policy rejection.
2026-06-11 05:20:07 +03:30
NimBold db293a2613 chore: re-release 0.7.1 2026-06-11 05:06:39 +03:30
NimBold 49a4358212 fix: increase yt-dlp timeout for YouTube JS PoW
The latest yt-dlp release (2026.06.09) requires heavy JavaScript execution to solve YouTube's PoW challenges. This takes ~35s on most machines, which exceeded the hardcoded 30s timeout, causing 'Failed to load options'. Increased the timeout to 120s and improved the UI to show actual errors instead of hardcoding 'Failed to load options'.
2026-06-11 05:00:39 +03:30
NimBold a916ff3f92 fix: prevent youtube metadata extraction timeout
This fixes an issue where node child processes spawned by yt-dlp would keep standard output pipes open, preventing the EOF event from firing and causing the application to hang until a 30s timeout occurred.
2026-06-11 04:24:40 +03:30
NimBold b4185306ce chore(release): prepare 0.7.1 2026-06-11 04:03:03 +03:30
NimBold 39355f8d2d fix: resolve correct filename for auto-captured downloads 2026-06-11 03:32:14 +03:30
NimBold 55c6dbf41b docs: prepare v0.7.1 release 2026-06-11 03:17:34 +03:30
NimBold ae3476293e fix(security): address v0.7.0 security audit vulnerabilities
- fix(media): use temp config file for yt-dlp credentials
- fix(rpc): exclude rpcSecret and rpcPort from serialization
- fix(ssrf): validate private hosts for all fetches
- fix(uri): add rate limiting to firelink scheme
- fix(auth): implement HMAC-SHA256 request signing for LocalExtensionServer
- fix(fs): prevent path traversal during file deletion
- fix(fs): apply strict 0o600 permissions to aria2.conf
- fix(crypto): remove insecure UUID fallback for pairing token
- fix(http): strip CRLF characters from custom headers
- fix(network): use POSIX socket to eliminate port-finding race window
- fix(ui): limit text input lengths in Add Downloads view
- fix(concurrency): implement thread-safe temp directory cleanup
- feat(ui): modernize sidebar and download table components
2026-06-11 03:13:47 +03:30
NimBold facab68237 chore(release): bump version to 0.7.0 and update changelog 2026-06-11 00:04:51 +03:30
NimBold 3a7c594d7c ci: restore macOS SDK 26 check per project requirements
- Ensure SDK version is at least 26 to avoid UI issues with older SDKs
2026-06-10 23:45:46 +03:30
NimBold 0ace3e4111 fix: resolve additional bugs identified in code review
- core: remove DispatchGroup from MediaExtractionEngine to fix hanging on yt-dlp launch failure
- core: clear readabilityHandlers in Aria2DownloadEngine if process fails to launch
- core: fix TOCTOU type warning for findFreePort
- build: fix inverted logic in Mach-O validation script
- ci: fix hardcoded macOS SDK version check in release workflow
- ci: prevent workflow_dispatch from failing on verify-tag
2026-06-10 23:43:10 +03:30
NimBold 7e2e0eeb77 fix: resolve critical download bugs and configuration issues
- proxy: Prevent SOCKS5 proxy type from being silently converted to HTTP.
- core: Remove DispatchGroup from MediaDownloadEngine to fix a hang caused by unclosed output pipes when child processes inherit file descriptors.
- settings: Fix keychain primer logic to prevent wiping extension credentials on debug builds.
- ui: Resolve a batch rename collision in AddDownloadsView by properly checking new filenames against the current batch sequence.
2026-06-10 23:36:16 +03:30
NimBold 2a5452b7c6 fix: resolve multiple critical bugs identified in code review
- fix(engine): resolve aria2 port TOCTOU race and secure conf file

- fix(server): eliminate main queue deadlock risk in local extension server

- fix(controller): ensure synchronous state save on app termination

- fix(ui): fix table skipping new items by diffing identity instead of count

- fix(scheduler): use 2-letter abbreviations to resolve day name collisions

- fix(settings): purge keychain orphans when primer access is denied

- fix(metadata): prevent synchronous DNS resolution from blocking async pool

- refactor: clean up dead code and unused variables
2026-06-10 19:25:06 +03:30
NimBold fdbacb8a7f feat(ui): overhaul context menu and toolbar for modern hig
- Flattened playback controls in context menu
- Added 'Copy File Path' context action for completed downloads
- Reorganized context menu to prioritize Open/Finder actions and pin Properties to bottom
- Removed ControlGroup and explicit placements in toolbar for clean borderless style
- Renamed Start/Stop toolbar items to Resume/Pause with proper SF Symbols
- Added informative tooltips to toolbar actions
2026-06-10 18:49:55 +03:30
NimBold ffba883961 feat(ui): modernize download list and sidebar styling
- Reintroduce Speed and ETA columns with dedicated rendering
- Restructure Status column to align progress bar natively
- Enhance typography with rounded fonts and monospaced digits
- Move Settings shortcut back into sidebar for faster access
- Optimize download list sorting performance by removing dynamic sortedItems
2026-06-10 17:38:16 +03:30
NimBold 3b695169d6 feat(ui): add stop time to scheduler and unit picker to speed limiter
This commit overhauls the UI of the Scheduler and Speed Limiter tools using modern GroupBox designs. It also adds a highly-requested Stop Time feature to the scheduler to pause queues, and introduces a dynamic unit selection (KB/s, MB/s) to the Speed Limiter.
2026-06-10 12:40:31 +03:30
NimBold a5828ec586 chore: remove temporary test file 2026-06-10 12:28:07 +03:30
NimBold efb4446739 fix(security): track executable modification date for primer detection
Includes the executable's modification date in the version hash to ensure ad-hoc and local recompilations correctly trigger the keychain primer UI, preventing unexpected system prompts on boot.
2026-06-10 12:27:48 +03:30
NimBold fd90055ccd fix(security): prevent eager keychain password fetch on startup
Defers fetching site login passwords until they are strictly needed (when a download starts or when viewing properties) to prevent the OS from showing the keychain prompt before the in-app primer UI.
2026-06-10 12:03:11 +03:30
NimBold 14924cf254 feat(settings): improve UI for Site Logins, Engine, and About panes
- Refactor Site Logins pane to use a data Table with search and a dedicated editor sheet
- Add 'Reveal in Finder' buttons for bundled engines in Engine pane
- Fix alignment of 'Bundled' labels and 'Browser Cookies' picker in Engine pane
- Remove build number from the version display in About pane
2026-06-10 06:21:35 +03:30
NimBold 85f55db66f feat(settings): add ask where to save option and overhaul Locations and Downloads UI 2026-06-10 05:43:13 +03:30
NimBold cc89f48221 feat: overhaul settings panes and add global configuration options
- Overhauled Downloads, Look & Feel, and Network settings panes to match modern UI guidelines.

- Implemented global User-Agent spoofing for all download engines to bypass restrictions.

- Added new configuration options: dock badge toggle, notification controls, completion sound, auto-retries, and "ask where to save" prompt.

- Integrated NSApp.dockTile.badgeLabel for tracking active downloads in the macOS Dock.

- Ensured newly added options correctly persist through AppSettings.
2026-06-10 05:26:45 +03:30
NimBold 22cc11f751 perf(media): optimize yt-dlp extraction speed and fix proxy propagation
- Add '--extractor-args youtube:player_client=ios,web' to bypass heavy JS challenge solving
- Add '--compat-options no-youtube-unavailable-videos' to skip blocked videos
- Fix missing proxy configuration in MediaExtractionEngine that caused timeouts
- Add '--live-from-start' for yt-dlp to handle live streams correctly
2026-06-10 03:12:42 +03:30
NimBold 88e99a8528 style(ui): update integration step counter visibility and add up-to-date status icon 2026-06-10 03:12:42 +03:30
NimBold 07e4a586a1 chore(release): bump version to 0.6.6 and update changelog 2026-06-10 02:17:43 +03:30
NimBold f93f0daef5 feat: show media format section with loading state while fetching 2026-06-10 01:53:58 +03:30
NimBold 3b4402def3 feat: cascading media format pickers 2026-06-10 01:47:53 +03:30
NimBold 62297f61ac fix: keychain prompts, AddDownloadsView dynamic sizing, and options format 2026-06-10 01:38:55 +03:30
NimBold 17dc080a20 fix: suppress keychain UI prompt, improve media format selector, overhaul update checker UI 2026-06-10 01:21:49 +03:30
NimBold 709f189f27 refactor: improve performance, UI components, and stability
- Add timeout to LocalExtensionServer to mitigate Slowloris
- Optimize media output resolution by checking explicit extensions
- Throttle progress updates to 4Hz
- Debounce download completion notifications
- Use native ProgressView in DownloadTable
- Group DownloadTable context menu items
- Fix drag & drop overwriting issue
- Fix focus rings on hidden keyboard shortcuts
2026-06-10 01:08:49 +03:30
NimBold ce9fb4a072 feat(security): implement keychain permission priming
Adds a modal sheet to explain why keychain access is needed before prompting the user after an app update.
2026-06-10 00:50:21 +03:30
NimBold b7380bea35 feat(settings): redesign integration pane and fix toast notification bugs 2026-06-09 13:40:07 +03:30
NimBold 0ec2213a4c feat: defer keychain access until explicitly granted 2026-06-09 12:47:49 +03:30
nimbold 17aa34b95f fix: resolve ambiguous bundle format error in yt-dlp codesign 2026-06-09 09:19:55 +03:30
nimbold a2be2012f8 docs: prepare release 0.6.4 2026-06-09 09:00:54 +03:30
nimbold 7e185d06d1 fix: correctly implement AppVersion equality and fix yt-dlp binary signing 2026-06-09 08:43:18 +03:30
nimbold 683eb45d0e feat(updates): replace Sparkle with GitHub release checks 2026-06-09 08:27:13 +03:30
github-actions[bot] 4b3c80cda9 chore(release): update appcast for 0.6.3 2026-06-09 03:29:56 +00:00
nimbold db2b1f6516 chore(release): bump version to 0.6.3 and update changelog 2026-06-09 06:58:22 +03:30
nimbold 336a50ed6c fix(integration): resolve CORS preflight bug and secure pairing token storage
- Allow GET method in LocalExtensionServer CORS preflight response
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore
- Upgrade token generation to use SecRandomCopyBytes
- Update IntegrationSettingsPane UI to be browser-agnostic with a Regenerate token action
2026-06-09 06:48:06 +03:30
nimbold f887c62195 feat: enforce dynamic browser extension pairing security
- Generate random pairing token in AppSettings

- Update LocalExtensionServer to strict check token on non-OPTIONS endpoints

- Add GET /ping endpoint for extension connection verification

- Redesign Integration settings pane with modern step-by-step UI and Toast notifications

- Harden media extraction and aria2 engine paths

- Update IP resolution with getaddrinfo
2026-06-09 06:30:39 +03:30
nimbold 109059e10c fix: resolve pipe race condition and optimize yt-dlp arguments
- Used DispatchGroup to fix stdout/stderr truncation race condition
- Added --force-ipv4 to prevent metadata fetching hang
- Removed hardcoded youtube:player_client to fix throttling issues
- Combined --js-runtimes flags as a comma-separated list
2026-06-08 21:42:14 +03:30
nimbold 81b3e0877b docs: add missing credits for yt-dlp, ffmpeg, and sparkle in README and About page 2026-06-08 21:24:56 +03:30
nimbold 6b2901bd50 feat(updater): upgrade sparkle to 2.9.3 and enhance integration
- Update Sparkle dependency to 2.9.3

- Replace brittle HTML parsing with native NSAttributedString parsing

- Fix dangling updater callbacks by handling view disappear events

- Add 'Remind Me Later' button in update prompt

- Add toggle for automatic background update checks
2026-06-08 21:18:36 +03:30
nimbold 9261385d59 fix(ui): clear add downloads view state on disappear to prevent old link auto-pasting 2026-06-08 21:18:36 +03:30
github-actions[bot] ef0ad42df3 chore(release): update appcast for 0.6.2 2026-06-08 13:15:06 +00:00
nimbold b71a58ae06 fix: resolve duplicate resolution window not closing
- Dispatch executeAddDownloads asynchronously after closing the sheet to avoid swallowing dismiss()
2026-06-08 16:42:11 +03:30
github-actions[bot] 6dcb0e33e8 chore(release): update appcast for 0.6.2 2026-06-08 12:54:49 +00:00
nimbold 34847b6234 fix: resolve duplicate resolution shortcuts, update freeze, and process id flashing
- Fix keyboard shortcut collision causing main window to intercept Enter/Escape keys when duplicate resolution sheet is open
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously
- Improve Sparkle changelog formatting by converting HTML tags to clean Markdown
- Change internal Process ID status message to Starting... when queueing downloads
- Fix EXC_BREAKPOINT crash on app launch by prioritizing Bundle.main over Bundle.module
- Update CHANGELOG.md for 0.6.2 release
2026-06-08 16:22:05 +03:30
github-actions[bot] ac7963e353 chore(release): update appcast for 0.6.1 2026-06-08 12:05:49 +00:00
nimbold a4936fa141 ci: automate Sparkle appcast updates 2026-06-08 15:33:43 +03:30
nimbold 9af9edbfd4 chore(release): prepare 0.6.1 2026-06-08 15:08:16 +03:30
nimbold 9f8e01839f fix: package bundled media engines 2026-06-08 14:57:07 +03:30
nimbold c1d97f31fb ci: automate fetching yt-dlp and ffmpeg binaries during release build 2026-06-08 14:25:18 +03:30
nimbold 02abef1443 feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle
- Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources.
- Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift.
- Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners.
- Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window.
- Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling.
2026-06-08 14:16:38 +03:30
nimbold b58df6b660 chore(release): prepare 0.6.0 release 2026-06-08 07:17:17 +03:30
nimbold 5ed9b42c62 fix: cap max height of download links text editor 2026-06-08 06:54:25 +03:30
nimbold 72ef99d211 refactor: add backward compatibility support for extension tokens 2026-06-08 05:46:24 +03:30
nimbold baddf0da6d feat: enhance mixed media support and add duplicate resolution 2026-06-08 05:37:27 +03:30
nimbold 34ca209e09 chore: update Firelink-Extension submodule to latest 2026-06-08 05:13:08 +03:30
nimbold 410059e3de feat(ui): redesign settings panes and enhance update flows
- Implemented `InlineUpdateUserDriver` to handle inline update checks inside Settings panes.
- Redesigned `AboutSettingsPane` with a modern inline update UI, release notes DisclosureGroup, and clear visual feedback states.
- Rebuilt `EngineSettingsPane` using native macOS layouts, removing custom stacks for better native alignment.
- Relocated browser cookie selection and media engine updates in `EngineSettingsPane` for better contextual grouping.
2026-06-08 02:18:56 +03:30
nimbold 4e48f1e42c fix: harden media download flow 2026-06-08 01:06:00 +03:30
nimbold 2e5b4ae7c3 fix: pass extractor arguments to yt-dlp download process
- Fix: Append --extractor-args youtube:player_client=ios,tv to the MediaDownloadEngine to prevent yt-dlp from falling back to web client and failing with bot detection during download.
2026-06-08 00:38:32 +03:30
nimbold 2d0c05e4e4 fix: restore single click selection by removing simultaneousGesture 2026-06-08 00:24:12 +03:30
nimbold 8d5eb74fba fix: restore Download Properties routing and gestures
- Fix: Revert WindowGroup to expect UUID and update ALL openWindow calls to explicitly use id: 'download-properties' to prevent routing poisoning
- Fix: Wrap the File Name cell in doubleClickableCell using .simultaneousGesture so double clicks trigger the action without eating single click row selection
2026-06-08 00:17:32 +03:30
nimbold 01ba9c4c9b fix: pass UUID as String for download properties WindowGroup to prevent routing failures 2026-06-08 00:11:23 +03:30
nimbold bd6aa867c7 fix: size column fallback and table row interactions
- Fix: Size column falls back to 'bytesText' if sizeBytes is unknown
- Fix: Prevent filename text from intercepting row selection taps
- Fix: Use explicit window ID to ensure download properties opens reliably on double click
2026-06-07 23:59:03 +03:30
nimbold fbae92aae9 fix: media download UX and table row selection
- Fix: Remove aria2c from yt-dlp to allow native concurrent downloader to print progress
- Fix: Use generic 'Fetching media data...' message instead of hardcoding YouTube
- Fix: Remove onTapGesture from Table cells to restore native macOS SwiftUI row selection and use simultaneousGesture on the Table itself for double clicks
2026-06-07 23:39:18 +03:30
nimbold 49003665ef feat: improve yt-dlp fetching speed and redesign media detection UI
- Bypass YouTube web bot detection by using iOS/Android player clients in yt-dlp
- Redesign media extraction UX to use a clean inline layout instead of replacing the entire Add window
- Use dropdown Pickers for Type, Quality, and Format selection
- Remove the obsolete MediaInspectorCard
2026-06-07 22:53:38 +03:30
nimbold 1a2c59d243 fix: media downloads connections, progress parsing, file size, and selection highlight
- Removed hardcoded 1 connection limit for media downloads
- Added support for aria2c progress parsing in media downloads
- Fixed missing file size after download completion by reading from disk
- Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture
2026-06-07 22:28:23 +03:30
nimbold c139ac50f2 fix(media): stabilize yt-dlp metadata and add-on updates 2026-06-07 12:48:00 +03:30
nimbold a900d97a5c feat: enhance media engine settings with cookie extraction and update checks
- Added `mediaCookieSource` to `AppSettings` to let users choose the browser for cookie extraction.
- Refactored `EngineSettingsPane` to display `yt-dlp` and `ffmpeg` statuses.
- Added a manual 'Check for Updates' button with UI loading feedback.
- Passed `--cookies-from-browser` to both metadata extraction and download engines if configured.
- Added `--ignore-no-formats-error` to `yt-dlp` metadata extraction to prevent crashes on restricted videos.
2026-06-07 12:15:50 +03:30
nimbold 8831318839 feat: modernize Integration settings UI and add official install button 2026-06-07 10:43:10 +03:30
nimbold 94180ddff9 docs: add Install on Firefox badge to README 2026-06-07 10:36:09 +03:30
nimbold e91414c478 docs: update README with new media features, credits, and live browser integration 2026-06-07 10:32:23 +03:30
nimbold eaea5bd908 feat: integrate yt-dlp to DownloadController and add global queue support 2026-06-07 10:23:55 +03:30
nimbold af05180ee6 feat: implement smart progressive disclosure UI and media extraction engine 2026-06-07 10:05:54 +03:30
nimbold 0799b08000 feat: implement gatekeeper architecture for on-demand media engine binaries 2026-06-07 09:57:27 +03:30
nimbold bf645898a9 chore(assets): update app icons and icon generation scripts 2026-06-07 09:08:52 +03:30
nimbold 6552bde261 fix(security): block automatic metadata fetch for private IP addresses 2026-06-07 09:08:11 +03:30
nimbold 125a8b9e6d fix: actually update extension icons with the 1.9x gradient icon (submodule bump) 2026-06-07 08:37:45 +03:30
nimbold 2589d878ea style: tone down icon gradient to 1.9x for modern subtle look 2026-06-07 08:11:26 +03:30
nimbold 2f7a1f52e7 style: increase gradient contrast for stronger lighting effect 2026-06-07 08:06:43 +03:30
nimbold 30d4d741af style: switch to lighter gradient (+1 to 0) 2026-06-07 08:01:04 +03:30
nimbold f98e6b8246 style: revert to plain mode without gradient 2026-06-06 16:13:38 +03:30
nimbold 387ada1afc fix: correctly remove black padding and mask corners 2026-06-06 16:09:27 +03:30
nimbold 37be7b14f0 style: apply premium gradient to the correct new icon 2026-06-06 16:04:26 +03:30
nimbold 1e064502db style: apply premium gradient to app icon 2026-06-06 16:00:38 +03:30
nimbold 09cea67041 fix(updates): harden Sparkle release metadata 2026-06-06 15:54:44 +03:30
nimbold 6a20250d22 style: remove redundant version string from up-to-date message 2026-06-06 15:31:05 +03:30
nimbold 99b8f66bcf fix: correct Sparkle SUNoUpdateError code to prevent false error messages 2026-06-06 15:28:53 +03:30
nimbold 890b6935b9 feat: inline Sparkle update checks to avoid unnecessary modals 2026-06-06 15:22:58 +03:30
nimbold b1802c718a chore: update appcast.xml with valid signature for new framework-embedded dmg 2026-06-06 15:12:48 +03:30
nimbold d0021e16f5 fix: embed Sparkle dynamic frameworks into app bundle to resolve crash 2026-06-06 15:10:37 +03:30
nimbold a42c7d9f74 chore: update appcast.xml for v0.5.7 Sparkle release 2026-06-06 15:07:29 +03:30
nimbold a375b2e685 feat: integrate Sparkle 2 for secure in-app updates 2026-06-06 15:01:40 +03:30
nimbold 301e337c2e fix: apply proper rounded corner mask to icon 2026-06-06 14:16:53 +03:30
nimbold 986687631d style: remove black corners from icon 2026-06-06 14:14:28 +03:30
nimbold 0714f6e707 style: update app icon 2026-06-06 14:11:21 +03:30
nimbold e25022fbe5 feat(branding): update app icon icns 2026-06-06 08:23:08 +03:30
nimbold 48dfd7c6fa feat(branding): update high-resolution app icon png 2026-06-06 08:21:55 +03:30
nimbold 5e442d1691 feat(branding): update monochrome menu bar icon 2026-06-06 08:21:26 +03:30
nimbold d1fa6dd910 feat(branding): update extension submodule reference 2026-06-06 08:21:08 +03:30
nimbold 27b9004d99 fix: repair app icons 2026-06-06 07:19:14 +03:30
nimbold ac8764bb78 fix: clear completed speeds and icon frame 2026-06-05 20:48:26 +03:30
nimbold 41535eca59 feat: update app icon branding 2026-06-05 20:38:55 +03:30
nimbold 2fb851b138 fix: correct download list row selection and remove build number from version string 2026-06-05 19:46:12 +03:30
nimbold 47eb226160 ci: fix github release title using commit subject instead of tag 2026-06-05 14:05:43 +03:30
nimbold a2135bb539 feat: redesign About pane and resolve download properties UI bugs
- Added transparent GitHub icon to About pane.

- Compacted About pane layout and consolidated footer.

- Fixed build script to correctly copy bundled image assets.

- Resolved progress bar state issues in Download Properties.

- Fixed text overlap bugs in Settings.
2026-06-05 13:56:02 +03:30
nimbold c18aa6fdaf fix: complete aria2 downloads via rpc 2026-06-05 12:38:14 +03:30
nimbold 4a9d4cab2f fix(settings): align preferences with runtime behavior 2026-06-05 11:41:56 +03:30
nimbold 2b820ec802 feat: modernize settings and add SOCKS5 support
- Split monolithic SettingsView.swift into modular Settings panes
- Add SOCKS5 proxy support to proxy configuration
- Fix ListRowDensity application in DownloadTable
- Improve discoverability of global speed limit by adding it to Downloads pane
2026-06-05 03:02:29 +03:30
nimbold 544db2aab6 docs: add 0.5.4 release notes 2026-06-05 03:02:29 +03:30
Nima c3bc35f669 Merge pull request #2 from nimbold/codex/fix-download-workflows
fix: harden download workflows
2026-06-04 15:45:23 +03:30
nimbold 0cbb982f5b fix: harden download workflows 2026-06-04 15:41:38 +03:30
nimbold 0040c23050 style: refine UI layouts, toolbars, and chunk map aesthetics
- Make global and contextual Start/Stop toolbar buttons mutually exclusive in main window
- Relocate Add Downloads primary action buttons from top toolbar to standard bottom Action Bar
- Expand Download Properties window width for better readability and condense font sizes
- Wrap Advanced Transfer settings in a native DisclosureGroup to reduce scrolling
- Redesign Chunk Map with rounded corners, larger boxes, and system accent colors
2026-06-04 14:29:13 +03:30
nimbold 062c4aec61 docs: release version 0.5.3
- Update CHANGELOG.md with 0.5.3 patch features and fixes
- Document Chunk Map and Drag-and-Drop in README.md
2026-06-04 13:45:10 +03:30
nimbold 8e32762217 feat: implement chunk map inspector, drag-and-drop, and UI refinements
- Add ChunkMapView to visualize active segmented downloads using aria2 RPC
- Add drag-and-drop support for URLs and text files in main window and dock
- Refactor settings panes to use native macOS HIG Form and .toolbar layouts
- Fix DNS rebinding vulnerability by validating Host header in local server
- Fix memory leak in console buffer by capping to 512KB
- Fix UI hang during aria2c version check by running it in detached task
2026-06-04 13:34:28 +03:30
Nima fec18deb1a Update README to mention Firefox extension only 2026-06-04 06:30:09 +03:30
nimbold bf0d1f6ce1 docs: update app screenshots 2026-06-04 06:27:45 +03:30
nimbold d9c7da23e9 fix: resolve settings tab layout and hit-testing issues 2026-06-04 06:03:30 +03:30
nimbold 2f5e593d0b feat: add icons to settings capsule tabs 2026-06-04 05:47:55 +03:30
nimbold fd349bfd3b fix: awk syntax error for parsing changelog 2026-06-04 05:39:40 +03:30
nimbold f35e981b0a fix: clone submodules in release action 2026-06-04 05:37:23 +03:30
nimbold 2b520f3e97 chore: prepare for v0.5.0 release 2026-06-04 05:34:48 +03:30
nimbold 7802b612a4 feat: integrate settings and speed limiter into main window
- Refactor SettingsView to act as a flat detail pane instead of a nested sidebar or macOS native window
- Add custom horizontal tab bar for switching between setting categories
- Create SpeedLimiterView and add it as a primary tool in the sidebar
- Add state retention using AppStorage for settings tab selection and speed limits
- Fix EXC_BAD_ACCESS in DownloadTable by using sortableSize instead of debugDescription
2026-06-04 05:29:15 +03:30
nimbold 28bb90e8c2 feat: add stop downloads control 2026-06-04 04:44:04 +03:30
nimbold 86d70a9ca7 Refine add window and font sizing 2026-06-04 04:36:06 +03:30
nimbold b440b567f1 docs(readme): compact screenshots and fix light theme 2026-06-04 04:18:40 +03:30
nimbold 692a48b4a8 docs(readme): refresh application screenshots 2026-06-04 04:09:12 +03:30
nimbold a90d190df9 docs(readme): add application screenshots for dark and light themes 2026-06-04 03:47:47 +03:30
nimbold b1b3f42ead feat(ui): simplify temporary Firefox extension install 2026-06-04 03:30:44 +03:30
nimbold 91cd65e1d7 fix(security): harden extension bridge and persistence 2026-06-04 03:19:57 +03:30
nimbold e948590335 fix(ui): route to about:debugging without fragment hash and update instructions 2026-06-04 02:45:02 +03:30
nimbold 643f87d366 fix(ui): use Process to invoke open instead of NSWorkspace to avoid url encoding the fragment hash 2026-06-04 02:21:41 +03:30
nimbold 0f522fa794 fix(ui): explicitly route about:debugging to firefox to prevent LaunchServices error 2026-06-04 00:24:32 +03:30
nimbold 604987a360 feat(ui): update integration page with extension install instructions 2026-06-04 00:01:24 +03:30
nimbold e0a1db690f build: migrate extension to git submodule 2026-06-03 23:50:26 +03:30
nimbold 5dc8d66f3c refactor(extension): remove xpi generation and installation
- Switch to copying Firefox extension directory into app bundle Resources
- Remove 'Install to Firefox' button and multi-browser selection logic
- Update Integrations tab to instruct users to load via about:debugging
- Map 'Show Extension Folder in Finder' to the bundled extension source directory
2026-06-03 22:53:54 +03:30
nimbold 0ddea52db8 feat(extension): add Firefox browser extension and native integration
- Implement Firefox extension to intercept downloads and scrape selected links via context menus
- Create LocalExtensionServer over TCP (localhost:6412) to receive links from the extension
- Automatically package extension to firelink.xpi in create_app_bundle.sh
- Add Integrations tab in SettingsView with one-click installer for Firefox variants
- Modify ContentView and TrayMenuView to automatically open the Add Downloads window on extension trigger
2026-06-03 22:15:12 +03:30
nimbold 93e6e11939 docs: update changelog for 0.4.3 release 2026-06-03 07:07:28 +03:30
nimbold 6750cce6aa perf: reduce main thread CPU usage and SSD wear from frequent JSON serialization 2026-06-03 07:03:18 +03:30
nimbold 1d22b16119 style: refine about page UI and simplify delete confirmation dialog 2026-06-03 06:48:53 +03:30
nimbold deb106f7af docs: update changelog and roadmap for 0.4.2 release 2026-06-03 06:30:28 +03:30
nimbold 1093d73b0e feat: add monochrome tray icon and download table enhancements
- Added proper monochrome template tray icon loaded explicitly with precise dimensions
- Added redownload functionality for completed or failed items
- Added double-click to open completed files directly from the table
- Added 'Copy Address' context menu action
- Improved context menu organization and conditionally displayed actions
2026-06-03 06:23:03 +03:30
nimbold 3d117fd6b0 chore: prepare release 0.4.1 2026-06-03 05:36:57 +03:30
nimbold 21b30a9369 feat: add site logins integration to Add Downloads window 2026-06-03 05:32:39 +03:30
nimbold c4f02ff78f fix: remove unused showMenuBarIcon from AppSettings and conditionally apply theme backgrounds 2026-06-03 05:17:25 +03:30
nimbold f6d34caaf1 style: use visual command icon instead of text for paste hint 2026-06-03 05:11:18 +03:30
nimbold 7e4915f31d fix: resolve SwiftUI infinite layout freeze caused by MenuBarExtra isInserted binding to StateObject by using AppStorage 2026-06-03 05:07:55 +03:30
nimbold ff03a4cda5 feat: add Font Size, List Row Density, and Menu Bar Icon settings 2026-06-03 05:03:31 +03:30
nimbold 1f82d342e3 fix: resolve Light/System theme bug by using NSApp.appearance and replace Modern Dark with standard Dark 2026-06-03 04:54:31 +03:30
nimbold c653f7e733 feat: add app theming engine with Look and Feel settings 2026-06-03 04:44:03 +03:30
nimbold 992d448e60 feat: add tray icon and context menu for main window and queues 2026-06-03 04:35:18 +03:30
369 changed files with 43135 additions and 5425 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="86" height="20" role="img" aria-label="Windows">
<title>Windows</title>
<rect width="86" height="20" fill="#0078D6"/>
<path fill="#fff" d="M7 5.3 12.8 4.5v5H7V5.3Zm6.6-1 7.4-1.1v6.3h-7.4V4.3ZM7 10.5h5.8v5L7 14.7v-4.2Zm6.6 0H21v6.3l-7.4-1.1v-5.2Z"/>
<text x="28" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">Windows</text>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+80
View File
@@ -0,0 +1,80 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
frontend:
name: Frontend checks
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: node --test scripts/*.node-test.js
- run: npm test -- --run
- run: npm run build
desktop:
name: Desktop checks (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libdbus-1-dev \
pkg-config
- run: npm ci
- name: Test Rust backend
if: runner.os != 'Windows'
working-directory: src-tauri
run: cargo test --all-targets --target ${{ matrix.target }}
- name: Test Rust backend
if: runner.os == 'Windows'
working-directory: src-tauri
run: |
cargo test --tests --target ${{ matrix.target }}
cargo test --lib --no-run --target ${{ matrix.target }}
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Stage and verify engines
run: |
node scripts/stage-engines.js --target ${{ matrix.target }}
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
+166 -81
View File
@@ -2,94 +2,179 @@ name: Release
on:
push:
tags:
- "v*"
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: "Release version, for example 0.1.0"
required: true
default: "0.1.0"
publish_release:
description: 'Publish the GitHub release after all platform build and verification jobs pass'
type: boolean
default: true
permissions:
contents: write
jobs:
macos-arm64-dmg:
name: Build macOS ARM64 DMG
runs-on: macos-26
build:
name: Build ${{ matrix.label }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS-arm64
os: macos-26
target: aarch64-apple-darwin
bundles: app,dmg
artifact: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
- label: Windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
bundles: nsis
artifact: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
- label: Linux-x64-AppImage
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
bundles: appimage
artifact: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Resolve version
id: version
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${{ inputs.version }}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Show build environment
run: |
uname -a
swift --version
xcodebuild -version
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- name: Verify macOS 26 SDK
shell: bash
run: |
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
SDK_MAJOR="${SDK_VERSION%%.*}"
if [[ "$SDK_MAJOR" != "26" ]]; then
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
exit 1
fi
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
BUILD_NUMBER: ${{ github.run_number }}
run: Scripts/create_app_bundle.sh
- name: Verify ARM64 binary
run: |
file build/Firelink.app/Contents/MacOS/Firelink
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
codesign --verify --deep --strict build/Firelink.app
- name: Create DMG
env:
VERSION: ${{ steps.version.outputs.version }}
ARCH: arm64
run: Scripts/create_dmg.sh
- name: Upload workflow artifact
uses: actions/upload-artifact@v7
- uses: actions/checkout@v7
with:
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
path: dist/*.dmg
if-no-files-found: error
- name: Publish GitHub release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --generate-notes --verify-tag
fi
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
appstream \
curl \
patchelf \
libdbus-1-dev \
pkg-config \
xvfb \
libtinfo5
- run: npm ci
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Build package
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
- name: Install pinned appimagetool (Linux only)
if: runner.os == 'Linux'
env:
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0
run: |
curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool"
echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c -
chmod +x "$RUNNER_TEMP/appimagetool"
- name: Repack AppImage with engines (Linux only)
if: runner.os == 'Linux'
run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool"
- name: Verify macOS packaged engines and launch
if: runner.os == 'macOS'
run: |
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
test -n "$DMG"
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
- name: Verify Windows installer payload and launch
if: runner.os == 'Windows'
shell: pwsh
run: |
$installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
7z x $installer.FullName "-o$extractRoot" -y
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
if ($install.ExitCode -ne 0) { throw "Silent NSIS install failed with exit code $($install.ExitCode)." }
$exe = $null
foreach ($attempt in 1..60) {
$exe = Get-ChildItem $installRoot -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1
if ($exe) { break }
Start-Sleep -Seconds 1
}
if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." }
node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows
- name: Verify Linux AppImage payload and launch
if: runner.os == 'Linux'
run: |
APPIMAGE="$(find src-tauri/target/${{ matrix.target }}/release/bundle/appimage -name '*.AppImage' -print -quit)"
chmod +x "$APPIMAGE"
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
- uses: actions/upload-artifact@v7
with:
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
publish:
name: Publish GitHub release
if: >-
startsWith(github.ref, 'refs/tags/v') &&
(
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
)
needs: build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: release-assets
merge-multiple: true
- name: Extract changelog release notes
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
test -s release_notes.md
- name: Normalize release asset names
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
rename_asset() {
local pattern="$1"
local destination="$2"
local source
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
if [[ -z "$source" ]]; then
echo "::error::Missing release asset matching $pattern"
exit 1
fi
mv "$source" "release-assets/$destination"
}
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
- name: Generate checksums
run: |
cd release-assets
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
files: release-assets/**
body_path: release_notes.md
+79 -4
View File
@@ -1,8 +1,83 @@
# OS and editor files
.DS_Store
.build/
build/
dist/
DerivedData/
skills-lock.json
.idea/
.vscode/
*.suo
*.sw?
*.xcuserdata/
*.xcuserstate
# Local agent and planning notes
AGENT.md
AGENTS.md
CLAUDE.md
GEMINI.md
implementation_plan.md
CROSS_PLATFORM_CHECKLIST.md
Cross-platform-checklist-gemini.MD
YouTube_media_download_handoff.md
# Frontend output and logs
node_modules/
dist/
dist-ssr/
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.local
# Rust and Tauri output
target/
src-tauri/target/
src-tauri/gen/
src-tauri/engine-dist/
src-tauri/provisioned-engines/
# Locally provisioned native engines
src-tauri/binaries/aria2c
src-tauri/binaries/deno
src-tauri/binaries/ffmpeg
src-tauri/binaries/yt-dlp
# Packaged artifacts
build/
*.dmg
*.AppImage
*.deb
*.msi
*.exe
# Local secrets and signing material
.env
.env.*
*.key
*.pem
!src-tauri/binaries/_internal/certifi/cacert.pem
*.p8
*.p12
*.cer
*.der
*.mobileprovision
sparkle_private_key*
SparklePrivateKey*
private-key*
private_key*
# Legacy Swift build output and downloaded engines
legacy/swift/.build/
legacy/swift/DerivedData/
legacy/swift/Sources/Firelink/*-version.txt
legacy/swift/Sources/Firelink/yt-dlp
legacy/swift/Sources/Firelink/deno
legacy/swift/Sources/Firelink/ffmpeg
legacy/swift/Sources/Firelink/aria2c
legacy/swift/Sources/Firelink/_internal/*
legacy/swift/Sources/Firelink/aria2-libs/
legacy/swift/Sources/Firelink/aria2-licenses/
legacy/swift/Sources/Firelink/aria2-cacert.pem
!legacy/swift/Sources/Firelink/_internal/.gitkeep
+3
View File
@@ -0,0 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
url = https://github.com/nimbold/Firelink-Extension.git
+387 -2
View File
@@ -1,14 +1,399 @@
# Changelog
All notable changes to this project will be documented in this file.
All notable changes to Firelink will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.3] - 2026-07-09
### Improved
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
### Fixed
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
## [1.0.2] - 2026-07-08
### New
- Add explicit **Fetch media** actions for Firelink Companion so video and audio pages can be sent to Firelink from the extension popup or page context menu.
- Add a Chromium extension install path in Firelink's Integrations settings and release documentation, addressing the Chromium support request in [#4](https://github.com/nimbold/Firelink/issues/4).
### Improved
- Improve YouTube and social-media download handling with better proxy support, cleaner diagnostics, safer log redaction, and more reliable metadata fetching based on reports in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
- Let category subfolders be disabled so files can save directly into a base download folder, addressing [#6](https://github.com/nimbold/Firelink/issues/6).
- Refresh bundled media engines and release tooling for the current cross-platform desktop builds.
- Strengthen release packaging checks for macOS, Windows, and Linux, including macOS ad-hoc signing verification and Windows installer icon handling.
### Fixed
- Fix YouTube downloads that failed when Chrome's cookie database could not be copied by retrying public media without browser cookies, addressing [#7](https://github.com/nimbold/Firelink/issues/7).
- Fix the hidden-sidebar trap on Settings and other pages so the sidebar can always be shown again, addressing [#9](https://github.com/nimbold/Firelink/issues/9).
- Fix Windows proxy detection so system proxy schemes are preserved for metadata and media requests, addressing [#5](https://github.com/nimbold/Firelink/issues/5).
- Fix captured and auto-captured browser links so they always route through Firelink's Add window before reaching the download list.
- Fix media downloads after a metadata fallback so pages without selectable preview formats can still let yt-dlp choose the best downloadable file instead of crashing.
- Fix media cleanup and retry paths so failed, canceled, or retried media downloads leave fewer stale temporary files behind.
## [1.0.1] - 2026-07-04
### Fixed
- Fix custom window controls on Windows and Linux so close, minimize, and maximize work in packaged builds.
## [1.0.0] - 2026-07-04
### Highlights
- Firelink is now a cross-platform desktop download manager built with Rust, Tauri, React, and TypeScript.
- macOS, Windows, and Linux release builds are prepared through native GitHub Actions runners.
- The older macOS-only Swift app has been removed from the active product and replaced by the new desktop app.
### New
- Add full browser-to-desktop integration through Firelink Companion, including signed local requests, desktop identity checks, and automatic-capture protection.
- Add a complete Add window for links captured from the browser, pasted manually, or extracted from media pages.
- Add segmented direct downloads, media downloads, queue controls, scheduling, speed limits, and duplicate handling.
- Add persistent downloads, categories, save-location settings, site logins, and safe redownload behavior.
- Add native tray controls, notifications, completion sounds, sleep prevention, file reveal/open/trash actions, and OS keychain support where available.
- Add built-in diagnostics for media engines, app logs, packaged-engine validation, and release smoke checks.
### Improved
- Rework the main window, settings, logs, Add window, download list, toasts, context menus, and table interactions for the new desktop interface.
- Improve media extraction with better YouTube metadata loading, format filtering, size estimates, output names, progress, retries, and cleanup.
- Improve download reliability on rate-limited servers, servers with broken range support, interrupted app sessions, and queue-concurrency changes.
- Improve extension captures so browser downloads are resumed unless Firelink confirms the handoff.
- Improve cross-platform packaging by bundling target-specific aria2, yt-dlp, FFmpeg, and Deno payloads.
- Improve privacy by avoiding startup keychain prompts, redacting secrets from local persistence, and narrowing when browser cookies are forwarded.
### Fixed
- Fix stuck queued/downloading/completed states, stale aria2 dispatches, queue shrink behavior, retry caps, scheduler actions, and post-queue system actions.
- Fix many Windows, Linux, and macOS packaging issues, including hidden helper consoles, AppImage engine handling, installer validation, and smoke-test timing.
- Fix media child-process cleanup so paused, canceled, failed, or removed downloads do not leave stray processes or partial files behind.
- Fix UI hangs, overflow, sorting, selection, animation, and contrast issues across the main list, Add window, settings, and log viewer.
- Fix security issues around local server authentication, request signing, replay protection, private IP metadata checks, path ownership, header sanitization, and command execution boundaries.
### Notes
- This is a major release from `0.7.3`. Older Firelink Companion builds are not compatible with every 1.0 capture path; update the extension together with the desktop app.
- macOS builds are unsigned and not notarized. Windows builds are unsigned. See [RELEASE.md](RELEASE.md) for distribution details.
## [0.7.3] - 2026-06-11
### New Features & Improvements
- Add Deno to about credits and engines list.
- Enhance speed and ETA display logic during pause and drop.
- Accumulate track sizes to present a unified overall progress bar.
### Fixes
- Resolve unknown speed flickering and ultra-wide high-resolution detection.
- Emit distinct status messages for individual tracks during download.
- Pad overall progress total for first track to prevent 100% snapback.
## [0.7.2] - 2026-06-11
### Fixed
- Prevented yt-dlp and JavaScript child processes from keeping metadata fetches or canceled downloads alive indefinitely.
- Replaced the repeatedly extracted one-file yt-dlp build with a stable prewarmed runtime cache.
- Bundled Deno so YouTube JavaScript challenges and formats above 720p do not depend on system-installed tools.
- Stopped masking empty-format extraction failures and removed brittle forced YouTube client selection.
### Changed
- Pinned and checksum-verified yt-dlp, Deno, FFmpeg, aria2, and aria2's libraries for matching local and GitHub Actions builds.
- Removed aria2's runtime dependency on Homebrew and configured its bundled CA certificate for direct and yt-dlp-delegated HTTPS downloads.
- Added bounded network retries and optional aria2c acceleration for large direct media downloads.
## [0.7.1] - 2026-06-11
### Fixes
- Increased the `yt-dlp` metadata extraction timeout to 120 seconds to properly handle YouTube's new JavaScript Proof-of-Work bot protection challenges.
- Improved the `AddDownloadsView` UI to display the exact underlying error message during extraction failures rather than a generic masked string.
### Security Fixes
- Addressed multiple vulnerabilities identified in the v0.7.0 security audit.
- Moved `yt-dlp` credential passing from CLI arguments to secure temporary configuration files to prevent process list leakage.
- Enforced strict `0o600` POSIX permissions on `aria2c` temporary configuration files to protect generated RPC secrets.
- Replaced the unauthenticated local connection protocol with a secure HMAC-SHA256 signature validation.
- Excluded sensitive properties like `rpcSecret` and `rpcPort` from `DownloadItem` serialization so they are never saved to disk in plaintext.
- Mitigated SSRF (Server-Side Request Forgery) by strictly validating metadata fetch requests against private IP addresses and loopback ranges.
- Prevented potential path traversal vulnerabilities by validating destination file URLs during duplicate resolution.
- Sanitized custom HTTP headers to prevent CR/LF injection vectors.
- Re-architected `aria2c` port-finding with POSIX sockets to eliminate a known race-condition window.
- Applied rate-limiting and text length bounds to the custom `firelink://` scheme to mitigate DoS and injection attempts.
### Fixes
- Fixed a metadata extraction timeout when downloading from YouTube by preventing child processes from holding process pipes open.
- Resolved an issue to correctly assign filenames for auto-captured downloads.
- Restored the UUID fallback for token generation to prevent silent failures if secure random byte generation fails.
- Hardened local API security by immediately rejecting requests if the expected pairing token is completely empty.
- Implemented a thread-safe cleanup mechanism for temporary directories to resolve a concurrency race condition during engine cancellation.
## [0.7.0] - 2026-06-11
### New Features & Improvements
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
### Fixes
- Fix issues regarding proxy environment propagation into media download processes.
- Resolve multiple critical bugs related to configuration storage and download stability.
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
## [0.6.6] - 2026-06-10
### New Features
- Add cascading media format pickers with inline loading states during metadata extraction.
- Redesign the Integration settings pane for a more modern experience.
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
### Improvements
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
- Optimize core UI components to significantly improve rendering performance and overall app stability.
### Fixes
- Fix layout and dynamic sizing bugs in the Add Downloads window.
- Fix formatting inconsistencies in media options selection.
- Fix toast notification rendering glitches.
## [0.6.5] - 2026-06-09
### Fixes
- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
## [0.6.4] - 2026-06-09
### New Features
- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
### Improvements
- Polish the browser extension pairing UI with a secure masked token field and improved styling.
### Changes
- Remove stale references to the legacy static token from the Firelink Companion extension.
### Fixes
- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
## [0.6.3] - 2026-06-09
### Improvements
- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
### Fixes
- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
## [0.6.2] - 2026-06-08
### Fixes
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
## [0.6.1] - 2026-06-08
### New Features
- No new user-facing features in this patch release.
### Improvements
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
### Changes
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
- Remove stale media add-on update language now that media engines are bundled with the app.
- Update Firelink Companion to `1.0.8`.
### Fixes
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
## [0.6.0] - 2026-06-08
### New features
- Enhance mixed media support and add duplicate resolution.
- Redesign settings panes and enhance update flows.
- Improve yt-dlp fetching speed and redesign media detection UI.
- Enhance media engine settings with cookie extraction and update checks.
- Modernize Integration settings UI and add official install button.
- Integrate yt-dlp to DownloadController and add global queue support.
- Implement smart progressive disclosure UI and media extraction engine.
- Implement gatekeeper architecture for on-demand media engine binaries.
- Inline update checks to avoid unnecessary modals.
### Changes
- Add backward compatibility support for extension tokens.
- Update Firelink-Extension submodule to latest.
- Update app icons and icon generation scripts.
- Tone down icon gradient to 1.9x for modern subtle look.
- Increase gradient contrast for stronger lighting effect.
- Switch to lighter gradient (+1 to 0).
- Revert to plain mode without gradient.
- Apply premium gradient to the correct new icon and app icon.
- Remove redundant version string from up-to-date message.
- Update release metadata for the framework-embedded dmg.
### Fixes
- Cap max height of download links text editor.
- Harden media download flow.
- Pass extractor arguments to yt-dlp download process.
- Restore single click selection by removing simultaneousGesture.
- Restore Download Properties routing and gestures.
- Pass UUID as String for download properties WindowGroup to prevent routing failures.
- Size column fallback and table row interactions.
- Media download UX and table row selection.
- Media downloads connections, progress parsing, file size, and selection highlight.
- Stabilize yt-dlp metadata and add-on updates.
- Block automatic metadata fetch for private IP addresses (security).
- Actually update extension icons with the 1.9x gradient icon.
- Correctly remove black padding and mask corners.
- Harden release metadata.
- Correct no-update handling to prevent false error messages.
## [0.5.7] - 2026-06-06
### New features
- Replaced the basic in-app update checker with an integrated release-checking flow.
- Added secure update metadata checks before presenting new releases in the app.
## [0.5.6] - 2026-06-05
### New features
- Added the official transparent GitHub icon to the Source Code link in the About page.
### Changes
- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
### Fixes
- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
## [0.5.5] - 2026-06-05
### New features
- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
- Added authenticated metadata probing so batch previews can use custom or saved credentials.
### Changes
- Updated Download Properties disclosure sections so their full title row opens and closes them.
- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
- Expanded download table hit areas so double-clicks register across empty cell space.
### Fixes
- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
- Fixed Chunk Map layout overlap in Download Properties.
- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
## [0.5.4] - 2026-06-04
### New features
- Added direct double-click access to Download Properties for unfinished downloads.
- Added a `make verify` command for local build and Firefox extension manifest checks.
### Changes
- Updated Firefox integration to probe the same local fallback ports used by the app.
- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
### Fixes
- Fixed browser handoff failures when the default local extension port is unavailable.
- Fixed dropped `Referer` headers from browser extension requests.
- Fixed scheduler configurations that could be enabled without any runnable queue target.
- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
## [0.5.3] - 2026-06-04
### New features
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
### Changes
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
### Fixes
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
## [0.5.2] - 2026-06-04
### Fixes
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
## [0.5.1] - 2026-06-04
### Changes
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
## [0.5.0] - 2026-06-04
### New features
- Added a dedicated Speed Limiter UI to the main sidebar for instant global bandwidth throttling.
- Integrated Settings directly into the main application window instead of a separate macOS scene, paving the way for future Windows/Linux cross-platform parity.
### Changes
- Modernized Settings with a sleek horizontal tab bar layout.
- Added persistent state retention across views (remembers the last visited settings tab and custom speed limits).
- Compacted the README file to be more concise and user-friendly.
### Fixes
- Fixed a critical memory crash (`EXC_BAD_ACCESS`) inside the Download Table caused by ephemeral string sorting during active downloads.
- Fixed sidebar layout glitches to prevent text overlap during scroll.
## [0.4.3] - 2026-06-03
### Changes
- Refined About page UI and simplified the delete confirmation dialog.
### Fixes
- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
## [0.4.2] - 2026-06-03
### Features added
- Added double-click to open completed files directly from the download table.
- Added redownload functionality for completed or failed items.
- Added 'Copy Address' context menu action.
- Added a monochrome template tray icon loaded explicitly with precise dimensions.
### Changes
- Improved context menu organization and conditionally displayed actions based on download status.
## [0.4.1] - 2026-06-03
### Features added
- Added app theming engine with Look and Feel settings.
- Added Font Size, List Row Density, and Menu Bar Icon settings.
- Added tray icon and context menu for main window and queues.
- Added site logins integration directly into the Add Downloads window.
### Changes
- Updated the paste hint to use a visual Command icon.
### Fixes
- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
- Fixed a bug with Light/System theme appearance.
- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
## [0.4.0] - 2026-06-03
### Changes
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
- Reorganized Settings sections so related download preferences sit together and app logs live under App.
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
- Prefer the bundled `aria2c` binary inside release builds.
Submodule Extensions/Firefox added at be632f368c
-17
View File
@@ -1,17 +0,0 @@
.PHONY: build app dmg run clean
build:
swift build -c release
app:
Scripts/create_app_bundle.sh
dmg: app
Scripts/create_dmg.sh
run:
swift run Firelink
clean:
swift package clean
rm -rf build dist
-19
View File
@@ -1,19 +0,0 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Firelink",
platforms: [
.macOS(.v14)
],
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
targets: [
.executableTarget(
name: "Firelink",
path: "Sources/Firelink"
)
]
)
+141 -82
View File
@@ -1,115 +1,174 @@
<div align="center">
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
# Firelink
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.3-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
</div>
---
<br/>
**Firelink** brings the efficiency of multi-segmented download managers (like IDM or FDM) to macOS with a modern, native SwiftUI interface. Designed specifically for Apple Silicon, it delivers high-speed concurrent transfers, drag-and-drop queue control, automated file organization, and Keychain-secured authentication—all in a lightweight native package.
<div align="center">
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
---
<details>
<summary><b>View more screenshots</b></summary>
<br/>
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
</details>
</div>
## ✨ Key Features
## Why Firelink
-**High-Speed Downloads:** Multi-segmented download engine powered by `aria2c` for concurrent connections and optimal bandwidth utilization. Supports HTTP, HTTPS, FTP, and SFTP.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and an in-app Settings page with a built-in update checker.
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
- 🛡️ **Reliability & Recovery:** Built-in download recovery and retry handling for interrupted or unstable transfers.
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
<details>
<summary>🔍 View Full Feature Index (30+ features)</summary>
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
### All native SwiftUI macOS features:
## Features
- **Core Download Engine:**
- Segmented downloads with per-file connection/split counts.
- Multi-threaded, parallel downloading with configurable limits.
- Support for HTTP, HTTPS, FTP, and SFTP transfers via `aria2c`.
- Automatic download recovery and retry handling for unstable connections.
- **Advanced Queue Control:**
- Drag-and-drop download reordering to manage priorities.
- Comprehensive download table with resizable, custom columns (Name, Size, Status, ETA, Dates).
- Individual download controls: pause, resume, stop, and queue actions.
- **Smart Link Ingestion:**
- Batch Add window for pasting multiple links at once.
- Automated link parsing (whitespaces, newlines, standard separators).
- Disk space availability checks and total size calculation prior to adding.
- **File Organization:**
- Automatic categorization of files (Music, Movies, Compressed, Documents, Images) into structured subfolders under `~/Downloads`.
- Smart fallback to customizable download folders per category.
- Automatic cleanup of unfinished `.aria2` metadata/cache files on removal.
- **Security & System:**
- Local macOS Keychain integration for site logins.
- Prevents system sleep while active transfers are in progress, preserving display sleep.
- Built-in update checker inside the Settings About pane.
- Release `.app` bundle build scripts ready for distribution.
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Add window** for metadata, duplicates, location choices, and captured links.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Diagnostics** with engine health checks, structured logs, and package verification.
</details>
## Installation
---
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
## ⚙️ Engine Architecture
| Platform | Package | Notes |
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
Firelink leverages `aria2c` under the hood as its core download engine. Unlike standard `curl`, `aria2c` allows:
- **Segmented Downloads:** Splits files into multiple streams for maximum transfer speeds.
- **Unified Connection Control:** A single slider regulates both the server connection count and segment split count, matching the intuitive behavior of classic managers like IDM/FDM.
- **Built-in Resumability:** Seamlessly resumes interrupted or paused downloads without data corruption.
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
---
## Browser Extension
## 🛠️ Requirements & Setup
<p align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
</p>
### 1. Requirements
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon).
- **Engine:** `aria2c` is fully packaged and bundled internally for a true Zero-Config experience. No external installations are required.
Firelink Companion sends browser links and downloads to the desktop app.
*For Developers:* If you are building the project from source, you must have the **Swift 6.0 toolchain** (Xcode 15+) installed.
What it adds:
### 2. Build & Run
Run the application directly via the terminal:
```bash
swift run Firelink
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.3.
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
## Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
## Development
### Requirements
- Node.js 22 or newer
- npm
- Rust and Cargo
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
Clone the repository with its browser-extension submodule:
```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink
```
Or build a production `.app` bundle:
```bash
make app && open build/Firelink.app
Install dependencies and launch the desktop app:
```sh
npm install
npm run tauri dev
```
Create a local Apple Silicon DMG:
```bash
make dmg
Run the core checks:
```sh
node --test scripts/*.node-test.js
npm test -- --run
npm run build
cd src-tauri
cargo test --all-targets
```
### Release
GitHub Actions builds and publishes the macOS ARM64 DMG when a version tag is pushed:
```bash
git tag v0.1.0
git push origin v0.1.0
Create a production bundle:
```sh
npm run tauri build
```
---
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
## 🗺️ Roadmap
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
- [x] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
- [x] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
- [ ] **Distribution:** Notarized `.app` releases and Homebrew formulae.
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
---
## Repository Structure
## 📄 License
```text
.
├── src/ React and TypeScript interface
├── src-tauri/ Rust backend, Tauri config, and native tests
├── scripts/ Engine provisioning, release, and smoke-test tooling
└── Extensions/Firefox/ Firelink Companion submodule
```
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
## Help and Project Status
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
## Technology & Credits
Firelink is made possible by these open-source projects:
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
- **[SQLite](https://www.sqlite.org/)** for persistent local data
- **[aria2](https://aria2.github.io/)** for segmented downloading
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
## License
Firelink is available under the [MIT License](LICENSE).
+75
View File
@@ -0,0 +1,75 @@
# Firelink Release Process
Targets:
- macOS arm64 DMG
- Windows x64 NSIS installer
- Linux x64 AppImage
## Distribution policy
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
## Engine supply chain
Firelink never falls back to system-installed media tools.
- `engines.lock.json` pins current committed macOS payload hashes.
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
- `scripts/provision-engines.js` downloads and verifies target archives.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
## Version update
Keep versions aligned:
- `package.json`
- `src-tauri/Cargo.toml`
- `src-tauri/tauri.conf.json`
## Local macOS build
```bash
npm ci
node scripts/stage-engines.js --target aarch64-apple-darwin
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
npm test -- --run
npm run build
cd src-tauri && cargo test --all-targets
cd ..
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
```
Verify packaged resources, then launch outside repository working directory:
```bash
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
```
GitHub release publication is intentionally manual. Tag pushes build and upload
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
tag when both release-certification inputs are checked after Windows, Linux,
and macOS clean-machine QA.
## Automated release builds
Push a version tag to build and verify native artifacts:
```bash
git tag v<version>
git push origin v<version>
```
GitHub Actions builds all targets on native runners, verifies engines inside
final package contents, performs packaged launch smoke where supported, and
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
run on the same tag after clean-machine QA is complete.
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}"
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}"
BUILD_NUMBER="${BUILD_NUMBER:-1}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>local.firelink.swiftui</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$ICON_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$MARKETING_VERSION</string>
<key>CFBundleVersion</key>
<string>$BUILD_NUMBER</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
</dict>
</plist>
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
fi
echo "Created $APP_DIR"
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
VERSION="${VERSION:-0.1.0}"
ARCH="${ARCH:-arm64}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
DIST_DIR="$ROOT_DIR/dist"
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
if [[ ! -d "$APP_DIR" ]]; then
echo "Missing app bundle: $APP_DIR" >&2
echo "Run Scripts/create_app_bundle.sh first." >&2
exit 1
fi
rm -rf "$DMG_STAGING_DIR"
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
ln -s /Applications "$DMG_STAGING_DIR/Applications"
rm -f "$DMG_PATH"
hdiutil create \
-volname "$APP_NAME $VERSION" \
-srcfolder "$DMG_STAGING_DIR" \
-ov \
-format UDZO \
"$DMG_PATH"
echo "Created $DMG_PATH"
-523
View File
@@ -1,523 +0,0 @@
import SwiftUI
struct AddDownloadsView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
@State private var linkText = ""
@State private var pendingDownloads: [PendingDownload] = []
@State private var connectionsPerServer = 16.0
@State private var overrideDestination = false
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID
@State private var speedLimitEnabled = false
@State private var speedLimitKiBPerSecond = 1024
@State private var showsAdvancedTransfer = false
@State private var checksumEnabled = false
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
@State private var checksumValue = ""
@State private var headerText = ""
@State private var cookieText = ""
@State private var mirrorText = ""
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(16)
}
Divider()
actionBar
}
.frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
linkText = text
controller.pendingPasteboardText = nil
}
}
.onDisappear {
metadataTask?.cancel()
}
}
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.headline)
TextEditor(text: $linkText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 96)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
.foregroundStyle(.secondary)
Spacer()
Button {
refreshMetadata(for: linkText)
} label: {
Label("Refresh Metadata", systemImage: "arrow.clockwise")
}
.disabled(DownloadURLParser.parse(linkText).isEmpty)
}
.font(.caption)
}
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.headline)
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 10) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.disabled(!overrideDestination)
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
.disabled(!overrideDestination)
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 170)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 110, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Toggle("Limit this batch", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
.disabled(!speedLimitEnabled)
}
}
}
}
}
private var summarySection: some View {
HStack(spacing: 10) {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
}
private var previewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.headline)
Table(pendingDownloads) {
TableColumn("File") { item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
}
}
}
TableColumn("Size") { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(95)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
}
.width(130)
}
.frame(minHeight: 170)
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button("Cancel") {
dismiss()
}
Button {
addDownloads(start: false)
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(!canAddDownloads)
Button {
addDownloads(start: true)
} label: {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
}
.padding(14)
.background(.bar)
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.headline)
HStack(spacing: 10) {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.labelsHidden()
.frame(width: 130)
TextField("Expected digest", text: $checksumValue)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.headline)
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.headline)
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.headline)
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
}
}
.padding(.top, 10)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.headline)
}
}
private var requiredSpaceText: String {
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
guard knownBytes > 0 else { return "Unknown" }
return ByteFormatter.string(knownBytes)
}
private var unknownSizeCount: Int {
pendingDownloads.filter { $0.sizeBytes == nil }.count
}
private var freeSpaceText: String {
guard let bytes = availableCapacity() else { return "Unknown" }
return ByteFormatter.string(bytes)
}
private var actionMessage: String {
if pendingDownloads.isEmpty {
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
}
if let validationMessage {
return validationMessage
}
if unknownSizeCount > 0 {
return "Some servers did not report file size before download."
}
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
}
private var canAddDownloads: Bool {
!pendingDownloads.isEmpty && validationMessage == nil
}
private var validationMessage: String? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Add the expected checksum digest, or turn checksum off."
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
return "Headers must use Name: Value lines."
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
}
return nil
}
private var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await MainActor.run {
refreshMetadata(for: text)
}
}
}
private func refreshMetadata(for text: String) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
pendingDownloads = urls.map { url in
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
url: url,
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
)
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
}
}
}
}
}
private func selectDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if let currentURL = overrideDirectory {
panel.directoryURL = currentURL
}
if panel.runModal() == .OK, let url = panel.url {
destinationPath = url.path
overrideDestination = true
}
}
private func addDownloads(start: Bool) {
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
queueID: targetQueueID,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
private var overrideDirectory: URL? {
guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath, isDirectory: true)
}
private func destinationDirectory(for item: PendingDownload) -> URL {
overrideDirectory ?? item.defaultDirectory
}
private func availableCapacity() -> Int64? {
let urls = pendingDownloads.isEmpty
? [settings.destinationDirectory(for: .other)]
: pendingDownloads.map { destinationDirectory(for: $0) }
return urls.compactMap { url in
let values = try? existingVolumeURL(for: url).resourceValues(forKeys: [
.volumeAvailableCapacityForImportantUsageKey,
.volumeAvailableCapacityKey
])
if let important = values?.volumeAvailableCapacityForImportantUsage {
return important
}
if let available = values?.volumeAvailableCapacity {
return Int64(available)
}
return nil
}
.min()
}
private func existingVolumeURL(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
private func categoryColor(_ category: DownloadCategory) -> Color {
switch category {
case .musics: .pink
case .movies: .indigo
case .compressed: .orange
case .pictures: .teal
case .documents: .blue
case .other: .gray
}
}
}
private struct SummaryTile: View {
let title: String
let value: String
let symbolName: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.title3)
.foregroundStyle(.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.headline.monospacedDigit())
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.frame(minHeight: 64)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private struct MetadataStatusView: View {
let state: PendingDownload.MetadataState
var body: some View {
switch state {
case .pending:
Label("Pending", systemImage: "clock")
.foregroundStyle(.secondary)
case .loading:
HStack {
ProgressView()
.controlSize(.small)
Text("Checking")
}
.foregroundStyle(.secondary)
case .loaded:
Label("Ready", systemImage: "checkmark.circle")
.foregroundStyle(.green)
case .failed:
Label("Unknown", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
}
-287
View File
@@ -1,287 +0,0 @@
import Foundation
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var urlPattern: String
var username: String
}
enum ProxyMode: String, Codable, CaseIterable, Sendable {
case none
case system
case custom
var title: String {
switch self {
case .none: "No proxy"
case .system: "Use system proxy"
case .custom: "Set proxy"
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
}
}
var uriScheme: String {
rawValue
}
}
struct ProxySettings: Codable, Equatable, Sendable {
var mode: ProxyMode = .none
var type: ProxyType = .http
var host = ""
var port = 8080
var normalized: ProxySettings {
var copy = self
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535)
return copy
}
var customProxyURI: String? {
let clean = normalized
guard !clean.host.isEmpty else { return nil }
return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)"
}
}
struct DownloadProxyConfiguration: Equatable, Sendable {
var mode: ProxyMode
var customProxyURI: String?
}
@MainActor
final class AppSettings: ObservableObject {
@Published var perServerConnections: Int {
didSet {
let clamped = min(max(perServerConnections, 1), 16)
if perServerConnections != clamped {
perServerConnections = clamped
}
save()
}
}
@Published var maxConcurrentDownloads: Int {
didSet {
let clamped = min(max(maxConcurrentDownloads, 1), 12)
if maxConcurrentDownloads != clamped {
maxConcurrentDownloads = clamped
}
save()
}
}
@Published var globalSpeedLimitKiBPerSecond: Int {
didSet {
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
if globalSpeedLimitKiBPerSecond != clamped {
globalSpeedLimitKiBPerSecond = clamped
return
}
save()
}
}
@Published var preventsSleepWhileDownloading: Bool {
didSet { save() }
}
@Published var proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
if proxySettings != normalized {
proxySettings = normalized
return
}
save()
}
}
@Published var downloadDirectories: [DownloadCategory: String] {
didSet { save() }
}
@Published var siteLogins: [SiteLogin] {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
private let storageKey = "Firelink.AppSettings.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
perServerConnections = 16
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
downloadDirectories = Self.defaultDirectories()
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
downloadDirectories[category] = Self.defaultDirectory(for: category).path
}
}
func destinationDirectory(for category: DownloadCategory) -> URL {
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
}
func setDirectory(_ path: String, for category: DownloadCategory) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
}
func resetDirectories() {
downloadDirectories = Self.defaultDirectories()
}
var downloadProxyConfiguration: DownloadProxyConfiguration {
DownloadProxyConfiguration(
mode: proxySettings.mode,
customProxyURI: proxySettings.customProxyURI
)
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty, !cleanUsername.isEmpty else {
message = "Add a URL pattern and username."
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
message = "Could not save the password to Keychain."
return
}
siteLogins.append(login)
message = "Added login for \(pattern)."
}
func deleteSiteLogins(at offsets: IndexSet) {
for offset in offsets {
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
}
siteLogins.remove(atOffsets: offsets)
}
func credentials(for url: URL) -> DownloadCredentials? {
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
func credentials(for login: SiteLogin) -> DownloadCredentials? {
guard let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
private func save() {
let stored = StoredSettings(
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
}
}
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !pattern.isEmpty else { return false }
let host = (url.host(percentEncoded: false) ?? "").lowercased()
let absolute = url.absoluteString.lowercased()
let normalizedPattern = URL(string: pattern)?.host ?? pattern
if normalizedPattern.hasPrefix("*.") {
let suffix = String(normalizedPattern.dropFirst(2))
return host == suffix || host.hasSuffix(".\(suffix)")
}
if normalizedPattern.contains("*") {
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
.replacingOccurrences(of: "\\*", with: ".*")
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
}
if normalizedPattern.contains("/") {
return absolute.contains(normalizedPattern)
}
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
}
private static func defaultDirectories() -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
}
private static func defaultDirectory(for category: DownloadCategory) -> URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
}
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
guard let category = DownloadCategory(rawValue: key) else { return nil }
return (category, value)
})
}
}
private struct StoredSettings: Codable {
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
}
-113
View File
@@ -1,113 +0,0 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
-399
View File
@@ -1,399 +0,0 @@
import Foundation
import CFNetwork
final class Aria2DownloadEngine {
struct Handle {
let processIdentifier: Int32
let cancel: @Sendable () -> Void
}
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
case unsupportedProxy(String)
var errorDescription: String? {
switch self {
case .executableNotFound:
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
case .launchFailed(let details):
"Could not start aria2c: \(details)"
case .unsupportedProxy(let details):
details
}
}
}
private let executableURL: URL?
init(executableURL: URL? = Aria2DownloadEngine.findExecutable()) {
self.executableURL = executableURL
}
static func findExecutable() -> URL? {
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
}
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for folder in path.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
if FileManager.default.isExecutableFile(atPath: candidate.path) {
return candidate
}
}
return nil
}
static func versionString() -> String? {
guard let executableURL = findExecutable() else { return nil }
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}
func start(
item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
guard let executableURL else {
throw EngineError.executableNotFound
}
try FileManager.default.createDirectory(
at: item.destinationDirectory,
withIntermediateDirectories: true
)
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
)
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = errorPipe
let parser = Aria2ProgressParser()
let outputBuffer = LockedDataBuffer()
let errorBuffer = LockedDataBuffer()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
outputBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
for update in parser.parse(text) {
progress(update)
}
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completion(.success(()))
return
}
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
}
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
throw EngineError.launchFailed(error.localizedDescription)
}
return Handle(processIdentifier: process.processIdentifier) {
if process.isRunning {
process.terminate()
}
}
}
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?
) throws -> [String] {
var arguments = [
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
"--summary-interval=1",
"--console-log-level=warn",
"--download-result=hide",
"--file-allocation=none",
"--min-split-size=1M",
"--max-tries=10",
"--retry-wait=5",
"--connect-timeout=30",
"--timeout=60",
"--uri-selector=adaptive",
"--input-file=-"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
switch configuration.mode {
case .none:
return clearedProxyArguments()
case .system:
switch systemProxyResolution(for: item.url) {
case .direct:
return clearedProxyArguments()
case .proxy(let proxyURI):
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
case .unsupported(let message):
throw EngineError.unsupportedProxy(message)
}
case .custom:
guard let proxyURI = configuration.customProxyURI else { return [] }
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
}
}
private func clearedProxyArguments() -> [String] {
[
"--all-proxy=",
"--http-proxy=",
"--https-proxy=",
"--ftp-proxy="
]
}
private func proxyArgumentName(for urlScheme: String?) -> String {
switch urlScheme?.lowercased() {
case "http": "--http-proxy"
case "https": "--https-proxy"
case "ftp": "--ftp-proxy"
default: "--all-proxy"
}
}
private enum SystemProxyResolution {
case direct
case proxy(String)
case unsupported(String)
}
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
return .direct
}
for proxy in proxies {
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
if type == kCFProxyTypeNone as String {
return .direct
}
if type == kCFProxyTypeSOCKS as String {
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
}
if type == kCFProxyTypeAutoConfigurationURL as String ||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
}
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
return .proxy(uri)
}
}
return .direct
}
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
!host.isEmpty else {
return nil
}
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
let scheme: String
if type == kCFProxyTypeHTTPS as String {
scheme = "https"
} else if type == kCFProxyTypeFTP as String {
scheme = "ftp"
} else {
scheme = "http"
}
guard let port else {
return "\(scheme)://\(host)"
}
return "\(scheme)://\(host):\(port)"
}
private func inputFileContent(for item: DownloadItem) -> String {
let connections = min(max(item.connectionsPerServer, 1), 16)
let urls = ([item.url] + (item.mirrorURLs ?? []))
.map { sanitizedOptionValue($0.absoluteString) }
.joined(separator: "\t")
var lines = [
urls,
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName))",
" split=\(connections)",
" max-connection-per-server=\(connections)"
]
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
}
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
}
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
}
if let credentials = item.credentials, !credentials.isEmpty {
let scheme = item.url.scheme?.lowercased()
if scheme == "ftp" || scheme == "sftp" {
lines.append(" ftp-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" ftp-passwd=\(sanitizedOptionValue(credentials.password))")
} else {
lines.append(" http-user=\(sanitizedOptionValue(credentials.username))")
lines.append(" http-passwd=\(sanitizedOptionValue(credentials.password))")
lines.append(" http-auth-challenge=true")
}
}
return lines.joined(separator: "\n") + "\n"
}
private func sanitizedOptionValue(_ value: String) -> String {
value.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
}
}
final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var storage = Data()
var data: Data {
lock.withLock { storage }
}
func append(_ data: Data) {
lock.withLock {
storage.append(data)
}
}
}
final class Aria2ProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
private let speedRegex = try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)
private let etaRegex = try? NSRegularExpression(pattern: #"ETA:([^\s\]]+)"#)
private let bytesRegex = try? NSRegularExpression(pattern: #"\s([0-9.]+(?:KiB|MiB|GiB|TiB|B)?/[0-9.]+(?:KiB|MiB|GiB|TiB|B)?)\("#)
func parse(_ text: String) -> [DownloadProgress] {
text
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.compactMap { parseLine(String($0)) }
}
private func parseLine(_ line: String) -> DownloadProgress? {
guard line.contains("%") else { return nil }
let percentage = firstCapture(in: line, regex: percentageRegex).flatMap(Double.init) ?? 0
let connections = firstCapture(in: line, regex: connectionRegex).flatMap(Int.init) ?? 0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let bytes = firstCapture(in: line, regex: bytesRegex) ?? "-"
return DownloadProgress(
fraction: min(max(percentage / 100, 0), 1),
bytesText: bytes,
speedText: speed,
etaText: eta,
connectionCount: connections
)
}
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
guard let regex else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
return nil
}
guard let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
-186
View File
@@ -1,186 +0,0 @@
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@State private var showDeleteConfirmation = false
var body: some View {
NavigationSplitView {
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
} detail: {
detailView
}
}
@ViewBuilder
private var detailView: some View {
switch sidebarSelection {
case .downloads(let filter):
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .settings:
SettingsView()
}
}
private func queueView(queueID: UUID) -> some View {
downloadsView(
filter: .all,
title: controller.queueName(for: queueID),
items: controller.queueItems(for: queueID),
queueID: queueID
)
}
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
downloadsView(
filter: filter,
title: filter.title,
items: filteredDownloads(for: filter),
queueID: nil
)
}
private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View {
VStack(spacing: 0) {
DownloadTable(
items: items,
selection: $selection,
title: title,
queueID: queueID
)
Divider()
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
ToolbarItem {
Button {
controller.startQueue(queueID: queueID)
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.background {
Button("") {
if !selection.isEmpty {
showDeleteConfirmation = true
}
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
handlePaste(queueID: queueID)
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
Button("") {
selectAll(items: items)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
}
}
private var selectedItems: [DownloadItem] {
controller.downloads.filter { selection.contains($0.id) }
}
private func deleteSelected(deleteFiles: Bool) {
for item in selectedItems {
controller.delete(item, deleteFiles: deleteFiles)
}
selection.removeAll()
}
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
private func selectAll(items: [DownloadItem]) {
selection = Set(items.map { $0.id })
}
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
controller.downloads
case .queued:
controller.downloads.filter { $0.status == .queued }
case .active:
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .unfinished:
controller.downloads.filter { $0.status != .completed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
}
}
-759
View File
@@ -1,759 +0,0 @@
import AppKit
import Combine
import Foundation
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
var pendingAddQueueID: UUID?
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
}()
init(settings: AppSettings) {
self.settings = settings
let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in
Task { @MainActor in
self?.updateSleepActivity()
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.saveDownloads()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
}
.store(in: &cancellables)
if shouldResumeRecoveredDownloads {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.queuePumpScope = .all
self.pumpQueue()
}
}
}
deinit {
sleepActivity?.end()
}
var activeCount: Int {
downloads.filter { $0.status == .downloading }.count
}
var queuedCount: Int {
downloads.filter { $0.status == .queued }.count
}
var completedCount: Int {
downloads.filter { $0.status == .completed }.count
}
var unfinishedCount: Int {
downloads.filter { $0.status != .completed }.count
}
var hasAria2: Bool {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
engineMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
return
}
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
let item = DownloadItem(
url: url,
fileName: fileName,
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
credentials: settings.credentials(for: url),
queueID: normalizedQueueID(queueID)
)
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID,
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
speedLimitKiBPerSecond: Int? = nil
) {
let clampedConnections = min(max(connectionsPerServer, 1), 16)
let targetQueueID = normalizedQueueID(queueID)
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in
DownloadItem(
url: pending.url,
fileName: pending.fileName,
category: pending.category,
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: settings.credentials(for: pending.url),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader,
mirrorURLs: transferOptions.mirrorURLs,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
)
}
downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
saveDownloads()
if startImmediately {
startQueue(queueID: targetQueueID)
}
}
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
if let queueID {
let queueID = normalizedQueueID(queueID)
switch queuePumpScope {
case .all:
break
case .idle:
queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: [])
case .scoped(var queueIDs, let itemIDs):
queueIDs.insert(queueID)
queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
} else {
queuePumpScope = .all
}
markQueuedDownloadsForAutoResume(queueID: queueID)
pumpQueue()
}
func pause(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func queue(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .queued
if item.status != .paused {
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
}
$0.message = "Added to queue"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
let queueID = normalizedQueueID(queueID)
var changed = false
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
guard downloads[index].status != .completed,
downloads[index].status != .downloading else {
continue
}
downloads[index].status = .queued
downloads[index].queueID = queueID
downloads[index].message = "Added to \(queueName(for: queueID))"
downloads[index].autoResumeOnLaunch = false
automaticRetryCounts[downloads[index].id] = nil
changed = true
}
if changed {
saveDownloads()
updateSleepActivity()
}
}
func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.message = ""
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
delete(item, deleteFiles: deleteFiles)
}
}
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
if deleteFiles {
trashFiles(for: item)
} else if item.status != .completed {
removeCacheFiles(for: item)
}
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
saveDownloads()
}
func queueName(for id: UUID) -> String {
queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name
}
func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id)
return downloads.filter { validQueueID($0.queueID) == id }
}
func queueCount(for id: UUID) -> Int {
queueItems(for: id).count
}
@discardableResult
func addQueue() -> DownloadQueue {
var index = 2
var name = "Queue \(index)"
let existingNames = Set(queues.map(\.name))
while existingNames.contains(name) {
index += 1
name = "Queue \(index)"
}
let queue = DownloadQueue(name: name)
queues.append(queue)
saveDownloads()
return queue
}
func renameQueue(id: UUID, name: String) {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty,
id != DownloadQueue.mainQueueID,
let index = queues.firstIndex(where: { $0.id == id }) else {
return
}
queues[index].name = cleanName
saveDownloads()
}
func removeQueue(id: UUID) {
guard id != DownloadQueue.mainQueueID,
queues.contains(where: { $0.id == id }) else {
return
}
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
downloads[index].queueID = nil
}
queues.removeAll { $0.id == id }
engineMessage = "Removed queue. Downloads remain in Unfinished."
saveDownloads()
}
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID)
guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return
}
let item = downloads.remove(at: source)
let insertionIndex = source < target ? target - 1 : target
downloads.insert(item, at: insertionIndex)
saveDownloads()
}
private func pumpQueue() {
guard hasAria2 else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return
}
pruneActiveQueueScopes()
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
start(next)
}
if restrictQueueToAutoResume &&
activeCount == 0 &&
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
pruneActiveQueueScopes()
}
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
$0.lastTryAt = Date()
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
$0.autoResumeOnLaunch = true
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
},
completion: { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.activeHandles[item.id] = nil
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
}
self.saveDownloads()
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.updateSleepActivity()
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
updateSleepActivity()
pumpQueue()
}
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
}
func updateDownload(
id: UUID,
url: URL,
fileName: String,
destinationDirectory: URL,
connectionsPerServer: Int,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions,
speedLimitKiBPerSecond: Int?
) {
update(id) {
$0.url = url
$0.fileName = fileName
$0.category = FileClassifier.category(forFileName: fileName)
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
$0.checksum = transferOptions.checksum
$0.requestHeaders = transferOptions.requestHeaders
$0.cookieHeader = transferOptions.cookieHeader
$0.mirrorURLs = transferOptions.mirrorURLs
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
$0.message = "Properties updated"
}
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
if let queueID {
let normalizedID = normalizedQueueID(queueID)
for index in downloads.indices where downloads[index].status == .queued &&
validQueueID(downloads[index].queueID) == normalizedID {
downloads[index].autoResumeOnLaunch = true
}
} else {
for index in downloads.indices where downloads[index].status == .queued {
downloads[index].autoResumeOnLaunch = true
}
}
saveDownloads()
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = error.localizedDescription
$0.autoResumeOnLaunch = false
}
saveDownloads()
return
}
automaticRetryCounts[itemID] = retryCount + 1
update(itemID) {
$0.status = .queued
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
switch queuePumpScope {
case .idle:
return false
case .all:
return true
case .scoped(let queueIDs, let itemIDs):
if itemIDs.contains(item.id) {
return true
}
guard let queueID = validQueueID(item.queueID) else { return false }
return queueIDs.contains(queueID)
}
}
private func pruneActiveQueueScopes() {
switch queuePumpScope {
case .idle:
return
case .all:
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
queuePumpScope = .idle
}
case .scoped(let queueIDs, let itemIDs):
let activeQueueIDs = queueIDs.filter { queueID in
downloads.contains { item in
validQueueID(item.queueID) == queueID &&
(item.status == .queued || item.status == .downloading)
}
}
let activeItemIDs = itemIDs.filter { itemID in
downloads.contains { item in
item.id == itemID && (item.status == .queued || item.status == .downloading)
}
}
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
? .idle
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
}
}
private enum QueuePumpScope {
case idle
case all
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
func includingItem(_ itemID: UUID) -> QueuePumpScope {
switch self {
case .idle:
return .scoped(queueIDs: [], itemIDs: [itemID])
case .all:
return .all
case .scoped(let queueIDs, var itemIDs):
itemIDs.insert(itemID)
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
}
}
private func updateSleepActivity() {
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
if shouldPreventSleep, sleepActivity == nil {
sleepActivity = SleepActivityHandle(activity: ProcessInfo.processInfo.beginActivity(
options: [.idleSystemSleepDisabled],
reason: "Firelink is downloading files."
))
} else if !shouldPreventSleep, let activity = sleepActivity {
activity.end()
sleepActivity = nil
}
}
private func removeCacheFiles(for item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
let candidates = [URL(fileURLWithPath: fileURL.path + ".aria2")]
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
try? FileManager.default.removeItem(at: candidate)
}
}
private func trashFiles(for item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
let candidates = [
fileURL,
URL(fileURLWithPath: fileURL.path + ".aria2")
]
for candidate in candidates where FileManager.default.fileExists(atPath: candidate.path) {
try? FileManager.default.trashItem(at: candidate, resultingItemURL: nil)
}
}
private func saveDownloads() {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queues, downloads: downloads)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
}
}
private func loadDownloads() -> Bool {
do {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL)
let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState
isLegacyDownloadList = false
} else {
state = StoredDownloadState(
queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
)
isLegacyDownloadList = true
}
var shouldResumeRecoveredDownloads = false
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.status == .downloading {
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = true
shouldResumeRecoveredDownloads = true
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
adjusted.message = "Recovered queued download."
shouldResumeRecoveredDownloads = true
}
return adjusted
}
if shouldResumeRecoveredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
} catch {
print("Failed to load downloads: \(error)")
return false
}
}
private func normalizedQueueID(_ id: UUID?) -> UUID {
validQueueID(id) ?? DownloadQueue.mainQueueID
}
private func validQueueID(_ id: UUID?) -> UUID? {
guard let id, queues.contains(where: { $0.id == id }) else {
return nil
}
return id
}
private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] {
var normalized = queues
if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) {
normalized.insert(.main, at: 0)
}
if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 {
let main = normalized.remove(at: mainIndex)
normalized.insert(main, at: 0)
}
return normalized
}
}
private struct StoredDownloadState: Codable {
var queues: [DownloadQueue]
var downloads: [DownloadItem]
}
private final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol
init(activity: NSObjectProtocol) {
self.activity = activity
}
func end() {
ProcessInfo.processInfo.endActivity(activity)
}
}
@@ -1,122 +0,0 @@
import Foundation
enum DownloadURLParser {
static func parse(_ text: String) -> [URL] {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
let tokenized = text
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")))
.compactMap { token -> URL? in
let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()"))
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
return nil
}
return url
}
var seen = Set<String>()
return (detected + tokenized).filter { url in
guard let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
return false
}
return seen.insert(url.absoluteString).inserted
}
}
}
enum DownloadMetadataFetcher {
static func fetch(
for url: URL,
settings: AppSettings,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
var pending = PendingDownload(
url: url,
fileName: initialName,
category: initialCategory,
defaultDirectory: initialDirectory,
state: .loading
)
guard url.scheme?.lowercased().hasPrefix("http") == true else {
pending.state = .loaded
return pending
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
request.setValue(header.value, forHTTPHeaderField: header.name)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
pending.state = .loaded
return pending
}
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
let fileName = fileName(fromContentDisposition: disposition) {
pending.fileName = fileName
pending.category = FileClassifier.category(forFileName: fileName)
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
}
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let bytes = Int64(contentLength) {
pending.sizeBytes = bytes
} else if response.expectedContentLength > 0 {
pending.sizeBytes = response.expectedContentLength
}
pending.mimeType = httpResponse.mimeType
pending.state = .loaded
} catch {
pending.state = .failed(error.localizedDescription)
}
return pending
}
private static func fileName(fromContentDisposition header: String) -> String? {
let parts = header.components(separatedBy: ";")
for part in parts {
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("filename*="),
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if trimmed.lowercased().hasPrefix("filename=") {
return String(trimmed.dropFirst("filename=".count))
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
return nil
}
}
enum ByteFormatter {
static func string(_ bytes: Int64?) -> String {
guard let bytes else { return "Unknown" }
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.includesUnit = true
formatter.includesCount = true
return formatter.string(fromByteCount: bytes)
}
}
@@ -1,308 +0,0 @@
import AppKit
import SwiftUI
struct DownloadPropertiesWindow: View {
@EnvironmentObject private var controller: DownloadController
let downloadID: UUID
var body: some View {
if let item = controller.downloads.first(where: { $0.id == downloadID }) {
DownloadPropertiesView(item: item)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.frame(width: 420, height: 240)
}
}
}
struct DownloadPropertiesView: View {
enum LoginMode: String, CaseIterable, Identifiable {
case matching = "Matching site login"
case custom = "Custom credentials"
case none = "No login"
var id: String { rawValue }
}
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
let item: DownloadItem
@State private var urlText: String
@State private var fileName: String
@State private var destinationPath: String
@State private var connections: Int
@State private var loginMode: LoginMode
@State private var username: String
@State private var password: String
@State private var speedLimitEnabled: Bool
@State private var speedLimitKiBPerSecond: Int
@State private var checksumEnabled: Bool
@State private var checksumAlgorithm: ChecksumAlgorithm
@State private var checksumValue: String
@State private var headerText: String
@State private var cookieText: String
@State private var mirrorText: String
@State private var errorMessage = ""
init(item: DownloadItem) {
self.item = item
_urlText = State(initialValue: item.url.absoluteString)
_fileName = State(initialValue: item.fileName)
_destinationPath = State(initialValue: item.destinationDirectory.path)
_connections = State(initialValue: item.connectionsPerServer)
_speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0)
_speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1))
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
_password = State(initialValue: credentials.password)
} else {
_loginMode = State(initialValue: .matching)
_username = State(initialValue: "")
_password = State(initialValue: "")
}
if let checksum = item.checksum {
_checksumEnabled = State(initialValue: true)
_checksumAlgorithm = State(initialValue: checksum.algorithm)
_checksumValue = State(initialValue: checksum.value)
} else {
_checksumEnabled = State(initialValue: false)
_checksumAlgorithm = State(initialValue: .sha256)
_checksumValue = State(initialValue: "")
}
_headerText = State(initialValue: (item.requestHeaders ?? []).map(\.headerLine).joined(separator: "\n"))
_cookieText = State(initialValue: item.cookieHeader ?? "")
_mirrorText = State(initialValue: (item.mirrorURLs ?? []).map(\.absoluteString).joined(separator: "\n"))
}
var body: some View {
VStack(spacing: 0) {
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.body, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
TextField("Save location", text: $destinationPath)
.font(.system(.body, design: .monospaced))
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
Toggle("Limit speed", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
Section("Site Login") {
Picker("Login", selection: $loginMode) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
if loginMode == .matching {
Text(matchingLoginText)
.font(.caption)
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
SecureField("Password", text: $password)
}
}
Section("Advanced Transfer") {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
TextField("Cookies", text: $cookieText)
.font(.system(.body, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
}
.formStyle(.grouped)
Divider()
HStack {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
.lineLimit(1)
Spacer()
Button("Cancel") {
dismiss()
}
Button {
save()
} label: {
Label("Save", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
}
.padding(14)
.background(.bar)
}
.frame(width: 620, height: 760)
}
private var matchingLoginText: String {
guard let url = URL(string: urlText),
let credentials = settings.credentials(for: url) else {
return "No matching saved login for this URL."
}
return "Will use saved login for \(credentials.username)."
}
private func selectDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSString(string: destinationPath).expandingTildeInPath)
if panel.runModal() == .OK, let url = panel.url {
destinationPath = url.path
}
}
private func save() {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
errorMessage = "Enter a valid HTTP, HTTPS, FTP, or SFTP URL."
return
}
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanFileName.isEmpty else {
errorMessage = "File name cannot be empty."
return
}
let destination = URL(
fileURLWithPath: NSString(string: destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)).expandingTildeInPath,
isDirectory: true
)
let credentials: DownloadCredentials?
switch loginMode {
case .matching:
credentials = settings.credentials(for: url)
case .custom:
let custom = DownloadCredentials(username: username, password: password)
credentials = custom.isEmpty ? nil : custom
case .none:
credentials = nil
}
guard let transferOptions = validatedTransferOptions else {
return
}
controller.updateDownload(
id: item.id,
url: url,
fileName: cleanFileName,
destinationDirectory: destination,
connectionsPerServer: connections,
credentials: credentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
private var validatedTransferOptions: DownloadTransferOptions? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "Add the expected checksum digest, or turn checksum off."
return nil
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
errorMessage = "Headers must use Name: Value lines."
return nil
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
errorMessage = "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
return nil
}
return DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
}
private struct InfoGrid: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
info("Status", item.status.rawValue)
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
info("Size", ByteFormatter.string(item.sizeBytes))
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
info("Destination", item.destinationPath)
}
}
private func info(_ label: String, _ value: String) -> some View {
GridRow {
Text(label)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(2)
}
}
}
-706
View File
@@ -1,706 +0,0 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
case priority = "#"
case fileName = "File name"
case size = "Size"
case progress = "Progress"
case status = "Status"
case lastTry = "Last try date"
case dateAdded = "Date added"
case category = "Category"
case connections = "Connections"
case liveConnections = "Live conn."
case speed = "Speed"
case eta = "ETA"
case destination = "Save location"
case url = "URL"
case message = "Message"
var id: String { rawValue }
var width: CGFloat {
switch self {
case .priority: return 58
case .fileName: return 340
case .size: return 100
case .status: return 105
case .progress: return 115
case .lastTry, .dateAdded: return 155
case .category: return 105
case .connections, .liveConnections: return 95
case .speed, .eta: return 90
case .destination: return 240
case .url: return 280
case .message: return 220
}
}
}
enum SortDirection: String, Codable {
case ascending
case descending
mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
final class TableSettings: ObservableObject {
@Published var visibleColumns: Set<DownloadColumn> {
didSet { save() }
}
@Published var columnWidths: [DownloadColumn: CGFloat] {
didSet { save() }
}
@Published var sortColumn: DownloadColumn {
didSet { save() }
}
@Published var sortDirection: SortDirection {
didSet { save() }
}
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.TableSettings.v1"
init() {
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
columnWidths = stored.columnWidths
sortColumn = stored.sortColumn
sortDirection = stored.sortDirection
} else {
visibleColumns = defaultVisibleColumns
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
sortColumn = .dateAdded
sortDirection = .descending
}
}
private func save() {
let stored = StoredTableSettings(
visibleColumns: visibleColumns,
columnWidths: columnWidths,
sortColumn: sortColumn,
sortDirection: sortDirection
)
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
}
}
}
private struct StoredTableSettings: Codable {
var visibleColumns: Set<DownloadColumn>
var columnWidths: [DownloadColumn: CGFloat]
var sortColumn: DownloadColumn
var sortDirection: SortDirection
}
struct DownloadTable: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
let items: [DownloadItem]
@Binding var selection: Set<DownloadItem.ID>
let title: String
var queueID: UUID?
@StateObject private var tableSettings = TableSettings()
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
@State private var lastSelectedIndex: Int?
@State private var draggedItemID: DownloadItem.ID?
var body: some View {
VStack(spacing: 0) {
HStack {
Text(title)
.font(.headline)
Text("\(items.count)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 5))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
GeometryReader { proxy in
let tableWidth = max(totalWidth, proxy.size.width)
let trailingWidth = max(0, tableWidth - totalWidth)
ScrollView(.horizontal) {
VStack(spacing: 0) {
tableHeader(trailingWidth: trailingWidth)
Divider()
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(sortedItems) { item in
tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth)
Divider()
}
}
.frame(width: tableWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.topLeading)
}
.frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press Command-V to paste one or more links.")
)
}
}
}
}
.confirmationDialog(
"Delete Download",
isPresented: Binding(
get: { pendingDeleteItems != nil },
set: { isPresented in
if !isPresented {
pendingDeleteItems = nil
}
}
),
presenting: pendingDeleteItems
) { ids in
Button("Remove from List") {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: false) }
selection.subtract(ids)
pendingDeleteItems = nil
}
Button("Move File and Cache to Trash", role: .destructive) {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: true) }
selection.subtract(ids)
pendingDeleteItems = nil
}
Button("Cancel", role: .cancel) {
pendingDeleteItems = nil
}
} message: { ids in
let items = controller.downloads.filter { ids.contains($0.id) }
if items.allSatisfy({ $0.status == .completed }) {
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink, or also move the downloaded files to Trash.")
} else {
Text("Remove \(items.count == 1 ? "this download" : "these \(items.count) downloads") from Firelink. Partial cache files are removed automatically; moving to Trash also sends any partial files there.")
}
}
}
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
DownloadRow(
item: item,
priorityNumber: priorityNumber(for: item),
visibleColumns: orderedVisibleColumns,
columnWidth: { width(for: $0) },
trailingWidth: trailingWidth
)
.id(item.id)
.frame(width: tableWidth, alignment: .leading)
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
let index = sortedItems.firstIndex(where: { $0.id == item.id })
if NSEvent.modifierFlags.contains(.command) {
if selection.contains(item.id) {
selection.remove(item.id)
} else {
selection.insert(item.id)
}
lastSelectedIndex = index
} else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index {
let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex)
let rangeIds = range.map { sortedItems[$0].id }
selection.formUnion(rangeIds)
} else {
selection = [item.id]
lastSelectedIndex = index
}
}
.contextMenu {
rowContextMenu(for: item)
}
.onDrag {
draggedItemID = item.id
return NSItemProvider(object: dragPayload(for: item) as NSString)
}
.onDrop(
of: [.text],
delegate: QueueDropDelegate(
item: item,
queueID: queueID,
draggedItemID: $draggedItemID,
controller: controller
)
)
}
private func tableHeader(trailingWidth: CGFloat) -> some View {
HStack(spacing: 0) {
ForEach(orderedVisibleColumns) { column in
ZStack(alignment: .trailing) {
headerContent(for: column)
Rectangle()
.fill(.secondary.opacity(0.18))
.frame(width: 1, height: 20)
.frame(width: 8, height: 34)
.contentShape(Rectangle())
.onHover { isHovering in
if isHovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
resizeBaseWidths[column] = baseWidth
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
}
.onEnded { _ in
resizeBaseWidths[column] = nil
}
)
}
.frame(width: width(for: column), height: 34)
.clipped()
}
if trailingWidth > 0 {
Color.clear
.frame(width: trailingWidth, height: 34)
}
}
.background(.bar)
.contextMenu {
Section("Columns") {
ForEach(availableColumns) { column in
Toggle(column.rawValue, isOn: Binding(
get: { tableSettings.visibleColumns.contains(column) },
set: { isVisible in
if isVisible {
tableSettings.visibleColumns.insert(column)
} else if tableSettings.visibleColumns.count > 1 {
tableSettings.visibleColumns.remove(column)
}
}
))
}
}
if queueID == nil {
Section("Sort By") {
ForEach(availableColumns) { column in
Button(column.rawValue) {
tableSettings.sortColumn = column
}
}
}
}
}
}
@ViewBuilder
private func rowContextMenu(for item: DownloadItem) -> some View {
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading }) {
Button {
for target in targetItems where target.status == .downloading {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = Set(targetItems.map(\.id))
} label: {
Label("Delete", systemImage: "trash")
}
}
private var orderedVisibleColumns: [DownloadColumn] {
var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
if queueID != nil, !columns.contains(.priority) {
columns.insert(.priority, at: 0)
} else if queueID == nil {
columns.removeAll { $0 == .priority }
}
return columns
}
private var availableColumns: [DownloadColumn] {
DownloadColumn.allCases.filter { $0 != .priority }
}
private var totalWidth: CGFloat {
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
}
private func width(for column: DownloadColumn) -> CGFloat {
max(70, tableSettings.columnWidths[column] ?? column.width)
}
private var sortedItems: [DownloadItem] {
if queueID != nil {
return items
}
return items.sorted { lhs, rhs in
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
if result == .orderedSame {
return lhs.id.uuidString < rhs.id.uuidString
}
return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
}
}
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
switch column {
case .priority:
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
case .progress: return compare(lhs.progress, rhs.progress)
case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
case .dateAdded: return compare(lhs.createdAt, rhs.createdAt)
case .category: return compare(lhs.category.rawValue, rhs.category.rawValue)
case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount)
case .speed: return compare(lhs.speedText, rhs.speedText)
case .eta: return compare(lhs.etaText, rhs.etaText)
case .destination: return compare(lhs.destinationPath, rhs.destinationPath)
case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString)
case .message: return compare(lhs.message, rhs.message)
}
}
@ViewBuilder
private func headerContent(for column: DownloadColumn) -> some View {
let content = HStack(spacing: 4) {
Spacer(minLength: 0)
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.center)
.layoutPriority(1)
if queueID == nil && tableSettings.sortColumn == column {
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(width: width(for: column), height: 34, alignment: .center)
.clipped()
if queueID == nil {
Button {
if tableSettings.sortColumn == column {
tableSettings.sortDirection.toggle()
} else {
tableSettings.sortColumn = column
tableSettings.sortDirection = .ascending
}
} label: {
content
}
.buttonStyle(.plain)
} else {
content
}
}
private func priorityNumber(for item: DownloadItem) -> Int? {
guard queueID != nil,
let index = items.firstIndex(where: { $0.id == item.id }) else {
return nil
}
return index + 1
}
private func dragPayload(for item: DownloadItem) -> String {
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
return draggedIDs
.map(\.uuidString)
.joined(separator: "\n")
}
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
return .orderedSame
}
private func showInFinder(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
private func existingFolder(for url: URL) -> URL {
var candidate = url
while !FileManager.default.fileExists(atPath: candidate.path) {
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
return URL(fileURLWithPath: NSHomeDirectory())
}
candidate = parent
}
return candidate
}
}
private struct DownloadRow: View {
let item: DownloadItem
let priorityNumber: Int?
let visibleColumns: [DownloadColumn]
let columnWidth: (DownloadColumn) -> CGFloat
let trailingWidth: CGFloat
var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(visibleColumns) { column in
cell(for: column)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(width: columnWidth(column), alignment: alignment(for: column))
.clipped()
}
if trailingWidth > 0 {
Color.clear
.frame(width: trailingWidth)
}
}
}
@ViewBuilder
private func cell(for column: DownloadColumn) -> some View {
switch column {
case .priority:
Text(priorityNumber.map(String.init) ?? "-")
.monospacedDigit()
.foregroundStyle(.secondary)
.lineLimit(1)
case .fileName:
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor)
.frame(width: 22)
VStack(alignment: .leading, spacing: 6) {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
case .size:
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
case .status, .progress:
progressBarCell()
case .lastTry:
Text(formatted(item.lastTryAt))
.lineLimit(1)
.truncationMode(.tail)
case .dateAdded:
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
case .category:
Text(item.category.rawValue)
.lineLimit(1)
.truncationMode(.tail)
case .connections:
Text("\(item.connectionsPerServer)")
.monospacedDigit()
.lineLimit(1)
case .liveConnections:
Text("\(item.connectionCount)")
.monospacedDigit()
.lineLimit(1)
case .speed:
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
case .eta:
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
case .destination:
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.tail)
case .url:
Text(item.url.absoluteString)
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.tail)
case .message:
Text(item.message)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
private func alignment(for column: DownloadColumn) -> Alignment {
column == .fileName ? .leading : .center
}
private var categoryColor: Color {
switch item.category {
case .musics: return .pink
case .movies: return .indigo
case .compressed: return .orange
case .pictures: return .teal
case .documents: return .blue
case .other: return .gray
}
}
private var statusColor: Color {
switch item.status {
case .queued: return .secondary
case .downloading: return .blue
case .paused: return .orange
case .completed: return .green
case .failed: return .red
case .canceled: return .gray
}
}
@ViewBuilder
private func progressBarCell() -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.lineLimit(1)
.truncationMode(.tail)
} else {
GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4)
.fill(statusColor)
.frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
}
}
.frame(height: 16)
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private struct QueueDropDelegate: DropDelegate {
let item: DownloadItem
let queueID: UUID?
@Binding var draggedItemID: DownloadItem.ID?
let controller: DownloadController
func dropEntered(info: DropInfo) {
guard let queueID,
let draggedItemID,
draggedItemID != item.id else {
return
}
controller.moveDownload(draggedItemID, before: item.id, in: queueID)
}
func performDrop(info: DropInfo) -> Bool {
draggedItemID = nil
return queueID != nil
}
}
-66
View File
@@ -1,66 +0,0 @@
import Foundation
enum FileClassifier {
private static let musicExtensions: Set<String> = [
"aac", "aif", "aiff", "alac", "amr", "ape", "au", "caf", "flac", "m4a",
"m4b", "mid", "midi", "mp3", "oga", "ogg", "opus", "ra", "wav", "weba",
"wma"
]
private static let movieExtensions: Set<String> = [
"3g2", "3gp", "avi", "divx", "f4v", "flv", "m2ts", "m4v", "mkv", "mov",
"mp4", "mpeg", "mpg", "mts", "ogm", "ogv", "rm", "rmvb", "ts", "vob",
"webm", "wmv"
]
private static let compressedExtensions: Set<String> = [
"7z", "ace", "alz", "apk", "appx", "ar", "arc", "arj", "bz", "bz2",
"cab", "cpio", "deb", "dmg", "gz", "gzip", "iso", "jar", "lha", "lzh",
"lz", "lz4", "lzip", "lzma", "pak", "pkg", "rar", "rpm", "sit", "sitx",
"tar", "tbz", "tbz2", "tgz", "tlz", "txz", "war", "whl", "xar", "xz",
"z", "zip", "zipx", "zst"
]
private static let pictureExtensions: Set<String> = [
"ai", "apng", "avif", "bmp", "cr2", "cr3", "dng", "emf", "eps", "gif",
"heic", "heif", "ico", "indd", "jfif", "jpeg", "jpg", "jxl", "nef",
"orf", "pbm", "pgm", "png", "pnm", "ppm", "psd", "raw", "rw2",
"svg", "tga", "tif", "tiff", "webp", "wmf"
]
private static let documentExtensions: Set<String> = [
"azw", "azw3", "csv", "djvu", "doc", "docm", "docx", "dot", "dotx",
"epub", "fb2", "htm", "html", "ics", "key", "log", "md", "mobi", "pdf",
"numbers", "odp", "ods", "odt", "pages", "pot", "potx", "pps", "ppsx",
"ppt", "pptm", "pptx", "rtf", "tex", "txt", "vcf", "xls", "xlsm",
"xlsx", "xml", "xps", "yaml", "yml"
]
static func category(forFileName fileName: String) -> DownloadCategory {
let ext = (fileName as NSString).pathExtension.lowercased()
guard !ext.isEmpty else { return .other }
if musicExtensions.contains(ext) { return .musics }
if movieExtensions.contains(ext) { return .movies }
if compressedExtensions.contains(ext) { return .compressed }
if pictureExtensions.contains(ext) { return .pictures }
if documentExtensions.contains(ext) { return .documents }
return .other
}
static func destinationDirectory(for category: DownloadCategory) -> URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
}
static func fileName(from url: URL) -> String {
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
if !pathName.isEmpty, pathName != "/" {
return pathName
}
let host = url.host(percentEncoded: false) ?? "download"
return "\(host)-download"
}
}
-54
View File
@@ -1,54 +0,0 @@
import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
init() {
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
}
}
.windowResizability(.contentSize)
.commands {
CommandGroup(after: .newItem) {
Button("Start Queue") {
controller.startQueue()
}
.keyboardShortcut("r", modifiers: [.command])
}
}
}
}
@@ -1,50 +0,0 @@
import Foundation
import Security
enum KeychainCredentialStore {
private static let service = "local.firelink.site-login"
static func password(for id: UUID) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
@discardableResult
static func setPassword(_ password: String, for id: UUID) -> Bool {
deletePassword(for: id)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
@discardableResult
static func deletePassword(for id: UUID) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
-239
View File
@@ -1,239 +0,0 @@
import Foundation
enum DownloadStatus: String, Codable, CaseIterable, Sendable {
case queued = "Queued"
case downloading = "Downloading"
case paused = "Paused"
case completed = "Completed"
case failed = "Failed"
case canceled = "Canceled"
}
enum DownloadCategory: String, Codable, CaseIterable, Sendable {
case musics = "Musics"
case movies = "Movies"
case compressed = "Compressed"
case pictures = "Pictures"
case documents = "Documents"
case other = "Other"
var symbolName: String {
switch self {
case .musics: "music.note"
case .movies: "film"
case .compressed: "archivebox"
case .pictures: "photo"
case .documents: "doc.text"
case .other: "folder"
}
}
}
struct DownloadQueue: Identifiable, Codable, Equatable, Sendable {
static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
var id = UUID()
var name: String
var isMain: Bool {
id == Self.mainQueueID
}
static var main: DownloadQueue {
DownloadQueue(id: mainQueueID, name: "Main queue")
}
}
struct DownloadCredentials: Codable, Equatable, Sendable {
var username: String
var password: String
var isEmpty: Bool {
username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
password.isEmpty
}
}
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
case md5
case sha1 = "sha-1"
case sha256 = "sha-256"
case sha512 = "sha-512"
var id: String { rawValue }
var title: String {
switch self {
case .md5: "MD5"
case .sha1: "SHA-1"
case .sha256: "SHA-256"
case .sha512: "SHA-512"
}
}
}
struct DownloadChecksum: Codable, Equatable, Sendable {
var algorithm: ChecksumAlgorithm
var value: String
var normalized: DownloadChecksum {
DownloadChecksum(
algorithm: algorithm,
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
)
}
var isEmpty: Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
struct DownloadRequestHeader: Codable, Equatable, Sendable {
var name: String
var value: String
var normalized: DownloadRequestHeader {
DownloadRequestHeader(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
var isEmpty: Bool {
let clean = normalized
return clean.name.isEmpty && clean.value.isEmpty
}
var headerLine: String {
let clean = normalized
return "\(clean.name): \(clean.value)"
}
}
struct DownloadTransferOptions: Equatable, Sendable {
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader] = []
var cookieHeader: String?
var mirrorURLs: [URL] = []
}
enum DownloadTransferOptionParser {
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
headerLines(text).compactMap { line in
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
let header = DownloadRequestHeader(name: name, value: value).normalized
return header.isEmpty || header.name.isEmpty ? nil : header
}
}
static func invalidHeaderLines(_ text: String) -> [String] {
headerLines(text).filter { line in
guard let colonIndex = line.firstIndex(of: ":") else { return true }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty
}
}
static func cleanCookieHeader(_ text: String) -> String? {
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
if value.lowercased().hasPrefix("cookie:") {
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value.isEmpty ? nil : value
}
static func parseMirrorURLs(_ text: String) -> [URL] {
DownloadURLParser.parse(text)
}
static func invalidMirrorLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
}
private static func headerLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var destinationDirectory: URL
var connectionsPerServer: Int
var credentials: DownloadCredentials?
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader]?
var cookieHeader: String?
var mirrorURLs: [URL]?
var speedLimitKiBPerSecond: Int?
var status: DownloadStatus = .queued
var progress: Double = 0
var speedText: String = "-"
var etaText: String = "-"
var connectionCount: Int = 0
var sizeBytes: Int64?
var bytesText: String = "-"
var message: String = ""
var createdAt = Date()
var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var queueID: UUID?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksum,
requestHeaders: requestHeaders ?? [],
cookieHeader: cookieHeader,
mirrorURLs: mirrorURLs ?? []
)
}
var speedLimitText: String {
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
return "No limit"
}
return "\(speedLimitKiBPerSecond) KiB/s"
}
}
struct DownloadProgress: Equatable, Sendable {
var fraction: Double
var bytesText: String
var speedText: String
var etaText: String
var connectionCount: Int
}
struct PendingDownload: Identifiable, Equatable, Sendable {
enum MetadataState: Equatable, Sendable {
case pending
case loading
case loaded
case failed(String)
}
var id = UUID()
var url: URL
var fileName: String
var category: DownloadCategory
var defaultDirectory: URL
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path
}
}
-231
View File
@@ -1,231 +0,0 @@
import AppKit
import Combine
import Foundation
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
case doNothing = "Do nothing"
case sleep = "Sleep"
case restart = "Restart"
case shutdown = "Shut down"
var id: String { rawValue }
}
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
var id: Int { rawValue }
var shortName: String {
switch self {
case .sunday: "S"
case .monday: "M"
case .tuesday: "T"
case .wednesday: "W"
case .thursday: "T"
case .friday: "F"
case .saturday: "S"
}
}
}
struct SchedulerSettings: Codable, Equatable {
var isEnabled: Bool = false
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = []
}
@MainActor
final class SchedulerController: ObservableObject {
@Published var settings: SchedulerSettings
@Published var isRunning: Bool = false
@Published var hasAutomationPermission: Bool = false
private let downloadController: DownloadController
private var cancellables = Set<AnyCancellable>()
private var timer: Timer?
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date?
init(downloadController: DownloadController) {
self.downloadController = downloadController
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
self.settings = stored
} else {
self.settings = SchedulerSettings()
}
checkAutomationPermission()
startTimer()
$settings
.dropFirst()
.sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
}
.store(in: &cancellables)
// Observe downloads to check if we should trigger post-action
downloadController.$downloads
.dropFirst()
.sink { [weak self] _ in
self?.checkIfRunningFinished()
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkSchedule()
}
}
}
private func checkSchedule() {
guard settings.isEnabled else { return }
let now = Date()
let calendar = Calendar.current
// Check if we already triggered in this exact minute
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
return
}
let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now)
let currentMinute = calendar.component(.minute, from: now)
let currentWeekday = calendar.component(.weekday, from: now)
if startHour == currentHour && startMinute == currentMinute {
let shouldRun: Bool
if settings.isEveryday {
shouldRun = true
} else {
let day = SchedulerDay(rawValue: currentWeekday)
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
}
if shouldRun {
lastTriggeredMinute = now
triggerQueues()
}
}
}
private func triggerQueues() {
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
guard !runnableQueueIDs.isEmpty else { return }
isRunning = true
for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID)
}
checkIfRunningFinished()
}
private func checkIfRunningFinished() {
guard isRunning else { return }
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
scriptCode = "tell application \"Finder\" to sleep"
case .restart:
scriptCode = "tell application \"Finder\" to restart"
case .shutdown:
scriptCode = "tell application \"Finder\" to shut down"
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to perform scheduler post action: \(error)")
}
}
}
func checkAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
hasAutomationPermission = (status == noErr)
}
func requestAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
if status != noErr {
triggerAutomationConsentPrompt()
}
checkAutomationPermission()
if !hasAutomationPermission {
openAutomationPermissionSettings()
}
}
func openAutomationPermissionSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
return
}
NSWorkspace.shared.open(url)
}
private func triggerAutomationConsentPrompt() {
let scriptCode = "tell application \"Finder\" to get name"
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to trigger Automation permission prompt: \(error)")
}
}
}
}
-214
View File
@@ -1,214 +0,0 @@
import SwiftUI
struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var startTime: Date = Date()
@State private var isEveryday: Bool = true
@State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
timeSelectionSection
queueSelectionSection
postActionSection
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
schedulerController.checkAutomationPermission()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
schedulerController.checkAutomationPermission()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Scheduler")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Settings") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Start Time")
.font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
Toggle("Everyday", isOn: $isEveryday)
if !isEveryday {
HStack(spacing: 12) {
ForEach(SchedulerDay.allCases) { day in
Toggle(day.shortName, isOn: Binding(
get: { selectedDays.contains(day) },
set: { isSelected in
if isSelected {
selectedDays.insert(day)
} else {
selectedDays.remove(day)
}
}
))
.toggleStyle(.button)
}
}
}
}
}
private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start")
.font(.headline)
if downloadController.queues.isEmpty {
Text("No queues available")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(downloadController.queues) { queue in
Toggle(queue.name, isOn: Binding(
get: { targetQueueIDs.contains(queue.id) },
set: { isSelected in
if isSelected {
targetQueueIDs.insert(queue.id)
} else {
targetQueueIDs.remove(queue.id)
}
}
))
}
}
}
}
}
private var postActionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("After Completion")
.font(.headline)
Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.pickerStyle(.radioGroup)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("System Permissions")
.font(.headline)
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
}
.buttonStyle(.borderedProminent)
}
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs
schedulerController.saveSettings()
}
}
-616
View File
@@ -1,616 +0,0 @@
import AppKit
import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case locations = "Locations"
case network = "Network"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case about = "About"
static let orderedCases: [SettingsSection] = [
.downloads,
.locations,
.network,
.siteLogins,
.power,
.engine,
.about
]
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .about: "info.circle"
}
}
var groupTitle: String {
switch self {
case .engine, .about:
"App"
default:
"Preferences"
}
}
}
struct SettingsView: View {
@State private var selection: SettingsSection = .downloads
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Settings")
.font(.largeTitle.weight(.semibold))
.padding(.horizontal, 28)
.padding(.top, 26)
.padding(.bottom, 18)
Divider()
HStack(spacing: 0) {
settingsSidebar
Divider()
ScrollView {
selectedPane
.frame(maxWidth: 720, alignment: .leading)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var settingsSidebar: some View {
List(selection: $selection) {
Section("Preferences") {
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
Section("App") {
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
}
.listStyle(.sidebar)
.frame(width: 210)
}
@ViewBuilder
private var selectedPane: some View {
switch selection {
case .downloads:
DownloadSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("GitHub") {
Link("@nimbold", destination: developerProfileURL)
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
private var version: String {
Aria2DownloadEngine.versionString() ?? "Unavailable"
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
private struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Bandwidth") {
Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s",
value: globalSpeedLimitValue,
in: 1...10_485_760,
step: 128
)
.disabled(settings.globalSpeedLimitKiBPerSecond == 0)
Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var globalSpeedLimitEnabled: Binding<Bool> {
Binding {
settings.globalSpeedLimitKiBPerSecond > 0
} set: { isEnabled in
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
}
}
private var globalSpeedLimitValue: Binding<Int> {
Binding {
max(settings.globalSpeedLimitKiBPerSecond, 1)
} set: { newValue in
settings.globalSpeedLimitKiBPerSecond = newValue
}
}
}
private struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
}
private struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
var body: some View {
HStack(spacing: 10) {
Label(category.rawValue, systemImage: category.symbolName)
.frame(width: 125, alignment: .leading)
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
settings.setDirectory(url.path, for: category)
}
}
}
private struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("URL Pattern")
TextField("*.github.com", text: $urlPattern)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Username")
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Password")
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
}
}
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
private struct PowerSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
-216
View File
@@ -1,216 +0,0 @@
import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case unfinished
case category(DownloadCategory)
var title: String {
switch self {
case .all: "All Downloads"
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .unfinished: "Unfinished"
case .category(let category): category.rawValue
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case scheduler
case settings
}
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@State private var queueName = ""
var body: some View {
List(selection: $selection) {
Section("Library") {
Label("All", systemImage: "tray.full")
.badge(controller.downloads.count)
.tag(SidebarSelection.downloads(.all))
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(SidebarSelection.downloads(.active))
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(SidebarSelection.downloads(.completed))
Label("Unfinished", systemImage: "circle.dashed")
.badge(controller.unfinishedCount)
.tag(SidebarSelection.downloads(.unfinished))
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
folderRow(for: category)
}
}
Section("Queues") {
ForEach(controller.queues) { queue in
queueRow(for: queue)
}
Button {
let queue = controller.addQueue()
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
}
}
.listStyle(.sidebar)
.alert("Rename Queue", isPresented: Binding(
get: { queueBeingRenamed != nil },
set: { isPresented in
if !isPresented {
queueBeingRenamed = nil
}
}
)) {
TextField("Queue name", text: $queueName)
Button("Rename") {
if let queue = queueBeingRenamed {
controller.renameQueue(id: queue.id, name: queueName)
}
queueBeingRenamed = nil
}
Button("Cancel", role: .cancel) {
queueBeingRenamed = nil
}
}
.confirmationDialog(
"Delete Queue",
isPresented: Binding(
get: { queueBeingRemoved != nil },
set: { isPresented in
if !isPresented {
queueBeingRemoved = nil
}
}
),
presenting: queueBeingRemoved
) { queue in
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
}
queueBeingRemoved = nil
}
Button("Cancel", role: .cancel) {
queueBeingRemoved = nil
}
} message: { queue in
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.contentShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.background {
if selection == .settings {
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.14))
}
}
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
.background(.bar)
}
}
private func folderRow(for category: DownloadCategory) -> some View {
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
}
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
of: [.text],
delegate: QueueSidebarDropDelegate(
queueID: queue.id,
selection: $selection,
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
private struct QueueSidebarDropDelegate: DropDelegate {
let queueID: UUID
@Binding var selection: SidebarSelection
let controller: DownloadController
func performDrop(info: DropInfo) -> Bool {
guard let provider = info.itemProviders(for: [.text]).first else {
return false
}
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
Task { @MainActor in
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
selection = .queue(queueID)
}
}
return true
}
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
let text: String?
if let data = item as? Data {
text = String(data: data, encoding: .utf8)
} else {
text = item as? String
}
guard let text else { return nil }
return Set(text
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
.compactMap { UUID(uuidString: String($0)) })
}
}
-21
View File
@@ -1,21 +0,0 @@
import SwiftUI
struct StatusBar: View {
@EnvironmentObject private var controller: DownloadController
var body: some View {
HStack {
Text(controller.engineMessage.isEmpty ? "Ready" : controller.engineMessage)
.foregroundStyle(controller.hasAria2 ? Color.secondary : Color.orange)
.lineLimit(1)
Spacer()
Text("\(controller.activeCount) active")
Text("\(controller.queuedCount) queued")
Text("\(controller.completedCount) done")
}
.font(.caption)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
}
}
+42
View File
@@ -0,0 +1,42 @@
# Third-Party Notices
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
## aria2
- Project: <https://aria2.github.io/>
- Source: <https://github.com/aria2/aria2>
- License: GNU General Public License version 2 or later
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
## FFmpeg
- Project and source: <https://ffmpeg.org/>
- License information: <https://ffmpeg.org/legal.html>
Current macOS binary reports `--enable-gpl --enable-version3`; distribution therefore follows GNU GPL version 3 requirements. Exact build identity is recorded in `engines.lock.json`.
Windows and Linux archives come from checksum-pinned BtbN FFmpeg GPL builds. Build project: <https://github.com/BtbN/FFmpeg-Builds>.
## yt-dlp
- Project and source: <https://github.com/yt-dlp/yt-dlp>
- License: The Unlicense
Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python packages keep their own license files inside `_internal` where supplied.
## Deno
- Project and source: <https://github.com/denoland/deno>
- License: MIT
## OpenSSL and bundled native libraries
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
+51
View File
@@ -0,0 +1,51 @@
{
"schemaVersion": 1,
"targets": {
"x86_64-pc-windows-msvc": {
"yt-dlp": {
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
}
},
"x86_64-unknown-linux-gnu": {
"yt-dlp": {
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"schemaVersion": 1,
"targets": {
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.07.04",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
},
"aria2c": {
"version": "1.37.0",
"source": "https://github.com/aria2/aria2",
"build": "arm64 executable with adjacent aria2-libs",
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "N-125450-gfad2e0bc50",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
},
"deno": {
"version": "2.9.2",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
}
},
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
},
"aria2-libs": {
"files": 7,
"sha256": "f6735fa4162513ca79adcc6bb65afdccdcd34c7c05e5d45c78da6d2e0c359e57"
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firelink Download Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2551
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "firelink",
"private": true,
"version": "1.0.3",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
"repository": {
"type": "git",
"url": "https://github.com/nimbold/Firelink.git"
},
"bugs": {
"url": "https://github.com/nimbold/Firelink/issues"
},
"keywords": [
"download-manager",
"tauri",
"rust",
"react",
"typescript",
"aria2",
"yt-dlp",
"ffmpeg",
"desktop"
],
"engines": {
"node": ">=22"
},
"type": "module",
"scripts": {
"dev": "vite",
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:updates": "node scripts/check-updates.js",
"verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tailwindcss/vite": "^4.3.1",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.23.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.10"
}
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
function run(command, args, options = {}) {
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
const result = spawnSync(executable, args, {
cwd: repoRoot,
stdio: 'inherit',
...options,
});
if (result.error) {
console.error(`Failed to run ${executable}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runNpmScript(script) {
if (process.env.npm_execpath) {
run(process.execPath, [process.env.npm_execpath, 'run', script]);
return;
}
if (process.platform === 'win32') {
run('cmd.exe', ['/d', '/s', '/c', 'npm', 'run', script], {
windowsHide: true,
});
return;
}
run('npm', ['run', script]);
}
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
fs.rmSync(engineDist, { recursive: true, force: true });
fs.mkdirSync(engineDist, { recursive: true });
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
}
runNpmScript('build');
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const userAgent = 'firelink-update-check';
function parseJsonFile(file) {
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
}
function normalizeVersion(value) {
return String(value || '')
.replace(/^v/, '')
.replace(/^release-/, '');
}
function compareVersions(left, right) {
const a = normalizeVersion(left).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
const b = normalizeVersion(right).split(/[.-]/).map(part => (/^\d+$/.test(part) ? Number(part) : part));
const length = Math.max(a.length, b.length);
for (let index = 0; index < length; index += 1) {
const av = a[index] ?? 0;
const bv = b[index] ?? 0;
if (av === bv) continue;
if (typeof av === 'number' && typeof bv === 'number') return av > bv ? 1 : -1;
return String(av).localeCompare(String(bv));
}
return 0;
}
function npmOutdated(cwd) {
try {
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
return {};
} catch (error) {
const output = error.stdout?.toString() || '{}';
return JSON.parse(output || '{}');
}
}
async function fetchJson(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.json();
}
async function fetchText(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.text();
}
async function githubLatest(repo) {
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
}
async function latestFfmpegStable() {
const html = await fetchText('https://ffmpeg.org/releases/');
const versions = [...html.matchAll(/ffmpeg-(\d+\.\d+(?:\.\d+)?)\.tar\.xz/g)].map(match => match[1]);
return [...new Set(versions)].sort(compareVersions).at(-1);
}
async function latestMartinRiedlMacArm64Release() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const releaseSection = html.split('Download Release Build')[1] || '';
const match =
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
return match?.[1];
}
async function latestMartinRiedlMacArm64Snapshot() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
const match =
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([A-Za-z0-9.-]+)/);
return match?.[1];
}
async function latestBtbnFfmpegN81Build() {
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
for (const release of releases) {
if (release.tag_name === 'latest') continue;
const versions = (release.assets || [])
.map(asset => asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(?:win64-gpl-8\.1\.zip|linux64-gpl-8\.1\.tar\.xz)$/)?.[1])
.filter(Boolean);
const unique = [...new Set(versions)];
if (unique.length === 1 && versions.length >= 2) {
return unique[0];
}
}
return undefined;
}
function printNpmReport(label, outdated) {
const entries = Object.entries(outdated);
if (!entries.length) {
console.log(`${label}: current`);
return 0;
}
console.log(`${label}: ${entries.length} outdated package(s)`);
for (const [name, info] of entries) {
console.log(` ${name}: ${info.current} -> ${info.latest} (wanted ${info.wanted})`);
}
return entries.length;
}
function sourceEngineVersions(sourceLock) {
const rows = [];
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
for (const [engine, meta] of Object.entries(engines)) {
rows.push({ target, engine, version: meta.version, url: meta.url });
}
}
return rows;
}
function packagedEngineVersions(engineLock) {
const rows = [];
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
rows.push({ target, engine, version: meta.version });
}
}
return rows;
}
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
let outdated = 0;
for (const row of rows) {
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
if (!latest) continue;
const current = normalizeVersion(row.version);
const wanted = normalizeVersion(latest);
const status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
if (status === 'outdated') outdated += 1;
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
}
return outdated;
}
async function main() {
let outdatedCount = 0;
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
outdatedCount += printNpmReport(
'Firefox extension npm',
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
);
const [
ytDlp,
deno,
aria2,
ffmpeg,
martinRiedlMacArm64Ffmpeg,
martinRiedlMacArm64Snapshot,
btbnFfmpegN81Build,
] = await Promise.all([
githubLatest('yt-dlp/yt-dlp'),
githubLatest('denoland/deno'),
githubLatest('aria2/aria2'),
latestFfmpegStable(),
latestMartinRiedlMacArm64Release(),
latestMartinRiedlMacArm64Snapshot(),
latestBtbnFfmpegN81Build(),
]);
const latestByEngine = {
'yt-dlp': ytDlp.tag_name,
deno: deno.tag_name,
aria2c: aria2.tag_name,
ffmpeg,
};
const latestByTargetEngine = {
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg,
};
console.log('\nlatest engines:');
for (const [engine, version] of Object.entries(latestByEngine)) {
console.log(` ${engine}: ${normalizeVersion(version)}`);
}
console.log('\nlatest engine provider builds:');
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build || ffmpeg)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
console.log('\nengine source lock:');
outdatedCount += checkRows(
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
latestByEngine,
latestByTargetEngine
);
console.log('\npackaged engine lock:');
outdatedCount += checkRows(
packagedEngineVersions(parseJsonFile('engines.lock.json')),
latestByEngine,
latestByTargetEngine
);
if (outdatedCount > 0) {
console.error(`\n${outdatedCount} outdated item(s) found.`);
process.exit(1);
}
console.log('\nAll checked packages and engines are current.');
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
export function collectRegularFiles(root, options = {}) {
const ignoredNames = new Set(options.ignoredNames || []);
const files = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
const relative = path.relative(root, file).split(path.sep).join('/');
if (ignoredNames.has(entry.name)) {
continue;
}
if (entry.isSymbolicLink()) {
throw new Error(`Unsupported symlink in engine payload: ${relative}`);
}
if (entry.isDirectory()) {
walk(file);
} else if (entry.isFile()) {
files.push(file);
} else {
throw new Error(`Unsupported filesystem entry in engine payload: ${relative}`);
}
}
};
walk(root);
return files.sort((left, right) => left.localeCompare(right));
}
export function treeDigest(root) {
const files = collectRegularFiles(root);
const digest = crypto.createHash('sha256');
for (const file of files) {
const relative = path.relative(root, file).split(path.sep).join('/');
digest.update(`${relative}\0${sha256(file)}\n`);
}
return { files: files.length, sha256: digest.digest('hex') };
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { collectRegularFiles, treeDigest } from './engine-payload-integrity.js';
test('collectRegularFiles returns regular files and honors ignored names', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
try {
fs.mkdirSync(path.join(root, 'nested'));
fs.writeFileSync(path.join(root, 'engine'), 'binary');
fs.writeFileSync(path.join(root, 'nested', 'runtime.dat'), 'runtime');
fs.writeFileSync(path.join(root, 'payload-manifest.json'), '{}');
const files = collectRegularFiles(root, {
ignoredNames: ['payload-manifest.json'],
}).map(file => path.relative(root, file).split(path.sep).join('/'));
assert.deepEqual(files, ['engine', 'nested/runtime.dat']);
const digest = treeDigest(path.join(root, 'nested'));
assert.equal(digest.files, 1);
assert.match(digest.sha256, /^[a-f0-9]{64}$/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('collectRegularFiles rejects symlinks in engine payloads', { skip: process.platform === 'win32' }, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-payload-'));
try {
fs.writeFileSync(path.join(root, 'target'), 'target');
fs.symlinkSync('target', path.join(root, 'link'));
assert.throws(
() => collectRegularFiles(root),
/Unsupported symlink in engine payload: link/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const sourceLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const target = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| process.env.TAURI_ENV_TARGET_TRIPLE;
if (!target) {
console.error('Pass --target <Rust target triple>.');
process.exit(1);
}
const targetSources = sourceLock.targets?.[target];
if (!targetSources) {
console.error(`No source lock exists for ${target}.`);
process.exit(1);
}
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
const isWindows = target.includes('windows');
const executableSuffix = isWindows ? '.exe' : '';
async function download(name, source) {
const sourcePath = new URL(source.url).pathname;
const archive = path.join(
temporary,
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
);
const response = await fetch(source.url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
const output = fs.createWriteStream(archive);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!output.write(Buffer.from(value))) {
await new Promise(resolve => output.once('drain', resolve));
}
}
await new Promise(resolve => output.end(resolve));
const actual = sha256(archive);
if (actual !== source.sha256) {
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
}
const extracted = path.join(temporary, `${name}-extracted`);
fs.mkdirSync(extracted);
if (archive.endsWith('.zip') && process.platform !== 'win32') {
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
} else {
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
}
return extracted;
}
function findFile(root, names) {
const wanted = new Set(names.map(name => name.toLowerCase()));
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) walk(file);
else if (entry.isFile() && wanted.has(entry.name.toLowerCase())) matches.push(file);
}
};
walk(root);
if (matches.length !== 1) {
throw new Error(`Expected one of [${names.join(', ')}] under ${root}, found ${matches.length}`);
}
return matches[0];
}
function copyExecutable(source, engine) {
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
fs.copyFileSync(source, output);
if (!isWindows) fs.chmodSync(output, 0o755);
}
function writePayloadManifest() {
const files = collectRegularFiles(destination, {
ignoredNames: ['payload-manifest.json'],
});
const manifest = {
schemaVersion: 1,
target,
generatedFrom: Object.fromEntries(
Object.entries(targetSources).map(([name, source]) => [
name,
{
version: source.version,
url: source.url || source.sourceUrl,
sha256: source.sha256 || source.sourceSha256
}
])
),
files: Object.fromEntries(
files.map(file => [
path.relative(destination, file).split(path.sep).join('/'),
sha256(file)
])
)
};
fs.writeFileSync(
path.join(destination, 'payload-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
}
try {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
copyExecutable(
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
'yt-dlp'
);
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
recursive: true,
preserveTimestamps: true
});
const deno = await download('deno', targetSources.deno);
copyExecutable(findFile(deno, isWindows ? ['deno.exe'] : ['deno']), 'deno');
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
const aria2 = await download('aria2c', targetSources.aria2c);
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
writePayloadManifest();
console.log(`Provisioned locked engine payload at ${destination}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function fail(message) {
console.error(message);
process.exit(1);
}
function mustBeDirectory(directory, label) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
fail(`${label} does not exist or is not a directory: ${directory}`);
}
}
function mustBeFile(file, label) {
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
fail(`${label} does not exist or is not a file: ${file}`);
}
}
function findSingleAppImage(directory) {
mustBeDirectory(directory, 'AppImage bundle directory');
const appImages = fs
.readdirSync(directory)
.filter(name => name.endsWith('.AppImage'))
.map(name => path.join(directory, name));
if (appImages.length !== 1) {
fail(`Expected exactly one AppImage in ${directory}, found ${appImages.length}.`);
}
return appImages[0];
}
function validatePayloadManifest(root, target, label) {
const manifestPath = path.join(root, 'payload-manifest.json');
mustBeFile(manifestPath, `${label} payload manifest`);
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(`${label} payload manifest is not valid JSON: ${error.message}`);
}
if (manifest.target !== target) {
fail(`${label} payload target mismatch: expected ${target}, got ${manifest.target}`);
}
const expectedFiles = Object.keys(manifest.files || {}).sort();
const actualFiles = collectRegularFiles(root, { ignoredNames: ['payload-manifest.json'] })
.map(file => path.relative(root, file).split(path.sep).join('/'))
.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
fail(`${label} payload files do not match payload-manifest.json.`);
}
for (const relative of expectedFiles) {
const file = path.join(root, relative);
if (sha256(file) !== manifest.files[relative]) {
fail(`${label} payload checksum mismatch: ${relative}`);
}
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
});
if (result.error) {
fail(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
if (options.stdio === 'pipe') {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
}
fail(`${command} exited with status ${result.status}`);
}
}
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE || process.env.TAURI_ENV_TARGET_TRIPLE;
if (!target) {
fail('Pass --target <triple>.');
}
const bundleDirectory = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle', 'appimage');
const appDir = path.resolve(argValue('--appdir') || path.join(bundleDirectory, 'Firelink.AppDir'));
const appImage = path.resolve(argValue('--appimage') || findSingleAppImage(bundleDirectory));
const appImageTool = path.resolve(argValue('--appimagetool') || process.env.APPIMAGETOOL || 'appimagetool');
const source = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const destination = path.join(appDir, 'usr', 'lib', 'Firelink', 'engine-dist', target);
mustBeDirectory(appDir, 'Firelink.AppDir');
mustBeDirectory(source, 'Provisioned engine payload');
mustBeFile(appImage, 'AppImage');
mustBeFile(appImageTool, 'appimagetool');
validatePayloadManifest(source, target, 'Provisioned engine');
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.cpSync(source, destination, {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
validatePayloadManifest(destination, target, 'AppDir engine');
fs.chmodSync(appImageTool, 0o755);
run(appImageTool, [appDir, appImage], {
env: {
APPIMAGE_EXTRACT_AND_RUN: '1',
ARCH: 'x86_64',
},
});
const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-appimage-'));
try {
fs.chmodSync(appImage, 0o755);
run(appImage, ['--appimage-extract'], {
cwd: extractRoot,
env: { APPIMAGE_EXTRACT_AND_RUN: '1' },
stdio: 'pipe',
});
const extractedPayload = path.join(extractRoot, 'squashfs-root', 'usr', 'lib', 'Firelink', 'engine-dist', target);
mustBeDirectory(extractedPayload, 'Extracted AppImage engine payload');
validatePayloadManifest(extractedPayload, target, 'Extracted AppImage engine');
} finally {
fs.rmSync(extractRoot, { recursive: true, force: true });
}
console.log(`Repacked and verified AppImage engine payload for ${target}.`);
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
import path from 'node:path';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const executableArg = argValue('--executable');
if (!executableArg) {
console.error('Pass --executable <path>.');
process.exit(1);
}
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
env: {
...process.env,
FIRELINK_SMOKE_TEST: '1',
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
GDK_BACKEND: 'x11',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let spawnError = null;
let readyPort = null;
child.on('error', error => {
spawnError = error;
});
child.on('exit', (code, signal) => {
if (readyPort === null) {
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
}
});
child.stderr.on('data', data => {
stderr += data.toString();
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function findReadyPort() {
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
if (spawnError) {
break;
}
for (let port = 6412; port <= 6422; port += 1) {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (response.headers.get('x-firelink-server') === '1') {
readyPort = port;
break;
}
} catch {}
}
if (readyPort === null) {
await sleep(250);
}
}
}
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
}
const script = `
$root = ${rootPid}
$all = Get-CimInstance Win32_Process
$pending = @($root)
$descendants = @()
while ($pending.Count -gt 0) {
$parent = $pending[0]
if ($pending.Count -eq 1) {
$pending = @()
} else {
$pending = $pending[1..($pending.Count - 1)]
}
$children = @($all | Where-Object { $_.ParentProcessId -eq $parent })
foreach ($child in $children) {
$descendants += $child.ProcessId
$pending += $child.ProcessId
}
}
$visible = foreach ($childPid in $descendants) {
$process = Get-Process -Id $childPid -ErrorAction SilentlyContinue
if ($process -and $process.MainWindowHandle -ne 0) {
"$($process.ProcessName)($childPid)"
}
}
if ($visible.Count -gt 0) {
Write-Error "Visible child process windows detected: $($visible -join ', ')"
exit 1
}
`;
try {
execFileSync('powershell', ['-NoProfile', '-Command', script], {
stdio: 'pipe',
windowsHide: true,
});
} catch (error) {
const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim();
throw new Error(detail || 'Visible child process window check failed.');
}
}
function terminateChild() {
if (!child.pid) {
return;
}
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
await findReadyPort();
try {
if (readyPort === null) {
if (spawnError) {
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
} else {
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
}
process.exit(1);
}
if (assertNoVisibleChildWindows) {
assertNoVisibleWindows(child.pid);
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
} finally {
terminateChild();
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
const target = argValue('--target')
|| process.env.TAURI_ENV_TARGET_TRIPLE
|| process.env.FIRELINK_TARGET_TRIPLE
|| hostTarget;
const isWindowsTarget = target.includes('windows');
const suffix = isWindowsTarget ? '.exe' : '';
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const expectedNames = engines.map(engine => `${engine}-${target}${suffix}`);
const targetLock = lock.targets?.[target];
const configuredSource = process.env.FIRELINK_ENGINE_SOURCE_DIR
? path.resolve(process.env.FIRELINK_ENGINE_SOURCE_DIR)
: null;
const canonicalSource = path.join(binariesRoot, target);
const provisionedSource = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const legacyMacSource = target.endsWith('apple-darwin') ? binariesRoot : null;
const source = [configuredSource, canonicalSource, provisionedSource, legacyMacSource]
.filter(Boolean)
.find(candidate => expectedNames.every(name => fs.existsSync(path.join(candidate, name))));
if (!source) {
console.error(`No complete engine payload found for ${target}.`);
console.error(`Expected source directory: ${canonicalSource}`);
console.error(`Expected files: ${expectedNames.join(', ')}`);
process.exit(1);
}
if (targetLock) {
for (const engine of engines) {
const name = `${engine}-${target}${suffix}`;
const expected = targetLock.engines?.[engine]?.sha256;
const actual = sha256(path.join(source, name));
if (!expected || actual !== expected) {
console.error(`Checksum mismatch for ${name}. Expected ${expected || 'missing lock'}, got ${actual}.`);
process.exit(1);
}
}
for (const [runtimeDir, expected] of Object.entries(targetLock.runtimeTrees || {})) {
const sourceDir = path.join(source, runtimeDir);
if (!fs.existsSync(sourceDir)) {
console.error(`Missing locked runtime directory ${runtimeDir} for ${target}.`);
process.exit(1);
}
const actual = treeDigest(sourceDir);
if (actual.files !== expected.files || actual.sha256 !== expected.sha256) {
console.error(`Runtime checksum mismatch for ${runtimeDir}.`);
process.exit(1);
}
}
} else {
const manifestPath = path.join(source, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
console.error(`No committed lock or payload manifest exists for ${target}.`);
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.target !== target) {
console.error(`Payload manifest target mismatch: ${manifest.target}`);
process.exit(1);
}
for (const [relative, expected] of Object.entries(manifest.files || {})) {
const file = path.join(source, relative);
if (!fs.existsSync(file) || sha256(file) !== expected) {
console.error(`Payload manifest mismatch: ${relative}`);
process.exit(1);
}
}
const actualFiles = collectRegularFiles(source, {
ignoredNames: ['payload-manifest.json'],
}).map(file => path.relative(source, file).split(path.sep).join('/'));
const expectedFiles = Object.keys(manifest.files || {}).sort();
actualFiles.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
console.error(`Payload contains files not covered by manifest for ${target}.`);
process.exit(1);
}
}
const destination = path.join(outputRoot, target);
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const name of expectedNames) {
fs.copyFileSync(path.join(source, name), path.join(destination, name));
if (!isWindowsTarget) {
fs.chmodSync(path.join(destination, name), 0o755);
}
}
for (const runtimeDir of ['_internal', 'aria2-libs']) {
const sourceDir = path.join(source, runtimeDir);
if (fs.existsSync(sourceDir)) {
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
}
}
const payloadManifest = path.join(source, 'payload-manifest.json');
if (fs.existsSync(payloadManifest)) {
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
}
console.log(`Staged Firelink engines for ${target} from ${source}`);
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
const currentArch = archMap[os.arch()];
const currentPlatform = platformMap[os.platform()];
if (!currentArch || !currentPlatform) {
console.error(`Unsupported architecture or platform: ${os.arch()} / ${os.platform()}`);
process.exit(1);
}
const targetTriple = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| `${currentArch}-${currentPlatform}`;
const hostTriple = `${currentArch}-${currentPlatform}`;
const canExecuteTarget = targetTriple === hostTriple;
const isWindows = targetTriple.includes('windows');
const isMacOS = targetTriple.includes('apple-darwin');
const isLinux = targetTriple.includes('linux');
const ext = isWindows ? '.exe' : '';
const suffix = `-${targetTriple}${ext}`;
const scriptsDir = __dirname;
const searchRoot = argValue('--search-root');
function findEngineRoot(root) {
const expected = `yt-dlp-${targetTriple}${ext}`;
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
walk(candidate);
}
}
};
walk(path.resolve(root));
if (matches.length !== 1) {
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
}
return matches[0];
}
const configuredRoot = argValue('--root')
|| (process.argv.includes('--staged')
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
: searchRoot
? findEngineRoot(searchRoot)
: null);
const binariesDir = configuredRoot
? path.resolve(configuredRoot)
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
const FORBIDDEN_STDERR = [
'Failed to load Python shared library',
'Library not loaded',
'image not found',
'Connection refused',
];
let exitCode = 0;
function fail(msg) {
console.error(`[FAIL] ${msg}`);
exitCode = 1;
}
function ok(msg) {
console.log(`[OK] ${msg}`);
}
function rejectSymlinks(root, label) {
if (!fs.existsSync(root)) {
return;
}
let symlinkCount = 0;
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
const relative = path.relative(root, file).split(path.sep).join('/');
if (entry.isSymbolicLink()) {
fail(`Unsupported symlink in ${label}: ${relative}`);
symlinkCount += 1;
} else if (entry.isDirectory()) {
walk(file);
}
}
};
walk(root);
if (symlinkCount === 0) {
ok(`${label} contains no symlinks`);
}
}
function binName(engine) {
return `${engine}${suffix}`;
}
function binPath(engine) {
return path.join(binariesDir, binName(engine));
}
function engineEnv(engine) {
if (engine !== 'aria2c') {
return process.env;
}
const modulesDir = path.join(binariesDir, 'aria2-libs');
if (!fs.existsSync(modulesDir)) {
return process.env;
}
return {
...process.env,
OPENSSL_MODULES: modulesDir,
};
}
// ───── Check 1: Sidecar existence ─────
console.log(`\n─── 1. Sidecar existence (${targetTriple}) ───`);
for (const eng of requiredEngines) {
const p = binPath(eng);
if (fs.existsSync(p)) {
ok(`Found ${binName(eng)}`);
} else {
fail(`Missing ${binName(eng)}`);
}
}
if (exitCode !== 0) {
console.error('\nAborting: missing required sidecars.');
process.exit(1);
}
// ───── Check 2: Executable permission ─────
console.log('\n─── 2. Executable permission ───');
for (const eng of requiredEngines) {
if (isWindows) {
ok(`Executable permission is represented by PE format on Windows: ${binName(eng)}`);
continue;
}
try {
fs.accessSync(binPath(eng), fs.constants.X_OK);
ok(`Executable ${binName(eng)}`);
} catch {
fail(`Not executable ${binName(eng)}`);
}
}
// ───── Check 3: file(1) identification ─────
console.log('\n─── 3. file(1) identification ───');
for (const eng of requiredEngines) {
if (os.platform() === 'win32') {
try {
const header = fs.readFileSync(binPath(eng)).subarray(0, 2).toString('ascii');
if (header === 'MZ') {
ok(`${binName(eng)}: Windows PE executable`);
} else {
fail(`${binName(eng)}: missing Windows PE MZ header`);
}
} catch (e) {
fail(`identify ${binName(eng)}: ${e.message}`);
}
continue;
}
try {
const out = execFileSync('file', ['--brief', binPath(eng)], {
encoding: 'utf-8',
timeout: 5000,
}).trim();
ok(`${binName(eng)}: ${out}`);
} catch (e) {
fail(`file ${binName(eng)}: ${e.message}`);
}
}
// ───── Check 4 & 5: otool -L linkage (macOS only) ─────
if (isMacOS) {
console.log('\n─── 4 & 5. otool -L linkage check ───');
for (const eng of requiredEngines) {
const p = binPath(eng);
try {
const out = execFileSync('otool', ['-L', p], {
encoding: 'utf-8',
timeout: 10000,
});
const lines = out
.split('\n')
.slice(1)
.map((l) => l.trim())
.filter(Boolean);
let hasBad = false;
for (const fp of FORBIDDEN_OTOOL_PATHS) {
for (const line of lines) {
if (line.includes(fp)) {
fail(`${binName(eng)} links to '${fp}': ${line}`);
hasBad = true;
}
}
}
if (!hasBad) {
ok(`${binName(eng)}: no local-only dylib paths`);
}
} catch (e) {
fail(`otool -L ${binName(eng)}: ${e.message}`);
}
}
}
// ───── Check 6: yt-dlp packaging ─────
console.log('\n─── 6. yt-dlp packaging ───');
{
const yt = binPath('yt-dlp');
if (!fs.existsSync(yt)) {
fail('yt-dlp binary not found, cannot verify packaging');
} else {
const internalDir = path.join(binariesDir, '_internal');
const hasInternal = fs.existsSync(internalDir) && fs.statSync(internalDir).isDirectory();
if (hasInternal) {
console.log(' Detected PyInstaller onedir layout (_internal/ present)');
let entries;
try {
entries = fs.readdirSync(internalDir);
ok(`_internal/ directory exists with ${entries.length} entries`);
} catch (e) {
fail(`Cannot read _internal/: ${e.message}`);
}
rejectSymlinks(internalDir, '_internal/');
const requiredRuntimeFiles = [
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'core.min.js'),
path.join(internalDir, 'yt_dlp_ejs', 'yt', 'solver', 'lib.min.js'),
];
for (const required of requiredRuntimeFiles) {
if (fs.existsSync(required)) {
ok(`yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
} else {
fail(`Missing yt-dlp runtime component: ${path.relative(binariesDir, required)}`);
}
}
const runtimeCandidates = isWindows
? fs.readdirSync(internalDir).filter(name => /^python.*\.dll$/i.test(name))
: isLinux
? fs.readdirSync(internalDir).filter(name => /^libpython.*\.so/i.test(name) || name === 'Python')
: ['Python', 'Python.framework'].filter(name => fs.existsSync(path.join(internalDir, name)));
if (runtimeCandidates.length > 0) {
ok(`yt-dlp embedded Python runtime: ${runtimeCandidates[0]}`);
} else {
fail(`Missing embedded Python runtime in ${internalDir}`);
}
} else {
fail('yt-dlp must use the self-contained onedir distribution; onefile adds ~17 seconds to every launch');
}
}
}
const aria2LibsDir = path.join(binariesDir, 'aria2-libs');
if (fs.existsSync(aria2LibsDir) && fs.statSync(aria2LibsDir).isDirectory()) {
rejectSymlinks(aria2LibsDir, 'aria2-libs/');
}
// ───── Check 7, 8 & 9: Engine version self-tests ─────
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
function runEngine(label, engine, args, timeout = 30000) {
const p = binPath(engine);
if (!fs.existsSync(p)) {
fail(`${label} binary not found at ${p}`);
return;
}
let stderr = '';
try {
const stdout = execFileSync(p, args, {
encoding: 'utf-8',
env: engineEnv(engine),
timeout,
stdio: ['ignore', 'pipe', 'pipe'],
});
const firstLine = stdout.trim().split('\n')[0];
ok(`${label} version: ${firstLine}`);
} catch (e) {
stderr = e.stderr || '';
const stdout = e.stdout || '';
const detail = stdout.trim().split('\n')[0] || e.message;
fail(`${label} execution failed: ${detail}`);
}
if (stderr) {
for (const pattern of FORBIDDEN_STDERR) {
if (stderr.includes(pattern)) {
fail(`${label} stderr contains '${pattern}'`);
}
}
}
}
const coldStartTimeout = isMacOS ? 120000 : 30000;
if (canExecuteTarget) {
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
runEngine('yt-dlp warm start', 'yt-dlp', ['--version'], 8000);
runEngine('ffmpeg cold start', 'ffmpeg', ['-version'], coldStartTimeout);
runEngine('deno cold start', 'deno', ['--version'], coldStartTimeout);
runEngine('aria2c cold start', 'aria2c', ['--version'], coldStartTimeout);
if (isMacOS) {
// Unsigned binaries can incur a one-time macOS provenance scan after copying.
// Warm checks enforce engine startup performance after that OS validation.
runEngine('ffmpeg warm start', 'ffmpeg', ['-version'], 8000);
runEngine('deno warm start', 'deno', ['--version'], 8000);
runEngine('aria2c warm start', 'aria2c', ['--version'], 8000);
}
} else {
console.log(` Runtime tests skipped on ${hostTriple}; native ${targetTriple} CI runs them.`);
}
// ───── aria2 RPC smoke test (native target only) ─────
if (canExecuteTarget) {
console.log('\n─── aria2 RPC smoke test ───');
await (async function testAria2Rpc() {
const p = binPath('aria2c');
if (!fs.existsSync(p)) {
fail('aria2c binary not found, cannot run RPC test');
return;
}
const port = 16801 + (process.pid % 1000);
const proc = spawn(p, [
'--enable-rpc',
`--rpc-listen-port=${port}`,
'--rpc-max-request-size=1K',
'--quiet',
'--console-log-level=error',
'--rpc-listen-all=false',
], {
env: engineEnv('aria2c'),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
});
let rpcStderr = '';
proc.stderr.on('data', (d) => {
rpcStderr += d.toString();
});
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'firelink-verify',
method: 'aria2.getVersion',
params: [],
});
const result = await new Promise((resolve) => {
const maxAttempts = 20;
let attempts = 0;
function tryFetch() {
attempts++;
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
.then(async (res) => {
resolve({ ok: true, data: await res.text() });
})
.catch(() => {
if (attempts >= maxAttempts) {
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
return;
}
setTimeout(tryFetch, 300);
});
}
tryFetch();
});
// Clean up
proc.kill('SIGTERM');
setTimeout(() => {
try {
proc.kill('SIGKILL');
} catch {}
}, 2000);
if (result.ok) {
try {
const resp = JSON.parse(result.data);
if (resp?.result?.version) {
ok(`aria2 RPC version: ${resp.result.version}`);
} else {
fail(`aria2 RPC unexpected response: ${result.data}`);
}
} catch (e) {
fail(`aria2 RPC parse error: ${e.message}`);
}
} else {
fail(result.error);
}
if (rpcStderr) {
for (const pattern of FORBIDDEN_STDERR) {
if (rpcStderr.includes(pattern)) {
fail(`aria2 RPC stderr contains '${pattern}'`);
}
}
}
})();
}
// ───── Result ─────
console.log('');
if (exitCode !== 0) {
console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`);
process.exit(1);
} else {
console.log('[PASS] All engine verification checks passed.');
process.exit(0);
}
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const appArg = argValue('--app');
const dmgArg = argValue('--dmg');
if (process.platform !== 'darwin') {
console.error('macOS signing verification must run on a macOS host.');
process.exit(1);
}
if (!appArg && !dmgArg) {
console.error('Pass --app <Firelink.app> and/or --dmg <Firelink.dmg>.');
process.exit(1);
}
let exitCode = 0;
function fail(message) {
console.error(`[FAIL] ${message}`);
exitCode = 1;
}
function ok(message) {
console.log(`[OK] ${message}`);
}
function note(message) {
console.log(`[INFO] ${message}`);
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
}
function runResult(command, args) {
return spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function assertAppPath(appPath, label) {
if (!fs.existsSync(appPath)) {
fail(`${label} does not exist at ${appPath}`);
return false;
}
const stat = fs.statSync(appPath);
if (!stat.isDirectory() || path.extname(appPath) !== '.app') {
fail(`${label} is not an .app bundle: ${appPath}`);
return false;
}
ok(`${label} exists: ${appPath}`);
return true;
}
function codesignDetails(targetPath) {
const result = runResult('codesign', ['-dv', '--verbose=4', targetPath]);
return {
ok: result.status === 0,
output: `${result.stdout || ''}${result.stderr || ''}`,
};
}
function verifyCodeSignature(targetPath, label, options = {}) {
const {
deep = false,
quietOk = false,
requireAdhoc = false,
warnOnVerifyFailure = false,
} = options;
const verifyArgs = ['--verify'];
if (deep) {
verifyArgs.push('--deep');
}
verifyArgs.push('--strict', '--verbose=2', targetPath);
const details = codesignDetails(targetPath);
if (!details.ok) {
fail(`${label}: no readable code signature: ${details.output.trim() || 'codesign -dv failed'}`);
return 'failed';
}
let status = 'verified';
try {
run('codesign', verifyArgs);
if (!quietOk) {
ok(`${label}: codesign verification passed`);
}
} catch (error) {
const detail = error.stderr?.trim() || error.message;
if (!warnOnVerifyFailure) {
fail(`${label}: codesign verification failed: ${detail}`);
return 'failed';
}
note(`${label}: signed, but individual verification warned: ${detail}`);
status = 'warning';
}
if (requireAdhoc && !details.output.includes('Signature=adhoc')) {
fail(`${label}: expected ad-hoc signature, but codesign did not report Signature=adhoc`);
return 'failed';
}
if (requireAdhoc && !quietOk) {
ok(`${label}: ad-hoc signature present`);
}
return status;
}
function walkFiles(root, visitor) {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
walkFiles(entryPath, visitor);
continue;
}
if (entry.isFile()) {
visitor(entryPath);
}
}
}
function fileBrief(filePath) {
try {
return run('file', ['--brief', filePath], { timeout: 5000 }).trim();
} catch (error) {
fail(`file(1) failed for ${filePath}: ${error.stderr?.trim() || error.message}`);
return '';
}
}
function verifyMachOObjects(appPath, label) {
const machOFiles = [];
walkFiles(appPath, filePath => {
const description = fileBrief(filePath);
if (description.includes('Mach-O')) {
machOFiles.push(filePath);
}
});
if (machOFiles.length === 0) {
fail(`${label}: no Mach-O files found inside app bundle`);
return;
}
let warningCount = 0;
let signedCount = 0;
let verifiedCount = 0;
for (const filePath of machOFiles) {
const relative = path.relative(appPath, filePath).split(path.sep).join('/');
const isPrimaryExecutable = relative === 'Contents/MacOS/firelink';
const isDirectEngine = /^Contents\/Resources\/engine-dist\/[^/]+\/(?:yt-dlp|aria2c|ffmpeg|deno)-/.test(relative);
const mayWarn = !isPrimaryExecutable && !isDirectEngine;
const result = verifyCodeSignature(filePath, `${label} ${relative}`, {
quietOk: true,
warnOnVerifyFailure: mayWarn,
});
if (result !== 'failed') {
signedCount += 1;
if (result === 'verified') {
verifiedCount += 1;
} else {
warningCount += 1;
}
}
}
ok(`${label}: found signatures on ${signedCount}/${machOFiles.length} Mach-O code object(s)`);
ok(`${label}: individually verified ${verifiedCount}/${machOFiles.length} Mach-O code object(s)`);
if (warningCount > 0) {
note(`${label}: ${warningCount} nested signed framework object(s) produced individual verification warnings; the outer bundle signature remains authoritative.`);
}
}
function assessGatekeeper(appPath, label) {
const result = runResult('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]);
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (result.status === 0) {
note(`${label}: Gatekeeper accepted this app (${output || 'no spctl detail'}).`);
return;
}
const normalized = output.toLowerCase();
if (normalized.includes('not signed at all') || normalized.includes('invalid signature')) {
fail(`${label}: Gatekeeper rejection indicates a broken signature: ${output}`);
return;
}
note(`${label}: Gatekeeper rejected the app as expected for ad-hoc, unnotarized distribution: ${output || `exit ${result.status}`}`);
}
function reportQuarantine(targetPath, label) {
const result = runResult('xattr', ['-p', 'com.apple.quarantine', targetPath]);
if (result.status === 0) {
fail(`${label}: build artifact unexpectedly has com.apple.quarantine=${result.stdout.trim()}`);
} else {
ok(`${label}: no quarantine xattr on generated artifact`);
}
}
function verifyApp(appPath, label) {
const resolved = path.resolve(appPath);
if (!assertAppPath(resolved, label)) {
return;
}
reportQuarantine(resolved, label);
verifyCodeSignature(resolved, label, { deep: true, requireAdhoc: true });
verifyMachOObjects(resolved, label);
assessGatekeeper(resolved, label);
}
function attachDmg(dmgPath) {
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-dmg-'));
try {
const plist = run('hdiutil', [
'attach',
'-plist',
'-nobrowse',
'-readonly',
'-mountpoint',
mountPoint,
dmgPath,
], { timeout: 60000 });
return { mountPoint, plist };
} catch (error) {
fs.rmSync(mountPoint, { recursive: true, force: true });
throw error;
}
}
function detachDmg(mountPoint) {
const result = runResult('hdiutil', ['detach', mountPoint]);
if (result.status !== 0) {
note(`Initial hdiutil detach failed, retrying with -force: ${result.stderr?.trim() || result.stdout?.trim()}`);
const forced = runResult('hdiutil', ['detach', '-force', mountPoint]);
if (forced.status !== 0) {
fail(`Failed to detach DMG mount point ${mountPoint}: ${forced.stderr?.trim() || forced.stdout?.trim()}`);
}
}
fs.rmSync(mountPoint, { recursive: true, force: true });
}
function verifyDmg(dmgPath) {
const resolved = path.resolve(dmgPath);
if (!fs.existsSync(resolved)) {
fail(`DMG does not exist at ${resolved}`);
return;
}
reportQuarantine(resolved, 'DMG');
let mount;
try {
mount = attachDmg(resolved);
ok(`DMG mounted at ${mount.mountPoint}`);
const apps = fs.readdirSync(mount.mountPoint)
.filter(name => name.endsWith('.app'))
.map(name => path.join(mount.mountPoint, name));
if (apps.length !== 1) {
fail(`Expected exactly one .app inside DMG, found ${apps.length}`);
return;
}
verifyApp(apps[0], 'DMG app');
} catch (error) {
fail(`DMG verification failed: ${error.stderr?.trim() || error.message}`);
} finally {
if (mount) {
detachDmg(mount.mountPoint);
}
}
}
if (appArg) {
verifyApp(appArg, 'Built app');
}
if (dmgArg) {
verifyDmg(dmgArg);
}
console.log('');
if (exitCode !== 0) {
console.error('[FAIL] macOS signing verification failed.');
process.exit(1);
}
console.log('[PASS] macOS ad-hoc signing verification passed.');
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const capability = JSON.parse(fs.readFileSync('src-tauri/capabilities/default.json', 'utf8'));
const permissions = new Set(capability.permissions);
test('custom window controls have required Tauri permissions', () => {
const windowControls = fs.readFileSync('src/components/WindowControls.tsx', 'utf8');
const requiredPermissions = new Map([
['.close()', 'core:window:allow-close'],
['.minimize()', 'core:window:allow-minimize'],
['.startDragging()', 'core:window:allow-start-dragging'],
['.toggleMaximize()', 'core:window:allow-toggle-maximize'],
]);
for (const [apiCall, permission] of requiredPermissions) {
if (windowControls.includes(apiCall)) {
assert.equal(
permissions.has(permission),
true,
`${apiCall} requires ${permission} in src-tauri/capabilities/default.json`
);
}
}
});
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+6984
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
[package]
name = "firelink"
version = "1.0.3"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
license = "MIT"
repository = "https://github.com/nimbold/Firelink"
homepage = "https://github.com/nimbold/Firelink"
keywords = ["download-manager", "tauri", "aria2", "yt-dlp", "ffmpeg"]
categories = ["network-programming"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "firelink_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3"
hmac = "0.13"
sha2 = "0.11"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.18"
axum = "0.8.9"
tower-http = { version = "0.7", features = ["cors"] }
sysproxy = "0.3.0"
semver = "1.0.28"
keepawake = "0.6.0"
system_shutdown = "4.1.0"
tokio-tungstenite = "0.29.0"
futures-util = { version = "0.3.32", features = ["sink"] }
chrono = "0.4.38"
url = "2"
rusqlite = { version = "0.40.1", features = ["bundled"] }
log = "0.4.32"
tauri-plugin-log = "2"
trash = "5"
async-trait = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7"
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More