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 Icon -

Firelink

-

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

- - Swift Version - Platform Support - Engine - yt-dlp Engine - ffmpeg Engine - Deno Engine - License -
- ---- - -**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 - -
- Firelink main window - Add downloads window - Settings -
- A premium native experience, from batch linking to advanced settings. -
- -
-Light Theme Screenshots -
- -
- Firelink main window light theme - Add downloads window light theme - Settings light theme -
- -
- ---- - -## ✨ 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. - -Install on Firefox - -*(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