diff --git a/legacy/swift/.github/workflows/release.yml b/legacy/swift/.github/workflows/release.yml
deleted file mode 100644
index 1b4828f..0000000
--- a/legacy/swift/.github/workflows/release.yml
+++ /dev/null
@@ -1,128 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - "v*"
- workflow_dispatch:
- inputs:
- version:
- description: "Release version, for example 0.1.0"
- required: true
- default: "0.1.0"
-
-permissions:
- contents: write
-
-jobs:
- macos-arm64-dmg:
- name: Build macOS ARM64 DMG
- runs-on: macos-26
-
- steps:
- - name: Check out repository
- uses: actions/checkout@v6
- with:
- submodules: recursive
-
- - name: Resolve version
- id: version
- shell: bash
- run: |
- if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
- VERSION="${GITHUB_REF_NAME#v}"
- TAG_NAME="${GITHUB_REF_NAME}"
- else
- VERSION="${{ inputs.version }}"
- TAG_NAME="v${VERSION}"
- fi
-
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "tag_name=$TAG_NAME" >> "$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" -lt 26 ]]; then
- echo "Expected at least macOS 26 SDK, got macOS $SDK_VERSION" >&2
- exit 1
- fi
-
- - name: Fetch media engines
- run: Scripts/fetch_media_engines.sh
-
- - 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
-
- RESOURCES="build/Firelink.app/Contents/Resources"
- test -d "$RESOURCES/_internal"
- test -x "$RESOURCES/yt-dlp"
- test -x "$RESOURCES/deno"
- test -x "$RESOURCES/ffmpeg"
- test -x "$RESOURCES/aria2c"
- test -f "$RESOURCES/aria2-cacert.pem"
- test -d "$RESOURCES/aria2-libs"
- test "$(tr -d '[:space:]' < "$RESOURCES/aria2-version.txt")" = "1.37.0-2-arm64-sonoma"
- test "$("$RESOURCES/yt-dlp" --version)" = "$(tr -d '[:space:]' < "$RESOURCES/yt-dlp-version.txt")"
- "$RESOURCES/deno" --version
- "$RESOURCES/ffmpeg" -version
- "$RESOURCES/aria2c" --version | grep -q '^aria2 version 1.37.0$'
- for library in "$RESOURCES"/aria2-libs/*.dylib; do
- lipo -archs "$library" | grep -qx arm64
- done
- ! otool -L "$RESOURCES/aria2c" "$RESOURCES"/aria2-libs/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'
-
- - 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
- 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' || github.event_name == 'workflow_dispatch'
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- TAG_NAME="${{ steps.version.outputs.tag_name }}"
- awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
- test -s release_notes.md
-
- if gh release view "$TAG_NAME" >/dev/null 2>&1; then
- gh release upload "$TAG_NAME" dist/*.dmg --clobber
- gh release edit "$TAG_NAME" --notes-file release_notes.md
- else
- if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
- gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md
- else
- gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
- fi
- fi
diff --git a/legacy/swift/CHANGELOG.md b/legacy/swift/CHANGELOG.md
deleted file mode 100644
index 9221447..0000000
--- a/legacy/swift/CHANGELOG.md
+++ /dev/null
@@ -1,364 +0,0 @@
-# Changelog
-
-All notable changes to this project 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).
-
-## [Unreleased]
-
-## [0.7.3] - 2026-06-11
-
-### New Features & Improvements
-- Add Deno to about credits and engines list.
-- Enhance speed and ETA display logic during pause and drop.
-- Accumulate track sizes to present a unified overall progress bar.
-
-### Fixes
-- Resolve unknown speed flickering and ultra-wide high-resolution detection.
-- Emit distinct status messages for individual tracks during download.
-- Pad overall progress total for first track to prevent 100% snapback.
-
-## [0.7.2] - 2026-06-11
-
-### Fixed
-- Prevented yt-dlp and JavaScript child processes from keeping metadata fetches or canceled downloads alive indefinitely.
-- Replaced the repeatedly extracted one-file yt-dlp build with a stable prewarmed runtime cache.
-- Bundled Deno so YouTube JavaScript challenges and formats above 720p do not depend on system-installed tools.
-- Stopped masking empty-format extraction failures and removed brittle forced YouTube client selection.
-
-### Changed
-- Pinned and checksum-verified yt-dlp, Deno, FFmpeg, aria2, and aria2's libraries for matching local and GitHub Actions builds.
-- Removed aria2's runtime dependency on Homebrew and configured its bundled CA certificate for direct and yt-dlp-delegated HTTPS downloads.
-- Added bounded network retries and optional aria2c acceleration for large direct media downloads.
-
-## [0.7.1] - 2026-06-11
-
-### Fixes
-- Increased the `yt-dlp` metadata extraction timeout to 120 seconds to properly handle YouTube's new JavaScript Proof-of-Work bot protection challenges.
-- Improved the `AddDownloadsView` UI to display the exact underlying error message during extraction failures rather than a generic masked string.
-
-### Security Fixes
-- Addressed multiple vulnerabilities identified in the v0.7.0 security audit.
-- Moved `yt-dlp` credential passing from CLI arguments to secure temporary configuration files to prevent process list leakage.
-- Enforced strict `0o600` POSIX permissions on `aria2c` temporary configuration files to protect generated RPC secrets.
-- Replaced the unauthenticated local connection protocol with a secure HMAC-SHA256 signature validation.
-- Excluded sensitive properties like `rpcSecret` and `rpcPort` from `DownloadItem` serialization so they are never saved to disk in plaintext.
-- Mitigated SSRF (Server-Side Request Forgery) by strictly validating metadata fetch requests against private IP addresses and loopback ranges.
-- Prevented potential path traversal vulnerabilities by validating destination file URLs during duplicate resolution.
-- Sanitized custom HTTP headers to prevent CR/LF injection vectors.
-- Re-architected `aria2c` port-finding with POSIX sockets to eliminate a known race-condition window.
-- Applied rate-limiting and text length bounds to the custom `firelink://` scheme to mitigate DoS and injection attempts.
-
-### Fixes
-- Fixed a metadata extraction timeout when downloading from YouTube by preventing child processes from holding process pipes open.
-- Resolved an issue to correctly assign filenames for auto-captured downloads.
-- Restored the UUID fallback for token generation to prevent silent failures if secure random byte generation fails.
-- Hardened local API security by immediately rejecting requests if the expected pairing token is completely empty.
-- Implemented a thread-safe cleanup mechanism for temporary directories to resolve a concurrency race condition during engine cancellation.
-
-## [0.7.0] - 2026-06-11
-
-### New Features & Improvements
-- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
-- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
-- Introduce an "Ask where to save" global configuration option for manual location picking per download.
-- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
-- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
-- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
-- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
-
-### Fixes
-- Fix issues regarding proxy environment propagation into media download processes.
-- Resolve multiple critical bugs related to configuration storage and download stability.
-- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
-
-## [0.6.6] - 2026-06-10
-
-### New Features
-- Add cascading media format pickers with inline loading states during metadata extraction.
-- Redesign the Integration settings pane for a more modern experience.
-- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
-
-### Improvements
-- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
-- Optimize core UI components to significantly improve rendering performance and overall app stability.
-
-### Fixes
-- Fix layout and dynamic sizing bugs in the Add Downloads window.
-- Fix formatting inconsistencies in media options selection.
-- Fix toast notification rendering glitches.
-
-## [0.6.5] - 2026-06-09
-
-### Fixes
-- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
-
-## [0.6.4] - 2026-06-09
-
-### New Features
-- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
-
-### Improvements
-- Polish the browser extension pairing UI with a secure masked token field and improved styling.
-
-### Changes
-- Remove stale references to the legacy static token from the Firelink Companion extension.
-
-### Fixes
-- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
-- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
-
-## [0.6.3] - 2026-06-09
-
-### Improvements
-- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
-- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
-- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
-- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
-
-### Fixes
-- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
-
-## [0.6.2] - 2026-06-08
-
-### Fixes
-- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
-- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
-- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
-- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
-- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
-- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
-
-## [0.6.1] - 2026-06-08
-
-### New Features
-- No new user-facing features in this patch release.
-
-### Improvements
-- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
-- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
-
-### Changes
-- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
-- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
-- Remove stale media add-on update language now that media engines are bundled with the app.
-- Update Firelink Companion to `1.0.8`.
-
-### Fixes
-- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
-- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
-- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
-- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
-
-## [0.6.0] - 2026-06-08
-
-### New features
-- Enhance mixed media support and add duplicate resolution.
-- Redesign settings panes and enhance update flows.
-- Improve yt-dlp fetching speed and redesign media detection UI.
-- Enhance media engine settings with cookie extraction and update checks.
-- Modernize Integration settings UI and add official install button.
-- Integrate yt-dlp to DownloadController and add global queue support.
-- Implement smart progressive disclosure UI and media extraction engine.
-- Implement gatekeeper architecture for on-demand media engine binaries.
-- Inline update checks to avoid unnecessary modals.
-
-### Changes
-- Add backward compatibility support for extension tokens.
-- Update Firelink-Extension submodule to latest.
-- Update app icons and icon generation scripts.
-- Tone down icon gradient to 1.9x for modern subtle look.
-- Increase gradient contrast for stronger lighting effect.
-- Switch to lighter gradient (+1 to 0).
-- Revert to plain mode without gradient.
-- Apply premium gradient to the correct new icon and app icon.
-- Remove redundant version string from up-to-date message.
-- Update release metadata for the framework-embedded dmg.
-
-### Fixes
-- Cap max height of download links text editor.
-- Harden media download flow.
-- Pass extractor arguments to yt-dlp download process.
-- Restore single click selection by removing simultaneousGesture.
-- Restore Download Properties routing and gestures.
-- Pass UUID as String for download properties WindowGroup to prevent routing failures.
-- Size column fallback and table row interactions.
-- Media download UX and table row selection.
-- Media downloads connections, progress parsing, file size, and selection highlight.
-- Stabilize yt-dlp metadata and add-on updates.
-- Block automatic metadata fetch for private IP addresses (security).
-- Actually update extension icons with the 1.9x gradient icon.
-- Correctly remove black padding and mask corners.
-- Harden release metadata.
-- Correct no-update handling to prevent false error messages.
-
-## [0.5.7] - 2026-06-06
-
-### New features
-- Replaced the basic in-app update checker with an integrated release-checking flow.
-- Added secure update metadata checks before presenting new releases in the app.
-
-## [0.5.6] - 2026-06-05
-
-### New features
-- Added the official transparent GitHub icon to the Source Code link in the About page.
-
-### Changes
-- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
-- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
-
-### Fixes
-- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
-
-## [0.5.5] - 2026-06-05
-
-### New features
-- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
-- Added authenticated metadata probing so batch previews can use custom or saved credentials.
-
-### Changes
-- Updated Download Properties disclosure sections so their full title row opens and closes them.
-- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
-- Expanded download table hit areas so double-clicks register across empty cell space.
-
-### Fixes
-- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
-- Fixed Chunk Map layout overlap in Download Properties.
-- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
-
-## [0.5.4] - 2026-06-04
-
-### New features
-- Added direct double-click access to Download Properties for unfinished downloads.
-- Added a `make verify` command for local build and Firefox extension manifest checks.
-
-### Changes
-- Updated Firefox integration to probe the same local fallback ports used by the app.
-- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
-- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
-- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
-
-### Fixes
-- Fixed browser handoff failures when the default local extension port is unavailable.
-- Fixed dropped `Referer` headers from browser extension requests.
-- Fixed scheduler configurations that could be enabled without any runnable queue target.
-- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
-- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
-
-## [0.5.3] - 2026-06-04
-
-### New features
-- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
-- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
-
-### Changes
-- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
-- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
-
-### Fixes
-- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
-- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
-- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
-
-## [0.5.2] - 2026-06-04
-
-### Fixes
-- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
-- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
-
-## [0.5.1] - 2026-06-04
-
-### Changes
-- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
-
-## [0.5.0] - 2026-06-04
-
-### New features
-- Added a dedicated Speed Limiter UI to the main sidebar for instant global bandwidth throttling.
-- Integrated Settings directly into the main application window instead of a separate macOS scene, paving the way for future Windows/Linux cross-platform parity.
-
-### Changes
-- Modernized Settings with a sleek horizontal tab bar layout.
-- Added persistent state retention across views (remembers the last visited settings tab and custom speed limits).
-- Compacted the README file to be more concise and user-friendly.
-
-### Fixes
-- Fixed a critical memory crash (`EXC_BAD_ACCESS`) inside the Download Table caused by ephemeral string sorting during active downloads.
-- Fixed sidebar layout glitches to prevent text overlap during scroll.
-
-## [0.4.3] - 2026-06-03
-
-### Changes
-- Refined About page UI and simplified the delete confirmation dialog.
-
-### Fixes
-- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
-
-## [0.4.2] - 2026-06-03
-
-### Features added
-- Added double-click to open completed files directly from the download table.
-- Added redownload functionality for completed or failed items.
-- Added 'Copy Address' context menu action.
-- Added a monochrome template tray icon loaded explicitly with precise dimensions.
-
-### Changes
-- Improved context menu organization and conditionally displayed actions based on download status.
-
-## [0.4.1] - 2026-06-03
-
-### Features added
-- Added app theming engine with Look and Feel settings.
-- Added Font Size, List Row Density, and Menu Bar Icon settings.
-- Added tray icon and context menu for main window and queues.
-- Added site logins integration directly into the Add Downloads window.
-
-### Changes
-- Updated the paste hint to use a visual Command icon.
-
-### Fixes
-- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
-- Fixed a bug with Light/System theme appearance.
-- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
-
-## [0.4.0] - 2026-06-03
-
-### Changes
-- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
-- 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.
-
-### Fixes
-- Fixed queue-specific starts so one queue no longer starts unrelated queued downloads.
-- Fixed scheduler completion handling so empty queues do not trigger post-download system actions.
-- Fixed queue drag reordering when moving items downward.
-- Fixed scheduler Automation permission prompting.
-
-### Features added
-- Added scheduler controls with explicit Automation permission UI.
-- Added global and per-download speed limits.
-- Added advanced transfer options for checksums, headers, cookies, and mirror URLs.
-
-## [0.3.0] - 2026-06-02
-
-### Added
-- **Zero-Config Setup:** Firelink now automatically bundles the `aria2c` engine and all of its dynamic library dependencies internally via `dylibbundler`. End-users no longer need to install Homebrew or `aria2c` manually!
-
-### Changed
-- **README Redesign:** Modernized the README with a clean layout, centered App Icon header, and updated roadmap.
-- **CI Releases:** The GitHub Actions DMG release pipeline now automatically fetches and packages dependencies during builds.
-
-## [0.2.1] - 2026-06-02
-### Changed
-- Fixed CI release runner specifying macOS 26.
-
-## [0.2.0] - 2026-06-01
-### Added
-- **In-App Update Checker:** Built-in GitHub release checks inside the Settings About pane.
-- **Queue Management:** Advanced drag-and-drop priority ordering and queue management controls.
-- **Download Recovery:** Built-in download recovery and automated retry policies.
-- Initial core download engine with `aria2c` support.
-- Native macOS Settings pane.
-- Smart file categorization and organization based on extension detection.
-- Keychain-secured authentication integration.
diff --git a/legacy/swift/Makefile b/legacy/swift/Makefile
deleted file mode 100644
index c95c14f..0000000
--- a/legacy/swift/Makefile
+++ /dev/null
@@ -1,27 +0,0 @@
-.PHONY: engines build app dmg release run verify clean
-
-engines:
- Scripts/fetch_media_engines.sh
-
-build: engines
- swift build -c release
-
-app:
- Scripts/create_app_bundle.sh
-
-dmg: app
- Scripts/create_dmg.sh
-
-release:
- Scripts/create_app_bundle.sh
- Scripts/create_dmg.sh
-
-run: engines
- swift run Firelink
-
-verify:
- Scripts/verify.sh
-
-clean:
- swift package clean
- rm -rf build dist
diff --git a/legacy/swift/Package.swift b/legacy/swift/Package.swift
deleted file mode 100644
index b97dff4..0000000
--- a/legacy/swift/Package.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-// swift-tools-version: 6.0
-
-import PackageDescription
-
-let package = Package(
- name: "Firelink",
- platforms: [
- .macOS(.v14)
- ],
- products: [
- .executable(name: "Firelink", targets: ["Firelink"])
- ],
- dependencies: [],
- targets: [
- .executableTarget(
- name: "Firelink",
- dependencies: [],
- path: "Sources/Firelink",
- exclude: [
- "deno-version.txt",
- "ffmpeg-version.txt"
- ],
- resources: [
- .process("Assets.xcassets"),
- .copy("yt-dlp"),
- .copy("yt-dlp-version.txt"),
- .copy("_internal"),
- .copy("deno"),
- .copy("ffmpeg"),
- .copy("aria2c"),
- .copy("aria2-libs"),
- .copy("aria2-cacert.pem"),
- .copy("aria2-version.txt"),
- .copy("aria2-licenses")
- ]
- )
- ]
-)
diff --git a/legacy/swift/README.md b/legacy/swift/README.md
deleted file mode 100644
index 33440d6..0000000
--- a/legacy/swift/README.md
+++ /dev/null
@@ -1,114 +0,0 @@
-> [!NOTE]
-> This is the archived SwiftUI implementation. The maintained Rust, Tauri,
-> React, and TypeScript application now lives at the repository root.
-
-
-

-
Firelink
-
The modern, blazing-fast download manager built natively for Apple Silicon macOS.
-
-

-

-

-

-

-

-

-
-
----
-
-**Firelink** reimagines macOS file downloading by wrapping legendary open-source engines (`aria2c`, `yt-dlp`, and `ffmpeg`) in a beautifully crafted, highly polished SwiftUI interface. Enjoy high-speed segmented downloads, native media extraction, seamless browser integration, and intelligent file organization without compromising on the aesthetics of your Mac.
-
----
-
-## 📸 Screenshots
-
-
-

-

-

-
-
A premium native experience, from batch linking to advanced settings.
-
-
-
-Light Theme Screenshots
-
-
-
-
-
-
----
-
-## ✨ Features
-
-- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits and a built-in download scheduler.
-- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
-- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface strictly adhering to Apple Human Interface Guidelines, featuring a visual chunk map and dynamic progress tracking.
-- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
-- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration and secure HMAC-SHA256 authenticated local API endpoints ensure your system remains strictly isolated and protected from malicious scripts.
-- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
-- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
-
----
-
-## 🧩 Browser Integration
-
-We are live! Send downloads directly from your browser to the Firelink app with zero friction.
-
-
-
-*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
-
----
-
-## 🌍 Cross-Platform Evolution (Firelink Reborn)
-
-I'm currently rewriting Firelink from the ground up using **Tauri, React, and Rust** to bring our blazing-fast native experience to Windows and Linux, while maintaining our standard of excellence on macOS.
-
-### 🚀 Development Progress
-- [x] **Core Engine Port:** `aria2c` and `yt-dlp` integration in Rust
-- [x] **UI Foundation:** Pixel-perfect React + Tailwind interface
-- [x] **Settings & State:** Fully wired frontend-to-backend communication
-- [ ] **Cross-Platform Binaries:** Automated builds for Windows (`.exe`) and Linux (`.AppImage`)
-- [ ] **Feature Parity:** Porting remaining media extraction and scheduler features
-
-*Stay tuned as we prepare our first true cross-platform beta release!*
-
----
-
-## 🛠️ Quick Start
-
-**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
-
-Run the application directly:
-```bash
-swift run Firelink
-```
-
-Or build a production `.app` bundle:
-```bash
-make app && open build/Firelink.app
-```
-
----
-
-## 🏆 Credits
-
-Firelink stands on the shoulders of giants. A massive thank you to the contributors of these phenomenal open-source projects:
-- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
-- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
-- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
-- **[Deno](https://deno.com/)** - The secure runtime for JavaScript and TypeScript solving complex media extraction challenges.
-
----
-
-## 📄 License
-
-Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
diff --git a/legacy/swift/Resources/AppIcon.icns b/legacy/swift/Resources/AppIcon.icns
deleted file mode 100644
index 3925aed..0000000
Binary files a/legacy/swift/Resources/AppIcon.icns and /dev/null differ
diff --git a/legacy/swift/Resources/AppIcon.png b/legacy/swift/Resources/AppIcon.png
deleted file mode 100644
index 872bbec..0000000
Binary files a/legacy/swift/Resources/AppIcon.png and /dev/null differ
diff --git a/legacy/swift/Resources/GitHubTemplate.png b/legacy/swift/Resources/GitHubTemplate.png
deleted file mode 100644
index d045356..0000000
Binary files a/legacy/swift/Resources/GitHubTemplate.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Dark/AddWindow.png b/legacy/swift/Resources/Screenshots/Dark/AddWindow.png
deleted file mode 100644
index 136a623..0000000
Binary files a/legacy/swift/Resources/Screenshots/Dark/AddWindow.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Dark/MainPage.png b/legacy/swift/Resources/Screenshots/Dark/MainPage.png
deleted file mode 100644
index 6cfc74d..0000000
Binary files a/legacy/swift/Resources/Screenshots/Dark/MainPage.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Dark/Settings.png b/legacy/swift/Resources/Screenshots/Dark/Settings.png
deleted file mode 100644
index 7bf126a..0000000
Binary files a/legacy/swift/Resources/Screenshots/Dark/Settings.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Light/AddWindow.png b/legacy/swift/Resources/Screenshots/Light/AddWindow.png
deleted file mode 100644
index 169dc03..0000000
Binary files a/legacy/swift/Resources/Screenshots/Light/AddWindow.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Light/MainPage.png b/legacy/swift/Resources/Screenshots/Light/MainPage.png
deleted file mode 100644
index 2d7f05c..0000000
Binary files a/legacy/swift/Resources/Screenshots/Light/MainPage.png and /dev/null differ
diff --git a/legacy/swift/Resources/Screenshots/Light/Settings.png b/legacy/swift/Resources/Screenshots/Light/Settings.png
deleted file mode 100644
index e7a2314..0000000
Binary files a/legacy/swift/Resources/Screenshots/Light/Settings.png and /dev/null differ
diff --git a/legacy/swift/Resources/new_icon_source.png b/legacy/swift/Resources/new_icon_source.png
deleted file mode 100644
index 0842b7c..0000000
Binary files a/legacy/swift/Resources/new_icon_source.png and /dev/null differ
diff --git a/legacy/swift/Scripts/create_app_bundle.sh b/legacy/swift/Scripts/create_app_bundle.sh
deleted file mode 100755
index e9a4f4c..0000000
--- a/legacy/swift/Scripts/create_app_bundle.sh
+++ /dev/null
@@ -1,168 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-APP_NAME="Firelink"
-CONFIGURATION="${CONFIGURATION:-release}"
-DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
-DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
-MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
-BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
-SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
-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"
-
-"$ROOT_DIR/Scripts/fetch_media_engines.sh"
-
-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"
-
-for media_engine in yt-dlp deno ffmpeg aria2c; do
- media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
- if [[ -x "$media_engine_path" ]]; then
- cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
- chmod +x "$RESOURCES_DIR/$media_engine"
- else
- echo "WARNING: $media_engine not found or not executable at $media_engine_path"
- fi
-done
-
-for resource_directory in _internal aria2-libs aria2-licenses; do
- source_path="$ROOT_DIR/Sources/Firelink/$resource_directory"
- if [[ ! -d "$source_path" ]]; then
- echo "Required runtime directory is missing: $source_path" >&2
- exit 1
- fi
- cp -R "$source_path" "$RESOURCES_DIR/$resource_directory"
-done
-
-for resource_file in yt-dlp-version.txt aria2-version.txt aria2-cacert.pem; do
- source_path="$ROOT_DIR/Sources/Firelink/$resource_file"
- if [[ ! -f "$source_path" ]]; then
- echo "Required runtime file is missing: $source_path" >&2
- exit 1
- fi
- cp "$source_path" "$RESOURCES_DIR/$resource_file"
-done
-
-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"
-
-
-FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
-mkdir -p "$FRAMEWORKS_DIR"
-
-install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
-
-
-cat > "$CONTENTS_DIR/Info.plist" <
-
-
-
- CFBundleDevelopmentRegion
- en
- CFBundleExecutable
- $APP_NAME
- CFBundleIdentifier
- local.firelink.swiftui
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $APP_NAME
- CFBundleIconFile
- $ICON_NAME
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $MARKETING_VERSION
- CFBundleVersion
- $BUILD_NUMBER
- LSMinimumSystemVersion
- 14.0
- LSApplicationCategoryType
- public.app-category.utilities
- NSHighResolutionCapable
-
- NSAppleEventsUsageDescription
- Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.
- CFBundleURLTypes
-
-
- CFBundleURLName
- local.firelink.swiftui
- CFBundleURLSchemes
-
- firelink
-
-
-
-
-
-PLIST
-
-if command -v codesign &> /dev/null; then
- if [[ -n "$SIGNING_IDENTITY" ]]; then
- CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
- echo "Signing app bundle with identity: $SIGNING_IDENTITY"
- else
- CODESIGN_ARGS=(--force --sign -)
- echo "Ad-hoc signing app bundle for local use."
- fi
-
- sign_path() {
- local path="$1"
- codesign "${CODESIGN_ARGS[@]}" "$path"
- }
-
- sign_mach_o_file() {
- local path="$1"
- case "$path" in
- */Python.framework/Python|*/Python.framework/Versions/Current/*)
- return
- ;;
- esac
-
- if file "$path" | grep -q 'Mach-O'; then
- if ! sign_path "$path"; then
- echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
- fi
- fi
- }
-
- if [[ -d "$FRAMEWORKS_DIR" ]]; then
- while IFS= read -r -d '' executable_path; do
- sign_mach_o_file "$executable_path"
- done < <(find "$FRAMEWORKS_DIR" -type f -print0)
-
- while IFS= read -r -d '' bundle_path; do
- sign_path "$bundle_path"
- done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
- fi
-
- while IFS= read -r -d '' executable_path; do
- sign_mach_o_file "$executable_path"
- done < <(find "$RESOURCES_DIR" -type f -print0)
-
- sign_path "$MACOS_DIR/$APP_NAME"
- sign_path "$APP_DIR"
- codesign --verify --deep --strict --verbose=2 "$APP_DIR"
-fi
-
-echo "Created $APP_DIR"
diff --git a/legacy/swift/Scripts/create_dmg.sh b/legacy/swift/Scripts/create_dmg.sh
deleted file mode 100755
index 253cb1c..0000000
--- a/legacy/swift/Scripts/create_dmg.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-APP_NAME="Firelink"
-VERSION="${VERSION:-}"
-ARCH="${ARCH:-arm64}"
-SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
-APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
-DIST_DIR="$ROOT_DIR/dist"
-DMG_STAGING_DIR="$ROOT_DIR/build/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
-
-if [[ -z "$VERSION" ]]; then
- VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DIR/Contents/Info.plist")"
-fi
-
-DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
-
-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"
-
-if [[ -n "$SIGNING_IDENTITY" ]]; then
- codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"
-fi
-
-echo "Created $DMG_PATH"
diff --git a/legacy/swift/Scripts/fetch_media_engines.sh b/legacy/swift/Scripts/fetch_media_engines.sh
deleted file mode 100755
index 08178ed..0000000
--- a/legacy/swift/Scripts/fetch_media_engines.sh
+++ /dev/null
@@ -1,337 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-SOURCE_DIR="$ROOT_DIR/Sources/Firelink"
-
-YTDLP_VERSION="2026.06.09"
-YTDLP_MACOS_ZIP_SHA256="62a3108d7c37090107f0bb9a2369b953b35e43f4bc76ab0ea87e4ab593c23ec7"
-
-DENO_VERSION="2.8.2"
-DENO_ARM64_ZIP_SHA256="02e5eb795c9f763772dfd081429cead9029e0a4a6aaff6d4e5f3ed6d2e94d361"
-DENO_X86_64_ZIP_SHA256="77cf27f835f1921e49434449675c57432c6314d54edc725e2474cc825546e206"
-
-FFMPEG_VERSION="8.1.1"
-FFMPEG_ARM64_URL="https://ffmpeg.martin-riedl.de/download/macos/arm64/1778761665_8.1.1/ffmpeg.zip"
-FFMPEG_ARM64_ZIP_SHA256="a05b1a47bb3ac89a95a55eec713f8bbb347051bb07015f3b7d08fb62ed81a21e"
-
-ARIA2_VERSION="1.37.0"
-ARIA2_BOTTLE_REVISION="2"
-ARIA2_RUNTIME_ID="$ARIA2_VERSION-$ARIA2_BOTTLE_REVISION-arm64-sonoma"
-ARIA2_BOTTLE_SHA256="8815b6b79395235863349628dc0d753bbee9069e99d94257b7646ffd85615623"
-CARES_VERSION="1.34.6"
-CARES_BOTTLE_SHA256="17f44048d8003b88231d69bac0408cf22be2f712ef8588d4933ff0811b92342c"
-LIBSSH2_VERSION="1.11.1_1"
-LIBSSH2_BOTTLE_SHA256="34927ad08cd265d32f1390a92d84451f85ab5b2f28101ca951da3d3e9df12047"
-OPENSSL_VERSION="3.6.2"
-OPENSSL_BOTTLE_SHA256="aaa5f4f3d87868ecd5f5fd6967da0c305eb335a58171faba193e9c7e39fbf35c"
-SQLITE_VERSION="3.53.0"
-SQLITE_BOTTLE_SHA256="36080e3273614fe3d606ff0bd5bb090ad33c19f186ba44c35807b8f97afa15be"
-GETTEXT_VERSION="1.0"
-GETTEXT_BOTTLE_SHA256="f9ea4eed738746ea4150a4f83e8dd11ca21ca3de5bb113995c25eec409bb5749"
-
-ARIA2_RUNTIME_SHA256="111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
-CARES_RUNTIME_SHA256="86ceec6264753bfffb1562df22e81cbbed72d370105936ee083f3152c9dc1673"
-LIBCRYPTO_RUNTIME_SHA256="a13e280563c6eb85058f590f6f558fb20f54e171024f3b9b3637df140add1714"
-LIBINTL_RUNTIME_SHA256="7e6628118b26b58b57346f3f088b1f87b263c677736ade678f0aced5579ea357"
-LIBSQLITE_RUNTIME_SHA256="27da39c4cc96e7f43c8ed0134d2de6dd2fd36008dcb044c03aeee3eec9edc545"
-LIBSSH2_RUNTIME_SHA256="67cbce90dca26590a8a7627af8f4abccfd94f41cae48f7fa67a5f0cb98efc85b"
-LIBSSL_RUNTIME_SHA256="f5676ffe68757ea2629898c29bcee5f15982e06fe878bec4f70d159dd1b70452"
-
-mkdir -p "$SOURCE_DIR"
-
-sha256() {
- shasum -a 256 "$1" | awk '{print $1}'
-}
-
-verify_sha256() {
- local path="$1"
- local expected="$2"
- local actual
- actual="$(sha256 "$path")"
- if [[ "$actual" != "$expected" ]]; then
- echo "Checksum mismatch for $path" >&2
- echo "Expected: $expected" >&2
- echo "Actual: $actual" >&2
- exit 1
- fi
-}
-
-version_matches() {
- local marker_path="$1"
- local expected="$2"
- [[ -f "$marker_path" ]] && [[ "$(tr -d '[:space:]' < "$marker_path")" == "$expected" ]]
-}
-
-fetch_homebrew_bottle() {
- local repository="$1"
- local digest="$2"
- local output_path="$3"
- local token
-
- token="$(
- curl -fsSL "https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/$repository:pull" |
- python3 -c 'import json, sys; print(json.load(sys.stdin)["token"])'
- )"
- curl -fsSL \
- -H "Authorization: Bearer $token" \
- "https://ghcr.io/v2/homebrew/core/$repository/blobs/sha256:$digest" \
- -o "$output_path"
- verify_sha256 "$output_path" "$digest"
-}
-
-fetch_ytdlp() {
- local executable="$SOURCE_DIR/yt-dlp"
- local runtime="$SOURCE_DIR/_internal"
- local marker="$SOURCE_DIR/yt-dlp-version.txt"
-
- if [[ -x "$executable" ]] && [[ -d "$runtime" ]] && version_matches "$marker" "$YTDLP_VERSION"; then
- return
- fi
-
- echo "Fetching yt-dlp $YTDLP_VERSION one-folder runtime..."
- local temp_dir
- temp_dir="$(mktemp -d)"
- curl -fsSL \
- "https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/yt-dlp_macos.zip" \
- -o "$temp_dir/yt-dlp.zip"
- verify_sha256 "$temp_dir/yt-dlp.zip" "$YTDLP_MACOS_ZIP_SHA256"
- unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir/runtime"
-
- rm -rf "$runtime"
- cp "$temp_dir/runtime/yt-dlp_macos" "$executable"
- cp -R "$temp_dir/runtime/_internal" "$runtime"
- touch "$runtime/.gitkeep"
- chmod +x "$executable"
- printf '%s\n' "$YTDLP_VERSION" > "$marker"
- rm -rf "$temp_dir"
-}
-
-fetch_deno() {
- local machine_arch
- machine_arch="$(uname -m)"
-
- local asset_arch
- local expected_sha
- case "$machine_arch" in
- arm64)
- asset_arch="aarch64"
- expected_sha="$DENO_ARM64_ZIP_SHA256"
- ;;
- x86_64)
- asset_arch="x86_64"
- expected_sha="$DENO_X86_64_ZIP_SHA256"
- ;;
- *)
- echo "Unsupported architecture for bundled Deno: $machine_arch" >&2
- exit 1
- ;;
- esac
-
- local executable="$SOURCE_DIR/deno"
- local marker="$SOURCE_DIR/deno-version.txt"
- if [[ -x "$executable" ]] && version_matches "$marker" "$DENO_VERSION-$machine_arch"; then
- return
- fi
-
- echo "Fetching Deno $DENO_VERSION for $machine_arch..."
- local temp_dir
- temp_dir="$(mktemp -d)"
- curl -fsSL \
- "https://github.com/denoland/deno/releases/download/v$DENO_VERSION/deno-$asset_arch-apple-darwin.zip" \
- -o "$temp_dir/deno.zip"
- verify_sha256 "$temp_dir/deno.zip" "$expected_sha"
- unzip -q -o "$temp_dir/deno.zip" -d "$temp_dir/runtime"
- cp "$temp_dir/runtime/deno" "$executable"
- chmod +x "$executable"
- printf '%s\n' "$DENO_VERSION-$machine_arch" > "$marker"
- rm -rf "$temp_dir"
-}
-
-fetch_ffmpeg() {
- local executable="$SOURCE_DIR/ffmpeg"
- local marker="$SOURCE_DIR/ffmpeg-version.txt"
-
- if [[ "$(uname -m)" != "arm64" ]]; then
- if [[ ! -x "$executable" ]]; then
- echo "A local ffmpeg executable is required on non-ARM64 development hosts." >&2
- exit 1
- fi
- return
- fi
-
- if [[ -x "$executable" ]] && version_matches "$marker" "$FFMPEG_VERSION-arm64"; then
- return
- fi
-
- echo "Fetching FFmpeg $FFMPEG_VERSION for arm64..."
- local temp_dir
- temp_dir="$(mktemp -d)"
- curl -fsSL "$FFMPEG_ARM64_URL" -o "$temp_dir/ffmpeg.zip"
- verify_sha256 "$temp_dir/ffmpeg.zip" "$FFMPEG_ARM64_ZIP_SHA256"
- unzip -q -o "$temp_dir/ffmpeg.zip" -d "$temp_dir/runtime"
- cp "$temp_dir/runtime/ffmpeg" "$executable"
- chmod +x "$executable"
- printf '%s\n' "$FFMPEG_VERSION-arm64" > "$marker"
- rm -rf "$temp_dir"
-}
-
-aria2_runtime_is_ready() {
- local required_paths=(
- "$SOURCE_DIR/aria2c"
- "$SOURCE_DIR/aria2-cacert.pem"
- "$SOURCE_DIR/aria2-libs/libcares.2.dylib"
- "$SOURCE_DIR/aria2-libs/libcrypto.3.dylib"
- "$SOURCE_DIR/aria2-libs/libintl.8.dylib"
- "$SOURCE_DIR/aria2-libs/libsqlite3.dylib"
- "$SOURCE_DIR/aria2-libs/libssh2.1.dylib"
- "$SOURCE_DIR/aria2-libs/libssl.3.dylib"
- )
-
- version_matches "$SOURCE_DIR/aria2-version.txt" "$ARIA2_RUNTIME_ID" || return 1
- [[ -x "$SOURCE_DIR/aria2c" ]] || return 1
- [[ -d "$SOURCE_DIR/aria2-licenses" ]] || return 1
-
- local path
- for path in "${required_paths[@]}"; do
- [[ -e "$path" ]] || return 1
- done
-
- [[ "$(sha256 "$SOURCE_DIR/aria2c")" == "$ARIA2_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcares.2.dylib")" == "$CARES_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libcrypto.3.dylib")" == "$LIBCRYPTO_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libintl.8.dylib")" == "$LIBINTL_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libsqlite3.dylib")" == "$LIBSQLITE_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssh2.1.dylib")" == "$LIBSSH2_RUNTIME_SHA256" ]] || return 1
- [[ "$(sha256 "$SOURCE_DIR/aria2-libs/libssl.3.dylib")" == "$LIBSSL_RUNTIME_SHA256" ]] || return 1
-}
-
-fetch_aria2() {
- if [[ "$(uname -m)" != "arm64" ]]; then
- echo "The pinned aria2 runtime is currently available only for ARM64 macOS." >&2
- exit 1
- fi
-
- if aria2_runtime_is_ready; then
- return
- fi
-
- echo "Fetching aria2 $ARIA2_VERSION Homebrew ARM64 Sonoma runtime..."
- local temp_dir
- temp_dir="$(mktemp -d)"
- local bottles_dir="$temp_dir/bottles"
- local extracted_dir="$temp_dir/extracted"
- local runtime_dir="$temp_dir/runtime"
- local libraries_dir="$runtime_dir/aria2-libs"
- local licenses_dir="$runtime_dir/aria2-licenses"
- mkdir -p "$bottles_dir" "$extracted_dir" "$libraries_dir" "$licenses_dir"
-
- fetch_homebrew_bottle "aria2" "$ARIA2_BOTTLE_SHA256" "$bottles_dir/aria2.tar.gz"
- fetch_homebrew_bottle "c-ares" "$CARES_BOTTLE_SHA256" "$bottles_dir/c-ares.tar.gz"
- fetch_homebrew_bottle "libssh2" "$LIBSSH2_BOTTLE_SHA256" "$bottles_dir/libssh2.tar.gz"
- fetch_homebrew_bottle "openssl/3" "$OPENSSL_BOTTLE_SHA256" "$bottles_dir/openssl.tar.gz"
- fetch_homebrew_bottle "sqlite" "$SQLITE_BOTTLE_SHA256" "$bottles_dir/sqlite.tar.gz"
- fetch_homebrew_bottle "gettext" "$GETTEXT_BOTTLE_SHA256" "$bottles_dir/gettext.tar.gz"
-
- local bottle
- for bottle in "$bottles_dir"/*.tar.gz; do
- tar -xzf "$bottle" -C "$extracted_dir"
- done
-
- cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/bin/aria2c" "$runtime_dir/aria2c"
- cp "$extracted_dir/c-ares/$CARES_VERSION/lib/libcares.2.19.5.dylib" "$libraries_dir/libcares.2.dylib"
- cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/lib/libssh2.1.dylib" "$libraries_dir/libssh2.1.dylib"
- cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libssl.3.dylib" "$libraries_dir/libssl.3.dylib"
- cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "$libraries_dir/libcrypto.3.dylib"
- cp "$extracted_dir/sqlite/$SQLITE_VERSION/lib/libsqlite3.3.53.0.dylib" "$libraries_dir/libsqlite3.dylib"
- cp "$extracted_dir/gettext/$GETTEXT_VERSION/lib/libintl.8.dylib" "$libraries_dir/libintl.8.dylib"
- cp "$SOURCE_DIR/_internal/certifi/cacert.pem" "$runtime_dir/aria2-cacert.pem"
-
- cp "$extracted_dir/aria2/$ARIA2_VERSION"_"$ARIA2_BOTTLE_REVISION/COPYING" "$licenses_dir/aria2-COPYING"
- cp "$extracted_dir/c-ares/$CARES_VERSION/LICENSE.md" "$licenses_dir/c-ares-LICENSE.md"
- cp "$extracted_dir/libssh2/$LIBSSH2_VERSION/COPYING" "$licenses_dir/libssh2-COPYING"
- cp "$extracted_dir/openssl@3/$OPENSSL_VERSION/LICENSE.txt" "$licenses_dir/openssl-LICENSE.txt"
- cp "$extracted_dir/gettext/$GETTEXT_VERSION/COPYING" "$licenses_dir/gettext-COPYING"
- cp "$extracted_dir/sqlite/$SQLITE_VERSION/sbom.spdx.json" "$licenses_dir/sqlite-sbom.spdx.json"
-
- install_name_tool \
- -change "@@HOMEBREW_PREFIX@@/opt/sqlite/lib/libsqlite3.dylib" "@loader_path/aria2-libs/libsqlite3.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/aria2-libs/libssl.3.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/aria2-libs/libcrypto.3.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/libssh2/lib/libssh2.1.dylib" "@loader_path/aria2-libs/libssh2.1.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/c-ares/lib/libcares.2.dylib" "@loader_path/aria2-libs/libcares.2.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/gettext/lib/libintl.8.dylib" "@loader_path/aria2-libs/libintl.8.dylib" \
- "$runtime_dir/aria2c"
-
- local library
- for library in "$libraries_dir"/*.dylib; do
- install_name_tool -id "@loader_path/$(basename "$library")" "$library"
- done
-
- install_name_tool \
- -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libssl.3.dylib" "@loader_path/libssl.3.dylib" \
- -change "@@HOMEBREW_PREFIX@@/opt/openssl@3/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \
- "$libraries_dir/libssh2.1.dylib"
- install_name_tool \
- -change "@@HOMEBREW_CELLAR@@/openssl@3/$OPENSSL_VERSION/lib/libcrypto.3.dylib" "@loader_path/libcrypto.3.dylib" \
- "$libraries_dir/libssl.3.dylib"
-
- chmod +x "$runtime_dir/aria2c"
- for library in "$libraries_dir"/*.dylib; do
- codesign --force --sign - "$library"
- done
- codesign --force --sign - "$runtime_dir/aria2c"
-
- verify_sha256 "$runtime_dir/aria2c" "$ARIA2_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libcares.2.dylib" "$CARES_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libcrypto.3.dylib" "$LIBCRYPTO_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libintl.8.dylib" "$LIBINTL_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libsqlite3.dylib" "$LIBSQLITE_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libssh2.1.dylib" "$LIBSSH2_RUNTIME_SHA256"
- verify_sha256 "$libraries_dir/libssl.3.dylib" "$LIBSSL_RUNTIME_SHA256"
-
- if otool -L "$runtime_dir/aria2c" "$libraries_dir"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then
- echo "The prepared aria2 runtime still contains a Homebrew path." >&2
- rm -rf "$temp_dir"
- exit 1
- fi
- if ! vtool -show-build "$runtime_dir/aria2c" | grep -q 'minos 14.0'; then
- echo "The pinned aria2 runtime no longer supports the app's macOS 14 deployment target." >&2
- rm -rf "$temp_dir"
- exit 1
- fi
- if [[ "$("$runtime_dir/aria2c" --version | head -n 1)" != "aria2 version $ARIA2_VERSION" ]]; then
- echo "The prepared aria2 runtime failed its version check." >&2
- rm -rf "$temp_dir"
- exit 1
- fi
-
- rm -rf "$SOURCE_DIR/aria2-libs" "$SOURCE_DIR/aria2-licenses"
- cp "$runtime_dir/aria2c" "$SOURCE_DIR/aria2c"
- cp "$runtime_dir/aria2-cacert.pem" "$SOURCE_DIR/aria2-cacert.pem"
- cp -R "$libraries_dir" "$SOURCE_DIR/aria2-libs"
- cp -R "$licenses_dir" "$SOURCE_DIR/aria2-licenses"
- printf '%s\n' "$ARIA2_RUNTIME_ID" > "$SOURCE_DIR/aria2-version.txt"
- rm -rf "$temp_dir"
-}
-
-fetch_ytdlp
-fetch_deno
-fetch_ffmpeg
-fetch_aria2
-
-if command -v xattr >/dev/null; then
- xattr -cr \
- "$SOURCE_DIR/yt-dlp" \
- "$SOURCE_DIR/_internal" \
- "$SOURCE_DIR/deno" \
- "$SOURCE_DIR/ffmpeg" \
- "$SOURCE_DIR/aria2c" \
- "$SOURCE_DIR/aria2-libs" 2>/dev/null || true
-fi
-
-echo "Media engines are ready:"
-echo " yt-dlp $YTDLP_VERSION"
-echo " Deno $DENO_VERSION"
-echo " FFmpeg $FFMPEG_VERSION"
-echo " aria2 $ARIA2_VERSION (Homebrew bottle revision $ARIA2_BOTTLE_REVISION)"
diff --git a/legacy/swift/Scripts/verify.sh b/legacy/swift/Scripts/verify.sh
deleted file mode 100755
index ed163e0..0000000
--- a/legacy/swift/Scripts/verify.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-cd "$ROOT_DIR"
-
-"$ROOT_DIR/Scripts/fetch_media_engines.sh"
-
-ARIA2C="$ROOT_DIR/Sources/Firelink/aria2c"
-ARIA2_LIBS="$ROOT_DIR/Sources/Firelink/aria2-libs"
-test -x "$ARIA2C"
-test -f "$ROOT_DIR/Sources/Firelink/aria2-cacert.pem"
-test -d "$ARIA2_LIBS"
-"$ARIA2C" --version | grep -q '^aria2 version 1.37.0$'
-test "$(tr -d '[:space:]' < "$ROOT_DIR/Sources/Firelink/aria2-version.txt")" = "1.37.0-2-arm64-sonoma"
-lipo -archs "$ARIA2C" | grep -qx arm64
-vtool -show-build "$ARIA2C" | grep -q 'minos 14.0'
-for library in "$ARIA2_LIBS"/*.dylib; do
- lipo -archs "$library" | grep -qx arm64
-done
-if otool -L "$ARIA2C" "$ARIA2_LIBS"/*.dylib | grep -Eq '(@@HOMEBREW|/opt/homebrew|/usr/local)'; then
- echo "Bundled aria2 runtime contains a non-portable dependency path." >&2
- exit 1
-fi
-
-swift build
-git diff --check
-python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
diff --git a/legacy/swift/Sources/Firelink/AddDownloadsView.swift b/legacy/swift/Sources/Firelink/AddDownloadsView.swift
deleted file mode 100644
index a916ab8..0000000
--- a/legacy/swift/Sources/Firelink/AddDownloadsView.swift
+++ /dev/null
@@ -1,897 +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?
- @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
-
- @State private var conflictingDownloads: [DuplicateDownloadItem] = []
- @State private var showingDuplicates = false
- @State private var pendingStartFlag = false
-
- var body: some View {
- VStack(spacing: 0) {
- HStack(spacing: 0) {
- // Left Column
- VStack(spacing: 0) {
- VStack(alignment: .leading, spacing: 16) {
- linkSection
- summarySection
- previewSection
- }
- .padding(20)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- Divider()
-
- // Right Column
- VStack(spacing: 0) {
- Form {
- optionsSection
- advancedTransferSection
- }
- .formStyle(.grouped)
- }
- .frame(width: 320)
- .background(Color(NSColor.controlBackgroundColor))
- }
- Divider()
- actionBar
- .padding(16)
- .background(.background)
- }
- .frame(minWidth: 720, idealWidth: 800, minHeight: 620, idealHeight: 680)
- .sheet(isPresented: $showingDuplicates) {
- DuplicateResolutionView(
- conflicts: $conflictingDownloads,
- onConfirm: {
- showingDuplicates = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
- }
- },
- onCancel: {
- showingDuplicates = false
- }
- )
- }
- .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()
- linkText = ""
- pendingDownloads = []
- headerText = ""
- cookieText = ""
- mirrorText = ""
- useAuthorization = false
- authUsername = ""
- authPassword = ""
- checksumEnabled = false
- checksumValue = ""
- }
- }
-
- 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, idealHeight: 100, maxHeight: 160)
-
- HStack {
- Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
- .foregroundStyle(.secondary)
- Spacer()
- Button {
- refreshMetadata(for: linkText, isAutoFetch: false)
- } label: {
- Label("Refresh Metadata", systemImage: "arrow.clockwise")
- }
- .disabled(DownloadURLParser.parse(linkText).isEmpty)
- }
- .font(.caption)
- }
- }
-
- private var optionsSection: some View {
- Group {
- if let firstMedia = pendingDownloads.first(where: { $0.isMedia }) {
- mediaFormatSection(for: firstMedia)
- }
-
- Section("Save Location") {
- Toggle("Use one folder for all files", isOn: $overrideDestination)
- if overrideDestination {
- HStack(spacing: 8) {
- TextField("Automatic by file type", text: $destinationPath)
- .textFieldStyle(.roundedBorder)
- .font(.system(.callout, design: .monospaced))
- Button {
- selectDestination()
- } label: {
- Label("Select...", systemImage: "folder.badge.plus")
- }
- }
- }
- }
-
- Section("Queue") {
- Picker("Target Queue", selection: $targetQueueID) {
- ForEach(controller.queues) { queue in
- Text(queue.name).tag(queue.id)
- }
- }
- .labelsHidden()
- }
-
- Section("Transfer Settings") {
- VStack(alignment: .leading, spacing: 4) {
- Text("Connections per File")
- HStack {
- Slider(value: $connectionsPerServer, in: 1...16, step: 1)
- Text("\(Int(connectionsPerServer))")
- .monospacedDigit()
- .frame(width: 24, alignment: .trailing)
- }
- }
-
- Toggle("Limit speed per file", isOn: $speedLimitEnabled)
- if speedLimitEnabled {
- Stepper(
- "\(speedLimitKiBPerSecond) KiB/s",
- value: $speedLimitKiBPerSecond,
- in: 1...10_485_760,
- step: 128
- )
- }
- }
-
- Section("Authorization") {
- Toggle("Use authorization", isOn: $useAuthorization)
- if useAuthorization {
- TextField("Username", text: $authUsername)
- .textFieldStyle(.roundedBorder)
- SecureField("Password", text: $authPassword)
- .textFieldStyle(.roundedBorder)
- 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(item.destinationPath)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
-
- TableColumn("Status") { $item in
- if item.isMedia, case .loading = item.state {
- HStack {
- ProgressView().controlSize(.small)
- Text("Checking")
- }.foregroundStyle(.secondary)
- } else {
- MetadataStatusView(state: item.state)
- }
- }
- .width(min: 110, ideal: 140, max: 200)
- }
- .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(showingDuplicates ? nil : .cancelAction)
-
- Button("Add to Queue") {
- addDownloads(start: false)
- }
- .disabled(!canAddDownloads)
-
- Button("Start Downloads") {
- addDownloads(start: true)
- }
- .buttonStyle(.borderedProminent)
- .disabled(!canAddDownloads)
- .keyboardShortcut(showingDuplicates ? nil : .defaultAction)
- }
- }
-
- private func updateMediaOption(for firstMedia: PendingDownload, newId: String) {
- for index in pendingDownloads.indices where pendingDownloads[index].isMedia {
- if let option = pendingDownloads[index].mediaOptions.first(where: { $0.id == newId }) {
- pendingDownloads[index].selectedMediaOption = option
- if let metadata = pendingDownloads[index].mediaMetadata {
- let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
- pendingDownloads[index].fileName = "\(cleanTitle).\(option.outputExtension)"
- pendingDownloads[index].category = FileClassifier.category(forFileName: pendingDownloads[index].fileName)
- }
- }
- }
- }
-
- @ViewBuilder
- private func mediaFormatSection(for firstMedia: PendingDownload) -> some View {
- Section {
- if firstMedia.mediaOptions.isEmpty {
- HStack(spacing: 8) {
- if case .loading = firstMedia.state {
- ProgressView().controlSize(.small)
- Text("Fetching media options...")
- .foregroundStyle(.secondary)
- } else if case .failed(let errorMsg) = firstMedia.state {
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 8) {
- Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
- Text("Failed to load options.")
- .foregroundStyle(.secondary)
- }
- Text(errorMsg)
- .font(.caption)
- .foregroundStyle(.red)
- .lineLimit(3)
- }
- } else {
- ProgressView().controlSize(.small)
- Text("Waiting for metadata...")
- .foregroundStyle(.secondary)
- }
- }
- .padding(.vertical, 4)
- } else {
- let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
- let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
- let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
- let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
- let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
-
- let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
- if $0 == "Best" { return true }
- if $1 == "Best" { return false }
- return $0 > $1
- })
-
- Picker("Type", selection: Binding(
- get: { currentOption.mediaType },
- set: { newType in
- if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
- updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
- }
- }
- )) {
- ForEach(availableTypes, id: \.self) { type in
- Text(type.rawValue).tag(type)
- }
- }
- .pickerStyle(.segmented)
- .padding(.bottom, 4)
-
- Picker("Format", selection: Binding(
- get: { currentOption.containerName },
- set: { newFormat in
- let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
- let fallback = optionsForType.first(where: { $0.containerName == newFormat })
- if let newOption = matching ?? fallback {
- updateMediaOption(for: firstMedia, newId: newOption.id)
- }
- }
- )) {
- ForEach(availableFormats, id: \.self) { format in
- Text(format).tag(format)
- }
- }
-
- if currentOption.mediaType == .video {
- Picker("Quality", selection: Binding(
- get: { currentOption.qualityName },
- set: { newQuality in
- if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
- updateMediaOption(for: firstMedia, newId: newOption.id)
- }
- }
- )) {
- ForEach(availableQualities, id: \.self) { quality in
- Text(quality).tag(quality)
- }
- }
- } else {
- Picker("Quality", selection: .constant("Best")) {
- Text("Best").tag("Best")
- }
- .disabled(true)
- }
- } // End of else block
- } header: {
- Text("Media Format").foregroundStyle(.blue)
- }
- }
-
- private var advancedTransferSection: some View {
- Section {
- DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
- VStack(alignment: .leading, spacing: 12) {
- Toggle("Verify Checksum", isOn: $checksumEnabled)
- if checksumEnabled {
- Picker("Algorithm", selection: $checksumAlgorithm) {
- ForEach(ChecksumAlgorithm.allCases) { algorithm in
- Text(algorithm.title).tag(algorithm)
- }
- }
- TextField("Expected digest", text: $checksumValue)
- .textFieldStyle(.roundedBorder)
- .font(.system(.callout, design: .monospaced))
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Headers")
- TextEditor(text: $headerText)
- .font(.system(.callout, design: .monospaced))
- .scrollContentBackground(.hidden)
- .background(.quaternary.opacity(0.35))
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .frame(minHeight: 48)
- .onChange(of: headerText) { _, newValue in
- if newValue.count > 65536 { headerText = String(newValue.prefix(65536)) }
- }
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Cookies")
- TextField("name=value; other=value", text: $cookieText)
- .textFieldStyle(.roundedBorder)
- .font(.system(.callout, design: .monospaced))
- .onChange(of: cookieText) { _, newValue in
- if newValue.count > 65536 { cookieText = String(newValue.prefix(65536)) }
- }
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Mirrors")
- TextEditor(text: $mirrorText)
- .font(.system(.callout, design: .monospaced))
- .scrollContentBackground(.hidden)
- .background(.quaternary.opacity(0.35))
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .frame(minHeight: 48)
- .onChange(of: mirrorText) { _, newValue in
- if newValue.count > 65536 { mirrorText = String(newValue.prefix(65536)) }
- }
- }
- }
- .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, isAutoFetch: true)
- }
- }
- }
-
- 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, isAutoFetch: Bool) {
- let urls = DownloadURLParser.parse(text)
- metadataTask?.cancel()
-
- pendingDownloads = urls.map { url in
- let isMedia = MediaDetector.isSupportedMedia(url: url)
- 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,
- isMedia: isMedia
- )
- }
-
- if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
- useAuthorization = true
- authUsername = creds.username
- authPassword = creds.password
- saveLogin = false
- }
-
- guard !urls.isEmpty else {
- metadataTask = nil
- return
- }
-
- metadataTask = Task {
- await withTaskGroup(of: PendingDownload.self) { group in
- for item in pendingDownloads {
- group.addTask {
- if item.isMedia {
- var fetchedItem = item
- do {
- try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
- let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
- for: item.url,
- cookieSource: settings.mediaCookieSource,
- credentials: metadataCredentials(for: item.url),
- transferOptions: transferOptions,
- proxyConfiguration: settings.downloadProxyConfiguration
- )
- fetchedItem.mediaMetadata = metadata
- fetchedItem.mediaOptions = options
- if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
- fetchedItem.selectedMediaOption = bestVideo
- let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
- fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
- fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
- }
- fetchedItem.state = .loaded
- } catch {
- fetchedItem.state = .failed(error.localizedDescription)
- }
- return fetchedItem
- } else {
- return await DownloadMetadataFetcher.fetch(
- for: item.url,
- settings: settings,
- credentials: metadataCredentials(for: item.url),
- transferOptions: transferOptions,
- isAutoFetch: isAutoFetch
- )
- }
- }
- }
-
- for await loadedItem in group {
- guard !Task.isCancelled else { break }
- await MainActor.run {
- 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 conflicts: [DuplicateDownloadItem] = []
- for pending in pendingDownloads {
- let destURL = overrideDirectory ?? pending.defaultDirectory
- let destPath = destURL.appendingPathComponent(pending.fileName).path
-
- if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
- conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
- } else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
- conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
- }
- }
-
- if !conflicts.isEmpty {
- conflictingDownloads = conflicts
- pendingStartFlag = start
- showingDuplicates = true
- return
- }
-
- executeAddDownloads(start: start)
- }
-
- private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
- var finalDownloads = pendingDownloads
-
- if let conflicts {
- for conflict in conflicts {
- guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
- switch conflict.resolution {
- case .skip:
- finalDownloads.remove(at: index)
- case .rename:
- let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
- var newName = finalDownloads[index].fileName
- var count = 1
- let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
- let ext = URL(fileURLWithPath: newName).pathExtension
- while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) || finalDownloads.prefix(upTo: index).contains(where: { (overrideDirectory ?? $0.defaultDirectory) == destURL && $0.fileName == newName }) {
- newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
- count += 1
- }
- finalDownloads[index].fileName = newName
- case .replace:
- let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
- let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
- guard URL(fileURLWithPath: path).standardized.path.hasPrefix(destURL.standardized.path) else { continue }
- if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
- controller.delete(controller.downloads[existingIndex], deleteFiles: true)
- } else if FileManager.default.fileExists(atPath: path) {
- try? FileManager.default.removeItem(atPath: path)
- }
- }
- }
- }
-
- guard !finalDownloads.isEmpty else {
- dismiss()
- return
- }
-
- let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
-
- controller.addPendingDownloads(
- finalDownloads,
- connectionsPerServer: Int(connectionsPerServer),
- overrideDirectory: overrideDirectory,
- startImmediately: start,
- queueID: targetQueueID,
- credentials: explicitCredentials,
- transferOptions: transferOptions,
- speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
- )
- dismiss()
- }
-
- private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? {
- guard useAuthorization else { return nil }
-
- let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !cleanUsername.isEmpty else { return nil }
-
- if saveLogin {
- var savedHosts = Set()
- for url in urls {
- if let host = url.host, !savedHosts.contains(host) {
- settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
- savedHosts.insert(host)
- }
- }
- }
-
- return DownloadCredentials(username: cleanUsername, password: authPassword)
- }
-
- 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)
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/AppSettings.swift b/legacy/swift/Sources/Firelink/AppSettings.swift
deleted file mode 100644
index 9e3810a..0000000
--- a/legacy/swift/Sources/Firelink/AppSettings.swift
+++ /dev/null
@@ -1,555 +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 BrowserCookieSource: String, Codable, CaseIterable, Sendable {
- case none = "None"
- case safari = "Safari"
- case chrome = "Chrome"
- case firefox = "Firefox"
- case edge = "Edge"
- case brave = "Brave"
-
- var ytDlpBrowserName: String? {
- switch self {
- case .none:
- nil
- case .safari, .chrome, .firefox, .edge, .brave:
- rawValue.lowercased()
- }
- }
-
- var statusTitle: String {
- switch self {
- case .none:
- "Not using browser cookies"
- case .safari, .chrome, .firefox, .edge, .brave:
- "Using \(rawValue) cookies"
- }
- }
-
- var statusDetail: String {
- switch self {
- case .none:
- "Restricted media may fail if the site requires login."
- case .safari:
- "yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
- case .chrome, .firefox, .edge, .brave:
- "yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
- }
- }
-}
-
-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)
- 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 showNotifications: Bool {
- didSet { save() }
- }
-
- @Published var customUserAgent: String {
- didSet { save() }
- }
-
- @Published var playCompletionSound: Bool {
- didSet { save() }
- }
-
- @Published var showDockBadge: Bool {
- didSet { save() }
- }
-
- @Published var maxAutomaticRetries: Int {
- didSet {
- let clamped = min(max(maxAutomaticRetries, 0), 10)
- if maxAutomaticRetries != clamped {
- maxAutomaticRetries = clamped
- return
- }
- 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 mediaCookieSource: BrowserCookieSource {
- didSet { save() }
- }
-
- @Published var extensionPairingToken: String {
- didSet {
- if isKeychainAccessGranted {
- KeychainCredentialStore.setExtensionToken(extensionPairingToken)
- }
- }
- }
-
- @Published var isKeychainAccessGranted: Bool {
- didSet { save() }
- }
-
- @Published var showKeychainPrimer = false
-
- @Published var askWhereToSaveEachFile: Bool {
- didSet { save() }
- }
-
- @Published var message = ""
-
- private let defaults: UserDefaults
- private let storageKey = "Firelink.AppSettings.v1"
- private var saveTask: Task?
-
- init(defaults: UserDefaults = .standard) {
- self.defaults = defaults
-
- let granted: Bool
- 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
- showNotifications = stored.showNotifications ?? true
- playCompletionSound = stored.playCompletionSound ?? true
- showDockBadge = stored.showDockBadge ?? true
- customUserAgent = stored.customUserAgent ?? ""
- maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
- proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
- siteLogins = stored.siteLogins
- mediaCookieSource = stored.mediaCookieSource ?? .none
- downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
- granted = stored.isKeychainAccessGranted ?? false
- isKeychainAccessGranted = granted
- askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? false
- } else {
- appTheme = .system
- appFontSize = .standard
- listRowDensity = .standard
- perServerConnections = 16
- maxConcurrentDownloads = 3
- globalSpeedLimitKiBPerSecond = 0
- preventsSleepWhileDownloading = true
- showNotifications = true
- playCompletionSound = true
- showDockBadge = true
- customUserAgent = ""
- maxAutomaticRetries = 3
- proxySettings = ProxySettings()
- siteLogins = []
- mediaCookieSource = .none
- downloadDirectories = Self.defaultDirectories()
- granted = false
- isKeychainAccessGranted = granted
- askWhereToSaveEachFile = false
- }
-
- let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
- let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
- var execHash = "unknown"
- if let execPath = Bundle.main.executablePath,
- let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
- let modDate = attr[.modificationDate] as? Date {
- execHash = String(modDate.timeIntervalSince1970)
- }
- let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
- let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
- defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
-
- var needsPrimer = false
- if granted {
- if let lastVersion, lastVersion != fullVersion {
- needsPrimer = true
- }
- }
-
- if granted {
- if needsPrimer {
- showKeychainPrimer = true
- extensionPairingToken = ""
- } else {
- if let token = KeychainCredentialStore.extensionToken() {
- extensionPairingToken = token
- } else {
- extensionPairingToken = Self.generateSecureToken()
- }
- }
- } else {
- extensionPairingToken = ""
- }
-
- 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)
- }
-
- func grantKeychainAccess() {
- isKeychainAccessGranted = true
- if let token = KeychainCredentialStore.extensionToken() {
- extensionPairingToken = token
- } else {
- extensionPairingToken = Self.generateSecureToken()
- }
- }
-
- func revokeKeychainAccess() {
- KeychainCredentialStore.deleteExtensionToken()
- for login in siteLogins {
- KeychainCredentialStore.deletePassword(for: login.id)
- }
- siteLogins.removeAll()
- extensionPairingToken = ""
- isKeychainAccessGranted = false
- }
-
- func resolveKeychainPrimer(grantAccess: Bool) {
- showKeychainPrimer = false
- if grantAccess {
- if let token = KeychainCredentialStore.extensionToken() {
- extensionPairingToken = token
- } else {
- extensionPairingToken = Self.generateSecureToken()
- }
- } else {
- revokeKeychainAccess()
- }
- }
-
- private func save() {
- let stored = StoredSettings(
- appTheme: appTheme,
- appFontSize: appFontSize,
- listRowDensity: listRowDensity,
- perServerConnections: perServerConnections,
- maxConcurrentDownloads: maxConcurrentDownloads,
- globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
- preventsSleepWhileDownloading: preventsSleepWhileDownloading,
- showNotifications: showNotifications,
- playCompletionSound: playCompletionSound,
- showDockBadge: showDockBadge,
- customUserAgent: customUserAgent,
- maxAutomaticRetries: maxAutomaticRetries,
- proxySettings: proxySettings.normalized,
- downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
- siteLogins: siteLogins,
- mediaCookieSource: mediaCookieSource,
- isKeychainAccessGranted: isKeychainAccessGranted,
- askWhereToSaveEachFile: askWhereToSaveEachFile
- )
- 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 generateSecureToken() -> String {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- guard status == errSecSuccess else {
- return UUID().uuidString
- }
- return Data(bytes).base64EncodedString()
- }
-
- 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 showNotifications: Bool?
- var playCompletionSound: Bool?
- var showDockBadge: Bool?
- var customUserAgent: String?
- var maxAutomaticRetries: Int?
- var proxySettings: ProxySettings?
- var downloadDirectories: [String: String]
- var siteLogins: [SiteLogin]
- var mediaCookieSource: BrowserCookieSource?
- var isKeychainAccessGranted: Bool?
- var askWhereToSaveEachFile: Bool?
-}
diff --git a/legacy/swift/Sources/Firelink/Aria2DownloadEngine.swift b/legacy/swift/Sources/Firelink/Aria2DownloadEngine.swift
deleted file mode 100644
index eb62824..0000000
--- a/legacy/swift/Sources/Firelink/Aria2DownloadEngine.swift
+++ /dev/null
@@ -1,713 +0,0 @@
-import Foundation
-import CFNetwork
-import Network
-
-final class Aria2DownloadEngine: Sendable {
- struct Handle {
- let processIdentifier: Int32
- let rpcPort: Int
- let rpcSecret: String
- let cancel: @Sendable () -> Void
- }
-
- static func findFreePort() -> (UInt16, Int32)? {
- var addr = sockaddr_in()
- addr.sin_len = UInt8(MemoryLayout.size)
- addr.sin_family = sa_family_t(AF_INET)
- addr.sin_addr.s_addr = inet_addr("127.0.0.1")
- addr.sin_port = 0
-
- let sock = socket(AF_INET, SOCK_STREAM, 0)
- guard sock >= 0 else { return nil }
-
- var addrPtr = addr
- let bindResult = withUnsafePointer(to: &addrPtr) { ptr in
- ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
- bind(sock, $0, socklen_t(MemoryLayout.size))
- }
- }
-
- guard bindResult == 0 else {
- close(sock)
- return nil
- }
-
- var boundAddr = sockaddr_in()
- var len = socklen_t(MemoryLayout.size)
- let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { ptr in
- ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
- getsockname(sock, $0, &len)
- }
- }
-
- guard getsocknameResult == 0 else {
- close(sock)
- return nil
- }
-
- let port = UInt16(bigEndian: boundAddr.sin_port)
- return (port, sock)
- }
-
- enum EngineError: LocalizedError {
- case executableNotFound
- case launchFailed(String)
- case unsupportedProxy(String)
-
- var errorDescription: String? {
- switch self {
- case .executableNotFound:
- "The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines."
- 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? {
- bundledResource(named: "aria2c", executable: true)
- }
-
- static func certificateBundleURL() -> URL? {
- bundledResource(named: "aria2-cacert.pem", executable: false)
- }
-
- private static func bundledResource(named name: String, executable: Bool) -> URL? {
- func validResource(in bundle: Bundle) -> URL? {
- guard let url = bundle.resourceURL?.appendingPathComponent(name) else { return nil }
- let isValid = executable
- ? FileManager.default.isExecutableFile(atPath: url.path)
- : FileManager.default.fileExists(atPath: url.path)
- return isValid ? url : nil
- }
-
- if let bundled = validResource(in: .main) {
- return bundled
- }
-
- if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
- #if SWIFT_PACKAGE
- return validResource(in: .module)
- #endif
- }
-
- 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
- ) async throws -> Handle {
- guard let executableURL else {
- throw EngineError.executableNotFound
- }
-
- try FileManager.default.createDirectory(
- at: item.destinationDirectory,
- withIntermediateDirectories: true
- )
-
- var lastError: Error?
-
- for _ in 1...5 {
- guard let (rpcPortVal, portSocket) = Self.findFreePort() else {
- lastError = EngineError.launchFailed("Could not find free port")
- continue
- }
- let rpcPort = Int(rpcPortVal)
- let rpcSecret = UUID().uuidString
- let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
-
- final class CleanupState: @unchecked Sendable {
- private let lock = NSLock()
- private var didCleanup = false
- func cleanup(tempDir: URL) {
- lock.lock()
- defer { lock.unlock() }
- if !didCleanup {
- try? FileManager.default.removeItem(at: tempDir)
- didCleanup = true
- }
- }
- }
-
- let cleanupState = CleanupState()
- let cleanupTempDir: @Sendable () -> Void = {
- cleanupState.cleanup(tempDir: tempDir)
- }
-
- do {
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
- } catch {
- close(portSocket)
- lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
- continue
- }
-
- let confURL = tempDir.appendingPathComponent("aria2.conf")
- do {
- let confContent = "rpc-secret=\(rpcSecret)\n"
- try confContent.write(to: confURL, atomically: true, encoding: .utf8)
- try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
- } catch {
- close(portSocket)
- lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
- continue
- }
-
- let process = Process()
- process.executableURL = executableURL
-
- do {
- process.arguments = try arguments(
- for: item,
- proxyConfiguration: proxyConfiguration,
- speedLimitKiBPerSecond: speedLimitKiBPerSecond,
- rpcPort: rpcPort,
- confURL: confURL
- )
- } catch {
- close(portSocket)
- lastError = error
- break
- }
-
- 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
- cleanupTempDir()
- 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)))
- }
-
- var didThrow = false
- do {
- close(portSocket)
- try process.run()
- if let input = inputFileContent(for: item).data(using: .utf8) {
- inputPipe.fileHandleForWriting.write(input)
- }
- inputPipe.fileHandleForWriting.closeFile()
- } catch {
- didThrow = true
- lastError = EngineError.launchFailed(error.localizedDescription)
- }
-
- if didThrow {
- outputPipe.fileHandleForReading.readabilityHandler = nil
- errorPipe.fileHandleForReading.readabilityHandler = nil
- cleanupTempDir()
- continue
- }
-
- try? await Task.sleep(nanoseconds: 100_000_000)
-
- if !process.isRunning {
- let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
- if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
- cleanupTempDir()
- continue
- }
- // If it exited for another reason, we might still want to fail or let the terminationHandler process it.
- // But the terminationHandler will hit completionGate, so we just return the handle.
- }
-
- 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 {
- ProcessTreeTerminator.terminate(process)
- }
- cleanupTempDir()
- }
- }
-
- throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
- }
-
- private static func monitorCompletion(
- rpcPort: Int,
- rpcSecret: String,
- process: Process,
- completionGate: CompletionGate
- ) -> Task {
- Task.detached {
- while !Task.isCancelled && process.isRunning {
- if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
- completionGate.complete(.success(()))
- if process.isRunning {
- ProcessTreeTerminator.terminate(process)
- }
- 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,
- confURL: URL
- ) throws -> [String] {
- var arguments = [
- "--conf-path=\(confURL.path)",
- "--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-listen-all=false"
- ]
-
- if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
- arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
- }
-
- if let certificateBundleURL = Self.certificateBundleURL() {
- arguments.append("--ca-certificate=\(certificateBundleURL.path)")
- }
-
- 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
-
- init(_ completion: @escaping @Sendable (Result) -> Void) {
- self.completion = completion
- }
-
- func complete(_ result: Result) {
- 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?
-
- func set(_ task: Task) {
- 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.. 1 else {
- return nil
- }
- guard let captureRange = Range(match.range(at: 1), in: text) else {
- return nil
- }
- return String(text[captureRange])
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json b/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json
deleted file mode 100644
index 5f84abb..0000000
--- a/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/Contents.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "images" : [
- {
- "filename" : "github-mark.png",
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "properties" : {
- "template-rendering-intent" : "template"
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png b/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png
deleted file mode 100644
index e28a837..0000000
Binary files a/legacy/swift/Sources/Firelink/Assets.xcassets/GitHub.imageset/github-mark.png and /dev/null differ
diff --git a/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/Contents.json
deleted file mode 100644
index c586150..0000000
--- a/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/Contents.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "images" : [
- {
- "filename" : "MenuBarIconTemplate.png",
- "idiom" : "mac",
- "scale" : "2x"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "properties" : {
- "template-rendering-intent" : "template"
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png b/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png
deleted file mode 100644
index 6d94556..0000000
Binary files a/legacy/swift/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png and /dev/null differ
diff --git a/legacy/swift/Sources/Firelink/ChunkMapView.swift b/legacy/swift/Sources/Firelink/ChunkMapView.swift
deleted file mode 100644
index c118a96..0000000
--- a/legacy/swift/Sources/Firelink/ChunkMapView.swift
+++ /dev/null
@@ -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?
- @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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/ContentView.swift b/legacy/swift/Sources/Firelink/ContentView.swift
deleted file mode 100644
index 64415d6..0000000
--- a/legacy/swift/Sources/Firelink/ContentView.swift
+++ /dev/null
@@ -1,336 +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 = []
- @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")
- }
- .onChange(of: controller.activeCount) { _, newCount in
- updateDockBadge(count: newCount, show: settings.showDockBadge)
- }
- .onChange(of: settings.showDockBadge) { _, show in
- updateDockBadge(count: controller.activeCount, show: show)
- }
- .onAppear {
- updateDockBadge(count: controller.activeCount, show: settings.showDockBadge)
- }
- .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 {
- let newText = url.absoluteString
- if let existing = controller.pendingPasteboardText, !existing.isEmpty {
- controller.pendingPasteboardText = existing + "\n" + newText
- } else {
- controller.pendingPasteboardText = newText
- }
- 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 {
- if let existing = controller.pendingPasteboardText, !existing.isEmpty {
- controller.pendingPasteboardText = existing + "\n" + text
- } else {
- controller.pendingPasteboardText = text
- }
- controller.pendingReferer = nil
- openWindow(id: "add-downloads")
- }
- }
- }
- }
- }
- return true
- }
- .sheet(isPresented: $settings.showKeychainPrimer) {
- KeychainPrimerView()
- .environmentObject(settings)
- }
- }
-
- @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 {
- ToolbarItemGroup {
- Button {
- controller.pendingAddQueueID = queueID
- openWindow(id: "add-downloads")
- } label: {
- Label("Add Download", systemImage: "plus")
- }
- .help("Add a new download")
-
- 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 ? "Resume All" : "Resume", systemImage: "play.fill")
- }
- .help(selectedItems.isEmpty ? "Resume all downloads" : "Resume selected downloads")
- .disabled(!canStart)
-
- let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading || $0.status == .queued })
- Button {
- if selectedItems.isEmpty {
- controller.pauseActiveDownloads(queueID: queueID)
- } else {
- for item in selectedItems where item.status == .downloading || item.status == .queued {
- controller.pause(item)
- }
- }
- } label: {
- Label(selectedItems.isEmpty ? "Pause All" : "Pause", systemImage: "pause.fill")
- }
- .help(selectedItems.isEmpty ? "Pause all active downloads" : "Pause selected downloads")
- .disabled(!canStop)
- }
- }
- .background {
- Button("") {
- if !selection.isEmpty {
- showDeleteConfirmation = true
- }
- }
- .keyboardShortcut(.delete, modifiers: [])
- .opacity(0)
- .buttonStyle(.plain)
- .focusable(false)
-
- Button("") {
- handlePaste(queueID: queueID)
- }
- .keyboardShortcut("v", modifiers: .command)
- .opacity(0)
- .buttonStyle(.plain)
- .focusable(false)
-
- Button("") {
- selectAll(items: items)
- }
- .keyboardShortcut("a", modifiers: .command)
- .opacity(0)
- .buttonStyle(.plain)
- .focusable(false)
-
- Button("") {
- if let item = selectedItems.first {
- performPrimaryAction(for: item)
- }
- }
- .keyboardShortcut(.return, modifiers: [])
- .opacity(0)
- .buttonStyle(.plain)
- .focusable(false)
- }
- .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 func updateDockBadge(count: Int, show: Bool) {
- if show && count > 0 {
- NSApp.dockTile.badgeLabel = "\(count)"
- } else {
- NSApp.dockTile.badgeLabel = nil
- }
- }
-
- 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 performPrimaryAction(for item: DownloadItem) {
- if item.status == .completed {
- NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
- } else {
- openWindow(id: "download-properties", value: item.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 }
- }
- }
-}
-
-struct KeychainPrimerView: View {
- @EnvironmentObject private var settings: AppSettings
- @Environment(\.dismiss) private var dismiss
-
- var body: some View {
- VStack(spacing: 24) {
- Image(systemName: "lock.shield")
- .resizable()
- .scaledToFit()
- .frame(width: 64, height: 64)
- .foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
- .padding(.top, 16)
-
- VStack(spacing: 8) {
- Text("Security Update")
- .font(.title2)
- .fontWeight(.semibold)
-
- Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
- .font(.body)
- .multilineTextAlignment(.center)
- .foregroundStyle(.secondary)
- .padding(.horizontal)
- }
-
- VStack(spacing: 12) {
- Button {
- settings.resolveKeychainPrimer(grantAccess: true)
- } label: {
- Text("Grant Secure Access")
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
-
- Button {
- settings.resolveKeychainPrimer(grantAccess: false)
- } label: {
- Text("Not Now")
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderless)
- .controlSize(.large)
- }
- .padding(.horizontal, 32)
- .padding(.bottom, 16)
- }
- .padding(24)
- .frame(width: 400)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/DownloadController.swift b/legacy/swift/Sources/Firelink/DownloadController.swift
deleted file mode 100644
index a8be0af..0000000
--- a/legacy/swift/Sources/Firelink/DownloadController.swift
+++ /dev/null
@@ -1,1258 +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 let mediaEngine = MediaDownloadEngine()
- private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
- private var activeMediaHandles: [UUID: MediaDownloadEngine.Handle] = [:]
- private var automaticRetryCounts: [UUID: Int] = [:]
- private var restrictQueueToAutoResume = false
- private var queuePumpScope: QueuePumpScope = .idle
- private var sleepActivity: SleepActivityHandle?
- private var cancellables = Set()
- 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?
- private var pendingNotifications: [(title: String, body: String)] = []
- private var notificationDebounceTask: Task?
- private var lastProgressUpdateTimes: [UUID: Date] = [:]
- private var speedUnknownSince: [UUID: Date] = [:]
-
- 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?.saveDownloadsSync()
- }
- .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
- }
-
- private var hasStartableQueuedDownloadIgnoringEngine: Bool {
- downloads.contains { item in
- item.status == .queued &&
- (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
- isAllowedToStart(item)
- }
- }
-
- private var hasRunnableQueuedDownload: Bool {
- downloads.contains { item in
- item.status == .queued &&
- (item.mediaFormatSelector != nil || hasAria2) &&
- (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
- isAllowedToStart(item)
- }
- }
-
- func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID, filename: String? = nil) {
- 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 explicitName = filename?.trimmingCharacters(in: .whitespacesAndNewlines)
- let resolvedFileName = (explicitName != nil && !explicitName!.isEmpty) ? explicitName! : FileClassifier.fileName(from: url)
- let category = FileClassifier.category(forFileName: resolvedFileName)
- let item = DownloadItem(
- url: url,
- fileName: resolvedFileName,
- 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 \(resolvedFileName) 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,
- mediaFormatSelector: pending.selectedMediaOption?.formatSelector,
- isAudioOnlyMedia: pending.selectedMediaOption?.isAudioOnly
- )
- }
-
- 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) {
- lastProgressUpdateTimes[item.id] = nil
- activeHandles[item.id]?.cancel()
- activeHandles[item.id] = nil
- activeMediaHandles[item.id]?.cancel()
- activeMediaHandles[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
- activeMediaHandles[item.id]?.cancel()
- activeMediaHandles[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
- activeMediaHandles[item.id]?.cancel()
- activeMediaHandles[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, 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
- activeMediaHandles[item.id]?.cancel()
- activeMediaHandles[item.id] = nil
- update(item.id) {
- $0.status = .canceled
- $0.message = "Canceled"
- $0.autoResumeOnLaunch = false
- }
- automaticRetryCounts[item.id] = nil
- saveDownloads()
- applySpeedLimitsToActiveDownloads()
- updateSleepActivity()
- pumpQueue()
- }
-
-
-
- func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
- activeHandles[item.id]?.cancel()
- activeHandles[item.id] = nil
- activeMediaHandles[item.id]?.cancel()
- activeMediaHandles[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 hasStartableQueuedDownloadIgnoringEngine else {
- return
- }
-
- guard hasRunnableQueuedDownload else {
- engineMessage = "The bundled aria2c runtime is missing. Reinstall Firelink or rebuild its media engines."
- return
- }
-
- pruneActiveQueueScopes()
-
- while activeCount < settings.maxConcurrentDownloads,
- let next = downloads.first(where: { item in
- item.status == .queued &&
- (item.mediaFormatSelector != nil || hasAria2) &&
- (!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 injectedEngineItem(from item: DownloadItem) -> DownloadItem {
- var engineItem = item
- if engineItem.credentials != nil {
- if let storedPassword = KeychainCredentialStore.password(for: engineItem.id) {
- engineItem.credentials?.password = storedPassword
- }
- }
- let ua = settings.customUserAgent.trimmingCharacters(in: .whitespacesAndNewlines)
- if !ua.isEmpty, !(engineItem.requestHeaders?.contains(where: { $0.name.caseInsensitiveCompare("user-agent") == .orderedSame }) ?? false) {
- var headers = engineItem.requestHeaders ?? []
- headers.append(DownloadRequestHeader(name: "User-Agent", value: ua))
- engineItem.requestHeaders = headers
- }
- return engineItem
- }
-
- 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()
-
- if item.mediaFormatSelector != nil {
- Task {
- do {
- update(item.id) {
- guard $0.status == .downloading else { return }
- $0.message = "Checking bundled media engines..."
- }
- try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
- guard let liveItem = activeDownloadItem(id: item.id) else { return }
-
- update(item.id) {
- guard $0.status == .downloading else { return }
- $0.message = "Starting yt-dlp..."
- }
- let handle = try await mediaEngine.start(
- item: injectedEngineItem(from: liveItem),
- cookieSource: settings.mediaCookieSource,
- proxyConfiguration: settings.downloadProxyConfiguration,
- speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
- progress: { [weak self] progress in
- Task { @MainActor in
- let now = Date()
- if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.5 {
- return
- }
- self?.lastProgressUpdateTimes[item.id] = now
- self?.update(item.id) {
- guard $0.status == .downloading else { return }
- $0.progress = progress.fraction
- $0.bytesText = progress.bytesText
- let isUnknown = progress.speedText.lowercased() == "unknown" || progress.speedText == "-"
- if !isUnknown || $0.speedText == "-" || $0.speedText.lowercased() == "unknown" {
- $0.speedText = progress.speedText
- self?.speedUnknownSince[item.id] = nil
- } else {
- if self?.speedUnknownSince[item.id] == nil {
- self?.speedUnknownSince[item.id] = Date()
- }
- if let since = self?.speedUnknownSince[item.id], Date().timeIntervalSince(since) > 3.0 {
- $0.speedText = "0 KiB/s"
- }
- }
- let isEtaUnknown = progress.etaText.lowercased() == "unknown" || progress.etaText == "-"
- if !isEtaUnknown || $0.etaText == "-" || $0.etaText.lowercased() == "unknown" {
- $0.etaText = progress.etaText
- }
- $0.connectionCount = progress.connectionCount
- }
- }
- },
- messageUpdate: { [weak self] message in
- Task { @MainActor in
- self?.update(item.id) {
- guard $0.status == .downloading else { return }
- $0.message = message
- }
- }
- },
- completion: { [weak self] result in
- Task { @MainActor in
- self?.handleMediaCompletion(item: item, result: result)
- }
- }
- )
- guard activeDownloadItem(id: item.id) != nil else {
- handle.cancel()
- return
- }
- activeMediaHandles[item.id] = handle
- saveDownloads()
- applySpeedLimitsToActiveDownloads()
- updateSleepActivity()
- } catch {
- guard shouldHandleStartFailure(for: item.id) else { return }
- handleDownloadFailure(itemID: item.id, error: error)
- applySpeedLimitsToActiveDownloads()
- updateSleepActivity()
- pumpQueue()
- }
- }
- } else {
- Task {
- do {
- let liveItem = injectedEngineItem(from: item)
- let handle = try await engine.start(
- item: liveItem,
- proxyConfiguration: settings.downloadProxyConfiguration,
- speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
- progress: { [weak self] progress in
- Task { @MainActor in
- let now = Date()
- if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.5 {
- return
- }
- self?.lastProgressUpdateTimes[item.id] = now
- self?.update(item.id) {
- guard $0.status == .downloading else { return }
- $0.progress = progress.fraction
- $0.bytesText = progress.bytesText
- let isUnknown = progress.speedText.lowercased() == "unknown" || progress.speedText == "-"
- if !isUnknown || $0.speedText == "-" || $0.speedText.lowercased() == "unknown" {
- $0.speedText = progress.speedText
- self?.speedUnknownSince[item.id] = nil
- } else {
- if self?.speedUnknownSince[item.id] == nil {
- self?.speedUnknownSince[item.id] = Date()
- }
- if let since = self?.speedUnknownSince[item.id], Date().timeIntervalSince(since) > 3.0 {
- $0.speedText = "0 KiB/s"
- }
- }
- let isEtaUnknown = progress.etaText.lowercased() == "unknown" || progress.etaText == "-"
- if !isEtaUnknown || $0.etaText == "-" || $0.etaText.lowercased() == "unknown" {
- $0.etaText = progress.etaText
- }
- $0.connectionCount = progress.connectionCount
- $0.message = "Downloading"
- }
- }
- },
- completion: { [weak self] result in
- Task { @MainActor in
- self?.handleCompletion(item: item, result: result)
- }
- }
- )
-
- await MainActor.run {
- guard activeDownloadItem(id: item.id) != nil else {
- handle.cancel()
- return
- }
- activeHandles[item.id] = handle
- update(item.id) {
- $0.rpcPort = handle.rpcPort
- $0.rpcSecret = handle.rpcSecret
- $0.message = "Starting..."
- }
- saveDownloads()
- applySpeedLimitsToActiveDownloads()
- updateSleepActivity()
- }
- } catch {
- await MainActor.run {
- handleDownloadFailure(itemID: item.id, error: error)
- applySpeedLimitsToActiveDownloads()
- updateSleepActivity()
- pumpQueue()
- }
- }
- }
- }
- }
-
- private func activeDownloadItem(id: UUID) -> DownloadItem? {
- downloads.first { $0.id == id && $0.status == .downloading }
- }
-
- private func shouldHandleStartFailure(for id: UUID) -> Bool {
- guard let item = downloads.first(where: { $0.id == id }) else {
- automaticRetryCounts[id] = nil
- return false
- }
- return item.status != .paused && item.status != .canceled
- }
-
- private func handleCompletion(item: DownloadItem, result: Result) {
- 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
-
- if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath),
- let size = attr[.size] as? Int64 {
- $0.sizeBytes = size
- $0.bytesText = ByteFormatter.string(size)
- }
- }
- 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()
- }
-
- private func handleMediaCompletion(item: DownloadItem, result: Result) {
- activeMediaHandles[item.id] = nil
-
- switch result {
- case .success(let outputURL):
- self.automaticRetryCounts[item.id] = nil
- self.update(item.id) {
- $0.status = .completed
- $0.progress = 1
- $0.speedText = "-"
- $0.etaText = "-"
- $0.connectionCount = 0
- $0.destinationDirectory = outputURL.deletingLastPathComponent()
- $0.fileName = outputURL.lastPathComponent
- $0.category = FileClassifier.category(forFileName: $0.fileName)
- $0.message = "Saved to \(outputURL.path)"
- $0.autoResumeOnLaunch = false
-
- if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
- let size = attr[.size] as? Int64 {
- $0.sizeBytes = size
- $0.bytesText = ByteFormatter.string(size)
- }
- }
- self.saveDownloads()
- self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent)
- 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()
- }
-
- 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) {
- guard let currentItem = downloads.first(where: { $0.id == itemID }) else {
- automaticRetryCounts[itemID] = nil
- return
- }
- guard currentItem.status != .paused, currentItem.status != .canceled else {
- return
- }
-
- let retryCount = automaticRetryCounts[itemID] ?? 0
-
- guard isAutomaticallyRecoverable(error), retryCount < settings.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)/\(settings.maxAutomaticRetries))."
- $0.autoResumeOnLaunch = true
- }
- saveDownloads()
- }
-
- private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
- if let engineError = error as? Aria2DownloadEngine.EngineError {
- switch engineError {
- case .executableNotFound, .unsupportedProxy:
- return false
- case .launchFailed:
- return true
- }
- }
-
- if let mediaError = error as? MediaDownloadEngine.EngineError {
- switch mediaError {
- case .missingEngine:
- return false
- case .launchFailed(let message):
- return isRecoverableMediaFailure(message)
- }
- }
-
- return true
- }
-
- private func isRecoverableMediaFailure(_ message: String) -> Bool {
- let lowercased = message.lowercased()
- let permanentMarkers = [
- "requires browser cookies",
- "choose a browser",
- "challenge solving failed",
- "install deno or node",
- "requested format is not available",
- "unsupported url",
- "private video",
- "sign in",
- "not a bot",
- "video unavailable",
- "no video formats found",
- "no audio formats found",
- "ffmpeg is not installed",
- "yt-dlp is not installed"
- ]
-
- if permanentMarkers.contains(where: { lowercased.contains($0) }) {
- return false
- }
-
- 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, itemIDs: Set)
-
- 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 saveDownloadsSync() {
- let queuesCopy = queues
- let downloadsCopy = downloads.map(\.redactedForPersistence)
- let storageURL = self.storageURL
-
- 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)
- try data.write(to: storageURL, options: .atomic)
- } catch {
- print("Failed to synchronously save downloads: \(error)")
- }
- }
-
- 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.status == .completed && adjusted.progress != 1 {
- adjusted.progress = 1
- shouldRewriteStoredDownloads = true
- }
-
- if adjusted.status == .completed &&
- (adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
- adjusted.speedText = "-"
- adjusted.etaText = "-"
- adjusted.connectionCount = 0
- adjusted.autoResumeOnLaunch = false
- 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) {
- guard settings.showNotifications || settings.playCompletionSound else { return }
-
- pendingNotifications.append((title: title, body: body))
- notificationDebounceTask?.cancel()
-
- notificationDebounceTask = Task { @MainActor in
- do {
- try await Task.sleep(nanoseconds: 1_000_000_000)
- guard !Task.isCancelled else { return }
-
- var options: UNAuthorizationOptions = []
- if self.settings.showNotifications { options.insert(.alert) }
- if self.settings.playCompletionSound { options.insert(.sound) }
-
- UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] granted, _ in
- guard granted, let self else { return }
- Task { @MainActor in
- let items = self.pendingNotifications
- self.pendingNotifications.removeAll()
- guard !items.isEmpty else { return }
-
- let content = UNMutableNotificationContent()
- if self.settings.showNotifications {
- if items.count == 1 {
- content.title = items[0].title
- content.body = items[0].body
- } else {
- content.title = "\(items.count) Downloads Completed"
- content.body = "Multiple items have finished downloading."
- }
- } else {
- content.title = "Download Completed"
- }
-
- if self.settings.playCompletionSound {
- content.sound = .default
- }
-
- let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
- UNUserNotificationCenter.current().add(request)
- }
- }
- } catch {}
- }
- }
-}
-
-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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/DownloadMetadataFetcher.swift b/legacy/swift/Sources/Firelink/DownloadMetadataFetcher.swift
deleted file mode 100644
index 6521639..0000000
--- a/legacy/swift/Sources/Firelink/DownloadMetadataFetcher.swift
+++ /dev/null
@@ -1,230 +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.. 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()
- 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(),
- isAutoFetch: Bool = false
- ) 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
- }
-
- if let host = url.host {
- let isPrivate = await Task.detached {
- isPrivateHost(host)
- }.value
- if isPrivate {
- 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
- }
-
- private static func isPrivateHost(_ host: String) -> Bool {
- let h = host.lowercased()
- if h == "localhost" || h.hasSuffix(".local") { return true }
- if !h.contains(".") && !h.contains(":") { return true }
-
- var hints = addrinfo(
- ai_flags: 0,
- ai_family: AF_UNSPEC,
- ai_socktype: SOCK_STREAM,
- ai_protocol: 0,
- ai_addrlen: 0,
- ai_canonname: nil,
- ai_addr: nil,
- ai_next: nil
- )
-
- var res: UnsafeMutablePointer?
- if getaddrinfo(host, nil, &hints, &res) == 0 {
- var current = res
- while let info = current {
- let family = info.pointee.ai_family
- if family == AF_INET {
- let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
- let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
- let first = (ip >> 24) & 0xFF
- let second = (ip >> 16) & 0xFF
-
- if first == 127 || first == 10 || (first == 192 && second == 168) {
- freeaddrinfo(res)
- return true
- }
- if first == 172 && (16...31).contains(second) {
- freeaddrinfo(res)
- return true
- }
- if first == 169 && second == 254 {
- freeaddrinfo(res)
- return true
- }
- } else if family == AF_INET6 {
- let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
- let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
-
- let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
- bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
- bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
- bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
-
- let isULA = (bytes.0 & 0xFE) == 0xFC
- let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
-
- let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
- bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
- bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
-
- if isLoopback || isULA || isLinkLocal {
- freeaddrinfo(res)
- return true
- }
- if isIPv4Mapped {
- let first = bytes.12
- let second = bytes.13
- if first == 127 || first == 10 || (first == 192 && second == 168) {
- freeaddrinfo(res)
- return true
- }
- if first == 172 && (16...31).contains(second) {
- freeaddrinfo(res)
- return true
- }
- if first == 169 && second == 254 {
- freeaddrinfo(res)
- return true
- }
- }
- }
- current = info.pointee.ai_next
- }
- freeaddrinfo(res)
- }
- return false
- }
-}
-
-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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/DownloadPropertiesView.swift b/legacy/swift/Sources/Firelink/DownloadPropertiesView.swift
deleted file mode 100644
index bfb7271..0000000
--- a/legacy/swift/Sources/Firelink/DownloadPropertiesView.swift
+++ /dev/null
@@ -1,491 +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)
- let storedPassword = KeychainCredentialStore.password(for: item.id) ?? credentials.password
- _password = State(initialValue: storedPassword)
- } 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: 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.displaySpeedText)
- summary("ETA", item.displayETAText)
- }
- 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)
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/DownloadTable.swift b/legacy/swift/Sources/Firelink/DownloadTable.swift
deleted file mode 100644
index 9980652..0000000
--- a/legacy/swift/Sources/Firelink/DownloadTable.swift
+++ /dev/null
@@ -1,433 +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
- let title: String
- var queueID: UUID?
-
- @State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
- @State private var pendingDeleteItems: Set?
- @State private var sortedItems: [DownloadItem] = []
-
- var body: some View {
- VStack(spacing: 0) {
- HStack {
- Text(title)
- .font(.title2)
- .fontWeight(.semibold)
- Text("\(items.count)")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary.opacity(0.5))
- .clipShape(Capsule())
- Spacer()
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
-
- 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.")
- )
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
- TableColumn("File Name", value: \.fileName) { item in
- 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)
- .allowsHitTesting(false)
- }
- .draggable(item.id.uuidString)
- }
- .width(min: 200, ideal: 340)
-
- TableColumn("Size", value: \.sortableSize) { item in
- if let size = item.sizeBytes, size > 0 {
- Text(ByteFormatter.string(size))
- .monospacedDigit()
- .lineLimit(1)
- .truncationMode(.tail)
- } else if item.bytesText != "-" && !item.bytesText.isEmpty {
- Text(item.bytesText)
- .monospacedDigit()
- .lineLimit(1)
- .truncationMode(.tail)
- } else {
- Text("Unknown")
- .monospacedDigit()
- .lineLimit(1)
- .truncationMode(.tail)
- }
- }
- .width(min: 80, ideal: 100)
-
- TableColumn("Status", value: \.status.rawValue) { item in
- combinedStatusCell(for: item)
- }
- .width(min: 160, ideal: 200)
-
- TableColumn("Speed", value: \.displaySpeedText) { item in
- if item.status == .downloading {
- formattedSpeedCell(for: item.displaySpeedText)
- } else {
- Text("-")
- .foregroundStyle(.tertiary)
- }
- }
- .width(min: 80, ideal: 100)
-
- TableColumn("ETA", value: \.displayETAText) { item in
- if item.status == .downloading {
- formattedETACell(for: item.displayETAText)
- } else {
- Text("-")
- .foregroundStyle(.tertiary)
- }
- }
- .width(min: 80, ideal: 100)
-
- TableColumn("Date Added", value: \.createdAt) { item in
- Text(formatted(item.createdAt))
- .lineLimit(1)
- .truncationMode(.tail)
- }
- .width(min: 100, ideal: 155)
- }
- .environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
- .animation(.default, value: sortedItems)
- .contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
- rowContextMenu(for: itemIDs)
- } primaryAction: { itemIDs in
- let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
- for target in targetItems {
- if target.status == .completed {
- openFile(target)
- }
- }
- }
- }
- }
- .onAppear { sortedItems = items.sorted(using: sortOrder) }
- .onChange(of: items) { _, newItems in
- let existingIDs = Set(sortedItems.map(\.id))
- let newIDs = Set(newItems.map(\.id))
- if existingIDs != newIDs {
- sortedItems = newItems.sorted(using: sortOrder)
- } else {
- let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
- sortedItems = sortedItems.compactMap { itemsDict[$0.id] }
- }
- }
- .onChange(of: sortOrder) { _, newOrder in
- sortedItems = items.sorted(using: newOrder)
- }
- .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.")
- }
- }
- }
-
- @ViewBuilder
- private func combinedStatusCell(for item: DownloadItem) -> some View {
- if item.status == .completed {
- Text("Completed")
- .foregroundStyle(.green)
- .fontWeight(.medium)
- } else {
- HStack(spacing: 8) {
- ProgressView(value: item.progress)
- .progressViewStyle(.linear)
- .tint(statusColor(for: item.status))
-
- if item.status == .downloading {
- Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
- .font(.system(size: 11, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.secondary)
- .frame(width: 35, alignment: .trailing)
- } else {
- Text(item.status.rawValue.capitalized)
- .font(.system(size: 11, weight: .medium, design: .rounded))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
-
- private func parseSpeed(_ text: String) -> [String] {
- var display = text
-
- if let index = display.firstIndex(where: { $0.isLetter }) {
- if display.distance(from: display.startIndex, to: index) > 0 {
- let prevIndex = display.index(before: index)
- if display[prevIndex] != " " {
- display.insert(" ", at: index)
- }
- }
- }
- return display.split(separator: " ", maxSplits: 1).map(String.init)
- }
-
- @ViewBuilder
- private func formattedSpeedCell(for text: String) -> some View {
- let components = parseSpeed(text)
- if components.count == 2 {
- HStack(alignment: .firstTextBaseline, spacing: 1) {
- Text(components[0])
- .font(.system(size: 13, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.primary)
- Text(components[1])
- .font(.system(size: 10, weight: .medium, design: .rounded))
- .foregroundStyle(.secondary)
- }
- .lineLimit(1)
- .truncationMode(.tail)
- } else {
- Text(components.joined(separator: " "))
- .font(.system(size: 13, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.primary)
- .lineLimit(1)
- .truncationMode(.tail)
- }
- }
-
- @ViewBuilder
- private func formattedETACell(for text: String) -> some View {
- Text(text)
- .font(.system(size: 13, weight: .medium, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.primary)
- .lineLimit(1)
- .truncationMode(.tail)
- }
-
- @ViewBuilder
- private func rowContextMenu(for itemIDs: Set) -> 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: "magnifyingglass")
- }
-
- 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("Resume", 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("Pause", systemImage: "pause.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")
- }
-
- if targetItems.allSatisfy({ $0.status == .completed }) {
- Button {
- NSPasteboard.general.clearContents()
- let paths = targetItems.map { $0.destinationPath }.joined(separator: "\n")
- if !paths.isEmpty {
- NSPasteboard.general.setString(paths, forType: .string)
- }
- } label: {
- Label(targetItems.count > 1 ? "Copy File Paths" : "Copy File Path", systemImage: "doc.on.doc")
- }
- }
-
- Divider()
-
- Button(role: .destructive) {
- pendingDeleteItems = itemIDs
- } label: {
- Label("Remove from List", systemImage: "trash")
- }
-
- Divider()
-
- Button {
- for target in targetItems {
- openWindow(id: "download-properties", value: target.id)
- }
- } label: {
- Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
- }
- } else {
- Button {
- openWindow(id: "add-downloads")
- } label: {
- Label("Add Download", systemImage: "plus")
- }
- }
- }
-
- 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
- }
- }
-
-
-
- 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))
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/DuplicateResolutionView.swift b/legacy/swift/Sources/Firelink/DuplicateResolutionView.swift
deleted file mode 100644
index 8a0f293..0000000
--- a/legacy/swift/Sources/Firelink/DuplicateResolutionView.swift
+++ /dev/null
@@ -1,87 +0,0 @@
-import SwiftUI
-
-struct DuplicateDownloadItem: Identifiable, Equatable {
- let id: UUID
- var pendingItem: PendingDownload
- var resolution: DuplicateResolutionAction = .rename
- let reason: DuplicateReason
-
- enum DuplicateReason: Equatable {
- case existingURL(String)
- case existingFile(String)
- }
-}
-
-enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
- case rename = "Rename"
- case replace = "Replace"
- case skip = "Skip"
- var id: String { rawValue }
-}
-
-struct DuplicateResolutionView: View {
- @Binding var conflicts: [DuplicateDownloadItem]
- let onConfirm: () -> Void
- let onCancel: () -> Void
-
- var body: some View {
- VStack(spacing: 0) {
- VStack(alignment: .leading, spacing: 8) {
- Text("Duplicate Downloads Detected")
- .font(.headline)
- Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- Divider()
-
- List($conflicts) { $conflict in
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(conflict.pendingItem.fileName)
- .lineLimit(1)
- .font(.body.weight(.medium))
- Text(reasonText(for: conflict.reason))
- .font(.caption)
- .foregroundStyle(.orange)
- }
-
- Spacer()
-
- Picker("", selection: $conflict.resolution) {
- ForEach(DuplicateResolutionAction.allCases) { action in
- Text(action.rawValue).tag(action)
- }
- }
- .labelsHidden()
- .frame(width: 100)
- }
- .padding(.vertical, 4)
- }
- .frame(minHeight: 200)
-
- Divider()
-
- HStack {
- Button("Cancel", action: onCancel)
- .keyboardShortcut(.cancelAction)
- Spacer()
- Button("Continue", action: onConfirm)
- .buttonStyle(.borderedProminent)
- .keyboardShortcut(.defaultAction)
- }
- .padding(16)
- }
- .frame(width: 500)
- }
-
- private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
- switch reason {
- case .existingURL(let msg): return msg
- case .existingFile(let msg): return msg
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/FileClassifier.swift b/legacy/swift/Sources/Firelink/FileClassifier.swift
deleted file mode 100644
index 339a6bf..0000000
--- a/legacy/swift/Sources/Firelink/FileClassifier.swift
+++ /dev/null
@@ -1,86 +0,0 @@
-import Foundation
-
-enum FileClassifier {
- private static let musicExtensions: Set = [
- "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 = [
- "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 = [
- "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 = [
- "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 = [
- "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))
- }
-}
diff --git a/legacy/swift/Sources/Firelink/FirelinkApp.swift b/legacy/swift/Sources/Firelink/FirelinkApp.swift
deleted file mode 100644
index 86622b7..0000000
--- a/legacy/swift/Sources/Firelink/FirelinkApp.swift
+++ /dev/null
@@ -1,155 +0,0 @@
-import SwiftUI
-
-@main
-struct FirelinkApp: App {
- @StateObject private var updateChecker: ReleaseUpdateChecker
- @StateObject private var settings: AppSettings
- @StateObject private var controller: DownloadController
- @StateObject private var schedulerController: SchedulerController
- @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
- @State private var lastURLSchemeInvocation: Date = .distantPast
- @State private var urlSchemeInvocationCount: Int = 0
-
- // Server must be retained to keep listening
- private let extensionServer: LocalExtensionServer?
-
- init() {
- self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
-
- 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, settings: settings)
- extensionServer?.start()
- controller.extensionServerPort = extensionServer?.port
- }
-
- var body: some Scene {
- WindowGroup(id: "main") {
- ContentView()
- .environmentObject(controller)
- .environmentObject(settings)
- .environmentObject(schedulerController)
- .environmentObject(updateChecker)
- .modifier(AppThemeModifier(theme: settings.appTheme))
- .modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
- .task {
- updateChecker.checkAutomaticallyIfNeeded()
- _ = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
- }
- .onOpenURL { url in
- let now = Date()
- if now.timeIntervalSince(lastURLSchemeInvocation) > 5 {
- urlSchemeInvocationCount = 0
- }
- guard urlSchemeInvocationCount < 3 else { return }
- urlSchemeInvocationCount += 1
- lastURLSchemeInvocation = now
-
- if url.scheme == "firelink" {
- if url.host == "add",
- let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
- let queryItems = components.queryItems,
- let link = queryItems.first(where: { $0.name == "url" })?.value,
- link.count < 65536 {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- controller.pendingPasteboardText = link
- controller.pendingReferer = nil
- NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
- }
- }
- return
- }
-
- guard url.absoluteString.count < 65536 else { return }
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- 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)
-
- Window("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", id: "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: 23, height: 23)
- 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
- }
-}
diff --git a/legacy/swift/Sources/Firelink/KeychainCredentialStore.swift b/legacy/swift/Sources/Firelink/KeychainCredentialStore.swift
deleted file mode 100644
index b741e6a..0000000
--- a/legacy/swift/Sources/Firelink/KeychainCredentialStore.swift
+++ /dev/null
@@ -1,100 +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,
- kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
- ]
-
- 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),
- kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- ]
-
- 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
- }
- private static let extensionTokenService = "local.firelink.extension-token"
- private static let extensionTokenAccount = "pairing-token"
-
- static func extensionToken() -> String? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: extensionTokenService,
- kSecAttrAccount as String: extensionTokenAccount,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne,
- kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
- ]
-
- 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 setExtensionToken(_ token: String) -> Bool {
- deleteExtensionToken()
-
- let attributes: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: extensionTokenService,
- kSecAttrAccount as String: extensionTokenAccount,
- kSecValueData as String: Data(token.utf8),
- kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- ]
-
- return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
- }
-
- @discardableResult
- static func deleteExtensionToken() -> Bool {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: extensionTokenService,
- kSecAttrAccount as String: extensionTokenAccount
- ]
-
- let status = SecItemDelete(query as CFDictionary)
- return status == errSecSuccess || status == errSecItemNotFound
- }
-}
diff --git a/legacy/swift/Sources/Firelink/LocalExtensionServer.swift b/legacy/swift/Sources/Firelink/LocalExtensionServer.swift
deleted file mode 100644
index 9cad850..0000000
--- a/legacy/swift/Sources/Firelink/LocalExtensionServer.swift
+++ /dev/null
@@ -1,325 +0,0 @@
-import Foundation
-import Network
-import AppKit
-import Combine
-import CryptoKit
-
-final class LocalExtensionServer: @unchecked Sendable {
- private enum Constants {
- static let portRange = 6412...6422
- static let maxRequestBytes = 128 * 1024
- static let maxURLCount = 200
- static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
- }
-
- private let listener: NWListener
- private let downloadController: DownloadController
- private let settings: AppSettings
- private let queue = DispatchQueue(label: "local.firelink.server")
- let port: UInt16
-
- private let tokenLock = NSLock()
- private var _pairingToken: String = ""
- private var cancellables = Set()
-
- @MainActor
- init?(downloadController: DownloadController, settings: AppSettings) {
- self.downloadController = downloadController
- self.settings = settings
- 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
-
- settings.$extensionPairingToken
- .sink { [weak self] token in
- self?.tokenLock.withLock { self?._pairingToken = token }
- }
- .store(in: &cancellables)
- }
-
- private var currentPairingToken: String {
- tokenLock.withLock { _pairingToken }
- }
-
- 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)
- let timeoutItem = DispatchWorkItem { [weak connection] in
- connection?.cancel()
- }
- queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
- receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem)
- }
-
- private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) {
- connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
- guard let self else {
- timeoutItem.cancel()
- connection.cancel()
- return
- }
-
- var requestData = accumulatedData
- if let data {
- requestData.append(data)
- }
-
- guard error == nil, requestData.count <= Constants.maxRequestBytes else {
- timeoutItem.cancel()
- self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
- return
- }
-
- if let request = HTTPRequest(data: requestData) {
- timeoutItem.cancel()
- let status = self.processRequest(request)
- self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
- return
- }
-
- if isComplete {
- timeoutItem.cancel()
- self.sendResponse(.badRequest, connection: connection, origin: nil)
- return
- }
-
- self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem)
- }
- }
-
- 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: GET, POST, OPTIONS")
- headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp")
- }
-
- 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 {
- let host = request.header(named: "host") ?? ""
- let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
- guard isLocalhost else {
- return .forbidden
- }
-
- if request.method == "OPTIONS" {
- return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
- }
-
- let expectedToken = currentPairingToken
- guard !expectedToken.isEmpty else {
- return .forbidden
- }
-
- let bodyString = String(data: request.body, encoding: .utf8) ?? ""
- guard let signatureHex = request.header(named: "x-firelink-signature"),
- let timestampStr = request.header(named: "x-firelink-timestamp"),
- let timestamp = TimeInterval(timestampStr) else {
- return .forbidden
- }
-
- let now = Date().timeIntervalSince1970 * 1000
- guard abs(now - timestamp) < 60000 else {
- return .forbidden
- }
-
- let keyData = expectedToken.data(using: .utf8) ?? Data()
- let key = SymmetricKey(data: keyData)
- let messageData = (timestampStr + bodyString).data(using: .utf8) ?? Data()
- let hmac = HMAC.authenticationCode(for: messageData, using: key)
- let expectedSignature = hmac.map { String(format: "%02x", $0) }.joined()
-
- guard signatureHex.lowercased() == expectedSignature else {
- return .forbidden
- }
-
- if request.path == "/ping" {
- return request.method == "GET" ? .ok : .methodNotAllowed
- }
-
- guard request.path == "/download" else {
- return .notFound
- }
-
- guard request.method == "POST" else {
- return .methodNotAllowed
- }
-
- guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
- return .unsupportedMediaType
- }
-
- struct Payload: Decodable {
- let urls: [String]
- let referer: String?
- let silent: Bool?
- let filename: 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
- let shouldAsk = self.settings.askWhereToSaveEachFile || payload.silent != true
- if shouldAsk {
- 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)
- } else {
- for validURL in validURLs {
- self.downloadController.add(urlText: validURL, filename: payload.filename)
- }
- self.downloadController.startQueue()
- }
- }
-
- 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[..= 2 else {
- return nil
- }
-
- var parsedHeaders: [String: String] = [:]
- for line in lines.dropFirst() {
- guard let colonIndex = line.firstIndex(of: ":") else {
- continue
- }
- let name = line[..= 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"
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/MediaDetector.swift b/legacy/swift/Sources/Firelink/MediaDetector.swift
deleted file mode 100644
index 085a32a..0000000
--- a/legacy/swift/Sources/Firelink/MediaDetector.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-import Foundation
-
-enum MediaDetector {
- private static let supportedDomains: Set = [
- "youtube.com", "youtu.be",
- "twitter.com", "x.com",
- "vimeo.com",
- "twitch.tv",
- "instagram.com",
- "tiktok.com",
- "facebook.com", "fb.watch",
- "reddit.com", "v.redd.it",
- "soundcloud.com"
- ]
-
- static func isSupportedMedia(url: URL) -> Bool {
- guard let host = url.host?.lowercased() else { return false }
-
- for domain in supportedDomains {
- if host == domain || host.hasSuffix(".\(domain)") {
- // Ignore raw files that happen to be hosted on these domains, if any,
- // though usually these domains serve web pages for media.
- return true
- }
- }
- return false
- }
-}
diff --git a/legacy/swift/Sources/Firelink/MediaDownloadEngine.swift b/legacy/swift/Sources/Firelink/MediaDownloadEngine.swift
deleted file mode 100644
index d7248a8..0000000
--- a/legacy/swift/Sources/Firelink/MediaDownloadEngine.swift
+++ /dev/null
@@ -1,505 +0,0 @@
-import Foundation
-
-final class MediaDownloadEngine: @unchecked Sendable {
- struct Handle {
- let cancel: @Sendable () -> Void
- }
-
- enum EngineError: LocalizedError {
- case missingEngine(String)
- case launchFailed(String)
-
- var errorDescription: String? {
- switch self {
- case .missingEngine(let msg): return msg
- case .launchFailed(let msg): return msg
- }
- }
- }
-
- func start(
- item: DownloadItem,
- cookieSource: BrowserCookieSource,
- proxyConfiguration: DownloadProxyConfiguration,
- speedLimitKiBPerSecond: Int?,
- progress: @escaping @Sendable (DownloadProgress) -> Void,
- messageUpdate: @escaping @Sendable (String) -> Void,
- completion: @escaping @Sendable (Result) -> Void
- ) async throws -> Handle {
- let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp)
- let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
-
- guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
- throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
- }
- guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
- throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
- }
-
- try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
-
- let process = Process()
- process.executableURL = ytDlpURL
-
- var arguments = [
- "--newline",
- "--ffmpeg-location", ffmpegURL.path,
- "--no-check-formats",
- "--socket-timeout", "20",
- "--retries", "3",
- "--extractor-retries", "3",
- "--fragment-retries", "10",
- "--retry-sleep", "0",
- "--skip-unavailable-fragments",
- "--compat-options", "no-youtube-unavailable-videos",
- "-o", item.destinationPath
- ]
-
- if let format = item.mediaFormatSelector {
- arguments.append("-f")
- arguments.append(format)
-
- if item.isAudioOnlyMedia == true {
- let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
- arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
- } else {
- let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
- arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
- }
- }
-
- let tempConfigDir = MediaExtractionEngine.appendCommonArguments(
- to: &arguments,
- cookieSource: cookieSource,
- credentials: item.credentials,
- transferOptions: item.transferOptions,
- preferredDenoURL: ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
- )
-
- if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
- arguments.append(contentsOf: ["--proxy", proxyURI])
- }
-
- if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
- arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
- }
-
- appendParallelDownloadArguments(
- to: &arguments,
- item: item,
- speedLimitKiBPerSecond: speedLimitKiBPerSecond
- )
-
- arguments.append(item.url.absoluteString)
- process.arguments = arguments
-
- let outputPipe = Pipe()
- let errorPipe = Pipe()
- process.standardOutput = outputPipe
- process.standardError = errorPipe
-
- let parser = YTDLPProgressParser(totalExpectedBytes: item.sizeBytes)
- let errorBuffer = LockedDataBuffer()
- let outputPathTracker = YTDLPOutputPathTracker()
- let completionGate = CompletionGate(completion)
- let outputHandler = YTDLPOutputHandler(
- parser: parser,
- outputPathTracker: outputPathTracker,
- progress: progress,
- messageUpdate: messageUpdate
- )
-
- let readGroup = DispatchGroup()
-
- readGroup.enter()
- outputPipe.fileHandleForReading.readabilityHandler = { handle in
- let data = handle.availableData
- if data.isEmpty {
- handle.readabilityHandler = nil
- readGroup.leave()
- } else if let text = String(data: data, encoding: .utf8) {
- outputHandler.handle(text)
- }
- }
-
- readGroup.enter()
- errorPipe.fileHandleForReading.readabilityHandler = { handle in
- let data = handle.availableData
- if data.isEmpty {
- handle.readabilityHandler = nil
- readGroup.leave()
- } else {
- errorBuffer.append(data)
- if let text = String(data: data, encoding: .utf8) {
- outputHandler.handle(text)
- }
- }
- }
-
- process.terminationHandler = { finishedProcess in
- if let tempConfigDir {
- try? FileManager.default.removeItem(at: tempConfigDir)
- }
- let complete: @Sendable () -> Void = {
- if finishedProcess.terminationStatus == 0 {
- completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
- } else {
- let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
- completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
- }
- }
- readGroup.notify(queue: .global(), execute: complete)
- DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
- outputPipe.fileHandleForReading.readabilityHandler = nil
- errorPipe.fileHandleForReading.readabilityHandler = nil
- complete()
- }
- }
-
- var didRun = false
- defer {
- if !didRun, let tempConfigDir {
- try? FileManager.default.removeItem(at: tempConfigDir)
- }
- }
- try process.run()
- didRun = true
- messageUpdate("Fetching media data...")
- outputPipe.fileHandleForWriting.closeFile()
- errorPipe.fileHandleForWriting.closeFile()
-
- return Handle(cancel: {
- if process.isRunning {
- ProcessTreeTerminator.terminate(process)
- }
- })
- }
-
- private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
- let expectedURL = URL(fileURLWithPath: item.destinationPath)
- if FileManager.default.fileExists(atPath: expectedURL.path) {
- return expectedURL
- }
-
- if let observedURL = tracker.lastExistingOutputURL {
- return observedURL
- }
-
- let baseName = expectedURL.deletingPathExtension().lastPathComponent
- let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"]
-
- var mostRecent: URL?
- var mostRecentDate: Date = .distantPast
-
- for ext in commonExtensions {
- let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)")
- if FileManager.default.fileExists(atPath: candidate.path) {
- let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
- if date >= mostRecentDate {
- mostRecentDate = date
- mostRecent = candidate
- }
- }
- }
-
- return mostRecent ?? expectedURL
- }
-
- private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
- guard !message.isEmpty else {
- return "Exit code \(status)"
- }
-
- if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
- message.localizedCaseInsensitiveContains("not a bot") ||
- message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
- return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
- }
-
- if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
- message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
- return "YouTube challenge solving failed. Install Deno or Node, then retry."
- }
-
- return message
- }
-
- private func appendParallelDownloadArguments(
- to arguments: inout [String],
- item: DownloadItem,
- speedLimitKiBPerSecond: Int?
- ) {
- let connections = min(max(item.connectionsPerServer, 1), 16)
- guard connections > 1 else { return }
-
- arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
- let largeDirectDownloadThreshold: Int64 = 128 * 1024 * 1024
- guard item.isAudioOnlyMedia != true,
- (item.sizeBytes ?? 0) >= largeDirectDownloadThreshold,
- speedLimitKiBPerSecond == nil,
- let aria2URL = Aria2DownloadEngine.findExecutable() else {
- return
- }
-
- let aria2Connections = min(connections, 8)
- let certificateArgument = Aria2DownloadEngine.certificateBundleURL().map {
- " --ca-certificate=\(Self.shellQuoted($0.path))"
- } ?? ""
- arguments.append(contentsOf: [
- "--downloader", aria2URL.path,
- "--downloader", "dash,m3u8:native",
- "--downloader-args",
- "aria2c:-x\(aria2Connections) -s\(aria2Connections) -k1M --file-allocation=none --summary-interval=1\(certificateArgument)"
- ])
- }
-
- private static func shellQuoted(_ value: String) -> String {
- "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
- }
-}
-
-final class YTDLPOutputHandler: @unchecked Sendable {
- private let parser: YTDLPProgressParser
- private let outputPathTracker: YTDLPOutputPathTracker
- private let progress: @Sendable (DownloadProgress) -> Void
- private let messageUpdate: @Sendable (String) -> Void
- private var trackCount = 0
-
- init(
- parser: YTDLPProgressParser,
- outputPathTracker: YTDLPOutputPathTracker,
- progress: @escaping @Sendable (DownloadProgress) -> Void,
- messageUpdate: @escaping @Sendable (String) -> Void
- ) {
- self.parser = parser
- self.outputPathTracker = outputPathTracker
- self.progress = progress
- self.messageUpdate = messageUpdate
- }
-
- func handle(_ text: String) {
- for line in text.split(whereSeparator: \.isNewline) {
- let stringLine = String(line)
- outputPathTracker.observe(stringLine)
- if let message = statusMessage(for: stringLine) {
- messageUpdate(message)
- } else if let update = parser.parse(stringLine) {
- progress(update)
- }
- }
- }
-
- private func statusMessage(for line: String) -> String? {
- if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
- return "Merging Media Tracks..."
- }
- if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
- return "Fetching YouTube data..."
- }
- if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
- return "Preparing media stream..."
- }
- if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
- line.localizedCaseInsensitiveContains("not a bot") ||
- line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
- return "YouTube requires browser cookies"
- }
- if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
- line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
- return "YouTube challenge solver unavailable"
- }
- if line.contains("Destination:") {
- return "Downloading Media"
- }
- return nil
- }
-}
-
-final class YTDLPOutputPathTracker: @unchecked Sendable {
- private let lock = NSLock()
- private var observedPaths: [String] = []
- private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
-
- var lastExistingOutputURL: URL? {
- lock.withLock {
- observedPaths
- .reversed()
- .map { URL(fileURLWithPath: $0) }
- .first { FileManager.default.fileExists(atPath: $0.path) }
- }
- }
-
- func observe(_ line: String) {
- let candidates = pathCandidates(from: line)
- guard !candidates.isEmpty else { return }
-
- lock.withLock {
- for candidate in candidates where !observedPaths.contains(candidate) {
- observedPaths.append(candidate)
- }
- }
- }
-
- private func pathCandidates(from line: String) -> [String] {
- var paths: [String] = []
-
- if line.contains("Destination:"),
- let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
- destination.hasPrefix("/") {
- paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
- }
-
- for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
- paths.append(quoted)
- }
-
- return paths
- }
-
- private func quotedCaptures(in text: String) -> [String] {
- guard let quotedPathRegex else { return [] }
- let range = NSRange(text.startIndex.. 1,
- let captureRange = Range(match.range(at: 1), in: text) else {
- return nil
- }
- return String(text[captureRange])
- }
- }
-}
-
-private extension String {
- func fileExtension(defaultValue: String) -> String {
- let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- return ext.isEmpty ? defaultValue : ext
- }
-}
-
-final class YTDLPProgressParser: @unchecked Sendable {
- private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
- private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
- private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
- private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
-
- private let totalExpectedBytes: Int64?
- private var accumulatedBytes: Int64 = 0
- private var currentFileBytes: Int64 = 0
- private var lastFraction: Double = 0
- private var fileIndex: Int = 0
- private var lastReportedOverallFraction: Double = 0
-
- init(totalExpectedBytes: Int64?) {
- self.totalExpectedBytes = totalExpectedBytes
- }
-
- private func processCumulativeProgress(
- fraction: Double,
- parsedSize: Int64,
- sizeStr: String
- ) -> (overallFraction: Double, displaySizeStr: String) {
- if fraction < lastFraction && lastFraction > 0.95 {
- accumulatedBytes += currentFileBytes
- fileIndex += 1
- }
-
- currentFileBytes = parsedSize
- lastFraction = fraction
-
- let totalDownloadedBytes = accumulatedBytes + Int64(Double(parsedSize) * fraction)
- var overallTotalBytes = max(totalExpectedBytes ?? 0, accumulatedBytes + parsedSize)
-
- // If we are on the first file, and its size is taking up almost all of totalExpectedBytes,
- // we pad overallTotalBytes by 20% to leave room for a potential audio track.
- // This prevents the progress bar from prematurely hitting 100%.
- if fileIndex == 0 {
- let paddedSize = Int64(Double(parsedSize) * 1.2)
- overallTotalBytes = max(overallTotalBytes, paddedSize)
- }
-
- var overallFraction = fraction
- var displaySizeStr = sizeStr
-
- if overallTotalBytes > 0 {
- overallFraction = Double(totalDownloadedBytes) / Double(overallTotalBytes)
- displaySizeStr = ByteFormatter.string(overallTotalBytes)
- }
-
- // Ensure overallFraction never decreases visually
- overallFraction = max(lastReportedOverallFraction, overallFraction)
- lastReportedOverallFraction = overallFraction
-
- return (overallFraction, displaySizeStr)
- }
-
- private func parseBytes(_ sizeStr: String) -> Int64 {
- let clean = sizeStr.replacingOccurrences(of: "~", with: "").trimmingCharacters(in: .whitespaces)
- guard let regex = try? NSRegularExpression(pattern: #"^([0-9.]+)([a-zA-Z]+)$"#) else { return 0 }
- let nsString = clean as NSString
- guard let match = regex.firstMatch(in: clean, range: NSRange(location: 0, length: clean.count)),
- match.numberOfRanges == 3 else { return 0 }
-
- let numStr = nsString.substring(with: match.range(at: 1))
- let unitStr = nsString.substring(with: match.range(at: 2)).lowercased()
-
- guard let value = Double(numStr) else { return 0 }
-
- switch unitStr {
- case "b": return Int64(value)
- case "k", "kb", "kib": return Int64(value * 1024)
- case "m", "mb", "mib": return Int64(value * 1024 * 1024)
- case "g", "gb", "gib": return Int64(value * 1024 * 1024 * 1024)
- default: return Int64(value)
- }
- }
-
- func parse(_ line: String) -> DownloadProgress? {
- if line.contains("[download]") && line.contains("%") {
- let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
- let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
- let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
- let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
-
- let parsedSize = parseBytes(size)
- let cumulative = processCumulativeProgress(fraction: fraction, parsedSize: parsedSize, sizeStr: size)
-
- return DownloadProgress(
- fraction: min(max(cumulative.overallFraction, 0), 1),
- bytesText: cumulative.displaySizeStr,
- speedText: speed,
- etaText: eta,
- connectionCount: 1
- )
- } else if line.contains("[#") && line.contains("DL:") {
- let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
- let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
- let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
- let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
- let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
-
- let parsedSize = parseBytes(size)
- let cumulative = processCumulativeProgress(fraction: fraction, parsedSize: parsedSize, sizeStr: size)
-
- return DownloadProgress(
- fraction: min(max(cumulative.overallFraction, 0), 1),
- bytesText: cumulative.displaySizeStr,
- speedText: speed,
- etaText: eta,
- connectionCount: cn
- )
- }
- return nil
- }
-
- private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
- guard let regex else { return nil }
- let range = NSRange(text.startIndex.. 1 else {
- return nil
- }
- guard let captureRange = Range(match.range(at: 1), in: text) else {
- return nil
- }
- return String(text[captureRange])
- }
-}
diff --git a/legacy/swift/Sources/Firelink/MediaEngineManager.swift b/legacy/swift/Sources/Firelink/MediaEngineManager.swift
deleted file mode 100644
index e8f8084..0000000
--- a/legacy/swift/Sources/Firelink/MediaEngineManager.swift
+++ /dev/null
@@ -1,263 +0,0 @@
-import Foundation
-import Combine
-import Darwin
-
-enum AddonState: Equatable, Sendable {
- case notInstalled
- case installed(version: String)
- case failed(error: String)
-}
-
-enum AddonType: String, CaseIterable, Sendable {
- case ytDlp = "yt-dlp"
- case ffmpeg
- case deno
-
- var binaryName: String {
- switch self {
- case .ytDlp: return "yt-dlp"
- case .ffmpeg: return "ffmpeg"
- case .deno: return "deno"
- }
- }
-}
-
-@MainActor
-final class MediaEngineManager: ObservableObject {
- static let shared = MediaEngineManager()
-
- @Published var ytDlpState: AddonState = .notInstalled
- @Published var ffmpegState: AddonState = .notInstalled
- @Published var denoState: AddonState = .notInstalled
- private var ytDlpPreparationTask: Task?
-
- private init() {
- checkLocalInstallation()
- Task { [weak self] in
- _ = await self?.preparedBinaryPath(for: .ytDlp)
- }
- }
-
- func preparedBinaryPath(for addon: AddonType) async -> URL? {
- guard addon == .ytDlp else { return binaryPath(for: addon) }
-
- if let ytDlpPreparationTask {
- return await ytDlpPreparationTask.value
- }
-
- guard let bundledURL = binaryPath(for: .ytDlp) else { return nil }
- let runtimeVersion = bundledRuntimeVersion(near: bundledURL)
- let task = Task.detached(priority: .userInitiated) {
- let executableURL = Self.installStableYtDlpRuntime(
- bundledExecutableURL: bundledURL,
- version: runtimeVersion
- ) ?? bundledURL
- Self.prewarm(executableURL)
- return executableURL
- }
- ytDlpPreparationTask = task
- return await task.value
- }
-
- func binaryPath(for addon: AddonType) -> URL? {
- if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
- FileManager.default.isExecutableFile(atPath: bundled.path) {
- return bundled
- }
-
- // Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
- if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
- #if SWIFT_PACKAGE
- if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
- FileManager.default.isExecutableFile(atPath: bundled.path) {
- return bundled
- }
- #endif
- }
- return nil
- }
-
- private func bundledRuntimeVersion(near executableURL: URL) -> String {
- let versionURL = executableURL.deletingLastPathComponent()
- .appendingPathComponent("yt-dlp-version.txt")
- if let version = try? String(contentsOf: versionURL, encoding: .utf8)
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !version.isEmpty {
- return version
- }
-
- return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
- }
-
- nonisolated private static func installStableYtDlpRuntime(
- bundledExecutableURL: URL,
- version: String
- ) -> URL? {
- let fileManager = FileManager.default
- let bundledDirectory = bundledExecutableURL.deletingLastPathComponent()
- let bundledInternalURL = bundledDirectory.appendingPathComponent("_internal", isDirectory: true)
- let bundledDenoURL = bundledDirectory.appendingPathComponent("deno")
- let hasBundledDeno = fileManager.isExecutableFile(atPath: bundledDenoURL.path)
-
- guard fileManager.fileExists(atPath: bundledInternalURL.path) else {
- return bundledExecutableURL
- }
-
- guard let applicationSupportURL = fileManager.urls(
- for: .applicationSupportDirectory,
- in: .userDomainMask
- ).first else {
- return bundledExecutableURL
- }
-
- let safeVersion = version.replacingOccurrences(
- of: #"[^A-Za-z0-9._-]"#,
- with: "_",
- options: .regularExpression
- )
- let enginesURL = applicationSupportURL
- .appendingPathComponent("Firelink", isDirectory: true)
- .appendingPathComponent("MediaEngines", isDirectory: true)
- .appendingPathComponent("yt-dlp", isDirectory: true)
- let runtimeURL = enginesURL.appendingPathComponent(safeVersion, isDirectory: true)
- let executableURL = runtimeURL.appendingPathComponent("yt-dlp")
- let internalURL = runtimeURL.appendingPathComponent("_internal", isDirectory: true)
- let denoURL = runtimeURL.appendingPathComponent("deno")
-
- if fileManager.isExecutableFile(atPath: executableURL.path),
- fileManager.fileExists(atPath: internalURL.path),
- !hasBundledDeno || fileManager.isExecutableFile(atPath: denoURL.path) {
- return executableURL
- }
-
- let temporaryURL = enginesURL.appendingPathComponent(
- ".install-\(UUID().uuidString)",
- isDirectory: true
- )
-
- do {
- try fileManager.createDirectory(at: enginesURL, withIntermediateDirectories: true)
- try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true)
- try fileManager.copyItem(
- at: bundledExecutableURL,
- to: temporaryURL.appendingPathComponent("yt-dlp")
- )
- try fileManager.copyItem(
- at: bundledInternalURL,
- to: temporaryURL.appendingPathComponent("_internal", isDirectory: true)
- )
- if hasBundledDeno {
- try fileManager.copyItem(
- at: bundledDenoURL,
- to: temporaryURL.appendingPathComponent("deno")
- )
- }
- removeTransportAttributesRecursively(at: temporaryURL)
-
- if fileManager.fileExists(atPath: runtimeURL.path) {
- try fileManager.removeItem(at: runtimeURL)
- }
- try fileManager.moveItem(at: temporaryURL, to: runtimeURL)
- return executableURL
- } catch {
- try? fileManager.removeItem(at: temporaryURL)
- return bundledExecutableURL
- }
- }
-
- nonisolated private static func removeTransportAttributesRecursively(at rootURL: URL) {
- removeTransportAttributes(at: rootURL)
-
- guard let enumerator = FileManager.default.enumerator(
- at: rootURL,
- includingPropertiesForKeys: nil,
- options: [],
- errorHandler: nil
- ) else {
- return
- }
-
- for case let itemURL as URL in enumerator {
- removeTransportAttributes(at: itemURL)
- }
- }
-
- nonisolated private static func removeTransportAttributes(at url: URL) {
- url.withUnsafeFileSystemRepresentation { path in
- guard let path else { return }
- removexattr(path, "com.apple.quarantine", 0)
- removexattr(path, "com.apple.provenance", 0)
- }
- }
-
- nonisolated private static func prewarm(_ executableURL: URL) {
- runVersionCommand(executableURL)
-
- let denoURL = executableURL.deletingLastPathComponent().appendingPathComponent("deno")
- if FileManager.default.isExecutableFile(atPath: denoURL.path) {
- runVersionCommand(denoURL)
- }
- }
-
- nonisolated private static func runVersionCommand(_ executableURL: URL) {
- let process = Process()
- process.executableURL = executableURL
- process.arguments = ["--version"]
- process.standardOutput = nil
- process.standardError = nil
- process.standardInput = nil
-
- do {
- try process.run()
- process.waitUntilExit()
- } catch {
- return
- }
- }
-
- func checkLocalInstallation() {
- for addon in AddonType.allCases {
- if binaryPath(for: addon) != nil {
- setState(for: addon, to: .installed(version: "Bundled"))
- } else {
- setState(for: addon, to: .notInstalled)
- }
- }
- }
-
- func ensureAvailable(addons requiredAddons: Set) async throws {
- checkLocalInstallation()
- let missingAddons = requiredAddons.filter { addon in
- switch state(for: addon) {
- case .installed:
- return false
- case .notInstalled, .failed:
- return true
- }
- }
-
- guard !missingAddons.isEmpty else { return }
-
- for missing in missingAddons {
- setState(for: missing, to: .failed(error: "Bundled executable missing"))
- }
-
- throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
- }
-
- private func state(for addon: AddonType) -> AddonState {
- switch addon {
- case .ytDlp: return ytDlpState
- case .ffmpeg: return ffmpegState
- case .deno: return denoState
- }
- }
-
- private func setState(for addon: AddonType, to state: AddonState) {
- switch addon {
- case .ytDlp: ytDlpState = state
- case .ffmpeg: ffmpegState = state
- case .deno: denoState = state
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/MediaExtractionEngine.swift b/legacy/swift/Sources/Firelink/MediaExtractionEngine.swift
deleted file mode 100644
index 4c988c6..0000000
--- a/legacy/swift/Sources/Firelink/MediaExtractionEngine.swift
+++ /dev/null
@@ -1,599 +0,0 @@
-import Foundation
-
-struct RawMediaFormat: Decodable, Sendable, Equatable {
- let format_id: String?
- let ext: String?
- let resolution: String?
- let format_note: String?
- let vcodec: String?
- let acodec: String?
- let height: Int?
- let filesize: Int64?
- let filesize_approx: Int64?
-}
-
-struct MediaMetadata: Decodable, Sendable, Equatable {
- let id: String?
- let title: String?
- let uploader: String?
- let channel: String?
- let thumbnail: URL?
- let duration: Double?
- let formats: [RawMediaFormat]?
-
- var displayUploader: String? {
- channel ?? uploader
- }
-}
-
-enum MediaType: String, Sendable, Equatable {
- case video = "Video"
- case audio = "Audio"
-}
-
-struct CleanFormatOption: Identifiable, Equatable, Sendable {
- var id: String { name }
- let name: String
- let formatSelector: String
- let isAudioOnly: Bool
- let symbol: String
- let outputExtension: String
- let detail: String
- let estimatedBytes: Int64?
-
- let mediaType: MediaType
- let qualityName: String
- let containerName: String
-}
-
-enum MediaExtractionEngine {
- private final class CacheEntry {
- let metadata: MediaMetadata
- let options: [CleanFormatOption]
- let date: Date
- init(metadata: MediaMetadata, options: [CleanFormatOption], date: Date) {
- self.metadata = metadata
- self.options = options
- self.date = date
- }
- }
- nonisolated(unsafe) private static let metadataCache = NSCache()
-
- private static let metadataTimeoutSeconds: UInt64 = 120
-
- enum ExtractionError: Error, LocalizedError {
- case processFailed(String)
- case invalidOutput
- case parsingFailed(Error)
- case timedOut
-
- var errorDescription: String? {
- switch self {
- case .processFailed(let msg): return "Extraction failed: \(msg)"
- case .invalidOutput: return "Invalid output from media engine."
- case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
- case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
- }
- }
- }
-
- static func fetchMetadata(
- for url: URL,
- cookieSource: BrowserCookieSource,
- credentials: DownloadCredentials?,
- transferOptions: DownloadTransferOptions,
- proxyConfiguration: DownloadProxyConfiguration
- ) async throws -> (MediaMetadata, [CleanFormatOption]) {
- if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
- return (cached.metadata, cached.options)
- }
- guard let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp),
- FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
- throw ExtractionError.processFailed("yt-dlp binary not found.")
- }
- let ytDlpPath = ytDlpURL.path
-
- var args = [
- "-J",
- "--no-warnings",
- "--no-playlist",
- "--no-check-formats",
- "--socket-timeout", "20",
- "--retries", "3",
- "--extractor-retries", "3",
- "--compat-options", "no-youtube-unavailable-videos"
- ]
-
- if let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg),
- FileManager.default.isExecutableFile(atPath: ffmpegURL.path) {
- args.append(contentsOf: ["--ffmpeg-location", ffmpegURL.path])
- }
-
- if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
- args.append(contentsOf: ["--proxy", proxyURI])
- }
-
- let tempConfigDir = appendCommonArguments(
- to: &args,
- cookieSource: cookieSource,
- credentials: credentials,
- transferOptions: transferOptions,
- preferredDenoURL: cachedDenoURL(near: ytDlpURL)
- )
- defer {
- if let tempConfigDir {
- try? FileManager.default.removeItem(at: tempConfigDir)
- }
- }
- args.append(url.absoluteString)
-
- let data = try await YTDLPMetadataProcess(
- executableURL: URL(fileURLWithPath: ytDlpPath),
- arguments: args
- ).run(timeoutSeconds: metadataTimeoutSeconds)
-
- guard !data.isEmpty else {
- throw ExtractionError.invalidOutput
- }
-
- do {
- let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
- let options = extractOptions(from: metadata)
- metadataCache.setObject(CacheEntry(metadata: metadata, options: options, date: Date()), forKey: url as NSURL)
- return (metadata, options)
- } catch {
- throw ExtractionError.parsingFailed(error)
- }
- }
-
- static func appendCommonArguments(
- to args: inout [String],
- cookieSource: BrowserCookieSource,
- credentials: DownloadCredentials?,
- transferOptions: DownloadTransferOptions,
- preferredDenoURL: URL? = nil
- ) -> URL? {
- if let browserName = cookieSource.ytDlpBrowserName {
- args.append(contentsOf: ["--cookies-from-browser", browserName])
- }
-
- appendJavaScriptRuntimeArguments(to: &args, preferredDenoURL: preferredDenoURL)
-
- for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
- args.append(contentsOf: ["--add-header", header.headerLine])
- }
-
- if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
- !cookieHeader.isEmpty {
- args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
- }
-
- var tempConfigDir: URL?
- if let credentials, !credentials.isEmpty {
- let configContent = "--username \"\(credentials.username)\"\n--password \"\(credentials.password)\"\n"
- let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-yt-dlp-\(UUID().uuidString)")
- try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
- let fileURL = tempDir.appendingPathComponent("yt-dlp.conf")
- try? configContent.write(to: fileURL, atomically: true, encoding: .utf8)
- try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
- args.append(contentsOf: ["--config-locations", fileURL.path])
- tempConfigDir = tempDir
- }
- return tempConfigDir
- }
-
- private static func appendJavaScriptRuntimeArguments(
- to args: inout [String],
- preferredDenoURL: URL?
- ) {
- var runtimes: [String] = []
- if let denoPath = executablePath(at: preferredDenoURL) ??
- bundledExecutablePath(named: "deno") ??
- executablePath(named: "deno", candidates: [
- "/opt/homebrew/bin/deno",
- "/usr/local/bin/deno"
- ]) {
- runtimes.append("deno:\(denoPath)")
- }
-
- if let nodePath = executablePath(named: "node", candidates: [
- "/opt/homebrew/bin/node",
- "/usr/local/bin/node",
- "/usr/bin/node"
- ]) {
- runtimes.append("node:\(nodePath)")
- }
-
- if !runtimes.isEmpty {
- args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
- }
- }
-
- private static func cachedDenoURL(near ytDlpURL: URL) -> URL? {
- let denoURL = ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
- return FileManager.default.isExecutableFile(atPath: denoURL.path) ? denoURL : nil
- }
-
- private static func executablePath(at url: URL?) -> String? {
- guard let url, FileManager.default.isExecutableFile(atPath: url.path) else {
- return nil
- }
- return url.path
- }
-
- private static func bundledExecutablePath(named name: String) -> String? {
- if let bundled = Bundle.main.url(forResource: name, withExtension: nil),
- FileManager.default.isExecutableFile(atPath: bundled.path) {
- return bundled.path
- }
-
- if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
- #if SWIFT_PACKAGE
- if let bundled = Bundle.module.url(forResource: name, withExtension: nil),
- FileManager.default.isExecutableFile(atPath: bundled.path) {
- return bundled.path
- }
- #endif
- }
-
- return nil
- }
-
- private static func executablePath(named name: String, candidates: [String]) -> String? {
- var safeCandidates = candidates
- safeCandidates.append(contentsOf: [
- "/opt/homebrew/bin/\(name)",
- "/usr/local/bin/\(name)",
- "/usr/bin/\(name)",
- "/opt/local/bin/\(name)"
- ])
-
- for candidate in safeCandidates {
- if FileManager.default.isExecutableFile(atPath: candidate) {
- return candidate
- }
- }
- return nil
- }
-
- private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
- var options: [CleanFormatOption] = []
- let rawFormats = metadata.formats ?? []
-
- let standardResolutions = [
- (2160, "4K"),
- (1440, "1440p"),
- (1080, "1080p"),
- (720, "720p"),
- (480, "480p"),
- (360, "360p")
- ]
-
- let availableResolutions = standardResolutions.filter { resolution, _ in
- rawFormats.contains { format in
- isVideo(format) && matchesHeight(format, height: resolution)
- }
- }
- let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
- let videoContainers = [
- ("mp4", "MP4"),
- ("mkv", "MKV"),
- ("webm", "WebM")
- ]
-
- for (height, qualityName) in videoQualities {
- for (container, containerName) in videoContainers {
- guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
- let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
- options.append(CleanFormatOption(
- name: "\(qualityName) \(containerName)",
- formatSelector: videoSelector(height: height, container: container),
- isAudioOnly: false,
- symbol: "play.tv.fill",
- outputExtension: container,
- detail: optionDetail(
- base: height == nil ? "Best available video" : "Up to \(qualityName)",
- estimatedBytes: estimatedBytes
- ),
- estimatedBytes: estimatedBytes,
- mediaType: .video,
- qualityName: qualityName,
- containerName: containerName
- ))
- }
- }
-
- if hasAudioFormat(rawFormats, preferredExtension: nil) {
- let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
- options.append(CleanFormatOption(
- name: "Audio MP3",
- formatSelector: "bestaudio/best",
- isAudioOnly: true,
- symbol: "music.note",
- outputExtension: "mp3",
- detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
- estimatedBytes: estimatedBytes,
- mediaType: .audio,
- qualityName: "Best",
- containerName: "MP3"
- ))
- }
-
- if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
- let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
- options.append(CleanFormatOption(
- name: "Audio M4A",
- formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
- isAudioOnly: true,
- symbol: "waveform",
- outputExtension: "m4a",
- detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
- estimatedBytes: estimatedBytes,
- mediaType: .audio,
- qualityName: "Best",
- containerName: "M4A"
- ))
- }
-
- if hasAudioFormat(rawFormats, preferredExtension: "webm") {
- let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
- options.append(CleanFormatOption(
- name: "Audio Opus",
- formatSelector: "bestaudio[ext=webm]/bestaudio/best",
- isAudioOnly: true,
- symbol: "waveform",
- outputExtension: "opus",
- detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
- estimatedBytes: estimatedBytes,
- mediaType: .audio,
- qualityName: "Best",
- containerName: "Opus"
- ))
- }
-
- return options
- }
-
- private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
- formats.contains { format in
- guard isVideo(format), matchesHeight(format, height: height) else { return false }
- return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
- }
- }
-
- private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
- formats.contains { format in
- guard isAudio(format) else { return false }
- guard let preferredExtension else { return true }
- return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
- }
- }
-
- private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
- let videoBytes = formats
- .filter { format in
- guard isVideo(format), matchesHeight(format, height: height) else { return false }
- return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
- }
- .compactMap { formatSize($0) }
- .max()
-
- guard let videoBytes else { return nil }
- let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
- estimatedAudioBytes(formats, preferredExtension: nil) ??
- 0
- return videoBytes + audioBytes
- }
-
- private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
- let preferred = formats
- .filter { format in
- guard isAudio(format) else { return false }
- guard let preferredExtension else { return true }
- return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
- }
- .compactMap { formatSize($0) }
- .max()
-
- if preferred != nil || preferredExtension == nil {
- return preferred
- }
-
- return estimatedAudioBytes(formats, preferredExtension: nil)
- }
-
- private static func isVideo(_ format: RawMediaFormat) -> Bool {
- guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
- return true
- }
-
- private static func isAudio(_ format: RawMediaFormat) -> Bool {
- let acodec = format.acodec?.lowercased()
- let vcodec = format.vcodec?.lowercased()
- return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
- }
-
- private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
- guard let height else { return true }
-
- if let note = format.format_note {
- if height == 2160 && (note.contains("2160p") || note.contains("4K") || note.contains("4k")) { return true }
- if height == 1440 && note.contains("1440p") { return true }
- if height == 1080 && note.contains("1080p") { return true }
- if height == 720 && note.contains("720p") { return true }
- if height == 480 && note.contains("480p") { return true }
- if height == 360 && note.contains("360p") { return true }
- }
-
- if let res = format.resolution {
- let parts = res.split(separator: "x").compactMap { Int($0) }
- if parts.count == 2 {
- let maxDim = max(parts[0], parts[1])
- switch height {
- case 2160: return maxDim >= 3800
- case 1440: return maxDim >= 2500 && maxDim < 3800
- case 1080: return maxDim >= 1900 && maxDim < 2500
- case 720: return maxDim >= 1200 && maxDim < 1900
- case 480: return maxDim >= 800 && maxDim < 1200
- case 360: return maxDim >= 600 && maxDim < 800
- default: break
- }
- }
- }
-
- guard let formatHeight = format.height else { return false }
-
- let tolerance: Int
- if height >= 2160 { tolerance = 600 }
- else if height >= 1440 { tolerance = 400 }
- else if height >= 1080 { tolerance = 300 }
- else if height >= 720 { tolerance = 200 }
- else { tolerance = 100 }
-
- return formatHeight <= height && formatHeight >= height - tolerance
- }
-
- private static func formatSize(_ format: RawMediaFormat) -> Int64? {
- format.filesize ?? format.filesize_approx
- }
-
- private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
- guard let estimatedBytes, estimatedBytes > 0 else { return base }
- return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
- }
-
- private static func videoSelector(height: Int?, container: String) -> String {
- let filter = heightFilter(height)
- switch container {
- case "mp4":
- return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
- case "webm":
- return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
- default:
- return "bestvideo\(filter)+bestaudio/best\(filter)"
- }
- }
-
- private static func heightFilter(_ height: Int?) -> String {
- guard let height else { return "" }
- return "[height<=\(height)]"
- }
-}
-
-private final class YTDLPMetadataProcess: @unchecked Sendable {
- private let executableURL: URL
- private let arguments: [String]
- private let lock = NSLock()
- private var process: Process?
-
- init(executableURL: URL, arguments: [String]) {
- self.executableURL = executableURL
- self.arguments = arguments
- }
-
- func run(timeoutSeconds: UInt64) async throws -> Data {
- try await withTaskCancellationHandler {
- try await withThrowingTaskGroup(of: Data.self) { group in
- group.addTask {
- try await self.runProcess()
- }
- group.addTask {
- try await Task.sleep(for: .seconds(timeoutSeconds))
- self.terminate()
- throw MediaExtractionEngine.ExtractionError.timedOut
- }
-
- guard let result = try await group.next() else {
- throw MediaExtractionEngine.ExtractionError.invalidOutput
- }
- group.cancelAll()
- return result
- }
- } onCancel: {
- self.terminate()
- }
- }
-
- private func runProcess() async throws -> Data {
- try await withCheckedThrowingContinuation { continuation in
- let process = Process()
- let outputPipe = Pipe()
- let errorPipe = Pipe()
- let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
- let errorBuffer = LockedDataBuffer()
-
- process.executableURL = executableURL
- process.arguments = arguments
- process.standardOutput = outputPipe
- process.standardError = errorPipe
- process.standardInput = nil
-
- outputPipe.fileHandleForReading.readabilityHandler = { handle in
- let data = handle.availableData
- if data.isEmpty {
- handle.readabilityHandler = nil
- } else {
- outputBuffer.append(data)
- }
- }
-
- errorPipe.fileHandleForReading.readabilityHandler = { handle in
- let data = handle.availableData
- if data.isEmpty {
- handle.readabilityHandler = nil
- } else {
- errorBuffer.append(data)
- }
- }
-
- lock.withLock {
- self.process = process
- }
-
- process.terminationHandler = { finishedProcess in
- // Allow a brief moment for final pipe data to flush
- DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
- outputPipe.fileHandleForReading.readabilityHandler = nil
- errorPipe.fileHandleForReading.readabilityHandler = nil
- if finishedProcess.terminationStatus == 0 {
- continuation.resume(returning: outputBuffer.data)
- } else {
- 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")
- continuation.resume(
- throwing: MediaExtractionEngine.ExtractionError.processFailed(
- message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
- )
- )
- }
- }
- }
-
- do {
- try process.run()
- if Task.isCancelled {
- self.terminate()
- }
- outputPipe.fileHandleForWriting.closeFile()
- errorPipe.fileHandleForWriting.closeFile()
- } catch {
- outputPipe.fileHandleForReading.readabilityHandler = nil
- errorPipe.fileHandleForReading.readabilityHandler = nil
- // We do not care about the DispatchGroup if we throw immediately here
- continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
- }
- }
- }
-
- private func terminate() {
- let p = lock.withLock { self.process }
- guard let p, p.isRunning else { return }
- ProcessTreeTerminator.terminate(p)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Models.swift b/legacy/swift/Sources/Firelink/Models.swift
deleted file mode 100644
index 219b3e5..0000000
--- a/legacy/swift/Sources/Firelink/Models.swift
+++ /dev/null
@@ -1,290 +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 {
- let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
- let cleanValue = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
- return DownloadRequestHeader(name: cleanName, value: cleanValue)
- }
-
- 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[.. [String] {
- headerLines(text).filter { line in
- guard let colonIndex = line.firstIndex(of: ":") else { return true }
- let name = String(line[.. 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 mediaFormatSelector: String?
- var isAudioOnlyMedia: Bool?
-
- private enum CodingKeys: String, CodingKey {
- case id, url, fileName, category, destinationDirectory, connectionsPerServer
- case credentials, checksum, requestHeaders, cookieHeader, mirrorURLs, speedLimitKiBPerSecond
- case status, progress, speedText, etaText, connectionCount, sizeBytes, bytesText, message
- case createdAt, lastTryAt, autoResumeOnLaunch, queueID
- case mediaFormatSelector, isAudioOnlyMedia
- }
-
- var displaySpeedText: String {
- if status == .completed || status == .paused || status == .queued || status == .canceled { return "-" }
- return speedText
- }
-
- var displayETAText: String {
- if status == .completed || status == .queued || status == .canceled { return "-" }
- return etaText
- }
-
- 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 isMedia: Bool = false
- var mediaOptions: [CleanFormatOption] = []
- var selectedMediaOption: CleanFormatOption?
- var mediaMetadata: MediaMetadata?
-
- var destinationPath: String {
- defaultDirectory.appendingPathComponent(fileName).path
- }
-}
diff --git a/legacy/swift/Sources/Firelink/ProcessTreeTerminator.swift b/legacy/swift/Sources/Firelink/ProcessTreeTerminator.swift
deleted file mode 100644
index 312598c..0000000
--- a/legacy/swift/Sources/Firelink/ProcessTreeTerminator.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-import Foundation
-import Darwin
-
-enum ProcessTreeTerminator {
- static func terminate(_ process: Process, forceAfter delay: TimeInterval = 0.5) {
- let rootPID = process.processIdentifier
- guard rootPID > 0 else { return }
-
- let processIDs = descendants(of: rootPID) + [rootPID]
- signal(processIDs.reversed(), with: SIGTERM)
-
- DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) {
- signal(processIDs.reversed(), with: SIGKILL)
- }
- }
-
- private static func descendants(of rootPID: pid_t) -> [pid_t] {
- var result: [pid_t] = []
- var pending = directChildren(of: rootPID)
-
- while let processID = pending.popLast() {
- result.append(processID)
- pending.append(contentsOf: directChildren(of: processID))
- }
-
- return result
- }
-
- private static func directChildren(of processID: pid_t) -> [pid_t] {
- var capacity = 32
-
- while capacity <= 4096 {
- var processIDs = [pid_t](repeating: 0, count: capacity)
- let count = processIDs.withUnsafeMutableBytes { buffer in
- proc_listchildpids(processID, buffer.baseAddress, Int32(buffer.count))
- }
-
- guard count > 0 else { return [] }
- if count < capacity {
- return Array(processIDs.prefix(Int(count))).filter { $0 > 0 }
- }
- capacity *= 2
- }
-
- return []
- }
-
- private static func signal(_ processIDs: S, with signal: Int32) where S.Element == pid_t {
- for processID in processIDs where processID > 0 {
- kill(processID, signal)
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/ReleaseUpdateChecker.swift b/legacy/swift/Sources/Firelink/ReleaseUpdateChecker.swift
deleted file mode 100644
index c530b4d..0000000
--- a/legacy/swift/Sources/Firelink/ReleaseUpdateChecker.swift
+++ /dev/null
@@ -1,325 +0,0 @@
-import Foundation
-
-struct GitHubReleaseCheckService: @unchecked Sendable {
- private let owner: String
- private let repository: String
- private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
-
- init(
- owner: String = "nimbold",
- repository: String = "Firelink",
- fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
- try await URLSession.shared.data(for: request)
- }
- ) {
- self.owner = owner
- self.repository = repository
- self.fetch = fetch
- }
-
- func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
- guard let current = AppVersion(currentVersion) else {
- throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
- }
-
- let release = try await latestStableRelease()
- guard let latest = AppVersion(release.tagName) else {
- throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
- }
-
- let update = AvailableReleaseUpdate(
- version: latest.description,
- tagName: release.tagName,
- title: release.name?.isEmpty == false ? release.name! : release.tagName,
- releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
- releaseURL: release.htmlURL,
- publishedAt: release.publishedAt
- )
-
- if latest > current {
- return .updateAvailable(update)
- }
-
- return .upToDate(latestVersion: latest.description, localVersion: current.description)
- }
-
- private func latestStableRelease() async throws -> GitHubRelease {
- guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
- throw ReleaseCheckFailure.invalidReleaseURL
- }
-
- var request = URLRequest(url: url)
- request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
- request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
-
- let (data, response) = try await fetch(request)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw ReleaseCheckFailure.invalidResponse
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
- }
-
- let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
- let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
- guard !stableReleases.isEmpty else {
- throw ReleaseCheckFailure.noStableRelease
- }
-
- let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
- guard let version = AppVersion(release.tagName) else { return nil }
- return (release, version)
- }
-
- guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
- throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
- }
-
- return latest.release
- }
-}
-
-struct GitHubRelease: Decodable, Equatable {
- let tagName: String
- let name: String?
- let body: String?
- let htmlURL: URL
- let draft: Bool
- let prerelease: Bool
- let publishedAt: Date?
-
- private enum CodingKeys: String, CodingKey {
- case tagName = "tag_name"
- case name
- case body
- case htmlURL = "html_url"
- case draft
- case prerelease
- case publishedAt = "published_at"
- }
-}
-
-struct AvailableReleaseUpdate: Equatable, Identifiable {
- var id: String { tagName }
- let version: String
- let tagName: String
- let title: String
- let releaseNotes: String
- let releaseURL: URL
- let publishedAt: Date?
-}
-
-enum ReleaseCheckOutcome: Equatable {
- case updateAvailable(AvailableReleaseUpdate)
- case upToDate(latestVersion: String, localVersion: String)
-}
-
-enum ReleaseUpdateState: Equatable {
- case idle
- case checking
- case updateAvailable(AvailableReleaseUpdate)
- case upToDate(latestVersion: String, localVersion: String)
- case failed(message: String, recovery: String)
-}
-
-enum ReleaseCheckFailure: Error, Equatable {
- case invalidReleaseURL
- case invalidResponse
- case httpStatus(Int)
- case noStableRelease
- case invalidCurrentVersion(String)
- case invalidReleaseVersion(String)
-}
-
-@MainActor
-final class ReleaseUpdateChecker: ObservableObject {
- @Published private(set) var state: ReleaseUpdateState = .idle
- @Published var automaticallyChecksForUpdates: Bool {
- didSet {
- UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
- }
- }
-
- private let service: GitHubReleaseCheckService
- private let bundle: Bundle
- private var automaticCheckTask: Task?
-
- private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
- private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
- private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
-
- init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
- self.service = service
- self.bundle = bundle
-
- if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
- self.automaticallyChecksForUpdates = true
- } else {
- self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
- }
- }
-
- var currentVersion: String {
- bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
- }
-
- func checkForUpdates() {
- guard state != .checking else { return }
- state = .checking
-
- Task {
- do {
- let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
- UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
- apply(outcome)
- } catch {
- state = Self.failedState(for: error)
- }
- }
- }
-
- func checkAutomaticallyIfNeeded() {
- guard automaticallyChecksForUpdates else { return }
- guard state == .idle else { return }
-
- if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
- Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
- return
- }
-
- automaticCheckTask?.cancel()
- automaticCheckTask = Task { [weak self] in
- self?.checkForUpdates()
- }
- }
-
- private func apply(_ outcome: ReleaseCheckOutcome) {
- switch outcome {
- case .updateAvailable(let update):
- state = .updateAvailable(update)
- case .upToDate(let latestVersion, let localVersion):
- state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
- }
- }
-
- private static func failedState(for error: Error) -> ReleaseUpdateState {
- if let urlError = error as? URLError {
- switch urlError.code {
- case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
- return .failed(
- message: "Couldn't reach GitHub.",
- recovery: "Check your internet connection, then try again."
- )
- default:
- return .failed(
- message: "The update check couldn't finish.",
- recovery: urlError.localizedDescription
- )
- }
- }
-
- if let failure = error as? ReleaseCheckFailure {
- switch failure {
- case .httpStatus(let statusCode):
- return .failed(
- message: "GitHub returned HTTP \(statusCode).",
- recovery: "Try again in a moment, or open the releases page manually."
- )
- case .noStableRelease:
- return .failed(
- message: "No stable release was found.",
- recovery: "Open GitHub Releases to check the project manually."
- )
- case .invalidCurrentVersion(let version):
- return .failed(
- message: "Firelink's current version could not be read.",
- recovery: "The app reported version \(version)."
- )
- case .invalidReleaseVersion(let tag):
- return .failed(
- message: "The latest release tag could not be compared.",
- recovery: "GitHub reported \(tag)."
- )
- case .invalidReleaseURL, .invalidResponse:
- return .failed(
- message: "The GitHub response could not be read.",
- recovery: "Try again in a moment."
- )
- }
- }
-
- return .failed(
- message: "The update check failed.",
- recovery: error.localizedDescription
- )
- }
-}
-
-struct AppVersion: Comparable, CustomStringConvertible {
- private let components: [Int]
-
- init?(_ rawValue: String) {
- var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if value.hasPrefix("v") || value.hasPrefix("V") {
- value.removeFirst()
- }
-
- let prefix = value.prefix { character in
- character.isNumber || character == "."
- }
-
- let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
- guard !version.isEmpty else { return nil }
-
- let components = version.split(separator: ".").compactMap { Int($0) }
- guard !components.isEmpty, components.count == version.split(separator: ".").count else {
- return nil
- }
-
- self.components = components
- }
-
- var description: String {
- components.map(String.init).joined(separator: ".")
- }
-
- static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
- let count = max(lhs.components.count, rhs.components.count)
-
- for index in 0.. Bool {
- let count = max(lhs.components.count, rhs.components.count)
-
- for index in 0.. = Set(SchedulerDay.allCases)
- var postQueueAction: PostQueueAction = .doNothing
- var targetQueueIDs: Set = [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()
- 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)
-
- let shouldRunToday: Bool
- if settings.isEveryday {
- shouldRunToday = true
- } else {
- let day = SchedulerDay(rawValue: currentWeekday)
- shouldRunToday = day.map { settings.selectedDays.contains($0) } ?? false
- }
-
- if shouldRunToday {
- if startHour == currentHour && startMinute == currentMinute {
- lastTriggeredMinute = now
- triggerQueues()
- }
-
- if settings.stopTimeEnabled {
- let stopHour = calendar.component(.hour, from: settings.stopTime)
- let stopMinute = calendar.component(.minute, from: settings.stopTime)
-
- if stopHour == currentHour && stopMinute == currentMinute {
- lastTriggeredMinute = now
- pauseQueues()
- }
- }
- }
- }
-
- private func pauseQueues() {
- let targetQueueIDs = effectiveTargetQueueIDs()
- for queueID in targetQueueIDs {
- downloadController.pauseActiveDownloads(queueID: queueID)
- }
- }
-
- 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 {
- 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)")
- }
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/SchedulerView.swift b/legacy/swift/Sources/Firelink/SchedulerView.swift
deleted file mode 100644
index 94011c9..0000000
--- a/legacy/swift/Sources/Firelink/SchedulerView.swift
+++ /dev/null
@@ -1,273 +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 stopTimeEnabled: Bool = false
- @State private var stopTime: Date = Date()
- @State private var isEveryday: Bool = true
- @State private var selectedDays: Set = []
- @State private var postQueueAction: PostQueueAction = .doNothing
- @State private var targetQueueIDs: Set = []
-
- var body: some View {
- VStack(spacing: 0) {
- headerView
- Divider()
-
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- Group {
- timeSelectionSection
- queueSelectionSection
- postActionSection
- }
- .opacity(isEnabled ? 1.0 : 0.5)
- .disabled(!isEnabled)
-
- 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 {
- GroupBox {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Label("Timing", systemImage: "clock")
- .font(.headline)
- Spacer()
- }
-
- HStack(spacing: 24) {
- VStack(alignment: .leading, spacing: 8) {
- Text("Start Time")
- .foregroundStyle(.secondary)
- DatePicker("Start", selection: $startTime, displayedComponents: [.hourAndMinute])
- .datePickerStyle(.stepperField)
- .labelsHidden()
- }
-
- VStack(alignment: .leading, spacing: 8) {
- Toggle("Stop Time", isOn: $stopTimeEnabled)
- .foregroundStyle(.secondary)
- DatePicker("Stop", selection: $stopTime, displayedComponents: [.hourAndMinute])
- .datePickerStyle(.stepperField)
- .labelsHidden()
- .disabled(!stopTimeEnabled)
- .opacity(stopTimeEnabled ? 1.0 : 0.5)
- }
- }
-
- Divider()
-
- Toggle("Run Every Day", isOn: $isEveryday)
-
- if !isEveryday {
- HStack(spacing: 8) {
- ForEach(SchedulerDay.allCases) { day in
- Button(action: {
- if selectedDays.contains(day) {
- selectedDays.remove(day)
- } else {
- selectedDays.insert(day)
- }
- }) {
- Text(day.shortName)
- .fontWeight(.medium)
- .frame(width: 28, height: 28)
- }
- .buttonStyle(.borderless)
- .background(selectedDays.contains(day) ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
- .foregroundStyle(selectedDays.contains(day) ? Color.white : Color.primary)
- .clipShape(Circle())
- }
- }
- }
- }
- .padding(8)
- }
- }
-
- private var queueSelectionSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Label("Queues to Schedule", systemImage: "list.bullet.rectangle")
- .font(.headline)
- Spacer()
- }
-
- if downloadController.queues.isEmpty {
- Text("No queues available")
- .foregroundStyle(.secondary)
- } else {
- VStack(alignment: .leading, spacing: 12) {
- 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)
- }
- }
- ))
- }
- }
- }
- }
- .padding(8)
- }
- }
-
- private var postActionSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Label("After Completion", systemImage: "powersleep")
- .font(.headline)
- Spacer()
- }
-
- Text("Choose what happens after scheduled downloads finish.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
-
- Picker("Action", selection: $postQueueAction) {
- ForEach(PostQueueAction.allCases) { action in
- Text(action.rawValue).tag(action)
- }
- }
- .labelsHidden()
- .pickerStyle(.radioGroup)
- }
- .padding(8)
- }
- }
-
- private var permissionsSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Label("System Permissions", systemImage: "lock.shield")
- .font(.headline)
- Spacer()
- }
-
- 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)
- }
- }
- .padding(8)
- }
- }
-
- 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
- stopTimeEnabled = schedulerController.settings.stopTimeEnabled
- stopTime = schedulerController.settings.stopTime
- 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.stopTimeEnabled = stopTimeEnabled
- schedulerController.settings.stopTime = stopTime
- schedulerController.settings.isEveryday = isEveryday
- schedulerController.settings.selectedDays = selectedDays
- schedulerController.settings.postQueueAction = postQueueAction
- schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
- ? [DownloadQueue.mainQueueID]
- : targetQueueIDs
- schedulerController.saveSettings()
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/AboutSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/AboutSettingsPane.swift
deleted file mode 100644
index 9414e37..0000000
--- a/legacy/swift/Sources/Firelink/Settings/AboutSettingsPane.swift
+++ /dev/null
@@ -1,184 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct AboutSettingsPane: View {
- @EnvironmentObject private var updateChecker: ReleaseUpdateChecker
-
- private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
- private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
- private let aria2URL = URL(string: "https://aria2.github.io/")!
- private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
- private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
- private let denoURL = URL(string: "https://deno.com/")!
- 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"
- }
-
-
- 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)")
- .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: 16) {
- updateStatusView
-
- Divider()
- .padding(.vertical, 2)
-
- Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
- }
- .padding(.vertical, 8)
- .animation(.easeInOut, value: updateChecker.state)
- }
-
- 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")
- HStack(spacing: 4) {
- Link("aria2", destination: aria2URL)
- Text("•").foregroundStyle(.secondary)
- Link("yt-dlp", destination: ytDlpURL)
- Text("•").foregroundStyle(.secondary)
- Link("ffmpeg", destination: ffmpegURL)
- Text("•").foregroundStyle(.secondary)
- Link("Deno", destination: denoURL)
- }
- Spacer()
- Link("MIT License", destination: licenseURL)
- }
-
- Text("Copyright © 2026 NimBold. All rights reserved.")
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- .font(.caption)
- .padding(.vertical, 4)
- }
- }
- .formStyle(.grouped)
- }
-
- @ViewBuilder
- private var updateStatusView: some View {
- HStack(alignment: .center) {
- switch updateChecker.state {
- case .idle:
- VStack(alignment: .leading, spacing: 2) {
- Text("Check for Updates")
- .font(.headline)
- Text("Firelink checks GitHub Releases for new versions.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Spacer()
- Button("Check Now") {
- updateChecker.checkForUpdates()
- }
-
- case .checking:
- VStack(alignment: .leading, spacing: 2) {
- Text("Checking for updates...")
- .font(.headline)
- Text("Connecting to GitHub...")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Spacer()
- ProgressView()
- .controlSize(.small)
- .padding(.trailing, 16)
-
- case .updateAvailable(let update):
- VStack(alignment: .leading, spacing: 2) {
- Text("Firelink \(update.version) is available!")
- .font(.headline)
- Text("You currently have version \(appVersion).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
-
- Link("View Release Notes", destination: update.releaseURL)
- .font(.caption)
- .padding(.top, 2)
- }
- Spacer()
- Button("Download Update") {
- NSWorkspace.shared.open(update.releaseURL)
- }
- .buttonStyle(.borderedProminent)
-
- case .upToDate(let latestVersion, _):
- Image(systemName: "checkmark.seal.fill")
- .font(.title2)
- .foregroundStyle(.green)
- VStack(alignment: .leading, spacing: 2) {
- Text("Firelink is up to date")
- .font(.headline)
- Text("Version \(latestVersion) is the newest stable release.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Spacer()
- Button("Check Again") {
- updateChecker.checkForUpdates()
- }
-
- case .failed(let message, let recovery):
- VStack(alignment: .leading, spacing: 2) {
- Text(message)
- .font(.headline)
- Text(recovery)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- Spacer()
- Button("Try Again") {
- updateChecker.checkForUpdates()
- }
- }
- }
- .padding(.vertical, 4)
- }
-
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/DownloadSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/DownloadSettingsPane.swift
deleted file mode 100644
index 3828676..0000000
--- a/legacy/swift/Sources/Firelink/Settings/DownloadSettingsPane.swift
+++ /dev/null
@@ -1,72 +0,0 @@
-import SwiftUI
-
-struct DownloadSettingsPane: View {
- @EnvironmentObject private var settings: AppSettings
-
- var body: some View {
- Form {
- Section {
- LabeledContent {
- Stepper("\(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16)
- } label: {
- VStack(alignment: .leading, spacing: 2) {
- Text("Default connections:")
- Text("For new downloads")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- LabeledContent {
- Stepper("\(settings.maxConcurrentDownloads)", value: $settings.maxConcurrentDownloads, in: 1...12)
- } label: {
- VStack(alignment: .leading, spacing: 2) {
- Text("Parallel downloads:")
- Text("Max simultaneous active files")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- LabeledContent {
- HStack {
- TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
- .textFieldStyle(.roundedBorder)
- .multilineTextAlignment(.trailing)
- .frame(width: 80)
- Text("KiB/s")
- .foregroundStyle(.secondary)
- }
- } label: {
- VStack(alignment: .leading, spacing: 2) {
- Text("Global speed limit:")
- Text("0 = unlimited speed")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- LabeledContent {
- Stepper("\(settings.maxAutomaticRetries)", value: $settings.maxAutomaticRetries, in: 0...10)
- } label: {
- VStack(alignment: .leading, spacing: 2) {
- Text("Automatic retries:")
- Text("If a connection fails")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- Toggle(isOn: $settings.showNotifications) {
- VStack(alignment: .leading, spacing: 2) {
- Text("Show notification when download completes")
- Text("Alerts you in Notification Center")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- Toggle(isOn: $settings.playCompletionSound) {
- Text("Play sound when download completes")
- }
- .disabled(!settings.showNotifications)
- }
- }
- .formStyle(.grouped)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/EngineSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/EngineSettingsPane.swift
deleted file mode 100644
index 75cc711..0000000
--- a/legacy/swift/Sources/Firelink/Settings/EngineSettingsPane.swift
+++ /dev/null
@@ -1,107 +0,0 @@
-import SwiftUI
-import AppKit
-
-struct EngineSettingsPane: View {
- @EnvironmentObject private var settings: AppSettings
- @StateObject private var engineManager = MediaEngineManager.shared
- @State private var version = "Checking..."
-
- private var executableURL: URL? {
- Aria2DownloadEngine.findExecutable()
- }
-
- var body: some View {
- Form {
- Section {
- LabeledContent("Version") {
- Text(version)
- .font(.system(.body, design: .monospaced))
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- }
-
- LabeledContent("Binary") {
- if let url = executableURL {
- Button("Reveal in Finder") {
- NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "")
- }
- } else {
- Text("Not found")
- .foregroundStyle(.red)
- }
- }
- } header: {
- Text("Core Downloader (Aria2)")
- } footer: {
- if executableURL == nil {
- Text("The bundled aria2 runtime is missing. Reinstall Firelink or rebuild its media engines.")
- .foregroundStyle(.red)
- } else {
- Text("Handles core HTTP/FTP and BitTorrent downloads.")
- }
- }
-
- Section {
- addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
-
- addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
-
- addonStatusRow(title: "Deno", state: engineManager.denoState, path: engineManager.binaryPath(for: .deno))
-
- LabeledContent("Browser Cookies") {
- HStack {
- Spacer()
- Picker("", selection: $settings.mediaCookieSource) {
- ForEach(BrowserCookieSource.allCases, id: \.self) { source in
- Text(source.rawValue).tag(source)
- }
- }
- .labelsHidden()
- .fixedSize()
- }
- }
- } header: {
- Text("Media Extractors")
- } footer: {
- VStack(alignment: .leading, spacing: 8) {
- Text("Powers video and audio extraction from supported sites.")
-
- if settings.mediaCookieSource != .none {
- Text(settings.mediaCookieSource.statusDetail)
- }
- }
- }
- }
- .formStyle(.grouped)
- .task {
- version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
- }
- }
-
- @ViewBuilder
- private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
- LabeledContent(title) {
- HStack(spacing: 8) {
- switch state {
- case .notInstalled:
- Text("Missing")
- .foregroundStyle(.red)
- case .installed(let version):
- Text(version)
- .foregroundStyle(.secondary)
- .font(.system(.body, design: .monospaced))
- case .failed(let error):
- Text("Error")
- .foregroundStyle(.red)
- .help(error)
- }
-
- if let path {
- Button("Reveal") {
- NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: "")
- }
- }
- }
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/IntegrationSettingsPane.swift
deleted file mode 100644
index 748fbe1..0000000
--- a/legacy/swift/Sources/Firelink/Settings/IntegrationSettingsPane.swift
+++ /dev/null
@@ -1,246 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct IntegrationSettingsPane: View {
- @EnvironmentObject private var controller: DownloadController
- @EnvironmentObject private var settings: AppSettings
- @State private var showToast = false
-
- var body: some View {
- Form {
- Section {
- HStack(alignment: .center, spacing: 12) {
- Image(systemName: "puzzlepiece.extension.fill")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 32, height: 32)
- .foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
- .accessibilityHidden(true)
-
- VStack(alignment: .leading, spacing: 2) {
- Text("Connect Browser Extension")
- .font(.title3.weight(.bold))
- Text("Capture downloads directly from your browser in three easy steps.")
- .foregroundStyle(.secondary)
- .font(.subheadline)
- }
- Spacer()
- }
- .padding(.bottom, 4)
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
-
- Section {
- KeychainAccessCard()
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
-
- if settings.isKeychainAccessGranted {
- Section {
- HStack(spacing: 8) {
- // Step 1
- CompactStepCardView(
- stepNumber: 1,
- title: "Copy Token",
- description: "This secure token authorizes your browser extension.",
- icon: "doc.on.clipboard.fill",
- iconColor: .blue,
- actionText: "Copy Token",
- action: {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
- withAnimation {
- showToast = true
- }
- },
- secondaryActionText: "Regenerate",
- secondaryAction: {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- if status == errSecSuccess {
- settings.extensionPairingToken = Data(bytes).base64EncodedString()
- } else {
- settings.extensionPairingToken = UUID().uuidString
- }
- }
- )
-
- Image(systemName: "chevron.right")
- .font(.title3.weight(.bold))
- .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
-
- // Step 2
- CompactStepCardView(
- stepNumber: 2,
- title: "Get Extension",
- description: "Install the Firelink Companion extension on your browser.",
- icon: "globe",
- iconColor: .orange,
- actionText: "Firefox Add-ons",
- action: {
- if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
- NSWorkspace.shared.open(url)
- }
- },
- secondaryActionText: "Releases",
- secondaryAction: {
- if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
- NSWorkspace.shared.open(url)
- }
- }
- )
-
- Image(systemName: "chevron.right")
- .font(.title3.weight(.bold))
- .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
-
- // Step 3
- CompactStepCardView(
- stepNumber: 3,
- title: "Paste & Connect",
- description: "Click the Firelink icon in your browser's toolbar and paste the token.",
- icon: "arrow.down.doc.fill",
- iconColor: .green,
- actionText: nil,
- action: nil
- )
- }
- .fixedSize(horizontal: false, vertical: true)
- } header: {
- Text("Setup Guide")
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
-
- Section {
- HStack {
- Text("Diagnostics:")
- .foregroundStyle(.secondary)
- Spacer()
- 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)
- }
- }
- .font(.footnote)
- .padding(8)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
- )
- } header: {
- Text("Status")
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
- }
- }
- .formStyle(.grouped)
- .toast(isShowing: $showToast, message: "Token copied to clipboard!")
- }
-}
-
-struct CompactStepCardView: View {
- let stepNumber: Int
- let title: String
- let description: String
- let icon: String
- let iconColor: Color
- let actionText: String?
- let action: (() -> Void)?
- var secondaryActionText: String? = nil
- var secondaryAction: (() -> Void)? = nil
-
- var body: some View {
- VStack(spacing: 8) {
- HStack(alignment: .top) {
- // Step Number Badge
- ZStack {
- Circle()
- .fill(Color.secondary.opacity(0.1))
- .frame(width: 20, height: 20)
-
- Text("\(stepNumber)")
- .font(.system(.caption2, design: .rounded).weight(.bold))
- .foregroundStyle(.primary)
- }
- Spacer()
- // Icon
- ZStack {
- RoundedRectangle(cornerRadius: 6)
- .fill(iconColor.opacity(0.15))
- .frame(width: 28, height: 28)
-
- Image(systemName: icon)
- .font(.system(size: 14))
- .foregroundStyle(iconColor)
- }
- }
-
- // Text Content
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.subheadline.weight(.semibold))
- .frame(maxWidth: .infinity, alignment: .leading)
- Text(description)
- .font(.caption2)
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- Spacer(minLength: 4)
-
- // Action Button
- VStack(spacing: 6) {
- if let actionText = actionText, let action = action {
- Button(action: action) {
- Text(actionText)
- .font(.caption2.weight(.medium))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 4)
- .background(Color.accentColor)
- .foregroundColor(.white)
- .cornerRadius(4)
- }
- .buttonStyle(.plain)
- }
-
- if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
- Button(action: secondaryAction) {
- Text(secondaryActionText)
- .font(.caption2.weight(.medium))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 4)
- .background(Color(nsColor: .controlBackgroundColor))
- .foregroundColor(.primary)
- .cornerRadius(4)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- }
- }
- }
- .padding(10)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 10)
- .fill(Color(nsColor: .controlBackgroundColor))
- .shadow(color: .black.opacity(0.05), radius: 4, y: 1)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 10)
- .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
- )
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/KeychainAccessCard.swift b/legacy/swift/Sources/Firelink/Settings/KeychainAccessCard.swift
deleted file mode 100644
index 0818353..0000000
--- a/legacy/swift/Sources/Firelink/Settings/KeychainAccessCard.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-import SwiftUI
-
-struct KeychainAccessCard: View {
- @EnvironmentObject private var settings: AppSettings
-
- var body: some View {
- HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
- ZStack {
- RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
- .fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
- .frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
-
- Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
- .font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
- .foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
- }
-
- VStack(alignment: .leading, spacing: 2) {
- Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
- .font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
- .foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
-
- if !settings.isKeychainAccessGranted {
- Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
-
- if settings.isKeychainAccessGranted {
- Button(role: .destructive) {
- settings.revokeKeychainAccess()
- } label: {
- Text("Revoke")
- }
- .controlSize(.small)
- } else {
- Button {
- settings.grantKeychainAccess()
- } label: {
- Text("Grant Access")
- .font(.subheadline.weight(.medium))
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(Color.accentColor)
- .foregroundColor(.white)
- .cornerRadius(6)
- }
- .buttonStyle(.plain)
- }
- }
- .padding(settings.isKeychainAccessGranted ? 8 : 12)
- .background(
- RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
- .fill(Color(nsColor: .controlBackgroundColor))
- .shadow(color: .black.opacity(0.05), radius: 4, y: 1)
- )
- .overlay(
- RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
- .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
- )
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/LocationsSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/LocationsSettingsPane.swift
deleted file mode 100644
index 5ae0317..0000000
--- a/legacy/swift/Sources/Firelink/Settings/LocationsSettingsPane.swift
+++ /dev/null
@@ -1,262 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct LocationsSettingsPane: View {
- @EnvironmentObject private var settings: AppSettings
-
- var body: some View {
- Form {
- Section(footer: Text("When enabled, you can choose the download location each time you add a download. Otherwise, files are saved automatically.")) {
- Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
- }
-
- Section(footer: Text("Folders will be created automatically when saving.")) {
- BulkDirectoryPickerRow()
-
- ForEach(DownloadCategory.allCases, id: \.self) { category in
- DirectoryPickerRow(category: category)
- }
-
- HStack {
- Spacer()
- Button("Reset Defaults") {
- settings.resetDirectories()
- }
- }
- }
- }
- .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 {
- LabeledContent {
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 8) {
- TextField("Folder path", text: $path, prompt: Text("Folder path"))
- .labelsHidden()
- .textFieldStyle(.roundedBorder)
- .multilineTextAlignment(.leading)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- applyPath()
- }
-
- Button("Choose...") {
- selectFolder()
- }
- }
-
- if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
- Text(displayMessage)
- .font(.caption)
- .foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
- }
- }
- } label: {
- Label(category.rawValue, systemImage: category.symbolName)
- }
- .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 {
- LabeledContent {
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 8) {
- TextField("Base folder path", text: $path, prompt: Text("Base folder path"))
- .labelsHidden()
- .textFieldStyle(.roundedBorder)
- .multilineTextAlignment(.leading)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- applyPath()
- }
-
- Button("Choose...") {
- selectFolder()
- }
- }
-
- if !message.isEmpty {
- Text(message)
- .font(.caption)
- .foregroundStyle(isErrorMessage(message) ? .red : .secondary)
- }
- }
- } label: {
- Label("All Categories", systemImage: "folder.fill.badge.plus")
- }
- }
-
- 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."
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift
deleted file mode 100644
index a922a97..0000000
--- a/legacy/swift/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift
+++ /dev/null
@@ -1,51 +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("macOS Integration") {
- Toggle("Show badge on Dock icon", isOn: $settings.showDockBadge)
- Text("Displays the number of active downloads on the Firelink Dock icon.")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- 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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/NetworkSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/NetworkSettingsPane.swift
deleted file mode 100644
index cdb0aea..0000000
--- a/legacy/swift/Sources/Firelink/Settings/NetworkSettingsPane.swift
+++ /dev/null
@@ -1,83 +0,0 @@
-import SwiftUI
-
-struct NetworkSettingsPane: View {
- @EnvironmentObject private var settings: AppSettings
-
- var body: some View {
- Form {
- Section("Proxy") {
- Picker("Mode", 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)
- }
- }
-
- Section("Identity") {
- TextField("User Agent", text: $settings.customUserAgent, prompt: Text("e.g. Mozilla/5.0..."))
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
-
- Text("Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- .formStyle(.grouped)
- }
-
- private var networkSummary: String {
- switch settings.proxySettings.mode {
- case .none:
- return "Downloads ignore configured proxies."
- case .system:
- return "Downloads use the matching macOS system proxy when one is configured."
- case .custom:
- if let proxyURI = settings.proxySettings.customProxyURI {
- return "Downloads use \(proxyURI)."
- } else {
- return "Enter a proxy host and port to enable the custom proxy."
- }
- }
- }
-
- private func proxyBinding(_ keyPath: WritableKeyPath) -> Binding {
- Binding {
- settings.proxySettings[keyPath: keyPath]
- } set: { newValue in
- var proxySettings = settings.proxySettings
- proxySettings[keyPath: keyPath] = newValue
- settings.proxySettings = proxySettings
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/PowerSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/PowerSettingsPane.swift
deleted file mode 100644
index fc3c2b4..0000000
--- a/legacy/swift/Sources/Firelink/Settings/PowerSettingsPane.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/SettingsContainer.swift b/legacy/swift/Sources/Firelink/Settings/SettingsContainer.swift
deleted file mode 100644
index fc2d462..0000000
--- a/legacy/swift/Sources/Firelink/Settings/SettingsContainer.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-import AppKit
-import SwiftUI
-
-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"
- }
- }
-}
-
-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()
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift b/legacy/swift/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift
deleted file mode 100644
index e30b77a..0000000
--- a/legacy/swift/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-import SwiftUI
-import AppKit
-
-struct SiteLoginsSettingsPane: View {
- @EnvironmentObject private var settings: AppSettings
- @State private var searchText = ""
- @State private var selection = Set()
- @State private var editingLogin: SiteLogin?
- @State private var showEditor = false
-
- var filteredLogins: [SiteLogin] {
- if searchText.isEmpty {
- return settings.siteLogins
- } else {
- return settings.siteLogins.filter {
- $0.urlPattern.localizedCaseInsensitiveContains(searchText) ||
- $0.username.localizedCaseInsensitiveContains(searchText)
- }
- }
- }
-
- var body: some View {
- Form {
- Section {
- KeychainAccessCard()
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
-
- if settings.isKeychainAccessGranted {
- Section {
- VStack(spacing: 0) {
- HStack {
- Image(systemName: "magnifyingglass")
- .foregroundColor(.secondary)
- TextField("Search Logins", text: $searchText)
- .textFieldStyle(.plain)
- if !searchText.isEmpty {
- Button {
- searchText = ""
- } label: {
- Image(systemName: "xmark.circle.fill")
- .foregroundColor(.secondary)
- }
- .buttonStyle(.plain)
- }
- }
- .padding(8)
- .background(Color(NSColor.textBackgroundColor))
-
- Divider()
-
- if settings.siteLogins.isEmpty {
- ContentUnavailableView("No saved logins", systemImage: "key")
- .frame(minHeight: 250)
- } else if filteredLogins.isEmpty {
- ContentUnavailableView.search(text: searchText)
- .frame(minHeight: 250)
- } else {
- Table(filteredLogins, selection: $selection) {
- TableColumn("URL Pattern") { login in
- Text(login.urlPattern)
- .font(.system(.body, design: .monospaced))
- .contextMenu {
- contextMenuActions(for: login)
- }
- }
- TableColumn("Username") { login in
- Text(login.username)
- .contextMenu {
- contextMenuActions(for: login)
- }
- }
- }
- .frame(minHeight: 200, idealHeight: 250, maxHeight: 300)
- }
-
- Divider()
-
- HStack {
- Button {
- editingLogin = nil
- showEditor = true
- } label: {
- Image(systemName: "plus")
- .frame(width: 24, height: 24)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .padding(.horizontal, 4)
-
- Divider()
- .frame(height: 16)
-
- Button {
- deleteSelected()
- } label: {
- Image(systemName: "minus")
- .frame(width: 24, height: 24)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .padding(.horizontal, 4)
- .disabled(selection.isEmpty)
-
- Spacer()
- }
- .padding(.vertical, 4)
- .padding(.horizontal, 4)
- .background(Color(NSColor.windowBackgroundColor))
- }
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
- )
- } header: {
- Text("Saved Logins")
- }
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets())
- }
- }
- .formStyle(.grouped)
- .sheet(isPresented: $showEditor) {
- LoginEditorSheet(login: editingLogin)
- }
- }
-
- @ViewBuilder
- private func contextMenuActions(for login: SiteLogin) -> some View {
- Button("Edit") {
- editingLogin = login
- showEditor = true
- }
- Divider()
- Button("Copy Username") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(login.username, forType: .string)
- }
- Button("Copy Password") {
- if let password = KeychainCredentialStore.password(for: login.id) {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(password, forType: .string)
- }
- }
- Divider()
- Button("Delete", role: .destructive) {
- if let index = settings.siteLogins.firstIndex(where: { $0.id == login.id }) {
- settings.deleteSiteLogins(at: IndexSet(integer: index))
- }
- selection.remove(login.id)
- }
- }
-
- private func deleteSelected() {
- let indices = settings.siteLogins.enumerated().compactMap { index, login in
- selection.contains(login.id) ? index : nil
- }
- settings.deleteSiteLogins(at: IndexSet(indices))
- selection.removeAll()
- }
-}
-
-struct LoginEditorSheet: View {
- @Environment(\.dismiss) private var dismiss
- @EnvironmentObject private var settings: AppSettings
-
- let login: SiteLogin?
-
- @State private var urlPattern = ""
- @State private var username = ""
- @State private var password = ""
-
- var body: some View {
- VStack(spacing: 0) {
- Text(login == nil ? "Add Login" : "Edit Login")
- .font(.headline)
- .padding()
-
- Form {
- TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
- TextField("Username", text: $username)
- SecureField(login == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
- }
- .padding(.horizontal)
- .padding(.bottom)
-
- if !settings.message.isEmpty && (settings.message.hasPrefix("Add a") || settings.message.hasPrefix("A login") || settings.message.hasPrefix("Could not")) {
- Text(settings.message)
- .foregroundColor(.red)
- .font(.caption)
- .padding(.bottom)
- }
-
- HStack {
- Button("Cancel") {
- dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button(login == nil ? "Add" : "Save") {
- settings.message = ""
- settings.saveSiteLogin(
- id: login?.id,
- urlPattern: urlPattern,
- username: username,
- password: password
- )
-
- if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
- dismiss()
- }
- }
- .keyboardShortcut(.defaultAction)
- }
- .padding()
- }
- .frame(width: 400)
- .onAppear {
- if let login = login {
- urlPattern = login.urlPattern
- username = login.username
- }
- settings.message = ""
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Settings/ToastNotification.swift b/legacy/swift/Sources/Firelink/Settings/ToastNotification.swift
deleted file mode 100644
index e359f1d..0000000
--- a/legacy/swift/Sources/Firelink/Settings/ToastNotification.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-import SwiftUI
-
-struct ToastNotification: ViewModifier {
- var message: String
- @Binding var isShowing: Bool
-
- func body(content: Content) -> some View {
- ZStack(alignment: .bottom) {
- content
-
- if isShowing {
- VStack {
- Spacer()
- Text(message)
- .font(.subheadline.weight(.medium))
- .foregroundColor(.white)
- .padding(.horizontal, 16)
- .padding(.vertical, 10)
- .background(
- Capsule()
- .fill(Color.black.opacity(0.8))
- .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
- )
- .transition(.move(edge: .bottom).combined(with: .opacity))
- .padding(.bottom, 24)
- }
- .zIndex(1)
- .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
- .task {
- try? await Task.sleep(nanoseconds: 2_000_000_000)
- withAnimation {
- isShowing = false
- }
- }
- }
- }
- }
-}
-
-extension View {
- func toast(isShowing: Binding, message: String) -> some View {
- self.modifier(ToastNotification(message: message, isShowing: isShowing))
- }
-}
diff --git a/legacy/swift/Sources/Firelink/SidebarView.swift b/legacy/swift/Sources/Firelink/SidebarView.swift
deleted file mode 100644
index 420de40..0000000
--- a/legacy/swift/Sources/Firelink/SidebarView.swift
+++ /dev/null
@@ -1,224 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-enum DownloadSidebarFilter: Hashable {
- case all
- case queued
- case active
- case completed
- case unfinished
- case category(DownloadCategory)
-
- var title: String {
- switch self {
- case .all: "All Downloads"
- case .queued: "Queue"
- case .active: "Active"
- case .completed: "Completed"
- case .unfinished: "Unfinished"
- case .category(let category): category.rawValue
- }
- }
-}
-
-
-enum SidebarSelection: Hashable {
- case downloads(DownloadSidebarFilter)
- case queue(UUID)
- case scheduler
- case speedLimiter
- case settings
-}
-
-struct SidebarView: View {
- @EnvironmentObject private var controller: DownloadController
- @Environment(\.controlActiveState) private var controlActiveState
- @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")
- .foregroundStyle(.secondary)
- }
- .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 ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear)
- .foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : 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(.all)
- }
- 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)))
- }
-
- @ViewBuilder
- private func queueRow(for queue: DownloadQueue) -> some View {
- let row = 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
- )
- )
-
- if queue.isMain {
- row
- } else {
- row.contextMenu {
- 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? {
- 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)) })
- }
-}
diff --git a/legacy/swift/Sources/Firelink/SpeedLimiterView.swift b/legacy/swift/Sources/Firelink/SpeedLimiterView.swift
deleted file mode 100644
index fe73c16..0000000
--- a/legacy/swift/Sources/Firelink/SpeedLimiterView.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-import SwiftUI
-
-enum SpeedUnit: String, CaseIterable, Identifiable {
- case kbs = "KB/s"
- case mbs = "MB/s"
- var id: String { rawValue }
-}
-
-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 displayedSpeedValue: Int = 1
- @State private var limitUnit: SpeedUnit = .mbs
-
- var body: some View {
- VStack(spacing: 0) {
- headerView
- Divider()
-
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- GroupBox {
- limitSelectionSection
- .padding(8)
- }
- .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: 20) {
- HStack {
- Label("Global Speed Limit", systemImage: "speedometer")
- .font(.headline)
- Spacer()
- }
-
- 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(spacing: 16) {
- TextField("Speed", value: $displayedSpeedValue, format: .number)
- .textFieldStyle(.roundedBorder)
- .frame(width: 80)
- .multilineTextAlignment(.trailing)
-
- Picker("Unit", selection: $limitUnit) {
- ForEach(SpeedUnit.allCases) { unit in
- Text(unit.rawValue).tag(unit)
- }
- }
- .pickerStyle(.segmented)
- .frame(width: 140)
- }
-
- Divider()
-
- VStack(alignment: .leading, spacing: 12) {
- Text("Quick Presets")
- .font(.subheadline)
- .foregroundStyle(.secondary)
-
- HStack(spacing: 12) {
- presetButton(title: "1 MB/s", value: 1, unit: .mbs)
- presetButton(title: "5 MB/s", value: 5, unit: .mbs)
- presetButton(title: "10 MB/s", value: 10, unit: .mbs)
- }
- }
- }
- }
-
- private func presetButton(title: String, value: Int, unit: SpeedUnit) -> some View {
- Button(action: {
- displayedSpeedValue = value
- limitUnit = unit
- }) {
- Text(title)
- .font(.subheadline.weight(.medium))
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- }
- .buttonStyle(.bordered)
- .controlSize(.regular)
- }
-
- 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
- let effectiveLimit = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
-
- if effectiveLimit % 1024 == 0 && effectiveLimit >= 1024 {
- displayedSpeedValue = effectiveLimit / 1024
- limitUnit = .mbs
- } else {
- displayedSpeedValue = effectiveLimit
- limitUnit = .kbs
- }
- }
-
- private func saveState() {
- let valueInKbs = limitUnit == .mbs ? displayedSpeedValue * 1024 : displayedSpeedValue
- let clampedSpeed = max(min(valueInKbs, 10_485_760), 1)
-
- lastCustomSpeedLimit = clampedSpeed
- settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
- }
-}
diff --git a/legacy/swift/Sources/Firelink/StatusBar.swift b/legacy/swift/Sources/Firelink/StatusBar.swift
deleted file mode 100644
index fca2207..0000000
--- a/legacy/swift/Sources/Firelink/StatusBar.swift
+++ /dev/null
@@ -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)
- }
-}
diff --git a/legacy/swift/Sources/Firelink/Theme.swift b/legacy/swift/Sources/Firelink/Theme.swift
deleted file mode 100644
index 7628ccf..0000000
--- a/legacy/swift/Sources/Firelink/Theme.swift
+++ /dev/null
@@ -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))
- }
-}
diff --git a/legacy/swift/Sources/Firelink/TrayMenuView.swift b/legacy/swift/Sources/Firelink/TrayMenuView.swift
deleted file mode 100644
index 1f2dad6..0000000
--- a/legacy/swift/Sources/Firelink/TrayMenuView.swift
+++ /dev/null
@@ -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")
- }
- }
-}
diff --git a/legacy/swift/Sources/Firelink/_internal/.gitkeep b/legacy/swift/Sources/Firelink/_internal/.gitkeep
deleted file mode 100644
index e69de29..0000000