Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f99bb791e | |||
| 0cbb982f5b | |||
| 0040c23050 | |||
| 062c4aec61 | |||
| 8e32762217 | |||
| fec18deb1a | |||
| bf0d1f6ce1 | |||
| d9c7da23e9 | |||
| 2f5e593d0b | |||
| fd349bfd3b | |||
| f35e981b0a | |||
| 2b520f3e97 | |||
| 7802b612a4 | |||
| 28bb90e8c2 | |||
| 86d70a9ca7 | |||
| b440b567f1 | |||
| 692a48b4a8 | |||
| a90d190df9 | |||
| b1b3f42ead | |||
| 91cd65e1d7 | |||
| e948590335 | |||
| 643f87d366 | |||
| 0f522fa794 | |||
| 604987a360 | |||
| e0a1db690f | |||
| 5dc8d66f3c | |||
| 0ddea52db8 | |||
| 93e6e11939 | |||
| 6750cce6aa | |||
| 1d22b16119 | |||
| deb106f7af | |||
| 1093d73b0e | |||
| 3d117fd6b0 | |||
| 21b30a9369 | |||
| c4f02ff78f | |||
| f6d34caaf1 | |||
| 7e4915f31d | |||
| ff03a4cda5 | |||
| 1f82d342e3 | |||
| c653f7e733 | |||
| 992d448e60 | |||
| 59d0c2dec1 | |||
| c4307fbaf1 | |||
| 0c5e031f49 | |||
| 8c2dee2c13 | |||
| 8f17fa1e47 | |||
| 343fe17111 | |||
| e920eb544d | |||
| 2dbb169c39 | |||
| 108ea1297c |
@@ -21,7 +21,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
@@ -40,6 +42,20 @@ 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
|
||||
@@ -54,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:
|
||||
@@ -62,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
|
||||
@@ -73,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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
@@ -5,6 +5,119 @@ 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.4] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added direct double-click access to Download Properties for unfinished downloads.
|
||||
- Added a `make verify` command for local build and Firefox extension manifest checks.
|
||||
|
||||
### Changes
|
||||
- Updated Firefox integration to probe the same local fallback ports used by the app.
|
||||
- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
|
||||
- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
|
||||
- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
|
||||
|
||||
### Fixes
|
||||
- Fixed browser handoff failures when the default local extension port is unavailable.
|
||||
- Fixed dropped `Referer` headers from browser extension requests.
|
||||
- Fixed scheduler configurations that could be enabled without any runnable queue target.
|
||||
- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
|
||||
- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
|
||||
|
||||
## [0.5.3] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
|
||||
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
|
||||
|
||||
### Changes
|
||||
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
|
||||
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
|
||||
|
||||
### Fixes
|
||||
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
|
||||
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
|
||||
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
|
||||
|
||||
## [0.5.2] - 2026-06-04
|
||||
|
||||
### Fixes
|
||||
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
|
||||
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
|
||||
|
||||
## [0.5.1] - 2026-06-04
|
||||
|
||||
### Changes
|
||||
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
|
||||
|
||||
## [0.5.0] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added a dedicated Speed Limiter UI to the main sidebar for instant global bandwidth throttling.
|
||||
- Integrated Settings directly into the main application window instead of a separate macOS scene, paving the way for future Windows/Linux cross-platform parity.
|
||||
|
||||
### Changes
|
||||
- Modernized Settings with a sleek horizontal tab bar layout.
|
||||
- Added persistent state retention across views (remembers the last visited settings tab and custom speed limits).
|
||||
- Compacted the README file to be more concise and user-friendly.
|
||||
|
||||
### Fixes
|
||||
- Fixed a critical memory crash (`EXC_BAD_ACCESS`) inside the Download Table caused by ephemeral string sorting during active downloads.
|
||||
- Fixed sidebar layout glitches to prevent text overlap during scroll.
|
||||
|
||||
## [0.4.3] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Refined About page UI and simplified the delete confirmation dialog.
|
||||
|
||||
### Fixes
|
||||
- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
|
||||
|
||||
## [0.4.2] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
- Added double-click to open completed files directly from the download table.
|
||||
- Added redownload functionality for completed or failed items.
|
||||
- Added 'Copy Address' context menu action.
|
||||
- Added a monochrome template tray icon loaded explicitly with precise dimensions.
|
||||
|
||||
### Changes
|
||||
- Improved context menu organization and conditionally displayed actions based on download status.
|
||||
|
||||
## [0.4.1] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
- Added app theming engine with Look and Feel settings.
|
||||
- Added Font Size, List Row Density, and Menu Bar Icon settings.
|
||||
- Added tray icon and context menu for main window and queues.
|
||||
- Added site logins integration directly into the Add Downloads window.
|
||||
|
||||
### Changes
|
||||
- Updated the paste hint to use a visual Command icon.
|
||||
|
||||
### Fixes
|
||||
- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
|
||||
- Fixed a bug with Light/System theme appearance.
|
||||
- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
|
||||
|
||||
## [0.4.0] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
|
||||
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
|
||||
- Prefer the bundled `aria2c` binary inside release builds.
|
||||
|
||||
### Fixes
|
||||
- Fixed queue-specific starts so one queue no longer starts unrelated queued downloads.
|
||||
- Fixed scheduler completion handling so empty queues do not trigger post-download system actions.
|
||||
- Fixed queue drag reordering when moving items downward.
|
||||
- Fixed scheduler Automation permission prompting.
|
||||
|
||||
### Features added
|
||||
- Added scheduler controls with explicit Automation permission UI.
|
||||
- Added global and per-download speed limits.
|
||||
- Added advanced transfer options for checksums, headers, cookies, and mirror URLs.
|
||||
|
||||
## [0.3.0] - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build app dmg run clean
|
||||
.PHONY: build app dmg run verify clean
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
@@ -12,6 +12,9 @@ dmg: app
|
||||
run:
|
||||
swift run Firelink
|
||||
|
||||
verify:
|
||||
Scripts/verify.sh
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf build dist
|
||||
|
||||
@@ -13,7 +13,10 @@ let package = Package(
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
path: "Sources/Firelink"
|
||||
path: "Sources/Firelink",
|
||||
resources: [
|
||||
.process("Assets.xcassets")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -15,66 +15,50 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ 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 an in-app Settings page with a built-in update checker.
|
||||
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
|
||||
- 🛡️ **Reliability & Recovery:** Built-in download recovery and retry handling for interrupted or unstable transfers.
|
||||
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
|
||||
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
|
||||
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
|
||||
|
||||
<div align="center">
|
||||
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
|
||||
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
|
||||
<br/>
|
||||
<sub>Main window, batch link intake, and Firefox integration setup</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>🔍 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`.
|
||||
- Automatic download recovery and retry handling for unstable connections.
|
||||
- **Advanced Queue Control:**
|
||||
- Drag-and-drop download reordering to manage priorities.
|
||||
- Comprehensive download table with resizable, custom columns (Name, Size, Status, ETA, Dates).
|
||||
- Individual download controls: pause, resume, stop, and queue actions.
|
||||
- **Smart Link Ingestion:**
|
||||
- Batch Add window for pasting multiple links at once.
|
||||
- Automated link parsing (whitespaces, newlines, standard separators).
|
||||
- Disk space availability checks and total size calculation prior to adding.
|
||||
- **File Organization:**
|
||||
- Automatic categorization of files (Music, Movies, Compressed, Documents, Images) into structured subfolders under `~/Downloads`.
|
||||
- Smart fallback to customizable download folders per category.
|
||||
- Automatic cleanup of unfinished `.aria2` metadata/cache files on removal.
|
||||
- **Security & System:**
|
||||
- Local macOS Keychain integration for site logins.
|
||||
- Prevents system sleep while active transfers are in progress, preserving display sleep.
|
||||
- Built-in update checker inside the Settings About pane.
|
||||
- Release `.app` bundle build scripts ready for distribution.
|
||||
|
||||
<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.
|
||||
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
|
||||
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
||||
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
|
||||
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
||||
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
|
||||
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requirements & Setup
|
||||
## 🛠️ Quick Start
|
||||
|
||||
### 1. Requirements
|
||||
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon).
|
||||
- **Engine:** `aria2c` is fully packaged and bundled internally for a true Zero-Config experience. No external installations are required.
|
||||
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
|
||||
|
||||
*For Developers:* If you are building the project from source, you must have the **Swift 6.0 toolchain** (Xcode 15+) installed.
|
||||
|
||||
### 2. Build & Run
|
||||
Run the application directly via the terminal:
|
||||
Run the application directly:
|
||||
```bash
|
||||
swift run Firelink
|
||||
```
|
||||
@@ -84,29 +68,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 (Firefox) at:
|
||||
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
|
||||
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
|
||||
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
|
||||
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
|
||||
- [ ] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
|
||||
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
|
||||
- [ ] **Distribution:** Notarized `.app` releases and Homebrew formulae.
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 552 KiB |
@@ -19,6 +19,16 @@ 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
|
||||
@@ -65,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"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
swift build
|
||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||
@@ -12,23 +12,38 @@ struct AddDownloadsView: View {
|
||||
@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
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
@@ -37,6 +52,7 @@ struct AddDownloadsView: View {
|
||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||
controller.pendingAddQueueID = nil
|
||||
if let text = controller.pendingPasteboardText {
|
||||
applyPendingReferer()
|
||||
linkText = text
|
||||
controller.pendingPasteboardText = nil
|
||||
}
|
||||
@@ -49,14 +65,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")
|
||||
@@ -74,19 +90,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 {
|
||||
@@ -98,40 +114,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
|
||||
@@ -141,10 +194,6 @@ struct AddDownloadsView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.fileName)
|
||||
.lineLimit(1)
|
||||
Text(item.url.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +202,7 @@ struct AddDownloadsView: View {
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(95)
|
||||
.width(86)
|
||||
|
||||
TableColumn("Save To") { item in
|
||||
Text(destinationDirectory(for: item).path)
|
||||
@@ -165,9 +214,9 @@ struct AddDownloadsView: View {
|
||||
TableColumn("Status") { item in
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
.width(130)
|
||||
.width(110)
|
||||
}
|
||||
.frame(minHeight: 230)
|
||||
.frame(minHeight: 135)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,27 +226,96 @@ struct AddDownloadsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if metadataTask != nil {
|
||||
Button {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button {
|
||||
addDownloads(start: false)
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
.disabled(pendingDownloads.isEmpty)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button {
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
}
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button("Start Downloads") {
|
||||
addDownloads(start: true)
|
||||
} label: {
|
||||
Label("Start Downloads", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(pendingDownloads.isEmpty)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 130)
|
||||
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(14)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var requiredSpaceText: String {
|
||||
@@ -220,6 +338,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."
|
||||
}
|
||||
@@ -227,6 +349,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 {
|
||||
@@ -238,6 +389,24 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingReferer() {
|
||||
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!referer.isEmpty,
|
||||
URL(string: referer) != nil else {
|
||||
controller.pendingReferer = nil
|
||||
return
|
||||
}
|
||||
|
||||
let refererHeader = "Referer: \(referer)"
|
||||
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
|
||||
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
|
||||
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? refererHeader
|
||||
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
}
|
||||
|
||||
private func refreshMetadata(for text: String) {
|
||||
let urls = DownloadURLParser.parse(text)
|
||||
metadataTask?.cancel()
|
||||
@@ -254,11 +423,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 {
|
||||
@@ -268,6 +444,9 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,12 +468,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,
|
||||
queueID: targetQueueID
|
||||
queueID: targetQueueID,
|
||||
credentials: explicitCredentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
@@ -363,22 +562,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))
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ struct DownloadProxyConfiguration: Equatable, Sendable {
|
||||
|
||||
@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)
|
||||
@@ -85,6 +97,17 @@ 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() }
|
||||
}
|
||||
@@ -112,21 +135,30 @@ 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 = []
|
||||
@@ -205,16 +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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +285,7 @@ final class AppSettings: ObservableObject {
|
||||
return absolute.contains(normalizedPattern)
|
||||
}
|
||||
|
||||
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
|
||||
return host == normalizedPattern
|
||||
}
|
||||
|
||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||
@@ -263,8 +307,12 @@ 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]
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
let rpcSecret: String
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> Int {
|
||||
var port: UInt16 = 6800
|
||||
let parameters = NWParameters.tcp
|
||||
for p in 6800...6900 {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
|
||||
if let listener = try? NWListener(using: parameters) {
|
||||
listener.cancel()
|
||||
port = UInt16(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
return Int(port)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case executableNotFound
|
||||
case launchFailed(String)
|
||||
@@ -31,6 +48,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",
|
||||
@@ -49,43 +71,47 @@ 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? {
|
||||
static func versionString() async -> 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()
|
||||
return await Task.detached {
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = nil
|
||||
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else { return nil }
|
||||
do {
|
||||
try process.run()
|
||||
// Close the write file handle in the parent process immediately
|
||||
// This guarantees readToEnd() won't hang waiting for the parent itself
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let data = try? outputPipe.fileHandleForReading.readToEnd()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0, let data = data else { return nil }
|
||||
|
||||
let 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
|
||||
}
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func start(
|
||||
item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||
) throws -> Handle {
|
||||
@@ -98,9 +124,18 @@ final class Aria2DownloadEngine {
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration)
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
@@ -160,14 +195,44 @@ final class Aria2DownloadEngine {
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier) {
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] {
|
||||
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
||||
|
||||
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.changeGlobalOption",
|
||||
"id": UUID().uuidString,
|
||||
"params": [
|
||||
"token:\(handle.rpcSecret)",
|
||||
["max-download-limit": limitValue]
|
||||
]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
request.timeoutInterval = 3
|
||||
|
||||
_ = try? await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
rpcPort: Int,
|
||||
rpcSecret: String
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
@@ -181,9 +246,18 @@ final class Aria2DownloadEngine {
|
||||
"--retry-wait=5",
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--input-file=-"
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-",
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-port=\(rpcPort)",
|
||||
"--rpc-secret=\(rpcSecret)",
|
||||
"--rpc-listen-all=false"
|
||||
]
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||
return arguments
|
||||
}
|
||||
@@ -281,14 +355,29 @@ final class Aria2DownloadEngine {
|
||||
|
||||
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" {
|
||||
@@ -313,6 +402,11 @@ final class Aria2DownloadEngine {
|
||||
final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = Data()
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxBytes: Int = 512 * 1024) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
lock.withLock { storage }
|
||||
@@ -321,6 +415,9 @@ final class LockedDataBuffer: @unchecked Sendable {
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "MenuBarIconTemplate.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChunkMapView: View {
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var bitfield: String = ""
|
||||
@State private var numPieces: Int = 0
|
||||
@State private var pollTask: Task<Void, Never>?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if numPieces > 0 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
|
||||
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await fetchStatus(port: port, secret: secret)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStatus(port: Int, secret: String) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.tellActive",
|
||||
"id": "1",
|
||||
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
request.httpBody = data
|
||||
|
||||
do {
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
let active = result.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedBitfield = active["bitfield"] as? String ?? ""
|
||||
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
|
||||
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
|
||||
|
||||
await MainActor.run {
|
||||
self.bitfield = fetchedBitfield
|
||||
self.numPieces = fetchedNumPieces
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
let boxSize: CGFloat = 10
|
||||
let spacing: CGFloat = 2
|
||||
let cornerSize = CGSize(width: 2, height: 2)
|
||||
let width = size.width
|
||||
|
||||
let x: CGFloat = 0
|
||||
let y: CGFloat = 0
|
||||
|
||||
let completedPath = Path { p in
|
||||
var cx = x
|
||||
var cy = y
|
||||
for piece in itemPieces {
|
||||
if piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPath = Path { p in
|
||||
var cx: CGFloat = 0
|
||||
var cy: CGFloat = 0
|
||||
for piece in itemPieces {
|
||||
if !piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
|
||||
context.fill(completedPath, with: .color(Color.accentColor))
|
||||
}
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var selection: Set<DownloadItem.ID> = []
|
||||
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
|
||||
@@ -12,8 +13,39 @@ struct ContentView: View {
|
||||
NavigationSplitView {
|
||||
SidebarView(selection: $sidebarSelection)
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
|
||||
.themeBackground(settings.appTheme.theme.secondaryBackground)
|
||||
} detail: {
|
||||
detailView
|
||||
.themeBackground(settings.appTheme.theme.background)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
if provider.canLoadObject(ofClass: URL.self) {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url = url {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: String.self) {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +56,12 @@ struct ContentView: View {
|
||||
downloadsView(filter: filter)
|
||||
case .queue(let queueID):
|
||||
queueView(queueID: queueID)
|
||||
case .scheduler:
|
||||
SchedulerView()
|
||||
case .speedLimiter:
|
||||
SpeedLimiterView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
SettingsPaneContainer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,42 +104,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} label: {
|
||||
Label("Start Queue", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup {
|
||||
if !selectedItems.isEmpty {
|
||||
if selectedItems.contains(where: { $0.status == .downloading }) {
|
||||
Button {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!canStop)
|
||||
|
||||
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
|
||||
controller.resume(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(!canStart)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -157,6 +185,7 @@ struct ContentView: View {
|
||||
private func handlePaste(queueID: UUID?) {
|
||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -165,6 +194,22 @@ struct ContentView: View {
|
||||
selection = Set(items.map { $0.id })
|
||||
}
|
||||
|
||||
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
|
||||
}
|
||||
|
||||
return controller.activeCount > 0
|
||||
}
|
||||
|
||||
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.queueItems(for: queueID).contains { $0.status == .queued }
|
||||
}
|
||||
|
||||
return controller.queuedCount > 0
|
||||
}
|
||||
|
||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||
switch filter {
|
||||
case .all:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class DownloadController: ObservableObject {
|
||||
@@ -8,6 +9,7 @@ final class DownloadController: ObservableObject {
|
||||
@Published var queues: [DownloadQueue] = [.main]
|
||||
@Published var engineMessage = ""
|
||||
@Published var pendingPasteboardText: String?
|
||||
@Published var pendingReferer: String?
|
||||
var pendingAddQueueID: UUID?
|
||||
|
||||
private let settings: AppSettings
|
||||
@@ -15,6 +17,7 @@ final class DownloadController: ObservableObject {
|
||||
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
|
||||
@@ -22,6 +25,7 @@ final class DownloadController: ObservableObject {
|
||||
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
|
||||
@@ -35,6 +39,25 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$globalSpeedLimitKiBPerSecond
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$maxConcurrentDownloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
self?.pumpQueue()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
$downloads
|
||||
.dropFirst()
|
||||
@@ -54,6 +77,7 @@ final class DownloadController: ObservableObject {
|
||||
Task { @MainActor in
|
||||
self.engineMessage = "Recovered downloads from the previous session."
|
||||
self.restrictQueueToAutoResume = true
|
||||
self.queuePumpScope = .all
|
||||
self.pumpQueue()
|
||||
}
|
||||
}
|
||||
@@ -103,6 +127,10 @@ final class DownloadController: ObservableObject {
|
||||
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()
|
||||
@@ -113,19 +141,29 @@ final class DownloadController: ObservableObject {
|
||||
connectionsPerServer: Int,
|
||||
overrideDirectory: URL?,
|
||||
startImmediately: Bool,
|
||||
queueID: UUID = DownloadQueue.mainQueueID
|
||||
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(
|
||||
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
|
||||
return DownloadItem(
|
||||
url: pending.url,
|
||||
fileName: pending.fileName,
|
||||
category: pending.category,
|
||||
fileName: fileName,
|
||||
category: FileClassifier.category(forFileName: fileName),
|
||||
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",
|
||||
@@ -133,6 +171,12 @@ final class DownloadController: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -145,6 +189,20 @@ final class DownloadController: ObservableObject {
|
||||
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()
|
||||
}
|
||||
@@ -163,6 +221,31 @@ final class DownloadController: ObservableObject {
|
||||
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()
|
||||
}
|
||||
|
||||
func queue(_ item: DownloadItem) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
@@ -213,6 +296,25 @@ final class DownloadController: ObservableObject {
|
||||
$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()
|
||||
@@ -247,6 +349,7 @@ 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()
|
||||
@@ -322,7 +425,8 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
let item = downloads.remove(at: source)
|
||||
downloads.insert(item, at: target)
|
||||
let insertionIndex = source < target ? target - 1 : target
|
||||
downloads.insert(item, at: insertionIndex)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
@@ -332,9 +436,13 @@ final class DownloadController: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
|
||||
while activeCount < settings.maxConcurrentDownloads,
|
||||
let next = downloads.first(where: { item in
|
||||
item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true)
|
||||
item.status == .queued &&
|
||||
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
|
||||
isAllowedToStart(item)
|
||||
}) {
|
||||
start(next)
|
||||
}
|
||||
@@ -344,6 +452,8 @@ final class DownloadController: ObservableObject {
|
||||
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
|
||||
restrictQueueToAutoResume = false
|
||||
}
|
||||
|
||||
pruneActiveQueueScopes()
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
@@ -361,6 +471,7 @@ final class DownloadController: ObservableObject {
|
||||
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) {
|
||||
@@ -390,6 +501,7 @@ final class DownloadController: ObservableObject {
|
||||
$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 {
|
||||
@@ -405,6 +517,8 @@ final class DownloadController: ObservableObject {
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
}
|
||||
saveDownloads()
|
||||
@@ -427,25 +541,84 @@ final class DownloadController: ObservableObject {
|
||||
fileName: String,
|
||||
destinationDirectory: URL,
|
||||
connectionsPerServer: Int,
|
||||
credentials: DownloadCredentials?
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
) {
|
||||
update(id) {
|
||||
$0.url = url
|
||||
$0.fileName = fileName
|
||||
$0.category = FileClassifier.category(forFileName: fileName)
|
||||
$0.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
$0.category = FileClassifier.category(forFileName: $0.fileName)
|
||||
$0.destinationDirectory = destinationDirectory
|
||||
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
||||
$0.credentials = credentials
|
||||
$0.checksum = transferOptions.checksum
|
||||
$0.requestHeaders = transferOptions.requestHeaders
|
||||
$0.cookieHeader = transferOptions.cookieHeader
|
||||
$0.mirrorURLs = transferOptions.mirrorURLs
|
||||
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
$0.message = "Properties updated"
|
||||
}
|
||||
if let password = credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: id)
|
||||
} else if credentials == nil {
|
||||
KeychainCredentialStore.deletePassword(for: id)
|
||||
}
|
||||
applySpeedLimitToActiveDownload(id: id)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
||||
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 applySpeedLimitsToActiveDownloads() {
|
||||
for item in downloads where item.status == .downloading {
|
||||
applySpeedLimitToActiveDownload(id: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func applySpeedLimitToActiveDownload(id: UUID) {
|
||||
guard let handle = activeHandles[id],
|
||||
let item = downloads.first(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
|
||||
Task {
|
||||
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
||||
let normalizedID = queueID.map(normalizedQueueID)
|
||||
for index in downloads.indices where downloads[index].status == .queued &&
|
||||
(normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) {
|
||||
downloads[index].autoResumeOnLaunch = true
|
||||
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()
|
||||
}
|
||||
@@ -464,6 +637,9 @@ final class DownloadController: ObservableObject {
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
saveDownloads()
|
||||
if let item = downloads.first(where: { $0.id == itemID }) {
|
||||
showNotification(title: "Download Failed", body: item.fileName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -492,6 +668,65 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return false
|
||||
case .all:
|
||||
return true
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
if itemIDs.contains(item.id) {
|
||||
return true
|
||||
}
|
||||
guard let queueID = validQueueID(item.queueID) else { return false }
|
||||
return queueIDs.contains(queueID)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneActiveQueueScopes() {
|
||||
switch queuePumpScope {
|
||||
case .idle:
|
||||
return
|
||||
case .all:
|
||||
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
|
||||
queuePumpScope = .idle
|
||||
}
|
||||
case .scoped(let queueIDs, let itemIDs):
|
||||
let activeQueueIDs = queueIDs.filter { queueID in
|
||||
downloads.contains { item in
|
||||
validQueueID(item.queueID) == queueID &&
|
||||
(item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
let activeItemIDs = itemIDs.filter { itemID in
|
||||
downloads.contains { item in
|
||||
item.id == itemID && (item.status == .queued || item.status == .downloading)
|
||||
}
|
||||
}
|
||||
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
|
||||
? .idle
|
||||
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueuePumpScope {
|
||||
case idle
|
||||
case all
|
||||
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
|
||||
|
||||
func includingItem(_ itemID: UUID) -> QueuePumpScope {
|
||||
switch self {
|
||||
case .idle:
|
||||
return .scoped(queueIDs: [], itemIDs: [itemID])
|
||||
case .all:
|
||||
return .all
|
||||
case .scoped(let queueIDs, var itemIDs):
|
||||
itemIDs.insert(itemID)
|
||||
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSleepActivity() {
|
||||
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
|
||||
|
||||
@@ -528,14 +763,23 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
private func saveDownloads() {
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queues, downloads: downloads)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,13 +801,24 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
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 = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
@@ -579,7 +834,7 @@ final class DownloadController: ObservableObject {
|
||||
return adjusted
|
||||
}
|
||||
|
||||
if shouldResumeRecoveredDownloads {
|
||||
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
|
||||
saveDownloads()
|
||||
}
|
||||
return shouldResumeRecoveredDownloads
|
||||
@@ -612,6 +867,18 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadURLParser {
|
||||
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
|
||||
static func parse(_ text: String) -> [URL] {
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
||||
|
||||
@@ -30,7 +31,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 +56,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)
|
||||
@@ -61,8 +72,8 @@ enum DownloadMetadataFetcher {
|
||||
|
||||
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
||||
let fileName = fileName(fromContentDisposition: disposition) {
|
||||
pending.fileName = fileName
|
||||
pending.category = FileClassifier.category(forFileName: fileName)
|
||||
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
||||
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,16 @@ 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 = ""
|
||||
@State private var showsAdvancedTransfer = false
|
||||
|
||||
init(item: DownloadItem) {
|
||||
self.item = item
|
||||
@@ -45,6 +54,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 +65,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 {
|
||||
@@ -61,11 +84,11 @@ struct DownloadPropertiesView: View {
|
||||
Form {
|
||||
Section("Download") {
|
||||
TextField("URL", text: $urlText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
TextField("File name", text: $fileName)
|
||||
HStack {
|
||||
TextField("Save location", text: $destinationPath)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
@@ -73,6 +96,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,10 +125,46 @@ struct DownloadPropertiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced Transfer", isExpanded: $showsAdvancedTransfer) {
|
||||
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(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
|
||||
TextField("Cookies", text: $cookieText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Progress") {
|
||||
ProgressView(value: item.progress)
|
||||
InfoGrid(item: item)
|
||||
}
|
||||
|
||||
if item.status == .downloading && item.rpcPort != nil {
|
||||
Section("Chunk Map") {
|
||||
ChunkMapView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
@@ -120,7 +188,7 @@ struct DownloadPropertiesView: View {
|
||||
.padding(14)
|
||||
.background(.bar)
|
||||
}
|
||||
.frame(width: 620, height: 560)
|
||||
.frame(width: 720, height: 720)
|
||||
}
|
||||
|
||||
private var matchingLoginText: String {
|
||||
@@ -153,7 +221,7 @@ struct DownloadPropertiesView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
|
||||
guard !cleanFileName.isEmpty else {
|
||||
errorMessage = "File name cannot be empty."
|
||||
return
|
||||
@@ -175,16 +243,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 +296,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)
|
||||
|
||||
@@ -2,104 +2,6 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
||||
case priority = "#"
|
||||
case fileName = "File name"
|
||||
case size = "Size"
|
||||
case progress = "Progress"
|
||||
case status = "Status"
|
||||
case lastTry = "Last try date"
|
||||
case dateAdded = "Date added"
|
||||
case category = "Category"
|
||||
case connections = "Connections"
|
||||
case liveConnections = "Live conn."
|
||||
case speed = "Speed"
|
||||
case eta = "ETA"
|
||||
case destination = "Save location"
|
||||
case url = "URL"
|
||||
case message = "Message"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var width: CGFloat {
|
||||
switch self {
|
||||
case .priority: return 58
|
||||
case .fileName: return 340
|
||||
case .size: return 100
|
||||
case .status: return 105
|
||||
case .progress: return 115
|
||||
case .lastTry, .dateAdded: return 155
|
||||
case .category: return 105
|
||||
case .connections, .liveConnections: return 95
|
||||
case .speed, .eta: return 90
|
||||
case .destination: return 240
|
||||
case .url: return 280
|
||||
case .message: return 220
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SortDirection: String, Codable {
|
||||
case ascending
|
||||
case descending
|
||||
|
||||
mutating func toggle() {
|
||||
self = self == .ascending ? .descending : .ascending
|
||||
}
|
||||
}
|
||||
|
||||
final class TableSettings: ObservableObject {
|
||||
@Published var visibleColumns: Set<DownloadColumn> {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var columnWidths: [DownloadColumn: CGFloat] {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortColumn: DownloadColumn {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortDirection: SortDirection {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.TableSettings.v1"
|
||||
|
||||
init() {
|
||||
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
|
||||
}
|
||||
|
||||
struct DownloadTable: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@@ -109,11 +11,12 @@ struct DownloadTable: View {
|
||||
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?
|
||||
@State private var draggedItemID: DownloadItem.ID?
|
||||
|
||||
var sortedItems: [DownloadItem] {
|
||||
items.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -131,37 +34,87 @@ 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)
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
TableColumn("File Name", value: \.fileName) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor(for: item.category))
|
||||
.frame(width: 22)
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.defaultScrollAnchor(.topLeading)
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
.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
|
||||
doubleClickableCell(for: item) {
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.status.rawValue)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 105)
|
||||
|
||||
TableColumn("Speed", value: \.speedText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.speedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.etaText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.etaText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
@@ -183,7 +136,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)
|
||||
@@ -203,301 +156,176 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
|
||||
DownloadRow(
|
||||
item: item,
|
||||
priorityNumber: priorityNumber(for: item),
|
||||
visibleColumns: orderedVisibleColumns,
|
||||
columnWidth: { width(for: $0) },
|
||||
trailingWidth: trailingWidth
|
||||
)
|
||||
.id(item.id)
|
||||
.frame(width: tableWidth, alignment: .leading)
|
||||
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let index = sortedItems.firstIndex(where: { $0.id == item.id })
|
||||
|
||||
if NSEvent.modifierFlags.contains(.command) {
|
||||
if selection.contains(item.id) {
|
||||
selection.remove(item.id)
|
||||
} else {
|
||||
selection.insert(item.id)
|
||||
}
|
||||
lastSelectedIndex = index
|
||||
} else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index {
|
||||
let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex)
|
||||
let rangeIds = range.map { sortedItems[$0].id }
|
||||
selection.formUnion(rangeIds)
|
||||
} else {
|
||||
selection = [item.id]
|
||||
lastSelectedIndex = index
|
||||
private func doubleClickableCell<Content: View>(
|
||||
for item: DownloadItem,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
content()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
performPrimaryAction(for: item)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
rowContextMenu(for: item)
|
||||
}
|
||||
.onDrag {
|
||||
draggedItemID = item.id
|
||||
return NSItemProvider(object: dragPayload(for: item) as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [.text],
|
||||
delegate: QueueDropDelegate(
|
||||
item: item,
|
||||
queueID: queueID,
|
||||
draggedItemID: $draggedItemID,
|
||||
controller: controller
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tableHeader(trailingWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(orderedVisibleColumns) { column in
|
||||
ZStack(alignment: .trailing) {
|
||||
headerContent(for: column)
|
||||
|
||||
Rectangle()
|
||||
.fill(.secondary.opacity(0.18))
|
||||
.frame(width: 1, height: 20)
|
||||
.frame(width: 8, height: 34)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { isHovering in
|
||||
if isHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
|
||||
resizeBaseWidths[column] = baseWidth
|
||||
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
|
||||
}
|
||||
.onEnded { _ in
|
||||
resizeBaseWidths[column] = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: width(for: column), height: 34)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth, height: 34)
|
||||
}
|
||||
}
|
||||
.background(.bar)
|
||||
.contextMenu {
|
||||
Section("Columns") {
|
||||
ForEach(availableColumns) { column in
|
||||
Toggle(column.rawValue, isOn: Binding(
|
||||
get: { tableSettings.visibleColumns.contains(column) },
|
||||
set: { isVisible in
|
||||
if isVisible {
|
||||
tableSettings.visibleColumns.insert(column)
|
||||
} else if tableSettings.visibleColumns.count > 1 {
|
||||
tableSettings.visibleColumns.remove(column)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
if queueID == nil {
|
||||
Section("Sort By") {
|
||||
ForEach(availableColumns) { column in
|
||||
Button(column.rawValue) {
|
||||
tableSettings.sortColumn = column
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for item: DownloadItem) -> some View {
|
||||
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
if !targetItems.isEmpty {
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = Set(targetItems.map(\.id))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
private func categoryColor(for category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedVisibleColumns: [DownloadColumn] {
|
||||
var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
|
||||
if queueID != nil, !columns.contains(.priority) {
|
||||
columns.insert(.priority, at: 0)
|
||||
} else if queueID == nil {
|
||||
columns.removeAll { $0 == .priority }
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
private var availableColumns: [DownloadColumn] {
|
||||
DownloadColumn.allCases.filter { $0 != .priority }
|
||||
}
|
||||
|
||||
private var totalWidth: CGFloat {
|
||||
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
|
||||
}
|
||||
|
||||
private func width(for column: DownloadColumn) -> CGFloat {
|
||||
max(70, tableSettings.columnWidths[column] ?? column.width)
|
||||
}
|
||||
|
||||
private var sortedItems: [DownloadItem] {
|
||||
if queueID != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.sorted { lhs, rhs in
|
||||
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
|
||||
if result == .orderedSame {
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
}
|
||||
return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
|
||||
switch column {
|
||||
case .priority:
|
||||
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
|
||||
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
|
||||
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
|
||||
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
|
||||
case .progress: return compare(lhs.progress, rhs.progress)
|
||||
case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
|
||||
case .dateAdded: return compare(lhs.createdAt, rhs.createdAt)
|
||||
case .category: return compare(lhs.category.rawValue, rhs.category.rawValue)
|
||||
case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
|
||||
case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount)
|
||||
case .speed: return compare(lhs.speedText, rhs.speedText)
|
||||
case .eta: return compare(lhs.etaText, rhs.etaText)
|
||||
case .destination: return compare(lhs.destinationPath, rhs.destinationPath)
|
||||
case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString)
|
||||
case .message: return compare(lhs.message, rhs.message)
|
||||
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 headerContent(for column: DownloadColumn) -> some View {
|
||||
let content = HStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
Text(column.rawValue)
|
||||
.font(.caption.weight(.semibold))
|
||||
private func progressBarCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.center)
|
||||
.layoutPriority(1)
|
||||
if queueID == nil && tableSettings.sortColumn == column {
|
||||
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(width: width(for: column), height: 34, alignment: .center)
|
||||
.clipped()
|
||||
|
||||
if queueID == nil {
|
||||
Button {
|
||||
if tableSettings.sortColumn == column {
|
||||
tableSettings.sortDirection.toggle()
|
||||
} else {
|
||||
tableSettings.sortColumn = column
|
||||
tableSettings.sortDirection = .ascending
|
||||
}
|
||||
} label: {
|
||||
content
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
content
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor(for: item.status))
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityNumber(for item: DownloadItem) -> Int? {
|
||||
guard queueID != nil,
|
||||
let index = items.firstIndex(where: { $0.id == item.id }) else {
|
||||
return nil
|
||||
}
|
||||
return index + 1
|
||||
}
|
||||
|
||||
private func dragPayload(for item: DownloadItem) -> String {
|
||||
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
|
||||
return draggedIDs
|
||||
.map(\.uuidString)
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
|
||||
if lhs < rhs { return .orderedAscending }
|
||||
if lhs > rhs { return .orderedDescending }
|
||||
return .orderedSame
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private func showInFinder(_ item: DownloadItem) {
|
||||
@@ -520,189 +348,13 @@ struct DownloadTable: View {
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadRow: View {
|
||||
let item: DownloadItem
|
||||
let priorityNumber: Int?
|
||||
let visibleColumns: [DownloadColumn]
|
||||
let columnWidth: (DownloadColumn) -> CGFloat
|
||||
let trailingWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
ForEach(visibleColumns) { column in
|
||||
cell(for: column)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: columnWidth(column), alignment: alignment(for: column))
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(for column: DownloadColumn) -> some View {
|
||||
switch column {
|
||||
case .priority:
|
||||
Text(priorityNumber.map(String.init) ?? "-")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
case .fileName:
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueDropDelegate: DropDelegate {
|
||||
let item: DownloadItem
|
||||
let queueID: UUID?
|
||||
@Binding var draggedItemID: DownloadItem.ID?
|
||||
let controller: DownloadController
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard let queueID,
|
||||
let draggedItemID,
|
||||
draggedItemID != item.id else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.moveDownload(draggedItemID, before: item.id, in: queueID)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
draggedItemID = nil
|
||||
return queueID != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +57,30 @@ enum FileClassifier {
|
||||
static func fileName(from url: URL) -> String {
|
||||
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
||||
if !pathName.isEmpty, pathName != "/" {
|
||||
return pathName
|
||||
return sanitizedFileName(pathName)
|
||||
}
|
||||
|
||||
let host = url.host(percentEncoded: false) ?? "download"
|
||||
return "\(host)-download"
|
||||
return sanitizedFileName("\(host)-download")
|
||||
}
|
||||
|
||||
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
|
||||
let separators = CharacterSet(charactersIn: "/\\")
|
||||
let invalidCharacters = separators
|
||||
.union(.newlines)
|
||||
.union(.controlCharacters)
|
||||
|
||||
let components = rawValue
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.filter { !$0.isEmpty }
|
||||
let clean = components.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !clean.isEmpty, clean != ".", clean != ".." else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return String(clean.prefix(255))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,36 @@ 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))
|
||||
.onOpenURL { url in
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
@@ -24,6 +42,8 @@ struct FirelinkApp: App {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
@@ -32,19 +52,67 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 21, height: 21)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
Image(nsImage: nsImage)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarIconURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeIcon = projectRoot
|
||||
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
static let extensionRequestToken = "firelink-extension-v1"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
var selectedPort: UInt16?
|
||||
for portValue in Constants.portRange {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||
do {
|
||||
createdListener = try NWListener(using: parameters)
|
||||
selectedPort = UInt16(portValue)
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let createdListener else {
|
||||
print("Failed to create listener on ports 6412-6422")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.listener = createdListener
|
||||
self.port = selectedPort ?? 6412
|
||||
}
|
||||
|
||||
func start() {
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
listener.stateUpdateHandler = { state in
|
||||
print("LocalExtensionServer state: \(state)")
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receiveRequest(from: connection, accumulatedData: Data())
|
||||
}
|
||||
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
var requestData = accumulatedData
|
||||
if let data {
|
||||
requestData.append(data)
|
||||
}
|
||||
|
||||
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
|
||||
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let request = HTTPRequest(data: requestData) {
|
||||
let status = self.processRequest(request)
|
||||
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
self.sendResponse(.badRequest, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
|
||||
var headers = [
|
||||
"HTTP/1.1 \(status.rawValue) \(status.reason)",
|
||||
"Content-Length: 0",
|
||||
"Connection: close"
|
||||
]
|
||||
|
||||
if let origin, isAllowedExtensionOrigin(origin) {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
|
||||
guard let url = URL(string: origin),
|
||||
let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == "moz-extension" || scheme == "chrome-extension"
|
||||
}
|
||||
|
||||
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.method == "OPTIONS" {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
return .unsupportedMediaType
|
||||
}
|
||||
|
||||
struct Payload: Decodable {
|
||||
let urls: [String]
|
||||
let referer: String?
|
||||
}
|
||||
|
||||
do {
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
|
||||
let validURLs = payload.urls
|
||||
.prefix(Constants.maxURLCount)
|
||||
.compactMap { rawURL -> String? in
|
||||
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
Constants.allowedSchemes.contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
guard !validURLs.isEmpty else {
|
||||
return .badRequest
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
return .ok
|
||||
} catch {
|
||||
print("Failed to parse local request JSON: \(error)")
|
||||
return .badRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HTTPRequest {
|
||||
var method: String
|
||||
var path: String
|
||||
var headers: [String: String]
|
||||
var body: Data
|
||||
|
||||
init?(data: Data) {
|
||||
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let headerData = data[..<headerRange.lowerBound]
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
guard let requestLine = lines.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
|
||||
guard requestParts.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedHeaders: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else {
|
||||
continue
|
||||
}
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
parsedHeaders[name] = value
|
||||
}
|
||||
|
||||
let bodyStart = headerRange.upperBound
|
||||
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
|
||||
guard expectedBodyLength >= 0,
|
||||
data.count >= bodyStart + expectedBodyLength else {
|
||||
return nil
|
||||
}
|
||||
|
||||
method = String(requestParts[0]).uppercased()
|
||||
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
|
||||
headers = parsedHeaders
|
||||
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
|
||||
}
|
||||
|
||||
func header(named name: String) -> String? {
|
||||
headers[name.lowercased()]
|
||||
}
|
||||
}
|
||||
|
||||
private enum HTTPStatus: Int {
|
||||
case ok = 200
|
||||
case noContent = 204
|
||||
case badRequest = 400
|
||||
case forbidden = 403
|
||||
case notFound = 404
|
||||
case methodNotAllowed = 405
|
||||
case payloadTooLarge = 413
|
||||
case unsupportedMediaType = 415
|
||||
|
||||
var reason: String {
|
||||
switch self {
|
||||
case .ok: "OK"
|
||||
case .noContent: "No Content"
|
||||
case .badRequest: "Bad Request"
|
||||
case .forbidden: "Forbidden"
|
||||
case .notFound: "Not Found"
|
||||
case .methodNotAllowed: "Method Not Allowed"
|
||||
case .payloadTooLarge: "Payload Too Large"
|
||||
case .unsupportedMediaType: "Unsupported Media Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,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
|
||||
@@ -62,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 = "-"
|
||||
@@ -74,10 +186,53 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
var sortableSize: Int64 {
|
||||
sizeBytes ?? 0
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
requestHeaders: requestHeaders ?? [],
|
||||
cookieHeader: cookieHeader,
|
||||
mirrorURLs: mirrorURLs ?? []
|
||||
)
|
||||
}
|
||||
|
||||
var speedLimitText: String {
|
||||
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
|
||||
return "No limit"
|
||||
}
|
||||
return "\(speedLimitKiBPerSecond) KiB/s"
|
||||
}
|
||||
|
||||
var redactedForPersistence: DownloadItem {
|
||||
var item = self
|
||||
if item.credentials != nil {
|
||||
item.credentials?.password = ""
|
||||
}
|
||||
item.cookieHeader = nil
|
||||
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadRequestHeader {
|
||||
var containsSensitiveValue: Bool {
|
||||
switch normalized.name.lowercased() {
|
||||
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgress: Equatable, Sendable {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
|
||||
case doNothing = "Do nothing"
|
||||
case sleep = "Sleep"
|
||||
case restart = "Restart"
|
||||
case shutdown = "Shut down"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .sunday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday: "T"
|
||||
case .wednesday: "W"
|
||||
case .thursday: "T"
|
||||
case .friday: "F"
|
||||
case .saturday: "S"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SchedulerController: ObservableObject {
|
||||
@Published var settings: SchedulerSettings
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var hasAutomationPermission: Bool = false
|
||||
private let downloadController: DownloadController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timer: Timer?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.SchedulerSettings.v1"
|
||||
|
||||
// We only trigger once per minute to prevent multiple triggers in the same minute
|
||||
private var lastTriggeredMinute: Date?
|
||||
|
||||
init(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
|
||||
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
|
||||
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
|
||||
self.settings = stored
|
||||
} else {
|
||||
self.settings = SchedulerSettings()
|
||||
}
|
||||
if self.settings.targetQueueIDs.isEmpty {
|
||||
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
startTimer()
|
||||
|
||||
$settings
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe downloads to check if we should trigger post-action
|
||||
downloadController.$downloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.checkIfRunningFinished()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkSchedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSchedule() {
|
||||
guard settings.isEnabled else { return }
|
||||
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Check if we already triggered in this exact minute
|
||||
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
|
||||
return
|
||||
}
|
||||
|
||||
let startHour = calendar.component(.hour, from: settings.startTime)
|
||||
let startMinute = calendar.component(.minute, from: settings.startTime)
|
||||
|
||||
let currentHour = calendar.component(.hour, from: now)
|
||||
let currentMinute = calendar.component(.minute, from: now)
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
let shouldRun: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRun = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRun {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerQueues() {
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let runnableQueueIDs = targetQueueIDs.filter { queueID in
|
||||
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
||||
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
||||
}
|
||||
|
||||
guard !runnableQueueIDs.isEmpty else { return }
|
||||
|
||||
isRunning = true
|
||||
|
||||
for queueID in runnableQueueIDs {
|
||||
downloadController.startQueue(queueID: queueID)
|
||||
}
|
||||
|
||||
checkIfRunningFinished()
|
||||
}
|
||||
|
||||
private func checkIfRunningFinished() {
|
||||
guard isRunning else { return }
|
||||
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let hasActiveItems = targetQueueIDs.contains { queueID in
|
||||
downloadController.queueItems(for: queueID).contains {
|
||||
$0.status == .queued || $0.status == .downloading
|
||||
}
|
||||
}
|
||||
|
||||
if !hasActiveItems {
|
||||
isRunning = false
|
||||
performPostAction()
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveTargetQueueIDs() -> Set<UUID> {
|
||||
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func performPostAction() {
|
||||
guard settings.postQueueAction != .doNothing else { return }
|
||||
|
||||
var scriptCode = ""
|
||||
switch settings.postQueueAction {
|
||||
case .sleep:
|
||||
scriptCode = "tell application \"Finder\" to sleep"
|
||||
case .restart:
|
||||
scriptCode = "tell application \"Finder\" to restart"
|
||||
case .shutdown:
|
||||
scriptCode = "tell application \"Finder\" to shut down"
|
||||
case .doNothing:
|
||||
break
|
||||
}
|
||||
|
||||
guard !scriptCode.isEmpty else { return }
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to perform scheduler post action: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
|
||||
hasAutomationPermission = (status == noErr)
|
||||
}
|
||||
|
||||
func requestAutomationPermission() {
|
||||
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
|
||||
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
|
||||
|
||||
if status != noErr {
|
||||
triggerAutomationConsentPrompt()
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
|
||||
if !hasAutomationPermission {
|
||||
openAutomationPermissionSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func openAutomationPermissionSettings() {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
|
||||
return
|
||||
}
|
||||
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func triggerAutomationConsentPrompt() {
|
||||
let scriptCode = "tell application \"Finder\" to get name"
|
||||
|
||||
var error: NSDictionary?
|
||||
if let script = NSAppleScript(source: scriptCode) {
|
||||
script.executeAndReturnError(&error)
|
||||
if let error {
|
||||
print("Failed to trigger Automation permission prompt: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SchedulerView: View {
|
||||
@EnvironmentObject private var downloadController: DownloadController
|
||||
@EnvironmentObject private var schedulerController: SchedulerController
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var startTime: Date = Date()
|
||||
@State private var isEveryday: Bool = true
|
||||
@State private var selectedDays: Set<SchedulerDay> = []
|
||||
@State private var postQueueAction: PostQueueAction = .doNothing
|
||||
@State private var targetQueueIDs: Set<UUID> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
timeSelectionSection
|
||||
queueSelectionSection
|
||||
postActionSection
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
Divider()
|
||||
permissionsSection
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
schedulerController.checkAutomationPermission()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Scheduler")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Save Settings") {
|
||||
saveState()
|
||||
withAnimation(.spring()) {
|
||||
showSaveToast = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation {
|
||||
showSaveToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var timeSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Start Time")
|
||||
.font(.headline)
|
||||
|
||||
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
|
||||
Toggle("Everyday", isOn: $isEveryday)
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Toggle(day.shortName, isOn: Binding(
|
||||
get: { selectedDays.contains(day) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
selectedDays.insert(day)
|
||||
} else {
|
||||
selectedDays.remove(day)
|
||||
}
|
||||
}
|
||||
))
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var queueSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Queues to Start")
|
||||
.font(.headline)
|
||||
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(downloadController.queues) { queue in
|
||||
Toggle(queue.name, isOn: Binding(
|
||||
get: { targetQueueIDs.contains(queue.id) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
targetQueueIDs.insert(queue.id)
|
||||
} else {
|
||||
targetQueueIDs.remove(queue.id)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var postActionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("After Completion")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("System Permissions")
|
||||
.font(.headline)
|
||||
|
||||
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if schedulerController.hasAutomationPermission {
|
||||
Button("Revoke Permissions") {
|
||||
schedulerController.openAutomationPermissionSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
schedulerController.requestAutomationPermission()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Settings Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func loadState() {
|
||||
isEnabled = schedulerController.settings.isEnabled
|
||||
startTime = schedulerController.settings.startTime
|
||||
isEveryday = schedulerController.settings.isEveryday
|
||||
selectedDays = schedulerController.settings.selectedDays
|
||||
postQueueAction = schedulerController.settings.postQueueAction
|
||||
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: schedulerController.settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
schedulerController.settings.isEnabled = isEnabled
|
||||
schedulerController.settings.startTime = startTime
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: targetQueueIDs
|
||||
schedulerController.saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,62 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case network = "Network"
|
||||
case locations = "Locations"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case about = "About"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var groupTitle: String {
|
||||
switch self {
|
||||
case .about:
|
||||
"App"
|
||||
default:
|
||||
"Preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var selection: SettingsSection = .downloads
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Settings")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 26)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
activeTab = filter
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: filter.symbolName)
|
||||
.font(.system(size: 16))
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
settingsSidebar
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(activeTab.rawValue)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
.padding(28)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var settingsSidebar: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Preferences") {
|
||||
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
|
||||
Section("App") {
|
||||
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.frame(width: 210)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch selection {
|
||||
switch activeTab {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .lookAndFeel:
|
||||
LookAndFeelSettingsPane()
|
||||
case .network:
|
||||
NetworkSettingsPane()
|
||||
case .locations:
|
||||
@@ -96,12 +67,60 @@ struct SettingsView: View {
|
||||
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?
|
||||
@@ -131,7 +150,7 @@ private struct AboutSettingsPane: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
@@ -141,30 +160,6 @@ private struct AboutSettingsPane: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Developer") {
|
||||
LabeledContent("Created by") {
|
||||
Text("NimBold")
|
||||
}
|
||||
|
||||
LabeledContent("GitHub") {
|
||||
Link("@nimbold", destination: developerProfileURL)
|
||||
}
|
||||
|
||||
LabeledContent("Source") {
|
||||
Link("nimbold/Firelink", destination: projectURL)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Credits") {
|
||||
LabeledContent("Download engine") {
|
||||
Link("aria2", destination: aria2URL)
|
||||
}
|
||||
|
||||
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Updates") {
|
||||
LabeledContent("Status") {
|
||||
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
|
||||
@@ -207,6 +202,26 @@ private struct AboutSettingsPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -284,14 +299,12 @@ private struct AboutSettingsPane: View {
|
||||
}
|
||||
|
||||
private struct EngineSettingsPane: View {
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
private var version: String {
|
||||
Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
@@ -323,6 +336,9 @@ private struct EngineSettingsPane: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +439,7 @@ private struct DownloadSettingsPane: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
@@ -432,18 +449,21 @@ private struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
Form {
|
||||
Section {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,25 +474,26 @@ private struct DirectoryPickerRow: View {
|
||||
@State private var path = ""
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.frame(width: 125, alignment: .leading)
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Folder path", text: Binding(
|
||||
get: { settings.downloadDirectories[category] ?? path },
|
||||
set: { newValue in
|
||||
path = newValue
|
||||
settings.setDirectory(newValue, for: category)
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
TextField("Folder path", text: Binding(
|
||||
get: { settings.downloadDirectories[category] ?? path },
|
||||
set: { newValue in
|
||||
path = newValue
|
||||
settings.setDirectory(newValue, for: category)
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,62 +518,56 @@ private struct SiteLoginsSettingsPane: View {
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("URL Pattern")
|
||||
TextField("*.github.com", text: $urlPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Form {
|
||||
Section("Add Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField("Password", text: $password)
|
||||
|
||||
GridRow {
|
||||
Text("Username")
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Password")
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
|
||||
if settings.message.hasPrefix("Added") {
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
|
||||
if settings.message.hasPrefix("Added") {
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
} label: {
|
||||
Label("Add Login", systemImage: "plus")
|
||||
}
|
||||
} label: {
|
||||
Label("Add Login", systemImage: "plus")
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
Section("Saved Logins") {
|
||||
if settings.siteLogins.isEmpty {
|
||||
Text("No saved logins.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,3 +586,165 @@ 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 = bundledFirefoxExtensionURL() else {
|
||||
installMessage = "The bundled Firefox extension folder was not found."
|
||||
return
|
||||
}
|
||||
|
||||
let destinationURL = downloadsExtensionURL
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
|
||||
copiedExtensionURL = destinationURL
|
||||
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
|
||||
showCopiedExtensionInFinder()
|
||||
} catch {
|
||||
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func bundledFirefoxExtensionURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
|
||||
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
|
||||
? sourceTreeExtension
|
||||
: nil
|
||||
}
|
||||
|
||||
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
|
||||
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
|
||||
try fileManager.copyItem(
|
||||
at: sourceURL.appendingPathComponent(component),
|
||||
to: destinationURL.appendingPathComponent(component)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func showCopiedExtensionInFinder() {
|
||||
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
|
||||
let manifestURL = folderURL.appendingPathComponent("manifest.json")
|
||||
if FileManager.default.fileExists(atPath: manifestURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
|
||||
} else if FileManager.default.fileExists(atPath: folderURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
|
||||
}
|
||||
}
|
||||
|
||||
private func openFirefoxDebugging() {
|
||||
let bundleIDs = [
|
||||
"org.mozilla.firefoxdeveloperedition",
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.nightly"
|
||||
]
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
for id in bundleIDs {
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
process.arguments = ["-a", appURL.path, "about:debugging"]
|
||||
try? process.run()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if let fallbackURL = URL(string: "about:debugging") {
|
||||
workspace.open(fallbackURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,37 @@ enum DownloadSidebarFilter: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -70,8 +98,37 @@ struct SidebarView: View {
|
||||
}
|
||||
.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
|
||||
@@ -116,30 +173,6 @@ struct SidebarView: View {
|
||||
} message: { queue in
|
||||
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
if selection == .settings {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.accentColor.opacity(0.14))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private func folderRow(for category: DownloadCategory) -> some View {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||