Compare commits

..

59 Commits

Author SHA1 Message Date
nimbold fd349bfd3b fix: awk syntax error for parsing changelog 2026-06-04 05:39:40 +03:30
nimbold f35e981b0a fix: clone submodules in release action 2026-06-04 05:37:23 +03:30
nimbold 2b520f3e97 chore: prepare for v0.5.0 release 2026-06-04 05:34:48 +03:30
nimbold 7802b612a4 feat: integrate settings and speed limiter into main window
- Refactor SettingsView to act as a flat detail pane instead of a nested sidebar or macOS native window
- Add custom horizontal tab bar for switching between setting categories
- Create SpeedLimiterView and add it as a primary tool in the sidebar
- Add state retention using AppStorage for settings tab selection and speed limits
- Fix EXC_BAD_ACCESS in DownloadTable by using sortableSize instead of debugDescription
2026-06-04 05:29:15 +03:30
nimbold 28bb90e8c2 feat: add stop downloads control 2026-06-04 04:44:04 +03:30
nimbold 86d70a9ca7 Refine add window and font sizing 2026-06-04 04:36:06 +03:30
nimbold b440b567f1 docs(readme): compact screenshots and fix light theme 2026-06-04 04:18:40 +03:30
nimbold 692a48b4a8 docs(readme): refresh application screenshots 2026-06-04 04:09:12 +03:30
nimbold a90d190df9 docs(readme): add application screenshots for dark and light themes 2026-06-04 03:47:47 +03:30
nimbold b1b3f42ead feat(ui): simplify temporary Firefox extension install 2026-06-04 03:30:44 +03:30
nimbold 91cd65e1d7 fix(security): harden extension bridge and persistence 2026-06-04 03:19:57 +03:30
nimbold e948590335 fix(ui): route to about:debugging without fragment hash and update instructions 2026-06-04 02:45:02 +03:30
nimbold 643f87d366 fix(ui): use Process to invoke open instead of NSWorkspace to avoid url encoding the fragment hash 2026-06-04 02:21:41 +03:30
nimbold 0f522fa794 fix(ui): explicitly route about:debugging to firefox to prevent LaunchServices error 2026-06-04 00:24:32 +03:30
nimbold 604987a360 feat(ui): update integration page with extension install instructions 2026-06-04 00:01:24 +03:30
nimbold e0a1db690f build: migrate extension to git submodule 2026-06-03 23:50:26 +03:30
nimbold 5dc8d66f3c refactor(extension): remove xpi generation and installation
- Switch to copying Firefox extension directory into app bundle Resources
- Remove 'Install to Firefox' button and multi-browser selection logic
- Update Integrations tab to instruct users to load via about:debugging
- Map 'Show Extension Folder in Finder' to the bundled extension source directory
2026-06-03 22:53:54 +03:30
nimbold 0ddea52db8 feat(extension): add Firefox browser extension and native integration
- Implement Firefox extension to intercept downloads and scrape selected links via context menus
- Create LocalExtensionServer over TCP (localhost:6412) to receive links from the extension
- Automatically package extension to firelink.xpi in create_app_bundle.sh
- Add Integrations tab in SettingsView with one-click installer for Firefox variants
- Modify ContentView and TrayMenuView to automatically open the Add Downloads window on extension trigger
2026-06-03 22:15:12 +03:30
nimbold 93e6e11939 docs: update changelog for 0.4.3 release 2026-06-03 07:07:28 +03:30
nimbold 6750cce6aa perf: reduce main thread CPU usage and SSD wear from frequent JSON serialization 2026-06-03 07:03:18 +03:30
nimbold 1d22b16119 style: refine about page UI and simplify delete confirmation dialog 2026-06-03 06:48:53 +03:30
nimbold deb106f7af docs: update changelog and roadmap for 0.4.2 release 2026-06-03 06:30:28 +03:30
nimbold 1093d73b0e feat: add monochrome tray icon and download table enhancements
- Added proper monochrome template tray icon loaded explicitly with precise dimensions
- Added redownload functionality for completed or failed items
- Added double-click to open completed files directly from the table
- Added 'Copy Address' context menu action
- Improved context menu organization and conditionally displayed actions
2026-06-03 06:23:03 +03:30
nimbold 3d117fd6b0 chore: prepare release 0.4.1 2026-06-03 05:36:57 +03:30
nimbold 21b30a9369 feat: add site logins integration to Add Downloads window 2026-06-03 05:32:39 +03:30
nimbold c4f02ff78f fix: remove unused showMenuBarIcon from AppSettings and conditionally apply theme backgrounds 2026-06-03 05:17:25 +03:30
nimbold f6d34caaf1 style: use visual command icon instead of text for paste hint 2026-06-03 05:11:18 +03:30
nimbold 7e4915f31d fix: resolve SwiftUI infinite layout freeze caused by MenuBarExtra isInserted binding to StateObject by using AppStorage 2026-06-03 05:07:55 +03:30
nimbold ff03a4cda5 feat: add Font Size, List Row Density, and Menu Bar Icon settings 2026-06-03 05:03:31 +03:30
nimbold 1f82d342e3 fix: resolve Light/System theme bug by using NSApp.appearance and replace Modern Dark with standard Dark 2026-06-03 04:54:31 +03:30
nimbold c653f7e733 feat: add app theming engine with Look and Feel settings 2026-06-03 04:44:03 +03:30
nimbold 992d448e60 feat: add tray icon and context menu for main window and queues 2026-06-03 04:35:18 +03:30
nimbold 59d0c2dec1 chore: prepare release 0.4.0 2026-06-03 04:21:26 +03:30
nimbold c4307fbaf1 chore: organize settings sections 2026-06-03 04:20:41 +03:30
nimbold 0c5e031f49 fix: harden release builds and queue scheduling 2026-06-03 04:15:53 +03:30
nimbold 8c2dee2c13 feat: add bandwidth speed limits 2026-06-03 03:58:17 +03:30
nimbold 8f17fa1e47 feat: add advanced transfer options 2026-06-03 03:36:06 +03:30
nimbold 343fe17111 fix: trigger scheduler automation permission prompt 2026-06-03 03:23:47 +03:30
nimbold e920eb544d feat: add robust scheduler with modern explicit permissions UI 2026-06-02 10:48:41 +03:30
nimbold 2dbb169c39 docs: check off data persistence from roadmap 2026-06-02 09:03:44 +03:30
nimbold 108ea1297c fix(ui): align connections row and clarify server connections behavior 2026-06-02 07:08:22 +03:30
nimbold f02a156b57 Fix CI runner: specify macos-26 for latest SDK 2026-06-02 06:54:25 +03:30
nimbold b54b5142f0 Fix CI release runner to use macos-latest 2026-06-02 06:44:02 +03:30
nimbold a6f2dc8324 docs: correct CHANGELOG.md to accurately reflect v0.3.0 vs v0.2.0 features 2026-06-02 06:32:44 +03:30
nimbold 5442174e70 chore: create CHANGELOG.md for v0.3.0 release 2026-06-02 06:25:44 +03:30
nimbold b91f6abdb0 build: bundle aria2c and dependencies via dylibbundler for zero-config distribution 2026-06-02 06:18:38 +03:30
nimbold cba94a7415 docs: use HTML badges inside centered header for reliable rendering 2026-06-02 06:08:01 +03:30
nimbold 7338ba0a9f docs: center app icon above title in README 2026-06-02 06:04:19 +03:30
nimbold 53d667c70e docs: add app icon to header and update roadmap 2026-06-02 05:58:47 +03:30
nimbold 31e0acd05c docs: update README with release features 2026-06-02 05:58:47 +03:30
Nima 4c7de3ab82 Merge pull request #1 from nimbold/codex/about-update-check
feat(settings): add about pane update checks
2026-06-02 05:26:30 +03:30
nimbold 2a7d359be8 feat(settings): add about pane update checks 2026-06-02 05:20:50 +03:30
nimbold fd6457ddc5 feat: improve queue management 2026-06-02 05:12:23 +03:30
nimbold ac039a50c8 feat: add queue management 2026-06-02 04:48:10 +03:30
nimbold dd348c46da feat: add download recovery 2026-06-02 04:29:14 +03:30
nimbold a4740190fd Add engine settings page 2026-06-02 04:22:47 +03:30
nimbold 22aeff4740 Add network proxy settings 2026-06-02 04:15:53 +03:30
nimbold fa018dff05 Move settings into main sidebar 2026-06-02 04:05:36 +03:30
nimbold 340ae6b0fe feat: add application icon 2026-06-02 03:54:50 +03:30
35 changed files with 3821 additions and 795 deletions
+27 -4
View File
@@ -17,11 +17,13 @@ permissions:
jobs:
macos-arm64-dmg:
name: Build macOS ARM64 DMG
runs-on: macos-15
runs-on: macos-26
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive
- name: Resolve version
id: version
@@ -40,6 +42,23 @@ jobs:
run: |
uname -a
swift --version
xcodebuild -version
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- name: Verify macOS 26 SDK
shell: bash
run: |
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
SDK_MAJOR="${SDK_VERSION%%.*}"
if [[ "$SDK_MAJOR" != "26" ]]; then
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
exit 1
fi
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Build app bundle
env:
@@ -51,6 +70,7 @@ jobs:
run: |
file build/Firelink.app/Contents/MacOS/Firelink
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
codesign --verify --deep --strict build/Firelink.app
- name: Create DMG
env:
@@ -59,7 +79,7 @@ jobs:
run: Scripts/create_dmg.sh
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
path: dist/*.dmg
@@ -70,8 +90,11 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --generate-notes --verify-tag
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
fi
+3
View File
@@ -0,0 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
url = https://github.com/nimbold/Firelink-Extension.git
+97
View File
@@ -0,0 +1,97 @@
# 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).
## [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.
Submodule Extensions/Firefox added at 2f99dda366
+50 -66
View File
@@ -1,11 +1,12 @@
<div align="center">
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
[![Swift Version](https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white)](https://swift.org)
[![Platform Support](https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white)](https://apple.com)
[![Engine](https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white)](https://aria2.github.io/)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
</div>
---
@@ -14,65 +15,48 @@
---
## ✨ Key Features
### 📸 Screenshots
-**High-Speed Downloads:** Multi-segmented download engine powered by `aria2c` for concurrent connections and optimal bandwidth utilization. Supports HTTP, HTTPS, FTP, and SFTP.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and a native Settings panel.
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
Dark mode is shown by default. Light mode is tucked away below so the README stays easy to scan.
<div align="center">
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup</sub>
</div>
<details>
<summary>🔍 View Full Feature Index (30+ features)</summary>
### All native SwiftUI macOS features:
- **Core Download Engine:**
- Segmented downloads with per-file connection/split counts.
- Multi-threaded, parallel downloading with configurable limits.
- Support for HTTP, HTTPS, FTP, and SFTP transfers via `aria2c`.
- **Advanced Queue Control:**
- Drag-and-drop download reordering to manage priorities.
- Comprehensive download table with resizable, custom columns (Name, Size, Status, ETA, Dates).
- Individual download controls: pause, resume, stop, and queue actions.
- **Smart Link Ingestion:**
- Batch Add window for pasting multiple links at once.
- Automated link parsing (whitespaces, newlines, standard separators).
- Disk space availability checks and total size calculation prior to adding.
- **File Organization:**
- Automatic categorization of files (Music, Movies, Compressed, Documents, Images) into structured subfolders under `~/Downloads`.
- Smart fallback to customizable download folders per category.
- Automatic cleanup of unfinished `.aria2` metadata/cache files on removal.
- **Security & System:**
- Local macOS Keychain integration for site logins.
- Prevents system sleep while active transfers are in progress, preserving display sleep.
- Release `.app` bundle build scripts ready for distribution.
<summary><b>☀️ View Light Theme Screenshots</b></summary>
<br/>
<div align="center">
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
</div>
</details>
---
## ⚙️ Engine Architecture
## ✨ Features
Firelink leverages `aria2c` under the hood as its core download engine. Unlike standard `curl`, `aria2c` allows:
- **Segmented Downloads:** Splits files into multiple streams for maximum transfer speeds.
- **Unified Connection Control:** A single slider regulates both the server connection count and segment split count, matching the intuitive behavior of classic managers like IDM/FDM.
- **Built-in Resumability:** Seamlessly resumes interrupted or paused downloads without data corruption.
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, Speed Limiter, and system sleep prevention during active downloads.
---
## 🛠️ Requirements & Setup
## 🛠️ Quick Start
### 1. Install Dependencies
Ensure you have **Homebrew** and the **Swift toolchain** installed:
```bash
brew install aria2
```
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon)
- **Toolchain:** Xcode 15+ / Swift 6.0 toolchain
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
### 2. Build & Run
Run the application directly via the terminal:
Run the application directly:
```bash
swift run Firelink
```
@@ -82,28 +66,28 @@ Or build a production `.app` bundle:
make app && open build/Firelink.app
```
Create a local Apple Silicon DMG:
```bash
make dmg
```
---
### Release
GitHub Actions builds and publishes the macOS ARM64 DMG when a version tag is pushed:
```bash
git tag v0.1.0
git push origin v0.1.0
```
## 🧩 Browser Extension
Find the companion browser extension (Safari, Chrome, Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
---
## 🗺️ Roadmap
- [ ] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
- [ ] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
- [ ] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
- [ ] **Distribution:** Notarized release builds, automated Sparkle updates, and Homebrew formulae.
- [x] Zero-Config `aria2c` bundling.
- [x] Global & per-download Speed Limiter.
- [x] Browser Extensions support.
- [x] In-app integrated Settings UI.
- [ ] Notarized `.app` releases and Homebrew formulae.
---
## 🏆 Credits
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
---
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+36
View File
@@ -10,6 +10,7 @@ APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
swift build -c "$CONFIGURATION"
@@ -17,6 +18,33 @@ 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"
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js"
cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json"
cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons"
cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup"
ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
@@ -33,6 +61,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$ICON_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -45,8 +75,14 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
</dict>
</plist>
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
fi
echo "Created $APP_DIR"
+213 -43
View File
@@ -11,28 +11,45 @@ struct AddDownloadsView: View {
@State private var overrideDestination = false
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID
@State private var speedLimitEnabled = false
@State private var speedLimitKiBPerSecond = 1024
@State private var showsAdvancedTransfer = false
@State private var checksumEnabled = false
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
@State private var checksumValue = ""
@State private var headerText = ""
@State private var cookieText = ""
@State private var mirrorText = ""
@State private var useAuthorization = false
@State private var authUsername = ""
@State private var authPassword = ""
@State private var saveLogin = false
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(20)
.padding(12)
}
Divider()
actionBar
}
.frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740)
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
linkText = text
controller.pendingPasteboardText = nil
@@ -46,14 +63,14 @@ struct AddDownloadsView: View {
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.headline)
.font(.subheadline.weight(.semibold))
TextEditor(text: $linkText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 140)
.frame(minHeight: 82)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -71,19 +88,19 @@ struct AddDownloadsView: View {
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.headline)
.font(.subheadline.weight(.semibold))
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 10) {
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.disabled(!overrideDestination)
Button {
@@ -95,40 +112,77 @@ struct AddDownloadsView: View {
}
}
GridRow {
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 220)
.frame(width: 145)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 130, alignment: .leading)
.frame(width: 98, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit this batch", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
.disabled(!speedLimitEnabled)
}
}
}
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.subheadline.weight(.semibold))
.padding(.top, 4)
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Firelink splits each file into this many parallel segments.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var summarySection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
HStack(spacing: 8) {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
}
private var previewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.headline)
.font(.subheadline.weight(.semibold))
Table(pendingDownloads) {
TableColumn("File") { item in
@@ -138,10 +192,6 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
@@ -150,7 +200,7 @@ struct AddDownloadsView: View {
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(95)
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
@@ -162,9 +212,9 @@ struct AddDownloadsView: View {
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
}
.width(130)
.width(110)
}
.frame(minHeight: 230)
.frame(minHeight: 135)
}
}
@@ -183,7 +233,7 @@ struct AddDownloadsView: View {
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(pendingDownloads.isEmpty)
.disabled(!canAddDownloads)
Button {
addDownloads(start: true)
@@ -191,12 +241,71 @@ struct AddDownloadsView: View {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(pendingDownloads.isEmpty)
.disabled(!canAddDownloads)
}
.padding(14)
.padding(10)
.background(.bar)
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.labelsHidden()
.frame(width: 130)
TextField("Expected digest", text: $checksumValue)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.subheadline.weight(.semibold))
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
}
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.semibold))
}
}
private var requiredSpaceText: String {
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
guard knownBytes > 0 else { return "Unknown" }
@@ -217,6 +326,10 @@ struct AddDownloadsView: View {
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."
}
@@ -224,6 +337,35 @@ struct AddDownloadsView: View {
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
}
private var canAddDownloads: Bool {
!pendingDownloads.isEmpty && validationMessage == nil
}
private var validationMessage: String? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Add the expected checksum digest, or turn checksum off."
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
return "Headers must use Name: Value lines."
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
}
return nil
}
private var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
@@ -251,11 +393,18 @@ struct AddDownloadsView: View {
)
}
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
useAuthorization = true
authUsername = creds.username
authPassword = creds.password
saveLogin = false
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings)
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
@@ -286,11 +435,32 @@ struct AddDownloadsView: View {
}
private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start
startImmediately: start,
queueID: targetQueueID,
credentials: explicitCredentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
@@ -359,22 +529,22 @@ private struct SummaryTile: View {
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.title3)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 24)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.headline.monospacedDigit())
.font(.subheadline.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(width: 190)
.frame(minHeight: 64)
.padding(9)
.frame(maxWidth: .infinity)
.frame(minHeight: 52)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+129 -2
View File
@@ -6,8 +6,77 @@ struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
var username: String
}
enum ProxyMode: String, Codable, CaseIterable, Sendable {
case none
case system
case custom
var title: String {
switch self {
case .none: "No proxy"
case .system: "Use system proxy"
case .custom: "Set proxy"
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
}
}
var uriScheme: String {
rawValue
}
}
struct ProxySettings: Codable, Equatable, Sendable {
var mode: ProxyMode = .none
var type: ProxyType = .http
var host = ""
var port = 8080
var normalized: ProxySettings {
var copy = self
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535)
return copy
}
var customProxyURI: String? {
let clean = normalized
guard !clean.host.isEmpty else { return nil }
return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)"
}
}
struct DownloadProxyConfiguration: Equatable, Sendable {
var mode: ProxyMode
var customProxyURI: String?
}
@MainActor
final class AppSettings: ObservableObject {
@Published var 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)
@@ -28,10 +97,32 @@ final class AppSettings: ObservableObject {
}
}
@Published var globalSpeedLimitKiBPerSecond: Int {
didSet {
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
if globalSpeedLimitKiBPerSecond != clamped {
globalSpeedLimitKiBPerSecond = clamped
return
}
save()
}
}
@Published var preventsSleepWhileDownloading: Bool {
didSet { save() }
}
@Published var proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
if proxySettings != normalized {
proxySettings = normalized
return
}
save()
}
}
@Published var downloadDirectories: [DownloadCategory: String] {
didSet { save() }
}
@@ -44,21 +135,32 @@ final class AppSettings: ObservableObject {
private let defaults: UserDefaults
private let storageKey = "Firelink.AppSettings.v1"
private var saveTask: Task<Void, Never>?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
appFontSize = stored.appFontSize ?? .standard
listRowDensity = stored.listRowDensity ?? .standard
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
appFontSize = .standard
listRowDensity = .standard
perServerConnections = 16
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
downloadDirectories = Self.defaultDirectories()
}
@@ -83,6 +185,13 @@ final class AppSettings: ObservableObject {
downloadDirectories = Self.defaultDirectories()
}
var downloadProxyConfiguration: DownloadProxyConfiguration {
DownloadProxyConfiguration(
mode: proxySettings.mode,
customProxyURI: proxySettings.customProxyURI
)
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -128,15 +237,28 @@ final class AppSettings: ObservableObject {
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
appFontSize: appFontSize,
listRowDensity: listRowDensity,
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
let defaults = self.defaults
let storageKey = self.storageKey
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: 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)
}
}
@@ -185,9 +307,14 @@ final class AppSettings: ObservableObject {
}
private struct StoredSettings: Codable {
var appTheme: AppTheme?
var appFontSize: AppFontSize?
var listRowDensity: ListRowDensity?
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
+168 -10
View File
@@ -1,4 +1,5 @@
import Foundation
import CFNetwork
final class Aria2DownloadEngine {
struct Handle {
@@ -9,6 +10,7 @@ final class Aria2DownloadEngine {
enum EngineError: LocalizedError {
case executableNotFound
case launchFailed(String)
case unsupportedProxy(String)
var errorDescription: String? {
switch self {
@@ -16,6 +18,8 @@ final class Aria2DownloadEngine {
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
case .launchFailed(let details):
"Could not start aria2c: \(details)"
case .unsupportedProxy(let details):
details
}
}
}
@@ -27,6 +31,11 @@ final class Aria2DownloadEngine {
}
static func findExecutable() -> URL? {
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
@@ -45,16 +54,39 @@ final class Aria2DownloadEngine {
}
}
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
return nil
}
static func versionString() -> String? {
guard let executableURL = findExecutable() else { return nil }
let process = Process()
let outputPipe = Pipe()
process.executableURL = executableURL
process.arguments = ["--version"]
process.standardOutput = outputPipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return output
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.first
.map(String.init)
} catch {
return nil
}
}
func start(
item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
@@ -69,7 +101,11 @@ final class Aria2DownloadEngine {
let process = Process()
process.executableURL = executableURL
process.arguments = arguments(for: item)
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
)
let inputPipe = Pipe()
let outputPipe = Pipe()
@@ -136,8 +172,12 @@ final class Aria2DownloadEngine {
}
}
private func arguments(for item: DownloadItem) -> [String] {
[
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?
) throws -> [String] {
var arguments = [
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
@@ -146,20 +186,138 @@ final class Aria2DownloadEngine {
"--download-result=hide",
"--file-allocation=none",
"--min-split-size=1M",
"--max-tries=10",
"--retry-wait=5",
"--connect-timeout=30",
"--timeout=60",
"--uri-selector=adaptive",
"--input-file=-"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] {
switch configuration.mode {
case .none:
return clearedProxyArguments()
case .system:
switch systemProxyResolution(for: item.url) {
case .direct:
return clearedProxyArguments()
case .proxy(let proxyURI):
return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"]
case .unsupported(let message):
throw EngineError.unsupportedProxy(message)
}
case .custom:
guard let proxyURI = configuration.customProxyURI else { return [] }
return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"]
}
}
private func clearedProxyArguments() -> [String] {
[
"--all-proxy=",
"--http-proxy=",
"--https-proxy=",
"--ftp-proxy="
]
}
private func proxyArgumentName(for urlScheme: String?) -> String {
switch urlScheme?.lowercased() {
case "http": "--http-proxy"
case "https": "--https-proxy"
case "ftp": "--ftp-proxy"
default: "--all-proxy"
}
}
private enum SystemProxyResolution {
case direct
case proxy(String)
case unsupported(String)
}
private func systemProxyResolution(for url: URL) -> SystemProxyResolution {
guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else {
return .direct
}
for proxy in proxies {
guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue }
if type == kCFProxyTypeNone as String {
return .direct
}
if type == kCFProxyTypeSOCKS as String {
return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.")
}
if type == kCFProxyTypeAutoConfigurationURL as String ||
type == kCFProxyTypeAutoConfigurationJavaScript as String {
return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.")
}
if let uri = proxyURI(fromSystemProxy: proxy, type: type) {
return .proxy(uri)
}
}
return .direct
}
private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? {
guard let host = proxy[kCFProxyHostNameKey as String] as? String,
!host.isEmpty else {
return nil
}
let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue
let scheme: String
if type == kCFProxyTypeHTTPS as String {
scheme = "https"
} else if type == kCFProxyTypeFTP as String {
scheme = "ftp"
} else {
scheme = "http"
}
guard let port else {
return "\(scheme)://\(host)"
}
return "\(scheme)://\(host):\(port)"
}
private func inputFileContent(for item: DownloadItem) -> String {
let connections = min(max(item.connectionsPerServer, 1), 16)
let urls = ([item.url] + (item.mirrorURLs ?? []))
.map { sanitizedOptionValue($0.absoluteString) }
.joined(separator: "\t")
var lines = [
sanitizedOptionValue(item.url.absoluteString),
urls,
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName))",
" 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" {
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "MenuBarIconTemplate.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+147 -89
View File
@@ -3,113 +3,162 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarFilter: DownloadSidebarFilter = .all
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@State private var showDeleteConfirmation = false
var body: some View {
NavigationSplitView {
SidebarView(selection: $sidebarFilter)
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
.themeBackground(settings.appTheme.theme.secondaryBackground)
} detail: {
VStack(spacing: 0) {
DownloadTable(
items: filteredDownloads,
selection: $selection,
title: sidebarFilter.title
)
Divider()
StatusBar()
detailView
.themeBackground(settings.appTheme.theme.background)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
}
@ViewBuilder
private var detailView: some View {
switch sidebarSelection {
case .downloads(let filter):
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .speedLimiter:
SpeedLimiterView()
case .settings:
SettingsPaneContainer()
}
}
private func queueView(queueID: UUID) -> some View {
downloadsView(
filter: .all,
title: controller.queueName(for: queueID),
items: controller.queueItems(for: queueID),
queueID: queueID
)
}
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
downloadsView(
filter: filter,
title: filter.title,
items: filteredDownloads(for: filter),
queueID: nil
)
}
private func downloadsView(filter: DownloadSidebarFilter, title: String, items: [DownloadItem], queueID: UUID?) -> some View {
VStack(spacing: 0) {
DownloadTable(
items: items,
selection: $selection,
title: title,
queueID: queueID
)
Divider()
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
ToolbarItem {
Button {
controller.startQueue(queueID: queueID)
} label: {
Label("Start Queue", systemImage: "play.fill")
}
ToolbarItem {
Button {
controller.startQueue()
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItem {
Button {
controller.pauseActiveDownloads(queueID: queueID)
} label: {
Label("Stop Downloads", systemImage: "stop.fill")
}
.disabled(!hasActiveDownloads(in: queueID))
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
Label("Stop", systemImage: "stop.fill")
}
}
}
ToolbarItem {
SettingsLink {
Label("Settings", systemImage: "gearshape")
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
}
}
.background {
Button("") {
if !selection.isEmpty {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
handlePaste()
}
}
.background {
Button("") {
if !selection.isEmpty {
showDeleteConfirmation = true
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
selectAll()
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
Button("") {
handlePaste(queueID: queueID)
}
.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?")
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
Button("") {
selectAll(items: items)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
}
}
@@ -124,18 +173,27 @@ struct ContentView: View {
selection.removeAll()
}
private func handlePaste() {
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
private func selectAll() {
selection = Set(filteredDownloads.map { $0.id })
private func selectAll(items: [DownloadItem]) {
selection = Set(items.map { $0.id })
}
private var filteredDownloads: [DownloadItem] {
switch sidebarFilter {
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 filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
controller.downloads
case .queued:
@@ -144,8 +202,8 @@ struct ContentView: View {
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .failed:
controller.downloads.filter { $0.status == .failed }
case .unfinished:
controller.downloads.filter { $0.status != .completed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
+509 -36
View File
@@ -1,28 +1,36 @@
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?
var pendingAddQueueID: UUID?
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
}()
private var saveTask: Task<Void, Never>?
init(settings: AppSettings) {
self.settings = settings
loadDownloads()
let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in
Task { @MainActor in
@@ -44,6 +52,15 @@ final class DownloadController: ObservableObject {
self?.saveDownloads()
}
.store(in: &cancellables)
if shouldResumeRecoveredDownloads {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.queuePumpScope = .all
self.pumpQueue()
}
}
}
deinit {
@@ -62,15 +79,15 @@ final class DownloadController: ObservableObject {
downloads.filter { $0.status == .completed }.count
}
var failedCount: Int {
downloads.filter { $0.status == .failed }.count
var unfinishedCount: Int {
downloads.filter { $0.status != .completed }.count
}
var hasAria2: Bool {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, connectionsPerServer: Int? = nil) {
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
@@ -86,20 +103,32 @@ final class DownloadController: ObservableObject {
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
credentials: settings.credentials(for: url)
credentials: settings.credentials(for: url),
queueID: normalizedQueueID(queueID)
)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
}
func addPendingDownloads(
_ pendingDownloads: [PendingDownload],
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool
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
DownloadItem(
@@ -108,23 +137,52 @@ final class DownloadController: ObservableObject {
category: pending.category,
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: settings.credentials(for: pending.url),
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"
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
)
}
for item in items {
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
}
downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
saveDownloads()
if startImmediately {
startQueue()
startQueue(queueID: targetQueueID)
}
}
func startQueue() {
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()
}
@@ -134,7 +192,35 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
func pauseActiveDownloads(queueID: UUID? = nil) {
let targetQueueID = queueID.map(normalizedQueueID)
let activeItems = downloads.filter { item in
item.status == .downloading && (targetQueueID == nil || item.queueID == targetQueueID)
}
guard !activeItems.isEmpty else { return }
for item in activeItems {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
}
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -151,15 +237,65 @@ final class DownloadController: ObservableObject {
$0.connectionCount = 0
}
$0.message = "Added to queue"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
let queueID = normalizedQueueID(queueID)
var changed = false
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
guard downloads[index].status != .completed,
downloads[index].status != .downloading else {
continue
}
downloads[index].status = .queued
downloads[index].queueID = queueID
downloads[index].message = "Added to \(queueName(for: queueID))"
downloads[index].autoResumeOnLaunch = false
automaticRetryCounts[downloads[index].id] = nil
changed = true
}
if changed {
saveDownloads()
updateSleepActivity()
}
}
func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.message = ""
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func 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()
}
@@ -169,7 +305,10 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
$0.autoResumeOnLaunch = false
}
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -189,12 +328,85 @@ final class DownloadController: ObservableObject {
} else if item.status != .completed {
removeCacheFiles(for: item)
}
KeychainCredentialStore.deletePassword(for: item.id)
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination)
saveDownloads()
}
func queueName(for id: UUID) -> String {
queues.first(where: { $0.id == normalizedQueueID(id) })?.name ?? DownloadQueue.main.name
}
func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id)
return downloads.filter { validQueueID($0.queueID) == id }
}
func queueCount(for id: UUID) -> Int {
queueItems(for: id).count
}
@discardableResult
func addQueue() -> DownloadQueue {
var index = 2
var name = "Queue \(index)"
let existingNames = Set(queues.map(\.name))
while existingNames.contains(name) {
index += 1
name = "Queue \(index)"
}
let queue = DownloadQueue(name: name)
queues.append(queue)
saveDownloads()
return queue
}
func renameQueue(id: UUID, name: String) {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty,
id != DownloadQueue.mainQueueID,
let index = queues.firstIndex(where: { $0.id == id }) else {
return
}
queues[index].name = cleanName
saveDownloads()
}
func removeQueue(id: UUID) {
guard id != DownloadQueue.mainQueueID,
queues.contains(where: { $0.id == id }) else {
return
}
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
downloads[index].queueID = nil
}
queues.removeAll { $0.id == id }
engineMessage = "Removed queue. Downloads remain in Unfinished."
saveDownloads()
}
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID)
guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return
}
let item = downloads.remove(at: source)
let insertionIndex = source < target ? target - 1 : target
downloads.insert(item, at: insertionIndex)
saveDownloads()
}
private func pumpQueue() {
@@ -203,10 +415,24 @@ final class DownloadController: ObservableObject {
return
}
pruneActiveQueueScopes()
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { $0.status == .queued }) {
let next = downloads.first(where: { item in
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
start(next)
}
if restrictQueueToAutoResume &&
activeCount == 0 &&
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
pruneActiveQueueScopes()
}
private func start(_ item: DownloadItem) {
@@ -216,11 +442,15 @@ final class DownloadController: ObservableObject {
$0.message = "Starting"
$0.speedText = "-"
$0.etaText = "-"
$0.autoResumeOnLaunch = true
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
@@ -240,22 +470,23 @@ final class DownloadController: ObservableObject {
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
@@ -267,12 +498,10 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
updateSleepActivity()
} catch {
update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
handleDownloadFailure(itemID: item.id, error: error)
updateSleepActivity()
pumpQueue()
}
@@ -289,7 +518,9 @@ final class DownloadController: ObservableObject {
fileName: String,
destinationDirectory: URL,
connectionsPerServer: Int,
credentials: DownloadCredentials?
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions,
speedLimitKiBPerSecond: Int?
) {
update(id) {
$0.url = url
@@ -298,8 +529,160 @@ final class DownloadController: ObservableObject {
$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)
}
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
if let queueID {
let normalizedID = normalizedQueueID(queueID)
for index in downloads.indices where downloads[index].status == .queued &&
validQueueID(downloads[index].queueID) == normalizedID {
downloads[index].autoResumeOnLaunch = true
}
} else {
for index in downloads.indices where downloads[index].status == .queued {
downloads[index].autoResumeOnLaunch = true
}
}
saveDownloads()
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = error.localizedDescription
$0.autoResumeOnLaunch = false
}
saveDownloads()
if let item = downloads.first(where: { $0.id == itemID }) {
showNotification(title: "Download Failed", body: item.fileName)
}
return
}
automaticRetryCounts[itemID] = retryCount + 1
update(itemID) {
$0.status = .queued
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
switch queuePumpScope {
case .idle:
return false
case .all:
return true
case .scoped(let queueIDs, let itemIDs):
if itemIDs.contains(item.id) {
return true
}
guard let queueID = validQueueID(item.queueID) else { return false }
return queueIDs.contains(queueID)
}
}
private func pruneActiveQueueScopes() {
switch queuePumpScope {
case .idle:
return
case .all:
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
queuePumpScope = .idle
}
case .scoped(let queueIDs, let itemIDs):
let activeQueueIDs = queueIDs.filter { queueID in
downloads.contains { item in
validQueueID(item.queueID) == queueID &&
(item.status == .queued || item.status == .downloading)
}
}
let activeItemIDs = itemIDs.filter { itemID in
downloads.contains { item in
item.id == itemID && (item.status == .queued || item.status == .downloading)
}
}
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
? .idle
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
}
}
private enum QueuePumpScope {
case idle
case all
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
func includingItem(_ itemID: UUID) -> QueuePumpScope {
switch self {
case .idle:
return .scoped(queueIDs: [], itemIDs: [itemID])
case .all:
return .all
case .scoped(let queueIDs, var itemIDs):
itemIDs.insert(itemID)
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
}
}
private func updateSleepActivity() {
@@ -338,37 +721,127 @@ final class DownloadController: ObservableObject {
}
private func saveDownloads() {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(downloads)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
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() {
private func loadDownloads() -> Bool {
do {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return }
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL)
let loaded = try JSONDecoder().decode([DownloadItem].self, from: data)
self.downloads = loaded.map { item in
let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState
isLegacyDownloadList = false
} else {
state = StoredDownloadState(
queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
)
isLegacyDownloadList = true
}
var shouldResumeRecoveredDownloads = false
var shouldRewriteStoredDownloads = isLegacyDownloadList
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
let redacted = adjusted.redactedForPersistence
if redacted != adjusted {
adjusted = redacted
shouldRewriteStoredDownloads = true
}
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
}
if adjusted.status == .downloading {
adjusted.status = .paused
adjusted.message = "Paused on startup"
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = true
shouldResumeRecoveredDownloads = true
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
adjusted.message = "Recovered queued download."
shouldResumeRecoveredDownloads = true
}
return adjusted
}
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
} catch {
print("Failed to load downloads: \(error)")
return false
}
}
private func normalizedQueueID(_ id: UUID?) -> UUID {
validQueueID(id) ?? DownloadQueue.mainQueueID
}
private func validQueueID(_ id: UUID?) -> UUID? {
guard let id, queues.contains(where: { $0.id == id }) else {
return nil
}
return id
}
private func normalizedQueues(_ queues: [DownloadQueue]) -> [DownloadQueue] {
var normalized = queues
if !normalized.contains(where: { $0.id == DownloadQueue.mainQueueID }) {
normalized.insert(.main, at: 0)
}
if let mainIndex = normalized.firstIndex(where: { $0.id == DownloadQueue.mainQueueID }), mainIndex != 0 {
let main = normalized.remove(at: mainIndex)
normalized.insert(main, at: 0)
}
return normalized
}
private func showNotification(title: String, body: String) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
}
private struct StoredDownloadState: Codable {
var queues: [DownloadQueue]
var downloads: [DownloadItem]
}
private final class SleepActivityHandle: @unchecked Sendable {
+11 -1
View File
@@ -30,7 +30,11 @@ enum DownloadURLParser {
}
enum DownloadMetadataFetcher {
static func fetch(for url: URL, settings: AppSettings) async -> PendingDownload {
static func fetch(
for url: URL,
settings: AppSettings,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
@@ -51,6 +55,12 @@ enum DownloadMetadataFetcher {
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
request.setValue(header.value, forHTTPHeaderField: header.name)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
+94 -2
View File
@@ -37,6 +37,14 @@ struct DownloadPropertiesView: View {
@State private var loginMode: LoginMode
@State private var username: String
@State private var password: String
@State private var speedLimitEnabled: Bool
@State private var speedLimitKiBPerSecond: Int
@State private var checksumEnabled: Bool
@State private var checksumAlgorithm: ChecksumAlgorithm
@State private var checksumValue: String
@State private var headerText: String
@State private var cookieText: String
@State private var mirrorText: String
@State private var errorMessage = ""
init(item: DownloadItem) {
@@ -45,6 +53,8 @@ struct DownloadPropertiesView: View {
_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)
@@ -54,6 +64,18 @@ struct DownloadPropertiesView: View {
_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 {
@@ -73,6 +95,15 @@ struct DownloadPropertiesView: View {
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
Toggle("Limit speed", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
Section("Site Login") {
@@ -93,6 +124,36 @@ struct DownloadPropertiesView: View {
}
}
Section("Advanced Transfer") {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
TextField("Cookies", text: $cookieText)
.font(.system(.body, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
@@ -120,7 +181,7 @@ struct DownloadPropertiesView: View {
.padding(14)
.background(.bar)
}
.frame(width: 620, height: 560)
.frame(width: 620, height: 760)
}
private var matchingLoginText: String {
@@ -175,16 +236,46 @@ struct DownloadPropertiesView: View {
credentials = nil
}
guard let transferOptions = validatedTransferOptions else {
return
}
controller.updateDownload(
id: item.id,
url: url,
fileName: cleanFileName,
destinationDirectory: destination,
connectionsPerServer: connections,
credentials: credentials
credentials: credentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
private var validatedTransferOptions: DownloadTransferOptions? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "Add the expected checksum digest, or turn checksum off."
return nil
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
errorMessage = "Headers must use Name: Value lines."
return nil
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
errorMessage = "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
return nil
}
return DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
}
private struct InfoGrid: View {
@@ -198,6 +289,7 @@ private struct InfoGrid: View {
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
+220 -496
View File
@@ -1,101 +1,6 @@
import AppKit
import SwiftUI
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
case fileName = "File name"
case size = "Size"
case progress = "Progress"
case status = "Status"
case lastTry = "Last try date"
case dateAdded = "Date added"
case category = "Category"
case connections = "Connections"
case liveConnections = "Live conn."
case speed = "Speed"
case eta = "ETA"
case destination = "Save location"
case url = "URL"
case message = "Message"
var id: String { rawValue }
var width: CGFloat {
switch self {
case .fileName: return 340
case .size: return 100
case .status: return 105
case .progress: return 115
case .lastTry, .dateAdded: return 155
case .category: return 105
case .connections, .liveConnections: return 95
case .speed, .eta: return 90
case .destination: return 240
case .url: return 280
case .message: return 220
}
}
}
enum SortDirection: String, Codable {
case ascending
case descending
mutating func toggle() {
self = self == .ascending ? .descending : .ascending
}
}
final class TableSettings: ObservableObject {
@Published var visibleColumns: Set<DownloadColumn> {
didSet { save() }
}
@Published var columnWidths: [DownloadColumn: CGFloat] {
didSet { save() }
}
@Published var sortColumn: DownloadColumn {
didSet { save() }
}
@Published var sortDirection: SortDirection {
didSet { save() }
}
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.TableSettings.v1"
init() {
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
visibleColumns = stored.visibleColumns
columnWidths = stored.columnWidths
sortColumn = stored.sortColumn
sortDirection = stored.sortDirection
} else {
visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
sortColumn = .dateAdded
sortDirection = .descending
}
}
private func save() {
let stored = StoredTableSettings(
visibleColumns: visibleColumns,
columnWidths: columnWidths,
sortColumn: sortColumn,
sortDirection: sortDirection
)
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
}
}
}
private struct StoredTableSettings: Codable {
var visibleColumns: Set<DownloadColumn>
var columnWidths: [DownloadColumn: CGFloat]
var sortColumn: DownloadColumn
var sortDirection: SortDirection
}
import UniformTypeIdentifiers
struct DownloadTable: View {
@EnvironmentObject private var controller: DownloadController
@@ -104,11 +9,14 @@ struct DownloadTable: View {
let items: [DownloadItem]
@Binding var selection: Set<DownloadItem.ID>
let title: String
var queueID: UUID?
@StateObject private var tableSettings = TableSettings()
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
@State private var lastSelectedIndex: Int?
var sortedItems: [DownloadItem] {
items.sorted(using: sortOrder)
}
var body: some View {
VStack(spacing: 0) {
@@ -126,38 +34,83 @@ struct DownloadTable: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
GeometryReader { proxy in
let tableWidth = max(totalWidth, proxy.size.width)
let trailingWidth = max(0, tableWidth - totalWidth)
ScrollView(.horizontal) {
VStack(spacing: 0) {
tableHeader(trailingWidth: trailingWidth)
Divider()
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(sortedItems) { item in
tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth)
Divider()
}
}
.frame(width: tableWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.topLeading)
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)
}
.frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add to paste one or more links.")
)
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
progressBarCell(for: item)
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
Text(item.status.rawValue)
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
for itemID in itemIDs {
if let item = controller.downloads.first(where: { $0.id == itemID }) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
}
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
}
.confirmationDialog(
"Delete Download",
@@ -178,7 +131,7 @@ struct DownloadTable: View {
pendingDeleteItems = nil
}
Button("Move File and Cache to Trash", role: .destructive) {
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)
@@ -198,238 +151,157 @@ struct DownloadTable: View {
}
}
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
DownloadRow(
item: item,
visibleColumns: orderedVisibleColumns,
columnWidth: { width(for: $0) },
trailingWidth: trailingWidth
)
.id(item.id)
.frame(width: tableWidth, alignment: .leading)
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
let index = sortedItems.firstIndex(where: { $0.id == item.id })
if NSEvent.modifierFlags.contains(.command) {
if selection.contains(item.id) {
selection.remove(item.id)
} else {
selection.insert(item.id)
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
if !targetItems.isEmpty {
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
for target in targetItems {
openFile(target)
}
} label: {
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
}
lastSelectedIndex = index
} else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index {
let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex)
let rangeIds = range.map { sortedItems[$0].id }
selection.formUnion(rangeIds)
} else {
selection = [item.id]
lastSelectedIndex = index
}
}
.contextMenu {
rowContextMenu(for: item)
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
Divider()
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Move to Queue", systemImage: "list.bullet")
}
Divider()
}
Button {
NSPasteboard.general.clearContents()
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
NSPasteboard.general.setString(urls, forType: .string)
} label: {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
}
}
}
private func tableHeader(trailingWidth: CGFloat) -> some View {
HStack(spacing: 0) {
ForEach(orderedVisibleColumns) { column in
ZStack(alignment: .trailing) {
Button {
if tableSettings.sortColumn == column {
tableSettings.sortDirection.toggle()
} else {
tableSettings.sortColumn = column
tableSettings.sortDirection = .ascending
}
} label: {
HStack(spacing: 4) {
Spacer(minLength: 0)
Text(column.rawValue)
.font(.caption.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.center)
.layoutPriority(1)
if tableSettings.sortColumn == column {
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
.font(.caption2)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(width: width(for: column), height: 34, alignment: .center)
.clipped()
}
.buttonStyle(.plain)
Rectangle()
.fill(.secondary.opacity(0.18))
.frame(width: 1, height: 20)
.frame(width: 8, height: 34)
.contentShape(Rectangle())
.onHover { isHovering in
if isHovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
resizeBaseWidths[column] = baseWidth
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
}
.onEnded { _ in
resizeBaseWidths[column] = nil
}
)
}
.frame(width: width(for: column), height: 34)
.clipped()
}
if trailingWidth > 0 {
Color.clear
.frame(width: trailingWidth, height: 34)
}
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
}
.background(.bar)
.contextMenu {
Section("Columns") {
ForEach(DownloadColumn.allCases) { column in
Toggle(column.rawValue, isOn: Binding(
get: { tableSettings.visibleColumns.contains(column) },
set: { isVisible in
if isVisible {
tableSettings.visibleColumns.insert(column)
} else if tableSettings.visibleColumns.count > 1 {
tableSettings.visibleColumns.remove(column)
}
}
))
}
}
Section("Sort By") {
ForEach(DownloadColumn.allCases) { column in
Button(column.rawValue) {
tableSettings.sortColumn = column
}
}
}
}
private func statusColor(for status: DownloadStatus) -> Color {
switch status {
case .queued: return .secondary
case .downloading: return .blue
case .paused: return .orange
case .completed: return .green
case .failed: return .red
case .canceled: return .gray
}
}
@ViewBuilder
private func rowContextMenu(for item: DownloadItem) -> some View {
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
private func progressBarCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.lineLimit(1)
.truncationMode(.tail)
} else {
GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4)
.fill(statusColor(for: item.status))
.frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading }) {
Button {
for target in targetItems where target.status == .downloading {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) {
Button {
for target in targetItems where target.status != .downloading && target.status != .queued {
controller.queue(target)
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = Set(targetItems.map(\.id))
} label: {
Label("Delete", systemImage: "trash")
.frame(height: 16)
}
}
private var orderedVisibleColumns: [DownloadColumn] {
DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
}
private var totalWidth: CGFloat {
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
}
private func width(for column: DownloadColumn) -> CGFloat {
max(70, tableSettings.columnWidths[column] ?? column.width)
}
private var sortedItems: [DownloadItem] {
items.sorted { lhs, rhs in
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
if result == .orderedSame {
return lhs.id.uuidString < rhs.id.uuidString
}
return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
}
}
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
switch column {
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
case .progress: return compare(lhs.progress, rhs.progress)
case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
case .dateAdded: return compare(lhs.createdAt, rhs.createdAt)
case .category: return compare(lhs.category.rawValue, rhs.category.rawValue)
case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount)
case .speed: return compare(lhs.speedText, rhs.speedText)
case .eta: return compare(lhs.etaText, rhs.etaText)
case .destination: return compare(lhs.destinationPath, rhs.destinationPath)
case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString)
case .message: return compare(lhs.message, rhs.message)
}
}
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
return .orderedSame
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
}
private func showInFinder(_ item: DownloadItem) {
@@ -452,161 +324,13 @@ struct DownloadTable: View {
}
return candidate
}
}
private struct DownloadRow: View {
let item: DownloadItem
let visibleColumns: [DownloadColumn]
let columnWidth: (DownloadColumn) -> CGFloat
let trailingWidth: CGFloat
var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(visibleColumns) { column in
cell(for: column)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(width: columnWidth(column), alignment: alignment(for: column))
.clipped()
}
if trailingWidth > 0 {
Color.clear
.frame(width: trailingWidth)
}
}
}
@ViewBuilder
private func cell(for column: DownloadColumn) -> some View {
switch column {
case .fileName:
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor)
.frame(width: 22)
VStack(alignment: .leading, spacing: 6) {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
case .size:
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
case .status, .progress:
progressBarCell()
case .lastTry:
Text(formatted(item.lastTryAt))
.lineLimit(1)
.truncationMode(.tail)
case .dateAdded:
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
case .category:
Text(item.category.rawValue)
.lineLimit(1)
.truncationMode(.tail)
case .connections:
Text("\(item.connectionsPerServer)")
.monospacedDigit()
.lineLimit(1)
case .liveConnections:
Text("\(item.connectionCount)")
.monospacedDigit()
.lineLimit(1)
case .speed:
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
case .eta:
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
case .destination:
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.tail)
case .url:
Text(item.url.absoluteString)
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.tail)
case .message:
Text(item.message)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
private func alignment(for column: DownloadColumn) -> Alignment {
column == .fileName ? .leading : .center
}
private var categoryColor: Color {
switch item.category {
case .musics: return .pink
case .movies: return .indigo
case .compressed: return .orange
case .pictures: return .teal
case .documents: return .blue
case .other: return .gray
}
}
private var statusColor: Color {
switch item.status {
case .queued: return .secondary
case .downloading: return .blue
case .paused: return .orange
case .completed: return .green
case .failed: return .red
case .canceled: return .gray
}
}
@ViewBuilder
private func progressBarCell() -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.lineLimit(1)
.truncationMode(.tail)
private func openFile(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.open(fileURL)
} else {
GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4)
.fill(statusColor)
.frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
}
}
.frame(height: 16)
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
+48 -5
View File
@@ -4,18 +4,31 @@ import SwiftUI
struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
// Server must be retained to keep listening
private let extensionServer: LocalExtensionServer?
init() {
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: DownloadController(settings: settings))
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
}
var body: some Scene {
WindowGroup {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
@@ -24,6 +37,8 @@ struct FirelinkApp: App {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
.windowResizability(.contentSize)
@@ -32,24 +47,52 @@ struct FirelinkApp: App {
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)
}
}
Settings {
SettingsView()
.environmentObject(settings)
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.isTemplate = true
return img
}() {
Image(nsImage: nsImage)
} else {
Image(systemName: "arrow.down.circle")
}
}
}
}
+246
View File
@@ -0,0 +1,246 @@
import Foundation
import Network
import AppKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let port = NWEndpoint.Port(rawValue: 6412)!
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 queue = DispatchQueue(label: "local.firelink.server")
init?(downloadController: DownloadController) {
self.downloadController = downloadController
let parameters = NWParameters.tcp
var createdListener: NWListener?
for portValue in 6412...6422 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
break
} catch {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection)
}
listener.stateUpdateHandler = { state in
print("LocalExtensionServer state: \(state)")
}
listener.start(queue: queue)
}
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(from: connection, accumulatedData: Data())
}
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else {
connection.cancel()
return
}
var requestData = accumulatedData
if let data {
requestData.append(data)
}
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
return
}
if let request = HTTPRequest(data: requestData) {
let status = self.processRequest(request)
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
return
}
if isComplete {
self.sendResponse(.badRequest, connection: connection, origin: nil)
return
}
self.receiveRequest(from: connection, accumulatedData: requestData)
}
}
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
var headers = [
"HTTP/1.1 \(status.rawValue) \(status.reason)",
"Content-Length: 0",
"Connection: close"
]
if let origin, isAllowedExtensionOrigin(origin) {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
guard let url = URL(string: origin),
let scheme = url.scheme?.lowercased() else {
return false
}
return scheme == "moz-extension" || scheme == "chrome-extension"
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
if request.method == "OPTIONS" {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
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?
}
do {
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
let validURLs = payload.urls
.prefix(Constants.maxURLCount)
.compactMap { rawURL -> String? in
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
Constants.allowedSchemes.contains(scheme) else {
return nil
}
return url.absoluteString
}
guard !validURLs.isEmpty else {
return .badRequest
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
}
return .ok
} catch {
print("Failed to parse local request JSON: \(error)")
return .badRequest
}
}
}
private struct HTTPRequest {
var method: String
var path: String
var headers: [String: String]
var body: Data
init?(data: Data) {
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
return nil
}
let headerData = data[..<headerRange.lowerBound]
guard let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else {
return nil
}
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
guard requestParts.count >= 2 else {
return nil
}
var parsedHeaders: [String: String] = [:]
for line in lines.dropFirst() {
guard let colonIndex = line.firstIndex(of: ":") else {
continue
}
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
parsedHeaders[name] = value
}
let bodyStart = headerRange.upperBound
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
guard expectedBodyLength >= 0,
data.count >= bodyStart + expectedBodyLength else {
return nil
}
method = String(requestParts[0]).uppercased()
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
headers = parsedHeaders
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
}
func header(named name: String) -> String? {
headers[name.lowercased()]
}
}
private enum HTTPStatus: Int {
case ok = 200
case noContent = 204
case badRequest = 400
case forbidden = 403
case notFound = 404
case methodNotAllowed = 405
case payloadTooLarge = 413
case unsupportedMediaType = 415
var reason: String {
switch self {
case .ok: "OK"
case .noContent: "No Content"
case .badRequest: "Bad Request"
case .forbidden: "Forbidden"
case .notFound: "Not Found"
case .methodNotAllowed: "Method Not Allowed"
case .payloadTooLarge: "Payload Too Large"
case .unsupportedMediaType: "Unsupported Media Type"
}
}
}
+170
View File
@@ -29,6 +29,21 @@ enum DownloadCategory: String, Codable, CaseIterable, Sendable {
}
}
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
@@ -39,6 +54,113 @@ struct DownloadCredentials: Codable, Equatable, Sendable {
}
}
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
case md5
case sha1 = "sha-1"
case sha256 = "sha-256"
case sha512 = "sha-512"
var id: String { rawValue }
var title: String {
switch self {
case .md5: "MD5"
case .sha1: "SHA-1"
case .sha256: "SHA-256"
case .sha512: "SHA-512"
}
}
}
struct DownloadChecksum: Codable, Equatable, Sendable {
var algorithm: ChecksumAlgorithm
var value: String
var normalized: DownloadChecksum {
DownloadChecksum(
algorithm: algorithm,
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
)
}
var isEmpty: Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
struct DownloadRequestHeader: Codable, Equatable, Sendable {
var name: String
var value: String
var normalized: DownloadRequestHeader {
DownloadRequestHeader(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
var isEmpty: Bool {
let clean = normalized
return clean.name.isEmpty && clean.value.isEmpty
}
var headerLine: String {
let clean = normalized
return "\(clean.name): \(clean.value)"
}
}
struct DownloadTransferOptions: Equatable, Sendable {
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader] = []
var cookieHeader: String?
var mirrorURLs: [URL] = []
}
enum DownloadTransferOptionParser {
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
headerLines(text).compactMap { line in
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
let header = DownloadRequestHeader(name: name, value: value).normalized
return header.isEmpty || header.name.isEmpty ? nil : header
}
}
static func invalidHeaderLines(_ text: String) -> [String] {
headerLines(text).filter { line in
guard let colonIndex = line.firstIndex(of: ":") else { return true }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty
}
}
static func cleanCookieHeader(_ text: String) -> String? {
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
if value.lowercased().hasPrefix("cookie:") {
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value.isEmpty ? nil : value
}
static func parseMirrorURLs(_ text: String) -> [URL] {
DownloadURLParser.parse(text)
}
static func invalidMirrorLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
}
private static func headerLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var url: URL
@@ -47,6 +169,11 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
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 = "-"
@@ -57,10 +184,53 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var message: String = ""
var createdAt = Date()
var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var queueID: UUID?
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 {
+231
View File
@@ -0,0 +1,231 @@
import AppKit
import Combine
import Foundation
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
case doNothing = "Do nothing"
case sleep = "Sleep"
case restart = "Restart"
case shutdown = "Shut down"
var id: String { rawValue }
}
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
var id: Int { rawValue }
var shortName: String {
switch self {
case .sunday: "S"
case .monday: "M"
case .tuesday: "T"
case .wednesday: "W"
case .thursday: "T"
case .friday: "F"
case .saturday: "S"
}
}
}
struct SchedulerSettings: Codable, Equatable {
var isEnabled: Bool = false
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = []
}
@MainActor
final class SchedulerController: ObservableObject {
@Published var settings: SchedulerSettings
@Published var isRunning: Bool = false
@Published var hasAutomationPermission: Bool = false
private let downloadController: DownloadController
private var cancellables = Set<AnyCancellable>()
private var timer: Timer?
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date?
init(downloadController: DownloadController) {
self.downloadController = downloadController
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
self.settings = stored
} else {
self.settings = SchedulerSettings()
}
checkAutomationPermission()
startTimer()
$settings
.dropFirst()
.sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
}
.store(in: &cancellables)
// Observe downloads to check if we should trigger post-action
downloadController.$downloads
.dropFirst()
.sink { [weak self] _ in
self?.checkIfRunningFinished()
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkSchedule()
}
}
}
private func checkSchedule() {
guard settings.isEnabled else { return }
let now = Date()
let calendar = Calendar.current
// Check if we already triggered in this exact minute
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
return
}
let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now)
let currentMinute = calendar.component(.minute, from: now)
let currentWeekday = calendar.component(.weekday, from: now)
if startHour == currentHour && startMinute == currentMinute {
let shouldRun: Bool
if settings.isEveryday {
shouldRun = true
} else {
let day = SchedulerDay(rawValue: currentWeekday)
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
}
if shouldRun {
lastTriggeredMinute = now
triggerQueues()
}
}
}
private func triggerQueues() {
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
guard !runnableQueueIDs.isEmpty else { return }
isRunning = true
for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID)
}
checkIfRunningFinished()
}
private func checkIfRunningFinished() {
guard isRunning else { return }
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
scriptCode = "tell application \"Finder\" to sleep"
case .restart:
scriptCode = "tell application \"Finder\" to restart"
case .shutdown:
scriptCode = "tell application \"Finder\" to shut down"
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to perform scheduler post action: \(error)")
}
}
}
func checkAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
hasAutomationPermission = (status == noErr)
}
func requestAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
if status != noErr {
triggerAutomationConsentPrompt()
}
checkAutomationPermission()
if !hasAutomationPermission {
openAutomationPermissionSettings()
}
}
func openAutomationPermissionSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
return
}
NSWorkspace.shared.open(url)
}
private func triggerAutomationConsentPrompt() {
let scriptCode = "tell application \"Finder\" to get name"
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to trigger Automation permission prompt: \(error)")
}
}
}
}
+214
View File
@@ -0,0 +1,214 @@
import SwiftUI
struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var startTime: Date = Date()
@State private var isEveryday: Bool = true
@State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
timeSelectionSection
queueSelectionSection
postActionSection
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
schedulerController.checkAutomationPermission()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
schedulerController.checkAutomationPermission()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Scheduler")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Settings") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Start Time")
.font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
Toggle("Everyday", isOn: $isEveryday)
if !isEveryday {
HStack(spacing: 12) {
ForEach(SchedulerDay.allCases) { day in
Toggle(day.shortName, isOn: Binding(
get: { selectedDays.contains(day) },
set: { isSelected in
if isSelected {
selectedDays.insert(day)
} else {
selectedDays.remove(day)
}
}
))
.toggleStyle(.button)
}
}
}
}
}
private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start")
.font(.headline)
if downloadController.queues.isEmpty {
Text("No queues available")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(downloadController.queues) { queue in
Toggle(queue.name, isOn: Binding(
get: { targetQueueIDs.contains(queue.id) },
set: { isSelected in
if isSelected {
targetQueueIDs.insert(queue.id)
} else {
targetQueueIDs.remove(queue.id)
}
}
))
}
}
}
}
}
private var postActionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("After Completion")
.font(.headline)
Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.pickerStyle(.radioGroup)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("System Permissions")
.font(.headline)
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
}
.buttonStyle(.borderedProminent)
}
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs
schedulerController.saveSettings()
}
}
+534 -21
View File
@@ -1,32 +1,410 @@
import AppKit
import SwiftUI
struct SettingsView: View {
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
Text(filter.rawValue)
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(Capsule())
}
}
.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()
}
}
}
private struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
private var version: String {
Aria2DownloadEngine.versionString() ?? "Unavailable"
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
TabView {
DownloadSettingsPane()
.tabItem {
Label("Downloads", systemImage: "arrow.down.circle")
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
LocationsSettingsPane()
.tabItem {
Label("Locations", systemImage: "folder")
}
SiteLoginsSettingsPane()
.tabItem {
Label("Site Logins", systemImage: "key.fill")
}
PowerSettingsPane()
.tabItem {
Label("Power", systemImage: "moon.zzz")
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
.padding(20)
.frame(width: 680, height: 470)
}
}
@@ -54,6 +432,7 @@ private struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
@@ -202,3 +581,137 @@ private struct PowerSettingsPane: View {
.formStyle(.grouped)
}
}
private struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
+198 -20
View File
@@ -1,11 +1,12 @@
import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case failed
case unfinished
case category(DownloadCategory)
var title: String {
@@ -14,52 +15,229 @@ enum DownloadSidebarFilter: Hashable {
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .failed: "Failed"
case .unfinished: "Unfinished"
case .category(let category): category.rawValue
}
}
}
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case scheduler
case speedLimiter
case settings
}
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: DownloadSidebarFilter
@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(DownloadSidebarFilter.all)
Label("Queue", systemImage: "list.bullet")
.badge(controller.queuedCount)
.tag(DownloadSidebarFilter.queued)
.tag(SidebarSelection.downloads(.all))
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(DownloadSidebarFilter.active)
.tag(SidebarSelection.downloads(.active))
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(DownloadSidebarFilter.completed)
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
.tag(DownloadSidebarFilter.failed)
.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
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(DownloadSidebarFilter.category(category))
folderRow(for: category)
}
}
Section("Engine") {
HStack {
Image(systemName: controller.hasAria2 ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(controller.hasAria2 ? .green : .orange)
Text(controller.hasAria2 ? "aria2c ready" : "aria2c missing")
Section("Queues") {
ForEach(controller.queues) { queue in
queueRow(for: queue)
}
Button {
let queue = controller.addQueue()
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
Label("Speed Limiter", systemImage: "speedometer")
.tag(SidebarSelection.speedLimiter)
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(selection == .settings ? Color.accentColor : Color.clear)
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.background(.regularMaterial)
}
.alert("Rename Queue", isPresented: Binding(
get: { queueBeingRenamed != nil },
set: { isPresented in
if !isPresented {
queueBeingRenamed = nil
}
}
)) {
TextField("Queue name", text: $queueName)
Button("Rename") {
if let queue = queueBeingRenamed {
controller.renameQueue(id: queue.id, name: queueName)
}
queueBeingRenamed = nil
}
Button("Cancel", role: .cancel) {
queueBeingRenamed = nil
}
}
.confirmationDialog(
"Delete Queue",
isPresented: Binding(
get: { queueBeingRemoved != nil },
set: { isPresented in
if !isPresented {
queueBeingRemoved = nil
}
}
),
presenting: queueBeingRemoved
) { queue in
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
}
queueBeingRemoved = nil
}
Button("Cancel", role: .cancel) {
queueBeingRemoved = nil
}
} message: { queue in
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
}
}
private func folderRow(for category: DownloadCategory) -> some View {
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
}
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
of: [.text],
delegate: QueueSidebarDropDelegate(
queueID: queue.id,
selection: $selection,
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
private struct QueueSidebarDropDelegate: DropDelegate {
let queueID: UUID
@Binding var selection: SidebarSelection
let controller: DownloadController
func performDrop(info: DropInfo) -> Bool {
guard let provider = info.itemProviders(for: [.text]).first else {
return false
}
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
Task { @MainActor in
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
selection = .queue(queueID)
}
}
return true
}
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
let text: String?
if let data = item as? Data {
text = String(data: data, encoding: .utf8)
} else {
text = item as? String
}
guard let text else { return nil }
return Set(text
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
.compactMap { UUID(uuidString: String($0)) })
}
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
limitSelectionSection
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Speed Limiter")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Limit") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit")
.font(.headline)
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:")
}
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
// Helpful presets
HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
}
.buttonStyle(.bordered)
.padding(.top, 8)
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Speed Limit Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
}
private func saveState() {
// Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed
lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
}
}
+173
View File
@@ -0,0 +1,173 @@
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
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
case system = "System Default"
case light = "Light"
case dark = "Dark"
case dracula = "Dracula"
case nord = "Nord"
var id: String { rawValue }
var theme: Theme {
switch self {
case .system, .dark:
return Theme.system
case .light:
return Theme.modernLight
case .dracula:
return Theme.dracula
case .nord:
return Theme.nord
}
}
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
}
}
}
struct Theme: Equatable, Sendable {
var background: Color?
var secondaryBackground: Color?
var text: Color?
var secondaryText: Color?
var accent: Color?
static let system = Theme(
background: nil,
secondaryBackground: nil,
text: nil,
secondaryText: nil,
accent: nil
)
static let modernLight = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
text: Color.primary,
secondaryText: Color.secondary,
accent: Color.accentColor
)
static let dracula = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
)
static let nord = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
)
}
struct AppThemeModifier: ViewModifier {
let theme: AppTheme
func body(content: Content) -> some View {
content
.tint(theme.theme.accent)
.onAppear {
NSApp.appearance = theme.appearance
}
.onChange(of: theme) { _, newTheme in
NSApp.appearance = newTheme.appearance
}
}
}
struct AppFontSizeModifier: ViewModifier {
let fontSize: AppFontSize
func body(content: Content) -> some View {
content
.font(fontSize.defaultFont)
.controlSize(fontSize.controlSize)
.dynamicTypeSize(fontSize.dynamicTypeSize)
}
}
struct ThemeBackgroundModifier: ViewModifier {
let color: Color?
func body(content: Content) -> some View {
if let color {
content
.scrollContentBackground(.hidden)
.background(color)
} else {
content
}
}
}
extension View {
func themeBackground(_ color: Color?) -> some View {
modifier(ThemeBackgroundModifier(color: color))
}
}
+41
View File
@@ -0,0 +1,41 @@
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")
}
}
}