Compare commits

...

29 Commits

Author SHA1 Message Date
nimbold 062c4aec61 docs: release version 0.5.3
- Update CHANGELOG.md with 0.5.3 patch features and fixes
- Document Chunk Map and Drag-and-Drop in README.md
2026-06-04 13:45:10 +03:30
nimbold 8e32762217 feat: implement chunk map inspector, drag-and-drop, and UI refinements
- Add ChunkMapView to visualize active segmented downloads using aria2 RPC
- Add drag-and-drop support for URLs and text files in main window and dock
- Refactor settings panes to use native macOS HIG Form and .toolbar layouts
- Fix DNS rebinding vulnerability by validating Host header in local server
- Fix memory leak in console buffer by capping to 512KB
- Fix UI hang during aria2c version check by running it in detached task
2026-06-04 13:34:28 +03:30
Nima fec18deb1a Update README to mention Firefox extension only 2026-06-04 06:30:09 +03:30
nimbold bf0d1f6ce1 docs: update app screenshots 2026-06-04 06:27:45 +03:30
nimbold d9c7da23e9 fix: resolve settings tab layout and hit-testing issues 2026-06-04 06:03:30 +03:30
nimbold 2f5e593d0b feat: add icons to settings capsule tabs 2026-06-04 05:47:55 +03:30
nimbold fd349bfd3b fix: awk syntax error for parsing changelog 2026-06-04 05:39:40 +03:30
nimbold f35e981b0a fix: clone submodules in release action 2026-06-04 05:37:23 +03:30
nimbold 2b520f3e97 chore: prepare for v0.5.0 release 2026-06-04 05:34:48 +03:30
nimbold 7802b612a4 feat: integrate settings and speed limiter into main window
- Refactor SettingsView to act as a flat detail pane instead of a nested sidebar or macOS native window
- Add custom horizontal tab bar for switching between setting categories
- Create SpeedLimiterView and add it as a primary tool in the sidebar
- Add state retention using AppStorage for settings tab selection and speed limits
- Fix EXC_BAD_ACCESS in DownloadTable by using sortableSize instead of debugDescription
2026-06-04 05:29:15 +03:30
nimbold 28bb90e8c2 feat: add stop downloads control 2026-06-04 04:44:04 +03:30
nimbold 86d70a9ca7 Refine add window and font sizing 2026-06-04 04:36:06 +03:30
nimbold b440b567f1 docs(readme): compact screenshots and fix light theme 2026-06-04 04:18:40 +03:30
nimbold 692a48b4a8 docs(readme): refresh application screenshots 2026-06-04 04:09:12 +03:30
nimbold a90d190df9 docs(readme): add application screenshots for dark and light themes 2026-06-04 03:47:47 +03:30
nimbold b1b3f42ead feat(ui): simplify temporary Firefox extension install 2026-06-04 03:30:44 +03:30
nimbold 91cd65e1d7 fix(security): harden extension bridge and persistence 2026-06-04 03:19:57 +03:30
nimbold e948590335 fix(ui): route to about:debugging without fragment hash and update instructions 2026-06-04 02:45:02 +03:30
nimbold 643f87d366 fix(ui): use Process to invoke open instead of NSWorkspace to avoid url encoding the fragment hash 2026-06-04 02:21:41 +03:30
nimbold 0f522fa794 fix(ui): explicitly route about:debugging to firefox to prevent LaunchServices error 2026-06-04 00:24:32 +03:30
nimbold 604987a360 feat(ui): update integration page with extension install instructions 2026-06-04 00:01:24 +03:30
nimbold e0a1db690f build: migrate extension to git submodule 2026-06-03 23:50:26 +03:30
nimbold 5dc8d66f3c refactor(extension): remove xpi generation and installation
- Switch to copying Firefox extension directory into app bundle Resources
- Remove 'Install to Firefox' button and multi-browser selection logic
- Update Integrations tab to instruct users to load via about:debugging
- Map 'Show Extension Folder in Finder' to the bundled extension source directory
2026-06-03 22:53:54 +03:30
nimbold 0ddea52db8 feat(extension): add Firefox browser extension and native integration
- Implement Firefox extension to intercept downloads and scrape selected links via context menus
- Create LocalExtensionServer over TCP (localhost:6412) to receive links from the extension
- Automatically package extension to firelink.xpi in create_app_bundle.sh
- Add Integrations tab in SettingsView with one-click installer for Firefox variants
- Modify ContentView and TrayMenuView to automatically open the Add Downloads window on extension trigger
2026-06-03 22:15:12 +03:30
nimbold 93e6e11939 docs: update changelog for 0.4.3 release 2026-06-03 07:07:28 +03:30
nimbold 6750cce6aa perf: reduce main thread CPU usage and SSD wear from frequent JSON serialization 2026-06-03 07:03:18 +03:30
nimbold 1d22b16119 style: refine about page UI and simplify delete confirmation dialog 2026-06-03 06:48:53 +03:30
nimbold deb106f7af docs: update changelog and roadmap for 0.4.2 release 2026-06-03 06:30:28 +03:30
nimbold 1093d73b0e feat: add monochrome tray icon and download table enhancements
- Added proper monochrome template tray icon loaded explicitly with precise dimensions
- Added redownload functionality for completed or failed items
- Added double-click to open completed files directly from the table
- Added 'Copy Address' context menu action
- Improved context menu organization and conditionally displayed actions
2026-06-03 06:23:03 +03:30
30 changed files with 1636 additions and 997 deletions
+6 -1
View File
@@ -22,6 +22,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Resolve version
id: version
@@ -88,8 +90,11 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --generate-notes --verify-tag
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
fi
+3
View File
@@ -0,0 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
url = https://github.com/nimbold/Firelink-Extension.git
+60
View File
@@ -5,6 +5,66 @@ 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.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
Submodule Extensions/Firefox added at 2f99dda366
+41 -64
View File
@@ -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:** Seamlessly import URLs or text files directly into the app or Dock icon.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, Speed Limiter, and system sleep prevention during active downloads.
---
## 🛠️ Requirements & Setup
## 🛠️ Quick Start
### 1. 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,22 @@ 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.4.1
git push origin v0.4.1
```
## 🧩 Browser Extension
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
---
## 🗺️ Roadmap
- [x] **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.
- [x] **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.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

+10
View File
@@ -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
+75 -55
View File
@@ -29,20 +29,20 @@ struct AddDownloadsView: View {
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(16)
.padding(12)
}
Divider()
}
.toolbar {
actionBar
}
.frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
@@ -63,14 +63,14 @@ struct AddDownloadsView: View {
private var linkSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Download Links", systemImage: "link")
.font(.headline)
.font(.subheadline.weight(.semibold))
TextEditor(text: $linkText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 96)
.frame(minHeight: 82)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -88,19 +88,19 @@ struct AddDownloadsView: View {
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.headline)
.font(.subheadline.weight(.semibold))
Toggle("Use one folder for all files", isOn: $overrideDestination)
}
GridRow {
Text("")
HStack(spacing: 10) {
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.disabled(!overrideDestination)
Button {
@@ -114,23 +114,23 @@ struct AddDownloadsView: View {
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: 170)
.frame(width: 145)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 110, alignment: .leading)
.frame(width: 98, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
.font(.headline)
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
HStack(spacing: 8) {
Toggle("Limit this batch", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
@@ -146,20 +146,20 @@ struct AddDownloadsView: View {
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.headline)
.font(.subheadline.weight(.semibold))
.padding(.top, 4)
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 10) {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.frame(width: 145)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.frame(width: 145)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
@@ -171,7 +171,7 @@ struct AddDownloadsView: View {
}
private var summarySection: some View {
HStack(spacing: 10) {
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")
@@ -182,7 +182,7 @@ struct AddDownloadsView: View {
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
@@ -200,7 +200,7 @@ struct AddDownloadsView: View {
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(95)
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
@@ -212,22 +212,41 @@ struct AddDownloadsView: View {
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
}
.width(130)
.width(110)
}
.frame(minHeight: 170)
.frame(minHeight: 135)
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
@ToolbarContentBuilder
private var actionBar: some ToolbarContent {
ToolbarItem(placement: .status) {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
addDownloads(start: false)
} label: {
@@ -243,17 +262,15 @@ struct AddDownloadsView: View {
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
}
.padding(14)
.background(.bar)
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.headline)
HStack(spacing: 10) {
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
@@ -264,45 +281,45 @@ struct AddDownloadsView: View {
TextField("Expected digest", text: $checksumValue)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.headline)
.font(.subheadline.weight(.semibold))
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
.frame(minHeight: 48)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.headline)
.font(.subheadline.weight(.semibold))
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.headline)
.font(.subheadline.weight(.semibold))
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
.frame(minHeight: 48)
}
}
.padding(.top, 10)
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.headline)
.font(.subheadline.weight(.semibold))
}
}
@@ -414,6 +431,9 @@ struct AddDownloadsView: View {
}
}
}
await MainActor.run {
metadataTask = nil
}
}
}
@@ -529,22 +549,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)
.padding(9)
.frame(maxWidth: .infinity)
.frame(minHeight: 64)
.frame(minHeight: 52)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+11 -2
View File
@@ -135,6 +135,7 @@ 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
@@ -247,9 +248,17 @@ final class AppSettings: ObservableObject {
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)
}
}
+69 -25
View File
@@ -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)
@@ -57,30 +74,38 @@ final class Aria2DownloadEngine {
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(
@@ -99,12 +124,17 @@ 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,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
)
let inputPipe = Pipe()
@@ -165,7 +195,7 @@ 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()
}
@@ -175,7 +205,9 @@ final class Aria2DownloadEngine {
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
rpcSecret: String
) throws -> [String] {
var arguments = [
"--continue=true",
@@ -191,7 +223,11 @@ final class Aria2DownloadEngine {
"--connect-timeout=30",
"--timeout=60",
"--uri-selector=adaptive",
"--input-file=-"
"--input-file=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
"--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
@@ -301,7 +337,7 @@ final class Aria2DownloadEngine {
var lines = [
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)"
]
@@ -342,6 +378,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 }
@@ -350,6 +391,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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+159
View File
@@ -0,0 +1,159 @@
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 = 6
let spacing: CGFloat = 1
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.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
}
cx += boxSize
if cx >= 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.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
}
cx += boxSize
if cx >= width {
cx = 0
cy += boxSize
}
}
}
context.fill(pendingPath, with: .color(Color.gray.opacity(0.3)))
context.fill(completedPath, with: .color(Color.green))
}
.frame(minHeight: 80)
}
}
+47 -7
View File
@@ -18,6 +18,33 @@ struct ContentView: View {
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
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
openWindow(id: "add-downloads")
}
}
}
}
}
return true
}
}
@ViewBuilder
@@ -29,8 +56,10 @@ struct ContentView: View {
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .speedLimiter:
SpeedLimiterView()
case .settings:
SettingsView()
SettingsPaneContainer()
}
}
@@ -81,6 +110,15 @@ struct ContentView: View {
}
}
ToolbarItem {
Button {
controller.pauseActiveDownloads(queueID: queueID)
} label: {
Label("Stop Downloads", systemImage: "stop.fill")
}
.disabled(!hasActiveDownloads(in: queueID))
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
@@ -102,12 +140,6 @@ struct ContentView: View {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
@@ -170,6 +202,14 @@ 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 filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
+108 -9
View File
@@ -1,6 +1,7 @@
import AppKit
import Combine
import Foundation
import UserNotifications
@MainActor
final class DownloadController: ObservableObject {
@@ -23,6 +24,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
@@ -105,6 +107,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()
@@ -144,6 +150,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()
@@ -188,6 +200,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
@@ -244,6 +281,24 @@ final class DownloadController: ObservableObject {
pumpQueue()
}
func redownload(_ item: DownloadItem) {
trashFiles(for: item)
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Redownloading"
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
@@ -273,6 +328,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()
@@ -424,6 +480,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 {
@@ -439,6 +496,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()
@@ -479,6 +538,11 @@ final class DownloadController: ObservableObject {
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
$0.message = "Properties updated"
}
if let password = credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: id)
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
saveDownloads()
}
@@ -533,6 +597,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
}
@@ -656,14 +723,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)")
}
}
}
@@ -685,13 +761,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."
@@ -707,7 +794,7 @@ final class DownloadController: ObservableObject {
return adjusted
}
if shouldResumeRecoveredDownloads {
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
@@ -740,6 +827,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 {
@@ -158,6 +158,12 @@ struct DownloadPropertiesView: View {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
if item.status == .downloading && item.rpcPort != nil {
Section("Chunk Map") {
ChunkMapView(item: item)
}
}
}
.formStyle(.grouped)
+216 -587
View File
@@ -2,107 +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() {
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
columnWidths = stored.columnWidths
sortColumn = stored.sortColumn
sortDirection = stored.sortDirection
} else {
visibleColumns = defaultVisibleColumns
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
@@ -112,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) {
@@ -134,38 +34,83 @@ struct DownloadTable: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
GeometryReader { proxy in
let tableWidth = max(totalWidth, proxy.size.width)
let trailingWidth = max(0, tableWidth - totalWidth)
ScrollView(.horizontal) {
VStack(spacing: 0) {
tableHeader(trailingWidth: trailingWidth)
Divider()
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(sortedItems) { item in
tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth)
Divider()
}
}
.frame(width: tableWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
.defaultScrollAnchor(.topLeading)
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading)
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
progressBarCell(for: item)
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
Text(item.status.rawValue)
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
for itemID in itemIDs {
if let item = controller.downloads.first(where: { $0.id == itemID }) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
}
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
}
.confirmationDialog(
"Delete Download",
@@ -186,7 +131,7 @@ struct DownloadTable: View {
pendingDeleteItems = nil
}
Button("Move File and Cache to Trash", role: .destructive) {
Button("Move to Trash", role: .destructive) {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: true) }
selection.subtract(ids)
@@ -206,301 +151,157 @@ 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
}
}
.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
)
)
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
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 !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")
}
}
if queueID == nil {
Section("Sort By") {
ForEach(availableColumns) { column in
Button(column.rawValue) {
tableSettings.sortColumn = column
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
Divider()
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Move to Queue", systemImage: "list.bullet")
}
Divider()
}
Button {
NSPasteboard.general.clearContents()
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
NSPasteboard.general.setString(urls, forType: .string)
} label: {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
}
}
}
private func categoryColor(for category: DownloadCategory) -> Color {
switch category {
case .musics: return .pink
case .movies: return .indigo
case .compressed: return .orange
case .pictures: return .teal
case .documents: return .blue
case .other: return .gray
}
}
private func statusColor(for status: DownloadStatus) -> Color {
switch status {
case .queued: return .secondary
case .downloading: return .blue
case .paused: return .orange
case .completed: return .green
case .failed: return .red
case .canceled: return .gray
}
}
@ViewBuilder
private func rowContextMenu(for item: DownloadItem) -> some View {
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Button {
for target in targetItems {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
}
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} 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
)
}
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
}
Divider()
Button(role: .destructive) {
pendingDeleteItems = Set(targetItems.map(\.id))
} label: {
Label("Delete", systemImage: "trash")
}
}
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)
}
}
@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) {
@@ -523,185 +324,13 @@ struct DownloadTable: View {
}
return candidate
}
}
private struct DownloadRow: View {
@EnvironmentObject private var settings: AppSettings
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, settings.listRowDensity.verticalPadding)
.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)
}
.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
}
}
+39 -5
View File
@@ -6,6 +6,9 @@ struct FirelinkApp: App {
@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()
@@ -13,6 +16,9 @@ struct FirelinkApp: App {
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
}
var body: some Scene {
@@ -22,7 +28,11 @@ struct FirelinkApp: App {
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
@@ -32,7 +42,7 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
.windowResizability(.contentSize)
@@ -42,27 +52,51 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.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("Firelink", systemImage: "arrow.down.circle", isInserted: $showMenuBarIcon) {
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.isTemplate = true
return img
}() {
Image(nsImage: nsImage)
} else {
Image(systemName: "arrow.down.circle")
}
}
}
}
+252
View File
@@ -0,0 +1,252 @@
import Foundation
import Network
import AppKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let port = NWEndpoint.Port(rawValue: 6412)!
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let queue = DispatchQueue(label: "local.firelink.server")
init?(downloadController: DownloadController) {
self.downloadController = downloadController
let parameters = NWParameters.tcp
var createdListener: NWListener?
for portValue in 6412...6422 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
break
} catch {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection)
}
listener.stateUpdateHandler = { state in
print("LocalExtensionServer state: \(state)")
}
listener.start(queue: queue)
}
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(from: connection, accumulatedData: Data())
}
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else {
connection.cancel()
return
}
var requestData = accumulatedData
if let data {
requestData.append(data)
}
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
return
}
if let request = HTTPRequest(data: requestData) {
let status = self.processRequest(request)
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
return
}
if isComplete {
self.sendResponse(.badRequest, connection: connection, origin: nil)
return
}
self.receiveRequest(from: connection, accumulatedData: requestData)
}
}
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
var headers = [
"HTTP/1.1 \(status.rawValue) \(status.reason)",
"Content-Length: 0",
"Connection: close"
]
if let origin, isAllowedExtensionOrigin(origin) {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
guard let url = URL(string: origin),
let scheme = url.scheme?.lowercased() else {
return false
}
return scheme == "moz-extension" || scheme == "chrome-extension"
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
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: "content-type")?.lowercased().contains("application/json") == true else {
return .unsupportedMediaType
}
struct Payload: Decodable {
let urls: [String]
let referer: String?
}
do {
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
let validURLs = payload.urls
.prefix(Constants.maxURLCount)
.compactMap { rawURL -> String? in
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
Constants.allowedSchemes.contains(scheme) else {
return nil
}
return url.absoluteString
}
guard !validURLs.isEmpty else {
return .badRequest
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
}
return .ok
} catch {
print("Failed to parse local request JSON: \(error)")
return .badRequest
}
}
}
private struct HTTPRequest {
var method: String
var path: String
var headers: [String: String]
var body: Data
init?(data: Data) {
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
return nil
}
let headerData = data[..<headerRange.lowerBound]
guard let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else {
return nil
}
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
guard requestParts.count >= 2 else {
return nil
}
var parsedHeaders: [String: String] = [:]
for line in lines.dropFirst() {
guard let colonIndex = line.firstIndex(of: ":") else {
continue
}
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
parsedHeaders[name] = value
}
let bodyStart = headerRange.upperBound
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
guard expectedBodyLength >= 0,
data.count >= bodyStart + expectedBodyLength else {
return nil
}
method = String(requestParts[0]).uppercased()
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
headers = parsedHeaders
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
}
func header(named name: String) -> String? {
headers[name.lowercased()]
}
}
private enum HTTPStatus: Int {
case ok = 200
case noContent = 204
case badRequest = 400
case forbidden = 403
case notFound = 404
case methodNotAllowed = 405
case payloadTooLarge = 413
case unsupportedMediaType = 415
var reason: String {
switch self {
case .ok: "OK"
case .noContent: "No Content"
case .badRequest: "Bad Request"
case .forbidden: "Forbidden"
case .notFound: "Not Found"
case .methodNotAllowed: "Method Not Allowed"
case .payloadTooLarge: "Payload Too Large"
case .unsupportedMediaType: "Unsupported Media Type"
}
}
}
+27
View File
@@ -186,11 +186,17 @@ 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,
@@ -206,6 +212,27 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
}
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 {
+269 -216
View File
@@ -1,104 +1,58 @@
import AppKit
import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case locations = "Locations"
case lookAndFeel = "Look and feel"
case network = "Network"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case about = "About"
static let orderedCases: [SettingsSection] = [
.downloads,
.locations,
.lookAndFeel,
.network,
.siteLogins,
.power,
.engine,
.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 .about: "info.circle"
}
}
var groupTitle: String {
switch self {
case .engine, .about:
"App"
default:
"Preferences"
}
}
}
struct SettingsView: View {
@EnvironmentObject private var settings: AppSettings
@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)
.themeBackground(settings.appTheme.theme.background)
}
private var settingsSidebar: some View {
List(selection: $selection) {
Section("Preferences") {
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
Section("App") {
ForEach(SettingsSection.orderedCases.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:
@@ -113,6 +67,8 @@ struct SettingsView: View {
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
@@ -194,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)
@@ -204,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)
@@ -270,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)
@@ -347,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 {
@@ -386,6 +336,9 @@ private struct EngineSettingsPane: View {
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
@@ -487,59 +440,30 @@ private struct DownloadSettingsPane: View {
.foregroundStyle(.secondary)
}
Section("Bandwidth") {
Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s",
value: globalSpeedLimitValue,
in: 1...10_485_760,
step: 128
)
.disabled(settings.globalSpeedLimitKiBPerSecond == 0)
Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var globalSpeedLimitEnabled: Binding<Bool> {
Binding {
settings.globalSpeedLimitKiBPerSecond > 0
} set: { isEnabled in
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
}
}
private var globalSpeedLimitValue: Binding<Int> {
Binding {
max(settings.globalSpeedLimitKiBPerSecond, 1)
} set: { newValue in
settings.globalSpeedLimitKiBPerSecond = newValue
}
}
}
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)
}
}
@@ -550,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)
}
}
@@ -593,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)
}
}
@@ -667,3 +586,137 @@ private struct PowerSettingsPane: View {
.formStyle(.grouped)
}
}
private struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
+51 -24
View File
@@ -21,10 +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
}
@@ -75,9 +102,33 @@ struct SidebarView: View {
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
@@ -122,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 {
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
limitSelectionSection
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Speed Limiter")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Limit") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit")
.font(.headline)
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:")
}
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
// Helpful presets
HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
}
.buttonStyle(.bordered)
.padding(.top, 8)
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Speed Limit Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
}
private func saveState() {
// Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed
lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
}
}
+35 -2
View File
@@ -10,9 +10,31 @@ enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: return .small
case .small: return .xSmall
case .standard: return .medium
case .large: return .xLarge
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
}
}
}
@@ -119,6 +141,17 @@ struct AppThemeModifier: ViewModifier {
}
}
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?
+3
View File
@@ -34,5 +34,8 @@ struct TrayMenuView: View {
Button("Exit") {
NSApplication.shared.terminate(nil)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
}
}