Compare commits

...

380 Commits

Author SHA1 Message Date
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
392 changed files with 41847 additions and 7329 deletions
+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 }}
+142 -81
View File
@@ -2,99 +2,160 @@ 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 QA is certified'
type: boolean
default: false
certified_cross_platform:
description: 'Confirm Windows, Linux, and macOS clean-machine QA passed'
type: boolean
default: false
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
- uses: actions/checkout@v7
with:
submodules: recursive
- 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/setup-node@v6
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'
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 \
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:
GH_TOKEN: ${{ github.token }}
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"
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: >-
github.event_name == 'workflow_dispatch' &&
startsWith(github.ref, 'refs/tags/v') &&
inputs.publish_release &&
inputs.certified_cross_platform
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
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 --notes-file release_notes.md --verify-tag
fi
test -s release_notes.md
- name: Generate checksums
run: |
cd release-assets
find . -type f -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
+228 -2
View File
@@ -1,10 +1,236 @@
# 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.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
@@ -131,7 +357,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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.
-20
View File
@@ -1,20 +0,0 @@
.PHONY: build app dmg run verify clean
build:
swift build -c release
app:
Scripts/create_app_bundle.sh
dmg: app
Scripts/create_dmg.sh
run:
swift run Firelink
verify:
Scripts/verify.sh
clean:
swift package clean
rm -rf build dist
-22
View File
@@ -1,22 +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",
resources: [
.process("Assets.xcassets")
]
)
]
)
+143 -70
View File
@@ -1,99 +1,172 @@
<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.0-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](https://img.shields.io/badge/Windows-0078D6?style=flat-square&logo=windows11&logoColor=white)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app/)
[![Rust](https://img.shields.io/badge/Rust-backend-000000?style=flat-square&logo=rust)](https://www.rust-lang.org/)
[![React](https://img.shields.io/badge/React-TypeScript-61DAFB?style=flat-square&logo=react&logoColor=111111)](https://react.dev/)
[![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>
---
**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.
---
### 📸 Screenshots
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
<div align="center">
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup</sub>
</div>
<details>
<summary><b>☀️ View Light Theme Screenshots</b></summary>
<br/>
<div align="center">
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
<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>
</details>
---
## Why Firelink
## ✨ Features
Firelink is built for people who want a real desktop download manager again: fast segmented transfers, browser capture, media extraction, scheduling, recovery, and clear control over where files land. Version 1.0.0 completes the move from the earlier macOS-only Swift app to a modern Rust/Tauri application with a React and TypeScript interface.
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
- 🧾 **Download Properties:** Inspect progress and tune per-download transfer settings.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
The app keeps the heavy work native. Downloads are coordinated by a Rust backend, accelerated with aria2, enriched with yt-dlp and FFmpeg for media workflows, and persisted locally with SQLite so queues survive restarts and app updates.
---
## Features
## 🛠️ Quick Start
- **Fast segmented downloads** powered by aria2 with configurable connections, retries, and speed limits.
- **Media extraction** with yt-dlp, FFmpeg, and Deno for video/audio links and richer format selection.
- **A real Add window** for manual, extension-captured, and media downloads, including metadata, duplicate handling, and save-location choices before downloads start.
- **Persistent queue management** with safe concurrency limits, pause/resume, retry, redownload, sorting, multi-select, and bulk controls.
- **Download scheduling** with start/stop windows, speed-limiter tools, and optional post-queue actions.
- **Smart organization** through categories, default folders, per-download overrides, and open/reveal/trash actions.
- **Private browser handoff** through authenticated local pairing with replay protection and desktop-server proof checks.
- **Native desktop integration** including tray controls, notifications, completion sounds, sleep prevention, and OS keychain support where available.
- **Diagnostics built in** with engine health checks, structured logs, and packaged-engine verification.
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
## Installation
Run the application directly:
```bash
swift run Firelink
Download the latest desktop build from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
| Platform | Package | Notes |
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Unsigned and not notarized. Open through Finder or approve once 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. |
Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for normal app usage.
## Browser Extension
<div align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20Firelink%20Companion-Firefox-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion on Firefox" /></a>
</div>
Firelink Companion connects your browser to the desktop app so links and browser downloads can open in Firelink instead of disappearing into the browser's default download shelf.
What it adds:
- **Automatic capture** for normal browser downloads, while still routing every captured link through Firelink's Add window.
- **Context-menu actions** for "Download with Firelink" and selected links.
- **Signed local requests** using the pairing token from **Settings -> Integrations**.
- **Server identity checks** so the extension only trusts the real local Firelink app.
- **Offline-safe behavior** that resumes browser downloads when Firelink is closed or rejects a handoff.
- **Protocol-aware compatibility** so older desktop builds are rejected before automatic capture can cancel a browser download.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. The Firefox add-on is maintained in the [Firelink-Extension](https://github.com/nimbold/Firelink-Extension) repository and is also vendored here as the `Extensions/Firefox` submodule.
## Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported. Automated native build, engine validation, packaged launch smoke test, and unsigned DMG packaging. |
| **Windows x64** | Supported. Native GitHub Actions build, engine validation, silent installer smoke test, and NSIS packaging. |
| **Linux x64** | Supported. Native GitHub Actions build, engine validation, AppImage repackaging, and xvfb launch smoke test. |
## 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
```
---
Run the core checks:
## 🧩 Browser Extension
```sh
node --test scripts/*.node-test.js
npm test -- --run
npm run build
cd src-tauri
cargo test --all-targets
```
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
Create a production bundle:
---
```sh
npm run tauri build
```
## 🗺️ Roadmap
macOS development uses locked payloads in `src-tauri/binaries`. Windows and Linux payloads are provisioned from checksum-pinned archives:
- [x] Zero-Config `aria2c` bundling.
- [x] Global & per-download Speed Limiter.
- [x] Browser Extensions support.
- [x] In-app integrated Settings UI.
- [ ] Notarized `.app` releases and Homebrew formulae.
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
---
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
## 🏆 Credits
## Repository Structure
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
```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
```
---
## Help and Project Status
## 📄 License
- 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.
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
## 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.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

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

-89
View File
@@ -1,89 +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"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js"
cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json"
cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons"
cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup"
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"
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
swift build
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
-663
View File
@@ -1,663 +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 = ""
@State private var useAuthorization = false
@State private var authUsername = ""
@State private var authPassword = ""
@State private var saveLogin = false
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(12)
}
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onChange(of: metadataRequestSignature) { _, _ in
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
scheduleMetadataRefresh(for: linkText)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
applyPendingReferer()
linkText = text
controller.pendingPasteboardText = nil
}
}
.onDisappear {
metadataTask?.cancel()
}
}
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.subheadline.weight(.semibold))
TextEditor(text: $linkText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 72)
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: 12, verticalSpacing: 8) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.subheadline.weight(.semibold))
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
.disabled(!overrideDestination)
Button {
selectDestination()
} label: {
Label("Select...", systemImage: "folder.badge.plus")
}
.disabled(!overrideDestination)
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Queue", systemImage: "tray.full")
.font(.subheadline.weight(.semibold))
Picker("Queue", selection: $targetQueueID) {
ForEach(controller.queues) { queue in
Text(queue.name).tag(queue.id)
}
}
.labelsHidden()
.frame(maxWidth: 220, alignment: .leading)
}
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 145)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 98, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit per File", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit each file", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
.disabled(!speedLimitEnabled)
}
}
}
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.subheadline.weight(.semibold))
.padding(.top, 4)
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var summarySection: some View {
CompactSummaryStrip(
metrics: [
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
]
)
}
private var previewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.subheadline.weight(.semibold))
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(86)
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(110)
}
.frame(minHeight: 160)
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
}
.disabled(!canAddDownloads)
Button("Start Downloads") {
addDownloads(start: true)
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
}
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
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(.callout, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.subheadline.weight(.semibold))
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
}
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.semibold))
}
}
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 var metadataRequestSignature: String {
[
headerText,
cookieText,
useAuthorization ? "auth" : "no-auth",
authUsername,
authPassword
].joined(separator: "\u{1f}")
}
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanUsername.isEmpty else { return nil }
return DownloadCredentials(username: cleanUsername, password: authPassword)
}
return settings.credentials(for: url)
}
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 applyPendingReferer() {
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
!referer.isEmpty,
URL(string: referer) != nil else {
controller.pendingReferer = nil
return
}
let refererHeader = "Referer: \(referer)"
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? refererHeader
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
}
controller.pendingReferer = nil
}
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
)
}
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
useAuthorization = true
authUsername = creds.username
authPassword = creds.password
saveLogin = false
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(
for: url,
settings: settings,
credentials: metadataCredentials(for: url),
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
}
}
}
}
await MainActor.run {
metadataTask = nil
}
}
}
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) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
queueID: targetQueueID,
credentials: explicitCredentials,
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 SummaryMetric: Identifiable {
let title: String
let value: String
let symbolName: String
var id: String { title }
}
private struct CompactSummaryStrip: View {
let metrics: [SummaryMetric]
var body: some View {
HStack(spacing: 12) {
ForEach(metrics) { metric in
HStack(spacing: 6) {
Image(systemName: metric.symbolName)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.title)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.value)
.font(.caption.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
if metric.id != metrics.last?.id {
Divider()
.frame(height: 14)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.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)
}
}
}
-361
View File
@@ -1,361 +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
case socks5
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS (legacy)"
case .ftp: "FTP (legacy)"
case .socks5: "SOCKS5"
}
}
var uriScheme: String {
switch self {
case .http, .https, .ftp:
"http"
case .socks5:
"socks5"
}
}
}
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)
if copy.type != .http {
copy.type = .http
}
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 appTheme: AppTheme = .system {
didSet { save() }
}
@Published var appFontSize: AppFontSize = .standard {
didSet { save() }
}
@Published var listRowDensity: ListRowDensity = .standard {
didSet { save() }
}
@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"
private var saveTask: Task<Void, Never>?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
appFontSize = stored.appFontSize ?? .standard
listRowDensity = stored.listRowDensity ?? .standard
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 {
appTheme = .system
appFontSize = .standard
listRowDensity = .standard
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) {
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
}
func saveSiteLogin(id: UUID?, 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
}
if let id,
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
message = "A login for \(pattern) already exists."
return
}
if let index = siteLogins.firstIndex(where: { login in
if let id {
return login.id == id
}
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
}) {
let loginID = siteLogins[index].id
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
message = "Could not save the password to Keychain."
return
}
siteLogins[index].urlPattern = pattern
siteLogins[index].username = cleanUsername
message = "Updated login for \(pattern)."
return
}
guard !password.isEmpty else {
message = "Add a password."
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
if !KeychainCredentialStore.setPassword(password, for: login.id) {
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(
appTheme: appTheme,
appFontSize: appFontSize,
listRowDensity: listRowDensity,
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
let defaults = self.defaults
let storageKey = self.storageKey
saveTask?.cancel()
saveTask = Task { @MainActor [defaults, storageKey] in
let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored)
}.value
guard !Task.isCancelled, let encoded = data else { return }
defaults.set(encoded, 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
}
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 appTheme: AppTheme?
var appFontSize: AppFontSize?
var listRowDensity: ListRowDensity?
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
}
}
}
-602
View File
@@ -1,602 +0,0 @@
import Foundation
import CFNetwork
import Network
final class Aria2DownloadEngine {
struct Handle {
let processIdentifier: Int32
let rpcPort: Int
let rpcSecret: String
let cancel: @Sendable () -> Void
}
static func findFreePort() -> Int {
var port: UInt16 = 6800
let parameters = NWParameters.tcp
for p in 6800...6900 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
if let listener = try? NWListener(using: parameters) {
listener.cancel()
port = UInt16(p)
break
}
}
return Int(port)
}
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() async -> String? {
guard let executableURL = findExecutable() else { return nil }
return await Task.detached {
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = nil
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
do {
try process.run()
// Close the write file handle in the parent process immediately
// This guarantees readToEnd() won't hang waiting for the parent itself
outputPipe.fileHandleForWriting.closeFile()
let data = try? outputPipe.fileHandleForReading.readToEnd()
process.waitUntilExit()
guard process.terminationStatus == 0, let data = data else { return nil }
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}.value
}
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 rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
)
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()
let completionGate = CompletionGate(completion)
let completionMonitor = CompletionMonitor()
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
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completionGate.complete(.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")
completionGate.complete(.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)
}
completionMonitor.set(
Self.monitorCompletion(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
process: process,
completionGate: completionGate
)
)
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel()
if process.isRunning {
process.terminate()
}
}
}
private static func monitorCompletion(
rpcPort: Int,
rpcSecret: String,
process: Process,
completionGate: CompletionGate
) -> Task<Void, Never> {
Task.detached {
while !Task.isCancelled && process.isRunning {
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionGate.complete(.success(()))
if process.isRunning {
process.terminate()
}
return
}
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
}
}
}
private static func completedDownloadStatus(rpcPort: Int, rpcSecret: String) async -> Bool {
guard let stopped = await rpcCall(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
method: "aria2.tellStopped",
arguments: [0, 10, ["status", "errorCode", "completedLength", "totalLength"]]
) as? [[String: Any]] else {
return false
}
if stopped.contains(where: { item in
(item["status"] as? String) == "complete"
}) {
return true
}
return stopped.contains { item in
guard (item["status"] as? String) == "error",
(item["errorCode"] as? String) == "0",
let completedLength = Int64(item["completedLength"] as? String ?? ""),
let totalLength = Int64(item["totalLength"] as? String ?? ""),
totalLength > 0 else {
return false
}
return completedLength >= totalLength
}
}
private static func rpcCall(
rpcPort: Int,
rpcSecret: String,
method: String,
arguments: [Any]
) async -> Any? {
guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/jsonrpc") else { return nil }
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": method,
"id": UUID().uuidString,
"params": ["token:\(rpcSecret)"] + arguments
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
request.timeoutInterval = 3
guard let (responseData, _) = try? await URLSession.shared.data(for: request),
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
return nil
}
return json["result"]
}
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.changeGlobalOption",
"id": UUID().uuidString,
"params": [
"token:\(handle.rpcSecret)",
["max-overall-download-limit": limitValue]
]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
request.timeoutInterval = 3
_ = try? await URLSession.shared.data(for: request)
}
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
rpcSecret: String
) 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=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
"--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-overall-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.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
" 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()
private let maxBytes: Int
init(maxBytes: Int = 512 * 1024) {
self.maxBytes = maxBytes
}
var data: Data {
lock.withLock { storage }
}
func append(_ data: Data) {
lock.withLock {
storage.append(data)
if storage.count > maxBytes {
storage.removeFirst(storage.count - maxBytes)
}
}
}
}
final class CompletionGate: @unchecked Sendable {
private let lock = NSLock()
private var didComplete = false
private let completion: @Sendable (Result<Void, Error>) -> Void
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
self.completion = completion
}
func complete(_ result: Result<Void, Error>) {
lock.lock()
let shouldComplete = !didComplete
if shouldComplete {
didComplete = true
}
lock.unlock()
guard shouldComplete else { return }
completion(result)
}
}
final class CompletionMonitor: @unchecked Sendable {
private let lock = NSLock()
private var task: Task<Void, Never>?
func set(_ task: Task<Void, Never>) {
lock.lock()
self.task = task
lock.unlock()
}
func cancel() {
lock.lock()
let task = self.task
self.task = nil
lock.unlock()
task?.cancel()
}
}
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])
}
}
@@ -1,15 +0,0 @@
{
"images" : [
{
"filename" : "github-mark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

@@ -1,16 +0,0 @@
{
"images" : [
{
"filename" : "MenuBarIconTemplate.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

-160
View File
@@ -1,160 +0,0 @@
import SwiftUI
struct ChunkMapView: View {
let item: DownloadItem
@State private var bitfield: String = ""
@State private var numPieces: Int = 0
@State private var pollTask: Task<Void, Never>?
@State private var isVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if numPieces > 0 {
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
} else {
Text("Loading chunk data...")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.onAppear {
isVisible = true
startPolling()
}
.onDisappear {
isVisible = false
pollTask?.cancel()
}
.onChange(of: item.status) { _, status in
if status != .downloading {
pollTask?.cancel()
} else if isVisible && pollTask == nil {
startPolling()
}
}
}
private func startPolling() {
pollTask?.cancel()
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
pollTask = Task {
while !Task.isCancelled {
await fetchStatus(port: port, secret: secret)
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
break
}
}
}
}
private func fetchStatus(port: Int, secret: String) async {
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.tellActive",
"id": "1",
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
request.httpBody = data
do {
let (responseData, _) = try await URLSession.shared.data(for: request)
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
let result = json["result"] as? [[String: Any]],
let active = result.first else {
return
}
let fetchedBitfield = active["bitfield"] as? String ?? ""
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
await MainActor.run {
self.bitfield = fetchedBitfield
self.numPieces = fetchedNumPieces
}
} catch {
// Ignore errors
}
}
}
struct ChunkGrid: View {
let bitfield: String
let numPieces: Int
private var pieces: [Bool] {
var result = [Bool]()
result.reserveCapacity(numPieces)
for char in bitfield {
if let val = char.hexDigitValue {
for i in (0..<4).reversed() {
if result.count < numPieces {
result.append((val & (1 << i)) != 0)
}
}
}
}
while result.count < numPieces {
result.append(false)
}
return result
}
var body: some View {
let itemPieces = pieces
Canvas { context, size in
let boxSize: CGFloat = 10
let spacing: CGFloat = 2
let cornerSize = CGSize(width: 2, height: 2)
let width = size.width
let x: CGFloat = 0
let y: CGFloat = 0
let completedPath = Path { p in
var cx = x
var cy = y
for piece in itemPieces {
if piece {
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
let pendingPath = Path { p in
var cx: CGFloat = 0
var cy: CGFloat = 0
for piece in itemPieces {
if !piece {
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
context.fill(completedPath, with: .color(Color.accentColor))
}
.frame(minHeight: 140)
}
}
-229
View File
@@ -1,229 +0,0 @@
import AppKit
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@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)
.themeBackground(settings.appTheme.theme.secondaryBackground)
} detail: {
detailView
.themeBackground(settings.appTheme.theme.background)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
for provider in providers {
if provider.canLoadObject(ofClass: URL.self) {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url = url {
DispatchQueue.main.async {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
} else if provider.canLoadObject(ofClass: String.self) {
_ = provider.loadObject(ofClass: String.self) { text, _ in
if let text = text {
DispatchQueue.main.async {
controller.pendingPasteboardText = text
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
}
}
}
}
return true
}
}
@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 .speedLimiter:
SpeedLimiterView()
case .settings:
SettingsPaneContainer()
}
}
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")
}
}
ToolbarItemGroup {
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
Button {
if selectedItems.isEmpty {
controller.pauseActiveDownloads(queueID: queueID)
} else {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
}
.disabled(!canStop)
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
Button {
if selectedItems.isEmpty {
controller.startQueue(queueID: queueID)
} else {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
}
.disabled(!canStart)
}
}
.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.pendingReferer = nil
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
private func selectAll(items: [DownloadItem]) {
selection = Set(items.map { $0.id })
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
}
return controller.activeCount > 0
}
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.queueItems(for: queueID).contains { $0.status == .queued }
}
return controller.queuedCount > 0
}
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 }
}
}
}
-933
View File
@@ -1,933 +0,0 @@
import AppKit
import Combine
import Foundation
import UserNotifications
@MainActor
final class DownloadController: ObservableObject {
@Published var downloads: [DownloadItem] = []
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
@Published var pendingReferer: String?
@Published var extensionServerPort: UInt16?
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")
}()
private var saveTask: Task<Void, Never>?
init(settings: AppSettings) {
self.settings = settings
let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in
Task { @MainActor in
self?.updateSleepActivity()
}
}
.store(in: &cancellables)
settings.$globalSpeedLimitKiBPerSecond
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
}
}
.store(in: &cancellables)
settings.$maxConcurrentDownloads
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
self?.pumpQueue()
}
}
.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)
)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID,
credentials: DownloadCredentials? = nil,
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
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
return DownloadItem(
url: pending.url,
fileName: fileName,
category: FileClassifier.category(forFileName: fileName),
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: 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
)
}
for item in items {
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
}
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()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
func pauseActiveDownloads(queueID: UUID? = nil) {
let targetQueueID = queueID.map(normalizedQueueID)
let activeItems = downloads.filter { item in
item.status == .downloading && (targetQueueID == nil || item.queueID == targetQueueID)
}
guard !activeItems.isEmpty else { return }
for item in activeItems {
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
}
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads()
applySpeedLimitsToActiveDownloads()
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()
applySpeedLimitsToActiveDownloads()
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 redownload(_ item: DownloadItem) {
trashFiles(for: item)
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Redownloading"
$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()
applySpeedLimitsToActiveDownloads()
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)
}
KeychainCredentialStore.deletePassword(for: item.id)
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
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()
self.showNotification(title: "Download Completed", body: item.fileName)
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.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
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 = FileClassifier.sanitizedFileName(fileName)
$0.category = FileClassifier.category(forFileName: $0.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"
}
if let password = credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: id)
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
applySpeedLimitToActiveDownload(id: id)
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
SpeedLimitPolicy.normalized(value)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
SpeedLimitPolicy.effectiveLimit(
itemLimit: item.speedLimitKiBPerSecond,
globalLimit: settings.globalSpeedLimitKiBPerSecond,
activeDownloadCount: activeCount
)
}
private func applySpeedLimitsToActiveDownloads() {
for item in downloads where item.status == .downloading {
applySpeedLimitToActiveDownload(id: item.id)
}
}
private func applySpeedLimitToActiveDownload(id: UUID) {
guard let handle = activeHandles[id],
let item = downloads.first(where: { $0.id == id }) else {
return
}
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
Task {
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
}
}
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()
if let item = downloads.first(where: { $0.id == itemID }) {
showNotification(title: "Download Failed", body: item.fileName)
}
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() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL
saveTask?.cancel()
saveTask = Task.detached(priority: .background) {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
guard !Task.isCancelled else { return }
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
var shouldRewriteStoredDownloads = isLegacyDownloadList
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
let redacted = adjusted.redactedForPersistence
if redacted != adjusted {
adjusted = redacted
shouldRewriteStoredDownloads = true
}
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
}
if adjusted.status == .completed && adjusted.progress != 1 {
adjusted.progress = 1
shouldRewriteStoredDownloads = true
}
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 || shouldRewriteStoredDownloads {
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 func showNotification(title: String, body: String) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
}
private struct StoredDownloadState: Codable {
var queues: [DownloadQueue]
var downloads: [DownloadItem]
}
enum SpeedLimitPolicy {
static let maximumKiBPerSecond = 10_485_760
static func normalized(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, maximumKiBPerSecond)
}
static func effectiveLimit(
itemLimit: Int?,
globalLimit: Int?,
activeDownloadCount: Int
) -> Int? {
let itemLimit = normalized(itemLimit)
let globalLimit = normalized(globalLimit)
.map { max(1, $0 / max(activeDownloadCount, 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 final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol
init(activity: NSObjectProtocol) {
self.activity = activity
}
func end() {
ProcessInfo.processInfo.endActivity(activity)
}
}
@@ -1,136 +0,0 @@
import Foundation
enum DownloadURLParser {
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static func parse(_ text: String) -> [URL] {
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,
credentials: DownloadCredentials? = nil,
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")
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
let token = "\(credentials.username):\(credentials.password)"
.data(using: .utf8)?
.base64EncodedString()
if let token {
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
}
}
for header in normalizedHeaders {
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 = FileClassifier.sanitizedFileName(fileName)
pending.category = FileClassifier.category(forFileName: pending.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,490 +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 = ""
@State private var showsAdvancedTransfer = false
@State private var showsChunkMap = false
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) {
DownloadSummaryHeader(item: item)
.padding(.horizontal, 18)
.padding(.vertical, 12)
Divider()
Form {
if let noticeText {
Section {
Label(noticeText, systemImage: noticeSystemImage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Download") {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("URL")
.foregroundStyle(.secondary)
TextField("URL", text: $urlText)
.font(.system(.callout, design: .monospaced))
.disabled(fileIdentityLocked)
}
GridRow {
Text("File name")
.foregroundStyle(.secondary)
TextField("File name", text: $fileName)
.disabled(fileIdentityLocked)
}
GridRow {
Text("Save location")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("Save location", text: $destinationPath)
.font(.system(.callout, design: .monospaced))
.disabled(fileIdentityLocked)
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
.disabled(fileIdentityLocked)
}
}
GridRow {
Text("Connections")
.foregroundStyle(.secondary)
Stepper("\(connections) per file", value: $connections, in: 1...16)
.disabled(transferSettingsLocked)
}
GridRow {
Text("Speed")
.foregroundStyle(.secondary)
HStack {
Toggle("Limit", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
}
}
}
Section(item.status == .completed ? "Site Login for Redownload" : "Site Login") {
Picker("Login", selection: $loginMode) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(transferSettingsLocked)
if loginMode == .matching {
Text(matchingLoginText)
.font(.caption)
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
.disabled(transferSettingsLocked)
SecureField("Password", text: $password)
.disabled(transferSettingsLocked)
}
}
Section {
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Checksum")
.foregroundStyle(.secondary)
Toggle("Verify", isOn: $checksumEnabled)
.disabled(transferSettingsLocked)
}
if checksumEnabled {
GridRow {
Text("Algorithm")
.foregroundStyle(.secondary)
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.disabled(transferSettingsLocked)
}
GridRow {
Text("Digest")
.foregroundStyle(.secondary)
TextField("Expected digest", text: $checksumValue)
.font(.system(.callout, design: .monospaced))
.disabled(transferSettingsLocked)
}
}
GridRow {
Text("Cookies")
.foregroundStyle(.secondary)
TextField("Cookies", text: $cookieText)
.font(.system(.callout, design: .monospaced))
.disabled(transferSettingsLocked)
}
}
CompactEditor(title: "Headers", text: $headerText)
.disabled(transferSettingsLocked)
CompactEditor(title: "Mirrors", text: $mirrorText)
.disabled(transferSettingsLocked)
}
}
if item.status == .downloading && item.rpcPort != nil {
Section {
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
ChunkMapView(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(12)
.background(.bar)
}
.frame(width: 720, height: 580)
}
private var fileIdentityLocked: Bool {
item.status == .completed || item.status == .downloading
}
private var transferSettingsLocked: Bool {
item.status == .downloading
}
private var noticeText: String? {
switch item.status {
case .completed:
return "File identity is read-only. Transfer settings are saved for redownload."
case .downloading:
return "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."
default:
return nil
}
}
private var noticeSystemImage: String {
item.status == .completed ? "checkmark.circle" : "bolt.horizontal.circle"
}
private var advancedTransferTitle: String {
item.status == .completed ? "Advanced Transfer for Redownload" : "Advanced Transfer"
}
private struct CompactEditor: View {
let title: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.foregroundStyle(.secondary)
TextEditor(text: $text)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 44, maxHeight: 54)
}
}
}
private struct CollapsibleGroup<Content: View>: View {
let title: String
@Binding var isExpanded: Bool
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 12)
Text(title)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
content()
.padding(.leading, 18)
}
}
}
}
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 = FileClassifier.sanitizedFileName(fileName)
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 DownloadSummaryHeader: View {
let item: DownloadItem
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Label(item.status.rawValue, systemImage: item.category.symbolName)
.foregroundStyle(statusColor)
}
ProgressView(value: item.status == .completed ? 1.0 : item.progress)
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
GridRow {
summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
summary("Size", ByteFormatter.string(item.sizeBytes))
summary("Speed", item.speedText)
summary("ETA", item.etaText)
}
GridRow {
summary("Live connections", "\(item.connectionCount)")
summary("Speed cap", item.speedLimitText)
summary("Category", item.category.rawValue)
summary("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
}
GridRow {
summary("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
.gridCellColumns(2)
summary("Destination", item.destinationPath)
.gridCellColumns(2)
}
}
.font(.caption)
}
}
private var statusColor: Color {
switch item.status {
case .queued:
.secondary
case .downloading:
.accentColor
case .paused:
.orange
case .completed:
.green
case .failed, .canceled:
.red
}
}
private func summary(_ label: String, _ value: String) -> some View {
HStack(spacing: 4) {
Text(label)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
-362
View File
@@ -1,362 +0,0 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
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?
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
var sortedItems: [DownloadItem] {
items.sorted(using: sortOrder)
}
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)
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
}
.draggable(item.id.uuidString)
}
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
doubleClickableCell(for: item) {
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
doubleClickableCell(for: item) {
progressBarCell(for: item)
}
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
doubleClickableCell(for: item) {
Text(item.status.rawValue)
}
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
doubleClickableCell(for: item) {
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
doubleClickableCell(for: item) {
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
doubleClickableCell(for: item) {
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 100, ideal: 155)
}
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "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 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 doubleClickableCell<Content: View>(
for item: DownloadItem,
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
performPrimaryAction(for: item)
}
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
if !targetItems.isEmpty {
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
for target in targetItems {
openFile(target)
}
} label: {
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
}
}
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 || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
Divider()
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("Move to Queue", systemImage: "list.bullet")
}
Divider()
}
Button {
NSPasteboard.general.clearContents()
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
NSPasteboard.general.setString(urls, forType: .string)
} label: {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
}
}
}
private func categoryColor(for category: DownloadCategory) -> Color {
switch 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 func statusColor(for status: DownloadStatus) -> Color {
switch 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(for item: DownloadItem) -> 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(for: item.status))
.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 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 func openFile(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.open(fileURL)
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
}
-86
View File
@@ -1,86 +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 sanitizedFileName(pathName)
}
let host = url.host(percentEncoded: false) ?? "download"
return sanitizedFileName("\(host)-download")
}
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
let separators = CharacterSet(charactersIn: "/\\")
let invalidCharacters = separators
.union(.newlines)
.union(.controlCharacters)
let components = rawValue
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: invalidCharacters)
.filter { !$0.isEmpty }
let clean = components.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, clean != ".", clean != ".." else {
return fallback
}
return String(clean.prefix(255))
}
}
-119
View File
@@ -1,119 +0,0 @@
import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
// Server must be retained to keep listening
private let extensionServer: LocalExtensionServer?
init() {
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
var body: some Scene {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
}
.windowResizability(.contentSize)
.commands {
CommandGroup(after: .newItem) {
Button("Add Downloads...") {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
controller.startQueue()
}
.keyboardShortcut("r", modifiers: [.command])
Button("Stop Downloads") {
controller.pauseActiveDownloads()
}
.disabled(controller.activeCount == 0)
}
}
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.isTemplate = true
return img
}() {
Image(nsImage: nsImage)
} else {
Image(systemName: "arrow.down.circle")
}
}
}
private func menuBarIconURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeIcon = projectRoot
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
}
}
@@ -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
}
}
-263
View File
@@ -1,263 +0,0 @@
import Foundation
import Network
import AppKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let portRange = 6412...6422
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
static let extensionRequestToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
self.downloadController = downloadController
let parameters = NWParameters.tcp
var createdListener: NWListener?
var selectedPort: UInt16?
for portValue in Constants.portRange {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
selectedPort = UInt16(portValue)
break
} catch {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
self.port = selectedPort ?? 6412
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection)
}
listener.stateUpdateHandler = { state in
print("LocalExtensionServer state: \(state)")
}
listener.start(queue: queue)
}
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(from: connection, accumulatedData: Data())
}
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else {
connection.cancel()
return
}
var requestData = accumulatedData
if let data {
requestData.append(data)
}
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
return
}
if let request = HTTPRequest(data: requestData) {
let status = self.processRequest(request)
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
return
}
if isComplete {
self.sendResponse(.badRequest, connection: connection, origin: nil)
return
}
self.receiveRequest(from: connection, accumulatedData: requestData)
}
}
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
var headers = [
"HTTP/1.1 \(status.rawValue) \(status.reason)",
"Content-Length: 0",
"Connection: close"
]
if let origin, isAllowedExtensionOrigin(origin) {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
guard let url = URL(string: origin),
let scheme = url.scheme?.lowercased() else {
return false
}
return scheme == "moz-extension" || scheme == "chrome-extension"
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
if request.method == "OPTIONS" {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
guard request.method == "POST" else {
return .methodNotAllowed
}
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
return .forbidden
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
return .unsupportedMediaType
}
struct Payload: Decodable {
let urls: [String]
let referer: String?
}
do {
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
let validURLs = payload.urls
.prefix(Constants.maxURLCount)
.compactMap { rawURL -> String? in
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
Constants.allowedSchemes.contains(scheme) else {
return nil
}
return url.absoluteString
}
guard !validURLs.isEmpty else {
return .badRequest
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
self.downloadController.pendingReferer = payload.referer
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
}
return .ok
} catch {
print("Failed to parse local request JSON: \(error)")
return .badRequest
}
}
}
private struct HTTPRequest {
var method: String
var path: String
var headers: [String: String]
var body: Data
init?(data: Data) {
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
return nil
}
let headerData = data[..<headerRange.lowerBound]
guard let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else {
return nil
}
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
guard requestParts.count >= 2 else {
return nil
}
var parsedHeaders: [String: String] = [:]
for line in lines.dropFirst() {
guard let colonIndex = line.firstIndex(of: ":") else {
continue
}
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
parsedHeaders[name] = value
}
let bodyStart = headerRange.upperBound
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
guard expectedBodyLength >= 0,
data.count >= bodyStart + expectedBodyLength else {
return nil
}
method = String(requestParts[0]).uppercased()
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
headers = parsedHeaders
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
}
func header(named name: String) -> String? {
headers[name.lowercased()]
}
}
private enum HTTPStatus: Int {
case ok = 200
case noContent = 204
case badRequest = 400
case forbidden = 403
case notFound = 404
case methodNotAllowed = 405
case payloadTooLarge = 413
case unsupportedMediaType = 415
var reason: String {
switch self {
case .ok: "OK"
case .noContent: "No Content"
case .badRequest: "Bad Request"
case .forbidden: "Forbidden"
case .notFound: "Not Found"
case .methodNotAllowed: "Method Not Allowed"
case .payloadTooLarge: "Payload Too Large"
case .unsupportedMediaType: "Unsupported Media Type"
}
}
}
-266
View File
@@ -1,266 +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 rpcPort: Int?
var rpcSecret: String?
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
var sortableSize: Int64 {
sizeBytes ?? 0
}
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"
}
var redactedForPersistence: DownloadItem {
var item = self
if item.credentials != nil {
item.credentials?.password = ""
}
item.cookieHeader = nil
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
return item
}
}
private extension DownloadRequestHeader {
var containsSensitiveValue: Bool {
switch normalized.name.lowercased() {
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
true
default:
false
}
}
}
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
}
}
-240
View File
@@ -1,240 +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> = [DownloadQueue.mainQueueID]
}
@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()
}
if self.settings.targetQueueIDs.isEmpty {
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
}
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 targetQueueIDs = effectiveTargetQueueIDs()
let runnableQueueIDs = 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 targetQueueIDs = effectiveTargetQueueIDs()
let hasActiveItems = targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func effectiveTargetQueueIDs() -> Set<UUID> {
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
}
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)")
}
}
}
}
-218
View File
@@ -1,218 +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.isEmpty
? [DownloadQueue.mainQueueID]
: 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.isEmpty
? [DownloadQueue.mainQueueID]
: targetQueueIDs
schedulerController.saveSettings()
}
}
@@ -1,187 +0,0 @@
import AppKit
import SwiftUI
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: 16) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.bold))
Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
}
Section("Updates") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
Spacer()
if let lastChecked = updateChecker.lastChecked {
Text("Last checked: \(lastChecked, format: .dateTime.month().day().hour().minute())")
.font(.caption)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
} else {
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
}
}
}
.padding(.vertical, 4)
}
Section {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Created by NimBold")
Spacer()
Link(destination: projectURL) {
HStack(spacing: 4) {
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
let nsImage = NSImage(contentsOfFile: imgPath) {
Image(nsImage: nsImage)
.resizable()
.renderingMode(.template)
.frame(width: 14, height: 14)
.foregroundStyle(.secondary)
}
Text("Source Code")
}
}
.buttonStyle(.plain)
}
HStack {
Text("Powered by")
Link("aria2", destination: aria2URL)
Spacer()
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. All rights reserved.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.font(.caption)
.padding(.vertical, 4)
}
}
.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
}
}
@@ -1,46 +0,0 @@
import SwiftUI
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 {
LabeledContent("Global speed limit") {
HStack {
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
}
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -1,46 +0,0 @@
import SwiftUI
import AppKit
struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
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)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
@@ -1,187 +0,0 @@
import AppKit
import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the approved add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Diagnostics") {
LabeledContent("Local receiver") {
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
LabeledContent("Copied folder") {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not copied", systemImage: "folder.badge.questionmark")
.foregroundStyle(.secondary)
}
}
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = bundledFirefoxExtensionURL() else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func bundledFirefoxExtensionURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
? sourceTreeExtension
: nil
}
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
try fileManager.copyItem(
at: sourceURL.appendingPathComponent(component),
to: destinationURL.appendingPathComponent(component)
)
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
@@ -1,289 +0,0 @@
import AppKit
import SwiftUI
struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
BulkDirectoryPickerRow()
GridRow {
Divider()
.gridCellColumns(2)
}
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
} footer: {
Text("Folders will be created automatically when applied.")
}
}
.formStyle(.grouped)
}
}
struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label(category.rawValue, systemImage: category.symbolName)
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button {
applyPath()
} label: {
Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
Text(displayMessage)
.font(.caption)
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
}
}
.onAppear {
syncPathFromSettings()
}
.onChange(of: settings.downloadDirectories[category]) { _, _ in
syncPathFromSettings()
}
}
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 {
path = url.path
settings.setDirectory(url.path, for: category)
message = "Saved."
}
}
private func syncPathFromSettings() {
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
message = ""
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
settings.setDirectory(expanded, for: category)
path = expanded
message = "Saved."
}
private func statusMessage(for path: String) -> String? {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "Enter a folder path." }
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
if !isDirectory.boolValue {
return "This path points to a file, not a folder."
}
return FileManager.default.isWritableFile(atPath: expanded)
? nil
: "Firelink cannot write to this folder."
}
return nil
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a folder path."
}
}
struct BulkDirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
@State private var path = ""
@State private var message = ""
var body: some View {
GridRow(alignment: .firstTextBaseline) {
Label("All Categories", systemImage: "folder.fill.badge.plus")
.gridColumnAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("", text: $path, prompt: Text("Base folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button {
applyPath()
} label: {
Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
if !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
} else {
Text("Automatically creates all category folders in the selected path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
path = url.path
applyPath()
}
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a base folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
for category in DownloadCategory.allCases {
let categoryPath = (expanded as NSString).appendingPathComponent(category.rawValue)
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: categoryPath, isDirectory: true),
withIntermediateDirectories: true
)
settings.setDirectory(categoryPath, for: category)
} catch {
message = "Could not create category folder \(category.rawValue): \(error.localizedDescription)"
return
}
}
message = "Created all categories in base folder."
path = ""
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message.hasPrefix("Could not create category folder") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a base folder path."
}
}
@@ -1,47 +0,0 @@
import SwiftUI
struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -1,73 +0,0 @@
import SwiftUI
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 {
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)
if settings.proxySettings.mode == .custom {
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
.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
}
}
}
@@ -1,17 +0,0 @@
import SwiftUI
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)
}
}
@@ -1,76 +0,0 @@
import AppKit
import SwiftUI
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
@@ -1,92 +0,0 @@
import SwiftUI
struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
@State private var editingLoginID: UUID?
var body: some View {
Form {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if editingLoginID != nil {
Button("Cancel Edit") {
resetForm()
}
}
Button {
settings.saveSiteLogin(
id: editingLoginID,
urlPattern: urlPattern,
username: username,
password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
resetForm()
}
} label: {
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
Button {
edit(login)
} label: {
Label("Edit", systemImage: "pencil")
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
.formStyle(.grouped)
}
private func edit(_ login: SiteLogin) {
editingLoginID = login.id
urlPattern = login.urlPattern
username = login.username
password = ""
}
private func resetForm() {
editingLoginID = nil
urlPattern = ""
username = ""
password = ""
}
}
-243
View File
@@ -1,243 +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 SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case scheduler
case speedLimiter
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)
Label("Speed Limiter", systemImage: "speedometer")
.tag(SidebarSelection.speedLimiter)
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(selection == .settings ? Color.accentColor : Color.clear)
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.background(.regularMaterial)
}
.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.")
}
}
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)) })
}
}
-132
View File
@@ -1,132 +0,0 @@
import SwiftUI
struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
limitSelectionSection
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Speed Limiter")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Limit") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit")
.font(.headline)
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:")
}
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
// Helpful presets
HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
}
.buttonStyle(.bordered)
.padding(.top, 8)
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Speed Limit Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
}
private func saveState() {
// Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed
lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 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)
}
}
-181
View File
@@ -1,181 +0,0 @@
import SwiftUI
import AppKit
enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
case small = "Small"
case standard = "Standard"
case large = "Large"
var id: String { rawValue }
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: return .xSmall
case .standard: return .medium
case .large: return .xxLarge
}
}
var defaultFont: Font {
switch self {
case .small:
return .system(size: 12)
case .standard:
return .system(size: 13)
case .large:
return .system(size: 15)
}
}
var controlSize: ControlSize {
switch self {
case .small:
return .small
case .standard:
return .regular
case .large:
return .large
}
}
}
enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
case compact = "Compact"
case standard = "Standard"
case relaxed = "Relaxed"
var id: String { rawValue }
var verticalPadding: CGFloat {
switch self {
case .compact: return 4
case .standard: return 8
case .relaxed: return 14
}
}
var minRowHeight: CGFloat {
switch self {
case .compact: return 24
case .standard: return 32
case .relaxed: return 44
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
case system = "System Default"
case light = "Light"
case dark = "Dark"
case dracula = "Dracula"
case nord = "Nord"
var id: String { rawValue }
var theme: Theme {
switch self {
case .system, .dark:
return Theme.system
case .light:
return Theme.modernLight
case .dracula:
return Theme.dracula
case .nord:
return Theme.nord
}
}
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
}
}
}
struct Theme: Equatable, Sendable {
var background: Color?
var secondaryBackground: Color?
var text: Color?
var secondaryText: Color?
var accent: Color?
static let system = Theme(
background: nil,
secondaryBackground: nil,
text: nil,
secondaryText: nil,
accent: nil
)
static let modernLight = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
text: Color.primary,
secondaryText: Color.secondary,
accent: Color.accentColor
)
static let dracula = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
)
static let nord = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
)
}
struct AppThemeModifier: ViewModifier {
let theme: AppTheme
func body(content: Content) -> some View {
content
.tint(theme.theme.accent)
.onAppear {
NSApp.appearance = theme.appearance
}
.onChange(of: theme) { _, newTheme in
NSApp.appearance = newTheme.appearance
}
}
}
struct AppFontSizeModifier: ViewModifier {
let fontSize: AppFontSize
func body(content: Content) -> some View {
content
.font(fontSize.defaultFont)
.controlSize(fontSize.controlSize)
.dynamicTypeSize(fontSize.dynamicTypeSize)
}
}
struct ThemeBackgroundModifier: ViewModifier {
let color: Color?
func body(content: Content) -> some View {
if let color {
content
.scrollContentBackground(.hidden)
.background(color)
} else {
content
}
}
}
extension View {
func themeBackground(_ color: Color?) -> some View {
modifier(ThemeBackgroundModifier(color: color))
}
}
-41
View File
@@ -1,41 +0,0 @@
import SwiftUI
struct TrayMenuView: View {
@Environment(\.openWindow) private var openWindow
@EnvironmentObject private var controller: DownloadController
var body: some View {
Button("Show main window") {
openWindow(id: "main")
NSApp.activate(ignoringOtherApps: true)
}
Button("Add downloads") {
openWindow(id: "add-downloads")
NSApp.activate(ignoringOtherApps: true)
}
Menu("Start queue") {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.startQueue(queueID: queue.id)
}
}
}
Button("Stop downloads") {
for download in controller.downloads where download.status == .downloading {
controller.pause(download)
}
}
Divider()
Button("Exit") {
NSApplication.shared.terminate(nil)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
}
}
+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.06.09",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_win.zip",
"sha256": "9bb27530494092870b5330deacfc65a40d3e980c7e2c67e5f09b902c37a6903d"
},
"deno": {
"version": "2.9.1",
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-pc-windows-msvc.zip",
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
},
"ffmpeg": {
"version": "8.1.2",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-win64-gpl-8.1.zip",
"sha256": "ddbe0deec00b9cfd3ee74298a46a22ac4061913ed47d92297cd168cc5a5cc625"
},
"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.06.09",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_linux.zip",
"sha256": "217bbc9c3ed19ea75a7f151a3e48dbfeac7f459a7dce2deeeecc2d6e2871bd5b"
},
"deno": {
"version": "2.9.1",
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
},
"ffmpeg": {
"version": "8.1.2",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-23-13-52/ffmpeg-n8.1.2-linux64-gpl-8.1.tar.xz",
"sha256": "0c6772b77fdbf127cc1498eca39a40e20b88817f36b66d553cebcfcca32b6d78"
},
"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.06.09",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
},
"aria2c": {
"version": "1.37.0",
"source": "https://github.com/aria2/aria2",
"build": "arm64 executable with adjacent aria2-libs",
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
},
"ffmpeg": {
"version": "N-125385-ge2e889d9da",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"sha256": "3027bd75166a596c31a271ce9ae14b5441492b9557011993c0bf873b5a7c6a09"
},
"deno": {
"version": "2.9.1",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "df5e2cca5253ec99b9a630fb059bf01cdbbf586fed1f344017875c5462b2c483"
}
},
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
},
"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>
+2190
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "firelink",
"private": true,
"version": "1.0.0",
"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",
"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": "^6.0.3",
"vite": "^8.1.3",
"vitest": "^4.1.9"
}
}
+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');
+178
View File
@@ -0,0 +1,178 @@
#!/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];
}
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] = await Promise.all([
githubLatest('yt-dlp/yt-dlp'),
githubLatest('denoland/deno'),
githubLatest('aria2/aria2'),
latestFfmpegStable(),
latestMartinRiedlMacArm64Release(),
]);
const latestByEngine = {
'yt-dlp': ytDlp.tag_name,
deno: deno.tag_name,
aria2c: aria2.tag_name,
ffmpeg,
};
const latestByTargetEngine = {
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Ffmpeg,
};
console.log('\nlatest engines:');
for (const [engine, version] of Object.entries(latestByEngine)) {
console.log(` ${engine}: ${normalizeVersion(version)}`);
}
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);
}
+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
+6869
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.0"
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"] }
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.12"
sha2 = "0.10"
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.6.11", 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.

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