Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a375b2e685 | |||
| 301e337c2e | |||
| 986687631d | |||
| 0714f6e707 | |||
| e25022fbe5 | |||
| 48dfd7c6fa | |||
| 5e442d1691 | |||
| d1fa6dd910 | |||
| 27b9004d99 | |||
| ac8764bb78 | |||
| 41535eca59 | |||
| 2fb851b138 | |||
| 47eb226160 | |||
| a2135bb539 | |||
| c18aa6fdaf | |||
| 4a9d4cab2f | |||
| 2b820ec802 | |||
| 544db2aab6 | |||
| c3bc35f669 | |||
| 0cbb982f5b | |||
| 0040c23050 | |||
| 062c4aec61 | |||
| 8e32762217 | |||
| fec18deb1a | |||
| bf0d1f6ce1 | |||
| d9c7da23e9 | |||
| 2f5e593d0b |
@@ -96,5 +96,5 @@ jobs:
|
||||
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 --notes-file release_notes.md --verify-tag
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
|
||||
@@ -5,6 +5,85 @@ 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.7] - 2026-06-06
|
||||
|
||||
### New features
|
||||
- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
|
||||
- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
|
||||
|
||||
## [0.5.6] - 2026-06-05
|
||||
|
||||
### New features
|
||||
- Added the official transparent GitHub icon to the Source Code link in the About page.
|
||||
|
||||
### Changes
|
||||
- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
|
||||
- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
|
||||
|
||||
### Fixes
|
||||
- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
|
||||
|
||||
## [0.5.5] - 2026-06-05
|
||||
|
||||
### New features
|
||||
- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
|
||||
- Added authenticated metadata probing so batch previews can use custom or saved credentials.
|
||||
|
||||
### Changes
|
||||
- Updated Download Properties disclosure sections so their full title row opens and closes them.
|
||||
- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
|
||||
- Expanded download table hit areas so double-clicks register across empty cell space.
|
||||
|
||||
### Fixes
|
||||
- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
|
||||
- Fixed Chunk Map layout overlap in Download Properties.
|
||||
- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
|
||||
|
||||
## [0.5.4] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added direct double-click access to Download Properties for unfinished downloads.
|
||||
- Added a `make verify` command for local build and Firefox extension manifest checks.
|
||||
|
||||
### Changes
|
||||
- Updated Firefox integration to probe the same local fallback ports used by the app.
|
||||
- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
|
||||
- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
|
||||
- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
|
||||
|
||||
### Fixes
|
||||
- Fixed browser handoff failures when the default local extension port is unavailable.
|
||||
- Fixed dropped `Referer` headers from browser extension requests.
|
||||
- Fixed scheduler configurations that could be enabled without any runnable queue target.
|
||||
- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
|
||||
- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
|
||||
|
||||
## [0.5.3] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
|
||||
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
|
||||
|
||||
### Changes
|
||||
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
|
||||
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
|
||||
|
||||
### Fixes
|
||||
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
|
||||
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
|
||||
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
|
||||
|
||||
## [0.5.2] - 2026-06-04
|
||||
|
||||
### Fixes
|
||||
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
|
||||
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
|
||||
|
||||
## [0.5.1] - 2026-06-04
|
||||
|
||||
### Changes
|
||||
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
|
||||
|
||||
## [0.5.0] - 2026-06-04
|
||||
|
||||
### New features
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build app dmg run clean
|
||||
.PHONY: build app dmg run verify clean
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
@@ -12,6 +12,9 @@ dmg: app
|
||||
run:
|
||||
swift run Firelink
|
||||
|
||||
verify:
|
||||
Scripts/verify.sh
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
rm -rf build dist
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
|
||||
"version" : "2.9.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -10,10 +10,19 @@ let package = Package(
|
||||
products: [
|
||||
.executable(name: "Firelink", targets: ["Firelink"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
path: "Sources/Firelink"
|
||||
dependencies: [
|
||||
.product(name: "Sparkle", package: "Sparkle")
|
||||
],
|
||||
path: "Sources/Firelink",
|
||||
resources: [
|
||||
.process("Assets.xcassets")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
### 📸 Screenshots
|
||||
|
||||
Dark mode is shown by default. Light mode is tucked away below so the README stays easy to scan.
|
||||
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%" />
|
||||
@@ -45,10 +45,14 @@ Dark mode is shown by default. Light mode is tucked away below so the README sta
|
||||
|
||||
- ⚡ **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.
|
||||
- 🧾 **Download Properties:** Inspect progress and tune per-download transfer settings.
|
||||
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
||||
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
|
||||
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
||||
- 🔄 **Sparkle Updates:** Secure, automatic in-app updates using EdDSA cryptographic signatures.
|
||||
- 🔒 **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.
|
||||
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +74,7 @@ make app && open build/Firelink.app
|
||||
|
||||
## 🧩 Browser Extension
|
||||
|
||||
Find the companion browser extension (Safari, Chrome, Firefox) at:
|
||||
Find the companion browser extension (Firefox) at:
|
||||
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
|
||||
|
||||
---
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 735 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 883 KiB |
@@ -20,6 +20,7 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
||||
|
||||
echo "Packaging Firefox extension..."
|
||||
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
||||
@@ -77,6 +78,12 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
swift build
|
||||
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||
@@ -38,19 +38,25 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||
.onChange(of: linkText) { _, newValue in
|
||||
scheduleMetadataRefresh(for: newValue)
|
||||
}
|
||||
.onChange(of: metadataRequestSignature) { _, _ in
|
||||
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
|
||||
scheduleMetadataRefresh(for: linkText)
|
||||
}
|
||||
.onAppear {
|
||||
connectionsPerServer = Double(settings.perServerConnections)
|
||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||
controller.pendingAddQueueID = nil
|
||||
if let text = controller.pendingPasteboardText {
|
||||
applyPendingReferer()
|
||||
linkText = text
|
||||
controller.pendingPasteboardText = nil
|
||||
}
|
||||
@@ -70,7 +76,7 @@ struct AddDownloadsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 82)
|
||||
.frame(minHeight: 72)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
@@ -106,12 +112,24 @@ struct AddDownloadsView: View {
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.disabled(!overrideDestination)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Queue", systemImage: "tray.full")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Queue", selection: $targetQueueID) {
|
||||
ForEach(controller.queues) { queue in
|
||||
Text(queue.name).tag(queue.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 220, alignment: .leading)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -127,11 +145,11 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Speed Limit", systemImage: "speedometer")
|
||||
Label("Speed Limit per File", systemImage: "speedometer")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Limit this batch", isOn: $speedLimitEnabled)
|
||||
Toggle("Limit each file", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
@@ -171,12 +189,14 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
HStack(spacing: 8) {
|
||||
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
|
||||
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
|
||||
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
|
||||
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
}
|
||||
CompactSummaryStrip(
|
||||
metrics: [
|
||||
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
|
||||
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
|
||||
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
|
||||
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
@@ -214,7 +234,7 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.width(110)
|
||||
}
|
||||
.frame(minHeight: 135)
|
||||
.frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,27 +244,37 @@ struct AddDownloadsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if metadataTask != nil {
|
||||
Button {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button {
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
.disabled(!canAddDownloads)
|
||||
|
||||
Button {
|
||||
Button("Start Downloads") {
|
||||
addDownloads(start: true)
|
||||
} label: {
|
||||
Label("Start Downloads", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
@@ -366,6 +396,26 @@ struct AddDownloadsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var metadataRequestSignature: String {
|
||||
[
|
||||
headerText,
|
||||
cookieText,
|
||||
useAuthorization ? "auth" : "no-auth",
|
||||
authUsername,
|
||||
authPassword
|
||||
].joined(separator: "\u{1f}")
|
||||
}
|
||||
|
||||
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
|
||||
if useAuthorization {
|
||||
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanUsername.isEmpty else { return nil }
|
||||
return DownloadCredentials(username: cleanUsername, password: authPassword)
|
||||
}
|
||||
|
||||
return settings.credentials(for: url)
|
||||
}
|
||||
|
||||
private func scheduleMetadataRefresh(for text: String) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = Task {
|
||||
@@ -377,6 +427,24 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingReferer() {
|
||||
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!referer.isEmpty,
|
||||
URL(string: referer) != nil else {
|
||||
controller.pendingReferer = nil
|
||||
return
|
||||
}
|
||||
|
||||
let refererHeader = "Referer: \(referer)"
|
||||
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
|
||||
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
|
||||
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? refererHeader
|
||||
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
}
|
||||
|
||||
private func refreshMetadata(for text: String) {
|
||||
let urls = DownloadURLParser.parse(text)
|
||||
metadataTask?.cancel()
|
||||
@@ -404,7 +472,12 @@ struct AddDownloadsView: View {
|
||||
var loaded: [PendingDownload] = []
|
||||
for url in urls {
|
||||
guard !Task.isCancelled else { return }
|
||||
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
|
||||
let item = await DownloadMetadataFetcher.fetch(
|
||||
for: url,
|
||||
settings: settings,
|
||||
credentials: metadataCredentials(for: url),
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
loaded.append(item)
|
||||
await MainActor.run {
|
||||
for loadedItem in loaded {
|
||||
@@ -414,6 +487,9 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +520,7 @@ struct AddDownloadsView: View {
|
||||
var savedHosts = Set<String>()
|
||||
for item in pendingDownloads {
|
||||
if let host = item.url.host, !savedHosts.contains(host) {
|
||||
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
|
||||
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
|
||||
savedHosts.insert(host)
|
||||
}
|
||||
}
|
||||
@@ -521,30 +597,41 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SummaryTile: View {
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String
|
||||
let symbolName: String
|
||||
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private struct CompactSummaryStrip: View {
|
||||
let metrics: [SummaryMetric]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: symbolName)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 12) {
|
||||
ForEach(metrics) { metric in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: metric.symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(metric.value)
|
||||
.font(.caption.weight(.semibold).monospacedDigit())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if metric.id != metrics.last?.id {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(9)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 52)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@@ -24,17 +24,24 @@ enum ProxyType: String, Codable, CaseIterable, Sendable {
|
||||
case http
|
||||
case https
|
||||
case ftp
|
||||
case socks5
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .http: "HTTP"
|
||||
case .https: "HTTPS"
|
||||
case .ftp: "FTP"
|
||||
case .https: "HTTPS (legacy)"
|
||||
case .ftp: "FTP (legacy)"
|
||||
case .socks5: "SOCKS5"
|
||||
}
|
||||
}
|
||||
|
||||
var uriScheme: String {
|
||||
rawValue
|
||||
switch self {
|
||||
case .http, .https, .ftp:
|
||||
"http"
|
||||
case .socks5:
|
||||
"socks5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +55,9 @@ struct ProxySettings: Codable, Equatable, Sendable {
|
||||
var copy = self
|
||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
copy.port = min(max(copy.port, 1), 65_535)
|
||||
if copy.type != .http {
|
||||
copy.type = .http
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
@@ -193,6 +203,10 @@ final class AppSettings: ObservableObject {
|
||||
}
|
||||
|
||||
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
||||
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
|
||||
}
|
||||
|
||||
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
|
||||
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -201,12 +215,39 @@ final class AppSettings: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
||||
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
|
||||
message = "Could not save the password to Keychain."
|
||||
if let id,
|
||||
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
|
||||
message = "A login for \(pattern) already exists."
|
||||
return
|
||||
}
|
||||
|
||||
if let index = siteLogins.firstIndex(where: { login in
|
||||
if let id {
|
||||
return login.id == id
|
||||
}
|
||||
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
|
||||
}) {
|
||||
let loginID = siteLogins[index].id
|
||||
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins[index].urlPattern = pattern
|
||||
siteLogins[index].username = cleanUsername
|
||||
message = "Updated login for \(pattern)."
|
||||
return
|
||||
}
|
||||
|
||||
guard !password.isEmpty else {
|
||||
message = "Add a password."
|
||||
return
|
||||
}
|
||||
|
||||
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
||||
if !KeychainCredentialStore.setPassword(password, for: login.id) {
|
||||
message = "Could not save the password to Keychain."
|
||||
return
|
||||
}
|
||||
siteLogins.append(login)
|
||||
message = "Added login for \(pattern)."
|
||||
}
|
||||
@@ -285,7 +326,7 @@ final class AppSettings: ObservableObject {
|
||||
return absolute.contains(normalizedPattern)
|
||||
}
|
||||
|
||||
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
|
||||
return host == normalizedPattern
|
||||
}
|
||||
|
||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppUpdateChecker: ObservableObject {
|
||||
enum Status: Equatable {
|
||||
case idle
|
||||
case checking
|
||||
case upToDate(String)
|
||||
case updateAvailable(latestVersion: String, releaseURL: URL)
|
||||
case unavailable(String)
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .idle:
|
||||
"Check GitHub Releases for the latest Firelink build."
|
||||
case .checking:
|
||||
"Checking for updates..."
|
||||
case .upToDate(let version):
|
||||
"Firelink is up to date. Latest version: \(version)."
|
||||
case .updateAvailable(let latestVersion, _):
|
||||
"Version \(latestVersion) is available."
|
||||
case .unavailable(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var status: Status = .idle
|
||||
@Published private(set) var lastChecked: Date?
|
||||
|
||||
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
|
||||
|
||||
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func checkForUpdates(currentVersion: String) async {
|
||||
status = .checking
|
||||
lastChecked = Date()
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: latestReleaseURL)
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
status = .unavailable("Could not read the update server response.")
|
||||
return
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
status = .unavailable("No published Firelink release was found.")
|
||||
return
|
||||
}
|
||||
|
||||
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
|
||||
let latestVersion = release.version
|
||||
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
|
||||
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
|
||||
} else {
|
||||
status = .upToDate(latestVersion)
|
||||
}
|
||||
} catch {
|
||||
status = .unavailable("Could not check for updates. Try again later.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GitHubRelease: Decodable {
|
||||
let tagName: String
|
||||
let htmlURL: URL
|
||||
|
||||
var version: String {
|
||||
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case tagName = "tag_name"
|
||||
case htmlURL = "html_url"
|
||||
}
|
||||
}
|
||||
|
||||
private enum VersionComparator {
|
||||
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
|
||||
let candidateParts = parts(from: candidate)
|
||||
let currentParts = parts(from: current)
|
||||
let count = max(candidateParts.count, currentParts.count)
|
||||
|
||||
for index in 0..<count {
|
||||
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
|
||||
let currentValue = index < currentParts.count ? currentParts[index] : 0
|
||||
if candidateValue != currentValue {
|
||||
return candidateValue > currentValue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func parts(from version: String) -> [Int] {
|
||||
version
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
|
||||
.split(separator: ".")
|
||||
.map { component in
|
||||
let digits = component.prefix(while: \.isNumber)
|
||||
return Int(digits) ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@ final class Aria2DownloadEngine {
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf")
|
||||
do {
|
||||
let confContent = "rpc-secret=\(rpcSecret)\n"
|
||||
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
|
||||
} catch {
|
||||
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
@@ -117,6 +155,8 @@ final class Aria2DownloadEngine {
|
||||
let parser = Aria2ProgressParser()
|
||||
let outputBuffer = LockedDataBuffer()
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
let completionGate = CompletionGate(completion)
|
||||
let completionMonitor = CompletionMonitor()
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
@@ -136,11 +176,13 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
completionMonitor.cancel()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
completion(.success(()))
|
||||
completionGate.complete(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,7 +194,7 @@ final class Aria2DownloadEngine {
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
||||
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -162,22 +204,142 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier) {
|
||||
completionMonitor.set(
|
||||
Self.monitorCompletion(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
process: process,
|
||||
completionGate: completionGate
|
||||
)
|
||||
)
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionMonitor.cancel()
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func monitorCompletion(
|
||||
rpcPort: Int,
|
||||
rpcSecret: String,
|
||||
process: Process,
|
||||
completionGate: CompletionGate
|
||||
) -> Task<Void, Never> {
|
||||
Task.detached {
|
||||
while !Task.isCancelled && process.isRunning {
|
||||
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionGate.complete(.success(()))
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func completedDownloadStatus(rpcPort: Int, rpcSecret: String) async -> Bool {
|
||||
guard let stopped = await rpcCall(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
method: "aria2.tellStopped",
|
||||
arguments: [0, 10, ["status", "errorCode", "completedLength", "totalLength"]]
|
||||
) as? [[String: Any]] else {
|
||||
return false
|
||||
}
|
||||
|
||||
if stopped.contains(where: { item in
|
||||
(item["status"] as? String) == "complete"
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
return stopped.contains { item in
|
||||
guard (item["status"] as? String) == "error",
|
||||
(item["errorCode"] as? String) == "0",
|
||||
let completedLength = Int64(item["completedLength"] as? String ?? ""),
|
||||
let totalLength = Int64(item["totalLength"] as? String ?? ""),
|
||||
totalLength > 0 else {
|
||||
return false
|
||||
}
|
||||
return completedLength >= totalLength
|
||||
}
|
||||
}
|
||||
|
||||
private static func rpcCall(
|
||||
rpcPort: Int,
|
||||
rpcSecret: String,
|
||||
method: String,
|
||||
arguments: [Any]
|
||||
) async -> Any? {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/jsonrpc") else { return nil }
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"id": UUID().uuidString,
|
||||
"params": ["token:\(rpcSecret)"] + arguments
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
request.timeoutInterval = 3
|
||||
|
||||
guard let (responseData, _) = try? await URLSession.shared.data(for: request),
|
||||
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return json["result"]
|
||||
}
|
||||
|
||||
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
||||
|
||||
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.changeGlobalOption",
|
||||
"id": UUID().uuidString,
|
||||
"params": [
|
||||
"token:\(handle.rpcSecret)",
|
||||
["max-overall-download-limit": limitValue]
|
||||
]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
request.timeoutInterval = 3
|
||||
|
||||
_ = try? await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
rpcPort: Int,
|
||||
confURL: URL
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--conf-path=\(confURL.path)",
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
"--auto-file-renaming=true",
|
||||
@@ -191,11 +353,14 @@ final class Aria2DownloadEngine {
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-"
|
||||
"--input-file=-",
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-port=\(rpcPort)",
|
||||
"--rpc-listen-all=false"
|
||||
]
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
|
||||
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||
@@ -342,6 +507,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,10 +520,54 @@ final class LockedDataBuffer: @unchecked Sendable {
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var didComplete = false
|
||||
private let completion: @Sendable (Result<Void, Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func complete(_ result: Result<Void, Error>) {
|
||||
lock.lock()
|
||||
let shouldComplete = !didComplete
|
||||
if shouldComplete {
|
||||
didComplete = true
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard shouldComplete else { return }
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
final class CompletionMonitor: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func set(_ task: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
self.task = task
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
let task = self.task
|
||||
self.task = nil
|
||||
lock.unlock()
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
final class Aria2ProgressParser: @unchecked Sendable {
|
||||
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
|
||||
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "github-mark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChunkMapView: View {
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var bitfield: String = ""
|
||||
@State private var numPieces: Int = 0
|
||||
@State private var pollTask: Task<Void, Never>?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if numPieces > 0 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
|
||||
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await fetchStatus(port: port, secret: secret)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStatus(port: Int, secret: String) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.tellActive",
|
||||
"id": "1",
|
||||
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
request.httpBody = data
|
||||
|
||||
do {
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
let active = result.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedBitfield = active["bitfield"] as? String ?? ""
|
||||
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
|
||||
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
|
||||
|
||||
await MainActor.run {
|
||||
self.bitfield = fetchedBitfield
|
||||
self.numPieces = fetchedNumPieces
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
let boxSize: CGFloat = 10
|
||||
let spacing: CGFloat = 2
|
||||
let cornerSize = CGSize(width: 2, height: 2)
|
||||
let width = size.width
|
||||
|
||||
let x: CGFloat = 0
|
||||
let y: CGFloat = 0
|
||||
|
||||
let completedPath = Path { p in
|
||||
var cx = x
|
||||
var cy = y
|
||||
for piece in itemPieces {
|
||||
if piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPath = Path { p in
|
||||
var cx: CGFloat = 0
|
||||
var cy: CGFloat = 0
|
||||
for piece in itemPieces {
|
||||
if !piece {
|
||||
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
|
||||
context.fill(completedPath, with: .color(Color.accentColor))
|
||||
}
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,32 @@ struct ContentView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
if provider.canLoadObject(ofClass: URL.self) {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url = url {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: String.self) {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -78,51 +104,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} label: {
|
||||
Label("Start Queue", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} label: {
|
||||
Label("Stop Downloads", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!hasActiveDownloads(in: queueID))
|
||||
}
|
||||
|
||||
ToolbarItemGroup {
|
||||
if !selectedItems.isEmpty {
|
||||
if selectedItems.contains(where: { $0.status == .downloading }) {
|
||||
Button {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
|
||||
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
|
||||
controller.resume(item)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!canStop)
|
||||
|
||||
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
|
||||
controller.resume(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(!canStart)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -176,6 +185,7 @@ struct ContentView: View {
|
||||
private func handlePaste(queueID: UUID?) {
|
||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||
controller.pendingPasteboardText = text
|
||||
controller.pendingReferer = nil
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -192,6 +202,14 @@ struct ContentView: View {
|
||||
return controller.activeCount > 0
|
||||
}
|
||||
|
||||
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
|
||||
if let queueID {
|
||||
return controller.queueItems(for: queueID).contains { $0.status == .queued }
|
||||
}
|
||||
|
||||
return controller.queuedCount > 0
|
||||
}
|
||||
|
||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||
switch filter {
|
||||
case .all:
|
||||
|
||||
@@ -9,6 +9,8 @@ final class DownloadController: ObservableObject {
|
||||
@Published var queues: [DownloadQueue] = [.main]
|
||||
@Published var engineMessage = ""
|
||||
@Published var pendingPasteboardText: String?
|
||||
@Published var pendingReferer: String?
|
||||
@Published var extensionServerPort: UInt16?
|
||||
var pendingAddQueueID: UUID?
|
||||
|
||||
private let settings: AppSettings
|
||||
@@ -38,6 +40,25 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$globalSpeedLimitKiBPerSecond
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
settings.$maxConcurrentDownloads
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.applySpeedLimitsToActiveDownloads()
|
||||
self?.pumpQueue()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
$downloads
|
||||
.dropFirst()
|
||||
@@ -131,10 +152,11 @@ final class DownloadController: ObservableObject {
|
||||
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
|
||||
let items = pendingDownloads.map { pending in
|
||||
DownloadItem(
|
||||
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
|
||||
return DownloadItem(
|
||||
url: pending.url,
|
||||
fileName: pending.fileName,
|
||||
category: pending.category,
|
||||
fileName: fileName,
|
||||
category: FileClassifier.category(forFileName: fileName),
|
||||
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
|
||||
connectionsPerServer: clampedConnections,
|
||||
credentials: credentials ?? settings.credentials(for: pending.url),
|
||||
@@ -196,6 +218,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -221,6 +244,7 @@ final class DownloadController: ObservableObject {
|
||||
|
||||
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -241,6 +265,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
@@ -309,6 +334,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -332,6 +358,7 @@ final class DownloadController: ObservableObject {
|
||||
downloads.removeAll { $0.id == item.id }
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
@@ -454,6 +481,7 @@ final class DownloadController: ObservableObject {
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
@@ -490,18 +518,23 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
self.pumpQueue()
|
||||
self.applySpeedLimitsToActiveDownloads()
|
||||
self.updateSleepActivity()
|
||||
}
|
||||
}
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
}
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
} catch {
|
||||
handleDownloadFailure(itemID: item.id, error: error)
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -524,8 +557,8 @@ final class DownloadController: ObservableObject {
|
||||
) {
|
||||
update(id) {
|
||||
$0.url = url
|
||||
$0.fileName = fileName
|
||||
$0.category = FileClassifier.category(forFileName: fileName)
|
||||
$0.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
$0.category = FileClassifier.category(forFileName: $0.fileName)
|
||||
$0.destinationDirectory = destinationDirectory
|
||||
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
||||
$0.credentials = credentials
|
||||
@@ -541,28 +574,37 @@ final class DownloadController: ObservableObject {
|
||||
} else if credentials == nil {
|
||||
KeychainCredentialStore.deletePassword(for: id)
|
||||
}
|
||||
applySpeedLimitToActiveDownload(id: id)
|
||||
saveDownloads()
|
||||
}
|
||||
|
||||
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
||||
guard let value, value > 0 else { return nil }
|
||||
return min(value, 10_485_760)
|
||||
SpeedLimitPolicy.normalized(value)
|
||||
}
|
||||
|
||||
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
|
||||
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
||||
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
|
||||
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
|
||||
SpeedLimitPolicy.effectiveLimit(
|
||||
itemLimit: item.speedLimitKiBPerSecond,
|
||||
globalLimit: settings.globalSpeedLimitKiBPerSecond,
|
||||
activeDownloadCount: activeCount
|
||||
)
|
||||
}
|
||||
|
||||
switch (itemLimit, globalLimit) {
|
||||
case let (.some(itemLimit), .some(globalLimit)):
|
||||
return min(itemLimit, globalLimit)
|
||||
case let (.some(itemLimit), .none):
|
||||
return itemLimit
|
||||
case let (.none, .some(globalLimit)):
|
||||
return globalLimit
|
||||
case (.none, .none):
|
||||
return nil
|
||||
private func applySpeedLimitsToActiveDownloads() {
|
||||
for item in downloads where item.status == .downloading {
|
||||
applySpeedLimitToActiveDownload(id: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func applySpeedLimitToActiveDownload(id: UUID) {
|
||||
guard let handle = activeHandles[id],
|
||||
let item = downloads.first(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
|
||||
Task {
|
||||
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +819,20 @@ final class DownloadController: ObservableObject {
|
||||
adjusted.credentials?.password = storedPassword
|
||||
}
|
||||
|
||||
if adjusted.status == .completed && adjusted.progress != 1 {
|
||||
adjusted.progress = 1
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .completed &&
|
||||
(adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
|
||||
adjusted.speedText = "-"
|
||||
adjusted.etaText = "-"
|
||||
adjusted.connectionCount = 0
|
||||
adjusted.autoResumeOnLaunch = false
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .downloading {
|
||||
adjusted.status = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
@@ -844,6 +900,36 @@ private struct StoredDownloadState: Codable {
|
||||
var downloads: [DownloadItem]
|
||||
}
|
||||
|
||||
enum SpeedLimitPolicy {
|
||||
static let maximumKiBPerSecond = 10_485_760
|
||||
|
||||
static func normalized(_ value: Int?) -> Int? {
|
||||
guard let value, value > 0 else { return nil }
|
||||
return min(value, maximumKiBPerSecond)
|
||||
}
|
||||
|
||||
static func effectiveLimit(
|
||||
itemLimit: Int?,
|
||||
globalLimit: Int?,
|
||||
activeDownloadCount: Int
|
||||
) -> Int? {
|
||||
let itemLimit = normalized(itemLimit)
|
||||
let globalLimit = normalized(globalLimit)
|
||||
.map { max(1, $0 / max(activeDownloadCount, 1)) }
|
||||
|
||||
switch (itemLimit, globalLimit) {
|
||||
case let (.some(itemLimit), .some(globalLimit)):
|
||||
return min(itemLimit, globalLimit)
|
||||
case let (.some(itemLimit), .none):
|
||||
return itemLimit
|
||||
case let (.none, .some(globalLimit)):
|
||||
return globalLimit
|
||||
case (.none, .none):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class SleepActivityHandle: @unchecked Sendable {
|
||||
private let activity: NSObjectProtocol
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum DownloadURLParser {
|
||||
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
|
||||
static func parse(_ text: String) -> [URL] {
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
||||
|
||||
@@ -33,6 +34,7 @@ enum DownloadMetadataFetcher {
|
||||
static func fetch(
|
||||
for url: URL,
|
||||
settings: AppSettings,
|
||||
credentials: DownloadCredentials? = nil,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
|
||||
) async -> PendingDownload {
|
||||
let initialName = FileClassifier.fileName(from: url)
|
||||
@@ -55,7 +57,19 @@ enum DownloadMetadataFetcher {
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 12
|
||||
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
||||
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
||||
|
||||
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
|
||||
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
|
||||
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
|
||||
let token = "\(credentials.username):\(credentials.password)"
|
||||
.data(using: .utf8)?
|
||||
.base64EncodedString()
|
||||
if let token {
|
||||
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
for header in normalizedHeaders {
|
||||
request.setValue(header.value, forHTTPHeaderField: header.name)
|
||||
}
|
||||
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
||||
@@ -71,8 +85,8 @@ enum DownloadMetadataFetcher {
|
||||
|
||||
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
||||
let fileName = fileName(fromContentDisposition: disposition) {
|
||||
pending.fileName = fileName
|
||||
pending.category = FileClassifier.category(forFileName: fileName)
|
||||
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
||||
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ struct DownloadPropertiesView: View {
|
||||
@State private var cookieText: String
|
||||
@State private var mirrorText: String
|
||||
@State private var errorMessage = ""
|
||||
@State private var showsAdvancedTransfer = false
|
||||
@State private var showsChunkMap = false
|
||||
|
||||
init(item: DownloadItem) {
|
||||
self.item = item
|
||||
@@ -80,39 +82,87 @@ struct DownloadPropertiesView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
DownloadSummaryHeader(item: item)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
Form {
|
||||
Section("Download") {
|
||||
TextField("URL", text: $urlText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
TextField("File name", text: $fileName)
|
||||
HStack {
|
||||
TextField("Save location", text: $destinationPath)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
|
||||
Toggle("Limit speed", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
if let noticeText {
|
||||
Section {
|
||||
Label(noticeText, systemImage: noticeSystemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Site Login") {
|
||||
Section("Download") {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("URL")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("URL", text: $urlText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("File name")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("File name", text: $fileName)
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Save location")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
TextField("Save location", text: $destinationPath)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(fileIdentityLocked)
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.disabled(fileIdentityLocked)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Connections")
|
||||
.foregroundStyle(.secondary)
|
||||
Stepper("\(connections) per file", value: $connections, in: 1...16)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Speed")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Toggle("Limit", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(item.status == .completed ? "Site Login for Redownload" : "Site Login") {
|
||||
Picker("Login", selection: $loginMode) {
|
||||
ForEach(LoginMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(transferSettingsLocked)
|
||||
|
||||
if loginMode == .matching {
|
||||
Text(matchingLoginText)
|
||||
@@ -120,43 +170,65 @@ struct DownloadPropertiesView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else if loginMode == .custom {
|
||||
TextField("Username", text: $username)
|
||||
.disabled(transferSettingsLocked)
|
||||
SecureField("Password", text: $password)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Advanced Transfer") {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
if checksumEnabled {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
Section {
|
||||
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("Checksum")
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Verify", isOn: $checksumEnabled)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
if checksumEnabled {
|
||||
GridRow {
|
||||
Text("Algorithm")
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Digest")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Cookies")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Cookies", text: $cookieText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 70)
|
||||
}
|
||||
|
||||
TextField("Cookies", text: $cookieText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 70)
|
||||
CompactEditor(title: "Headers", text: $headerText)
|
||||
.disabled(transferSettingsLocked)
|
||||
CompactEditor(title: "Mirrors", text: $mirrorText)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Progress") {
|
||||
ProgressView(value: item.progress)
|
||||
InfoGrid(item: item)
|
||||
if item.status == .downloading && item.rpcPort != nil {
|
||||
Section {
|
||||
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
|
||||
ChunkMapView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -178,10 +250,82 @@ struct DownloadPropertiesView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(14)
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
.frame(width: 620, height: 760)
|
||||
.frame(width: 720, height: 580)
|
||||
}
|
||||
|
||||
private var fileIdentityLocked: Bool {
|
||||
item.status == .completed || item.status == .downloading
|
||||
}
|
||||
|
||||
private var transferSettingsLocked: Bool {
|
||||
item.status == .downloading
|
||||
}
|
||||
|
||||
private var noticeText: String? {
|
||||
switch item.status {
|
||||
case .completed:
|
||||
return "File identity is read-only. Transfer settings are saved for redownload."
|
||||
case .downloading:
|
||||
return "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var noticeSystemImage: String {
|
||||
item.status == .completed ? "checkmark.circle" : "bolt.horizontal.circle"
|
||||
}
|
||||
|
||||
private var advancedTransferTitle: String {
|
||||
item.status == .completed ? "Advanced Transfer for Redownload" : "Advanced Transfer"
|
||||
}
|
||||
|
||||
private struct CompactEditor: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $text)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.frame(minHeight: 44, maxHeight: 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollapsibleGroup<Content: View>: View {
|
||||
let title: String
|
||||
@Binding var isExpanded: Bool
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 12)
|
||||
Text(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
content()
|
||||
.padding(.leading, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var matchingLoginText: String {
|
||||
@@ -214,7 +358,7 @@ struct DownloadPropertiesView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
|
||||
guard !cleanFileName.isEmpty else {
|
||||
errorMessage = "File name cannot be empty."
|
||||
return
|
||||
@@ -278,31 +422,69 @@ struct DownloadPropertiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoGrid: View {
|
||||
private struct DownloadSummaryHeader: View {
|
||||
let item: DownloadItem
|
||||
|
||||
var body: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
|
||||
info("Status", item.status.rawValue)
|
||||
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
info("Size", ByteFormatter.string(item.sizeBytes))
|
||||
info("Speed", item.speedText)
|
||||
info("ETA", item.etaText)
|
||||
info("Live connections", "\(item.connectionCount)")
|
||||
info("Speed cap", item.speedLimitText)
|
||||
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
||||
info("Category", item.category.rawValue)
|
||||
info("Destination", item.destinationPath)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Label(item.status.rawValue, systemImage: item.category.symbolName)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
ProgressView(value: item.status == .completed ? 1.0 : item.progress)
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
|
||||
GridRow {
|
||||
summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
|
||||
summary("Size", ByteFormatter.string(item.sizeBytes))
|
||||
summary("Speed", item.displaySpeedText)
|
||||
summary("ETA", item.displayETAText)
|
||||
}
|
||||
GridRow {
|
||||
summary("Live connections", "\(item.connectionCount)")
|
||||
summary("Speed cap", item.speedLimitText)
|
||||
summary("Category", item.category.rawValue)
|
||||
summary("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
|
||||
}
|
||||
GridRow {
|
||||
summary("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.gridCellColumns(2)
|
||||
summary("Destination", item.destinationPath)
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private func info(_ label: String, _ value: String) -> some View {
|
||||
GridRow {
|
||||
private var statusColor: Color {
|
||||
switch item.status {
|
||||
case .queued:
|
||||
.secondary
|
||||
case .downloading:
|
||||
.accentColor
|
||||
case .paused:
|
||||
.orange
|
||||
case .completed:
|
||||
.green
|
||||
case .failed, .canceled:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private func summary(_ label: String, _ value: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(label)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,70 +37,76 @@ struct DownloadTable: View {
|
||||
|
||||
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)
|
||||
doubleClickableCell(for: item) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor(for: item.category))
|
||||
.frame(width: 22)
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
}
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
TableColumn("Size", value: \.sortableSize) { item in
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
doubleClickableCell(for: item) {
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
progressBarCell(for: item)
|
||||
doubleClickableCell(for: item) {
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
Text(item.status.rawValue)
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.status.rawValue)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 105)
|
||||
|
||||
TableColumn("Speed", value: \.speedText) { item in
|
||||
Text(item.speedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.etaText) { item in
|
||||
Text(item.etaText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.displayETAText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
doubleClickableCell(for: item) {
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.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 {
|
||||
@@ -151,6 +157,26 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleClickableCell<Content: View>(
|
||||
for item: DownloadItem,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
performPrimaryAction(for: item)
|
||||
})
|
||||
}
|
||||
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
@@ -57,10 +57,30 @@ enum FileClassifier {
|
||||
static func fileName(from url: URL) -> String {
|
||||
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
||||
if !pathName.isEmpty, pathName != "/" {
|
||||
return pathName
|
||||
return sanitizedFileName(pathName)
|
||||
}
|
||||
|
||||
let host = url.host(percentEncoded: false) ?? "download"
|
||||
return "\(host)-download"
|
||||
return sanitizedFileName("\(host)-download")
|
||||
}
|
||||
|
||||
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
|
||||
let separators = CharacterSet(charactersIn: "/\\")
|
||||
let invalidCharacters = separators
|
||||
.union(.newlines)
|
||||
.union(.controlCharacters)
|
||||
|
||||
let components = rawValue
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: invalidCharacters)
|
||||
.filter { !$0.isEmpty }
|
||||
let clean = components.joined(separator: "_")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !clean.isEmpty, clean != ".", clean != ".." else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return String(clean.prefix(255))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
final class SparkleUpdater: ObservableObject {
|
||||
let controller: SPUStandardUpdaterController
|
||||
init(controller: SPUStandardUpdaterController) {
|
||||
self.controller = controller
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct FirelinkApp: App {
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
@StateObject private var sparkleUpdater: SparkleUpdater
|
||||
|
||||
@StateObject private var settings: AppSettings
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
@@ -11,6 +22,11 @@ struct FirelinkApp: App {
|
||||
private let extensionServer: LocalExtensionServer?
|
||||
|
||||
init() {
|
||||
// Initialize Sparkle updater
|
||||
let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
||||
self.updaterController = updaterController
|
||||
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater(controller: updaterController))
|
||||
|
||||
let settings = AppSettings()
|
||||
let controller = DownloadController(settings: settings)
|
||||
_settings = StateObject(wrappedValue: settings)
|
||||
@@ -19,6 +35,7 @@ struct FirelinkApp: App {
|
||||
|
||||
extensionServer = LocalExtensionServer(downloadController: controller)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -27,8 +44,14 @@ struct FirelinkApp: App {
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(sparkleUpdater)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.onOpenURL { url in
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
@@ -81,11 +104,12 @@ struct FirelinkApp: App {
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(sparkleUpdater)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 21, height: 21)
|
||||
img.size = NSSize(width: 23, height: 23)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
@@ -95,4 +119,19 @@ struct FirelinkApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarIconURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeIcon = projectRoot
|
||||
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,30 @@ import AppKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let port = NWEndpoint.Port(rawValue: 6412)!
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
static let extensionRequestToken = "firelink-extension-v1"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController) {
|
||||
self.downloadController = downloadController
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
for portValue in 6412...6422 {
|
||||
var selectedPort: UInt16?
|
||||
for portValue in Constants.portRange {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||
do {
|
||||
createdListener = try NWListener(using: parameters)
|
||||
selectedPort = UInt16(portValue)
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
@@ -35,6 +40,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
self.listener = createdListener
|
||||
self.port = selectedPort ?? 6412
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -95,7 +101,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
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")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
@@ -118,6 +124,12 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
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
|
||||
}
|
||||
@@ -126,6 +138,10 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
return .unsupportedMediaType
|
||||
}
|
||||
@@ -155,6 +171,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
|
||||
Task { @MainActor in
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,16 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
|
||||
@@ -35,7 +35,7 @@ struct SchedulerSettings: Codable, Equatable {
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
var targetQueueIDs: Set<UUID> = []
|
||||
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -62,6 +62,9 @@ final class SchedulerController: ObservableObject {
|
||||
} else {
|
||||
self.settings = SchedulerSettings()
|
||||
}
|
||||
if self.settings.targetQueueIDs.isEmpty {
|
||||
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
|
||||
}
|
||||
|
||||
checkAutomationPermission()
|
||||
startTimer()
|
||||
@@ -131,7 +134,8 @@ final class SchedulerController: ObservableObject {
|
||||
}
|
||||
|
||||
private func triggerQueues() {
|
||||
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let runnableQueueIDs = targetQueueIDs.filter { queueID in
|
||||
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
||||
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
||||
}
|
||||
@@ -150,7 +154,8 @@ final class SchedulerController: ObservableObject {
|
||||
private func checkIfRunningFinished() {
|
||||
guard isRunning else { return }
|
||||
|
||||
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
let hasActiveItems = targetQueueIDs.contains { queueID in
|
||||
downloadController.queueItems(for: queueID).contains {
|
||||
$0.status == .queued || $0.status == .downloading
|
||||
}
|
||||
@@ -162,6 +167,10 @@ final class SchedulerController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveTargetQueueIDs() -> Set<UUID> {
|
||||
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func performPostAction() {
|
||||
guard settings.postQueueAction != .doNothing else { return }
|
||||
|
||||
|
||||
@@ -199,7 +199,9 @@ struct SchedulerView: View {
|
||||
isEveryday = schedulerController.settings.isEveryday
|
||||
selectedDays = schedulerController.settings.selectedDays
|
||||
postQueueAction = schedulerController.settings.postQueueAction
|
||||
targetQueueIDs = schedulerController.settings.targetQueueIDs
|
||||
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: schedulerController.settings.targetQueueIDs
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
@@ -208,7 +210,9 @@ struct SchedulerView: View {
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs
|
||||
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
|
||||
? [DownloadQueue.mainQueueID]
|
||||
: targetQueueIDs
|
||||
schedulerController.saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettingsPane: View {
|
||||
@EnvironmentObject var sparkleUpdater: SparkleUpdater
|
||||
|
||||
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section("Updates") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
sparkleUpdater.controller.checkForUpdates(nil)
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
|
||||
} label: {
|
||||
Label("Open Releases", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Created by NimBold")
|
||||
Spacer()
|
||||
Link(destination: projectURL) {
|
||||
HStack(spacing: 4) {
|
||||
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
|
||||
let nsImage = NSImage(contentsOfFile: imgPath) {
|
||||
Image(nsImage: nsImage)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Source Code")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Powered by")
|
||||
Link("aria2", destination: aria2URL)
|
||||
Spacer()
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
|
||||
Text("Copyright © 2026 NimBold. All rights reserved.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DownloadSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(
|
||||
"Default connections per server: \(settings.perServerConnections)",
|
||||
value: $settings.perServerConnections,
|
||||
in: 1...16
|
||||
)
|
||||
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Stepper(
|
||||
"Parallel downloads: \(settings.maxConcurrentDownloads)",
|
||||
value: $settings.maxConcurrentDownloads,
|
||||
in: 1...12
|
||||
)
|
||||
Text("Controls how many files Firelink downloads at the same time.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Global speed limit") {
|
||||
HStack {
|
||||
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct EngineSettingsPane: View {
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status") {
|
||||
Label(
|
||||
executableURL == nil ? "Missing" : "Ready",
|
||||
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
|
||||
)
|
||||
.foregroundStyle(executableURL == nil ? .orange : .green)
|
||||
}
|
||||
|
||||
LabeledContent("Binary") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if executableURL == nil {
|
||||
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationSettingsPane: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@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("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it 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 approved add-on.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Diagnostics") {
|
||||
LabeledContent("Local receiver") {
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Copied folder") {
|
||||
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
|
||||
Label("Ready", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not copied", systemImage: "folder.badge.questionmark")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Permissions & Privacy") {
|
||||
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
|
||||
copiedExtensionURL = downloadsExtensionURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var downloadsExtensionURL: URL {
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
||||
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
|
||||
}
|
||||
|
||||
private func copyExtensionToDownloads() {
|
||||
guard let sourceURL = bundledFirefoxExtensionURL() else {
|
||||
installMessage = "The bundled Firefox extension folder was not found."
|
||||
return
|
||||
}
|
||||
|
||||
let destinationURL = downloadsExtensionURL
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
|
||||
copiedExtensionURL = destinationURL
|
||||
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
|
||||
showCopiedExtensionInFinder()
|
||||
} catch {
|
||||
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func bundledFirefoxExtensionURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
|
||||
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
|
||||
? sourceTreeExtension
|
||||
: nil
|
||||
}
|
||||
|
||||
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
|
||||
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
|
||||
try fileManager.copyItem(
|
||||
at: sourceURL.appendingPathComponent(component),
|
||||
to: destinationURL.appendingPathComponent(component)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func showCopiedExtensionInFinder() {
|
||||
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
|
||||
let manifestURL = folderURL.appendingPathComponent("manifest.json")
|
||||
if FileManager.default.fileExists(atPath: manifestURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
|
||||
} else if FileManager.default.fileExists(atPath: folderURL.path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
|
||||
}
|
||||
}
|
||||
|
||||
private func openFirefoxDebugging() {
|
||||
let bundleIDs = [
|
||||
"org.mozilla.firefoxdeveloperedition",
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.nightly"
|
||||
]
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
for id in bundleIDs {
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
process.arguments = ["-a", appURL.path, "about:debugging"]
|
||||
try? process.run()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if let fallbackURL = URL(string: "about:debugging") {
|
||||
workspace.open(fallbackURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
GridRow {
|
||||
Divider()
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Folders will be created automatically when applied.")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
let category: DownloadCategory
|
||||
|
||||
@State private var path = ""
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
|
||||
Text(displayMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncPathFromSettings()
|
||||
}
|
||||
.onChange(of: settings.downloadDirectories[category]) { _, _ in
|
||||
syncPathFromSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func selectFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.directoryURL = settings.destinationDirectory(for: category)
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
path = url.path
|
||||
settings.setDirectory(url.path, for: category)
|
||||
message = "Saved."
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPathFromSettings() {
|
||||
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
|
||||
message = ""
|
||||
}
|
||||
|
||||
private func applyPath() {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
message = "Enter a folder path."
|
||||
return
|
||||
}
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
guard isDirectory.boolValue else {
|
||||
message = "This path points to a file, not a folder."
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: expanded, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
message = "Could not create folder: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard FileManager.default.isWritableFile(atPath: expanded) else {
|
||||
message = "Firelink cannot write to this folder."
|
||||
return
|
||||
}
|
||||
|
||||
settings.setDirectory(expanded, for: category)
|
||||
path = expanded
|
||||
message = "Saved."
|
||||
}
|
||||
|
||||
private func statusMessage(for path: String) -> String? {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "Enter a folder path." }
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
if !isDirectory.boolValue {
|
||||
return "This path points to a file, not a folder."
|
||||
}
|
||||
return FileManager.default.isWritableFile(atPath: expanded)
|
||||
? nil
|
||||
: "Firelink cannot write to this folder."
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isErrorMessage(_ message: String) -> Bool {
|
||||
message == "This path points to a file, not a folder." ||
|
||||
message.hasPrefix("Could not create folder:") ||
|
||||
message == "Firelink cannot write to this folder." ||
|
||||
message == "Enter a folder path."
|
||||
}
|
||||
}
|
||||
|
||||
struct BulkDirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var path = ""
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Base folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
|
||||
} else {
|
||||
Text("Automatically creates all category folders in the selected path.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
path = url.path
|
||||
applyPath()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPath() {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
message = "Enter a base folder path."
|
||||
return
|
||||
}
|
||||
|
||||
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||
guard isDirectory.boolValue else {
|
||||
message = "This path points to a file, not a folder."
|
||||
return
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: expanded, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
message = "Could not create folder: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard FileManager.default.isWritableFile(atPath: expanded) else {
|
||||
message = "Firelink cannot write to this folder."
|
||||
return
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases {
|
||||
let categoryPath = (expanded as NSString).appendingPathComponent(category.rawValue)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: categoryPath, isDirectory: true),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
settings.setDirectory(categoryPath, for: category)
|
||||
} catch {
|
||||
message = "Could not create category folder \(category.rawValue): \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
message = "Created all categories in base folder."
|
||||
path = ""
|
||||
}
|
||||
|
||||
private func isErrorMessage(_ message: String) -> Bool {
|
||||
message == "This path points to a file, not a folder." ||
|
||||
message.hasPrefix("Could not create folder:") ||
|
||||
message.hasPrefix("Could not create category folder") ||
|
||||
message == "Firelink cannot write to this folder." ||
|
||||
message == "Enter a base folder path."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LookAndFeelSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("App Theme") {
|
||||
Picker("Theme", selection: $settings.appTheme) {
|
||||
ForEach(AppTheme.allCases) { theme in
|
||||
Text(theme.rawValue)
|
||||
.tag(theme)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text("Select a color palette for the app's user interface.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Display") {
|
||||
Picker("Font Size", selection: $settings.appFontSize) {
|
||||
ForEach(AppFontSize.allCases) { size in
|
||||
Text(size.rawValue).tag(size)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("List Row Density", selection: $settings.listRowDensity) {
|
||||
ForEach(ListRowDensity.allCases) { density in
|
||||
Text(density.rawValue).tag(density)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
|
||||
Text("Provides quick access to downloads and queues from the macOS menu bar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NetworkSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Proxy", selection: proxyBinding(\.mode)) {
|
||||
ForEach(ProxyMode.allCases, id: \.self) { mode in
|
||||
Text(mode.title)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
if settings.proxySettings.mode == .custom {
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("IP or Host")
|
||||
TextField("127.0.0.1", text: proxyBinding(\.host))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Port")
|
||||
TextField("8080", value: proxyBinding(\.port), format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 110)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(networkSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if settings.proxySettings.mode == .custom {
|
||||
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var networkSummary: String {
|
||||
switch settings.proxySettings.mode {
|
||||
case .none:
|
||||
"Downloads ignore configured proxies."
|
||||
case .system:
|
||||
"Downloads use the matching macOS system proxy when one is configured."
|
||||
case .custom:
|
||||
if let proxyURI = settings.proxySettings.customProxyURI {
|
||||
"Downloads use \(proxyURI)."
|
||||
} else {
|
||||
"Enter a proxy host and port to enable the custom proxy."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
|
||||
Binding {
|
||||
settings.proxySettings[keyPath: keyPath]
|
||||
} set: { newValue in
|
||||
var proxySettings = settings.proxySettings
|
||||
proxySettings[keyPath: keyPath] = newValue
|
||||
settings.proxySettings = proxySettings
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PowerSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
|
||||
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
activeTab = filter
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: filter.symbolName)
|
||||
.font(.system(size: 16))
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(activeTab.rawValue)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch activeTab {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .lookAndFeel:
|
||||
LookAndFeelSettingsPane()
|
||||
case .network:
|
||||
NetworkSettingsPane()
|
||||
case .locations:
|
||||
LocationsSettingsPane()
|
||||
case .siteLogins:
|
||||
SiteLoginsSettingsPane()
|
||||
case .power:
|
||||
PowerSettingsPane()
|
||||
case .engine:
|
||||
EngineSettingsPane()
|
||||
case .integration:
|
||||
IntegrationSettingsPane()
|
||||
case .about:
|
||||
AboutSettingsPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var editingLoginID: UUID?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if editingLoginID != nil {
|
||||
Button("Cancel Edit") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
settings.saveSiteLogin(
|
||||
id: editingLoginID,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
resetForm()
|
||||
}
|
||||
} label: {
|
||||
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.accessibilityHidden(true)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
edit(login)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func edit(_ login: SiteLogin) {
|
||||
editingLoginID = login.id
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
password = ""
|
||||
}
|
||||
|
||||
private func resetForm() {
|
||||
editingLoginID = nil
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
@@ -1,717 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
activeTab = filter
|
||||
} label: {
|
||||
Text(filter.rawValue)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(activeTab.rawValue)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.bottom, 24)
|
||||
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch activeTab {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .lookAndFeel:
|
||||
LookAndFeelSettingsPane()
|
||||
case .network:
|
||||
NetworkSettingsPane()
|
||||
case .locations:
|
||||
LocationsSettingsPane()
|
||||
case .siteLogins:
|
||||
SiteLoginsSettingsPane()
|
||||
case .power:
|
||||
PowerSettingsPane()
|
||||
case .engine:
|
||||
EngineSettingsPane()
|
||||
case .integration:
|
||||
IntegrationSettingsPane()
|
||||
case .about:
|
||||
AboutSettingsPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LookAndFeelSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("App Theme") {
|
||||
Picker("Theme", selection: $settings.appTheme) {
|
||||
ForEach(AppTheme.allCases) { theme in
|
||||
Text(theme.rawValue)
|
||||
.tag(theme)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text("Select a color palette for the app's user interface.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Display") {
|
||||
Picker("Font Size", selection: $settings.appFontSize) {
|
||||
ForEach(AppFontSize.allCases) { size in
|
||||
Text(size.rawValue).tag(size)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("List Row Density", selection: $settings.listRowDensity) {
|
||||
ForEach(ListRowDensity.allCases) { density in
|
||||
Text(density.rawValue).tag(density)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
|
||||
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AboutSettingsPane: View {
|
||||
@StateObject private var updateChecker = AppUpdateChecker()
|
||||
@State private var availableUpdate: AvailableUpdate?
|
||||
|
||||
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 56, height: 56)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Updates") {
|
||||
LabeledContent("Status") {
|
||||
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
|
||||
.foregroundStyle(updateStatusColor)
|
||||
}
|
||||
|
||||
if let lastChecked = updateChecker.lastChecked {
|
||||
LabeledContent("Last checked") {
|
||||
Text(lastChecked, format: .dateTime.month().day().hour().minute())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
Task {
|
||||
await updateChecker.checkForUpdates(currentVersion: appVersion)
|
||||
}
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(updateChecker.status == .checking)
|
||||
|
||||
Button {
|
||||
openReleasesPage()
|
||||
} label: {
|
||||
Label("Open Releases", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
|
||||
Button {
|
||||
NSWorkspace.shared.open(releaseURL)
|
||||
} label: {
|
||||
Label("Download Latest Version", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Developer") {
|
||||
LabeledContent("Created by") {
|
||||
Text("NimBold")
|
||||
}
|
||||
|
||||
LabeledContent("Source") {
|
||||
Link("nimbold/Firelink", destination: projectURL)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Credits") {
|
||||
LabeledContent("Download engine") {
|
||||
Link("aria2", destination: aria2URL)
|
||||
}
|
||||
|
||||
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Legal") {
|
||||
LabeledContent("License") {
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
|
||||
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onChange(of: updateChecker.status) { _, status in
|
||||
if case .updateAvailable(let latestVersion, let releaseURL) = status {
|
||||
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
|
||||
}
|
||||
}
|
||||
.alert("Update Available", isPresented: Binding(
|
||||
get: { availableUpdate != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
availableUpdate = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Button("Not Now", role: .cancel) {
|
||||
availableUpdate = nil
|
||||
}
|
||||
Button("Yes") {
|
||||
if let releaseURL = availableUpdate?.url {
|
||||
NSWorkspace.shared.open(releaseURL)
|
||||
}
|
||||
availableUpdate = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
|
||||
}
|
||||
}
|
||||
|
||||
private var updateStatusSymbol: String {
|
||||
switch updateChecker.status {
|
||||
case .idle:
|
||||
"sparkle.magnifyingglass"
|
||||
case .checking:
|
||||
"arrow.clockwise"
|
||||
case .upToDate:
|
||||
"checkmark.seal.fill"
|
||||
case .updateAvailable:
|
||||
"arrow.down.circle.fill"
|
||||
case .unavailable:
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var updateStatusColor: Color {
|
||||
switch updateChecker.status {
|
||||
case .idle, .checking:
|
||||
.secondary
|
||||
case .upToDate:
|
||||
.green
|
||||
case .updateAvailable:
|
||||
.accentColor
|
||||
case .unavailable:
|
||||
.orange
|
||||
}
|
||||
}
|
||||
|
||||
private func openReleasesPage() {
|
||||
NSWorkspace.shared.open(updateChecker.releasesURL)
|
||||
}
|
||||
|
||||
private struct AvailableUpdate: Equatable {
|
||||
var version: String
|
||||
var url: URL
|
||||
}
|
||||
}
|
||||
|
||||
private struct EngineSettingsPane: View {
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
private var version: String {
|
||||
Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status") {
|
||||
Label(
|
||||
executableURL == nil ? "Missing" : "Ready",
|
||||
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
|
||||
)
|
||||
.foregroundStyle(executableURL == nil ? .orange : .green)
|
||||
}
|
||||
|
||||
LabeledContent("Binary") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if executableURL == nil {
|
||||
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Proxy", selection: proxyBinding(\.mode)) {
|
||||
ForEach(ProxyMode.allCases, id: \.self) { mode in
|
||||
Text(mode.title)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
if settings.proxySettings.mode == .custom {
|
||||
Picker("Proxy type", selection: proxyBinding(\.type)) {
|
||||
ForEach(ProxyType.allCases, id: \.self) { type in
|
||||
Text(type.title)
|
||||
.tag(type)
|
||||
}
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("IP or Host")
|
||||
TextField("127.0.0.1", text: proxyBinding(\.host))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Port")
|
||||
TextField("8080", value: proxyBinding(\.port), format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 110)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(networkSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var networkSummary: String {
|
||||
switch settings.proxySettings.mode {
|
||||
case .none:
|
||||
"Downloads ignore configured proxies."
|
||||
case .system:
|
||||
"Downloads use the matching macOS system proxy when one is configured."
|
||||
case .custom:
|
||||
if let proxyURI = settings.proxySettings.customProxyURI {
|
||||
"Downloads use \(proxyURI)."
|
||||
} else {
|
||||
"Enter a proxy host and port to enable the custom proxy."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
|
||||
Binding {
|
||||
settings.proxySettings[keyPath: keyPath]
|
||||
} set: { newValue in
|
||||
var proxySettings = settings.proxySettings
|
||||
proxySettings[keyPath: keyPath] = newValue
|
||||
settings.proxySettings = proxySettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(
|
||||
"Default connections per server: \(settings.perServerConnections)",
|
||||
value: $settings.perServerConnections,
|
||||
in: 1...16
|
||||
)
|
||||
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Stepper(
|
||||
"Parallel downloads: \(settings.maxConcurrentDownloads)",
|
||||
value: $settings.maxConcurrentDownloads,
|
||||
in: 1...12
|
||||
)
|
||||
Text("Controls how many files Firelink downloads at the same time.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
let category: DownloadCategory
|
||||
|
||||
@State private var path = ""
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.frame(width: 125, alignment: .leading)
|
||||
|
||||
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))
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.directoryURL = settings.destinationDirectory(for: category)
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
settings.setDirectory(url.path, for: category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@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)
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
} label: {
|
||||
Label("Add Login", systemImage: "plus")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PowerSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
|
||||
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case .relaxed: return 14
|
||||
}
|
||||
}
|
||||
|
||||
var minRowHeight: CGFloat {
|
||||
switch self {
|
||||
case .compact: return 24
|
||||
case .standard: return 32
|
||||
case .relaxed: return 44
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def process_images(src_path):
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
width, height = img.size
|
||||
|
||||
# Apply a standard macOS rounded rectangle mask
|
||||
# macOS standard radius is approx 22.5% of the width
|
||||
radius = int(width * 0.225)
|
||||
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
|
||||
|
||||
# Apply mask
|
||||
img.putalpha(mask)
|
||||
|
||||
# Save standard png
|
||||
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
|
||||
img_1024.save("Resources/AppIcon.png")
|
||||
|
||||
# Save Firefox extension icons
|
||||
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
|
||||
img_48.save("Extensions/Firefox/icons/icon-48.png")
|
||||
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
|
||||
img_128.save("Extensions/Firefox/icons/icon-128.png")
|
||||
|
||||
# MenuBarIconTemplate (64x64 monochrome)
|
||||
data = img.getdata()
|
||||
new_data = []
|
||||
|
||||
for item in data:
|
||||
r, g, b, a = item
|
||||
if r > 100 and r > b * 1.5 and a > 0:
|
||||
alpha = min(255, max(0, int((r - 40) * 1.2)))
|
||||
new_data.append((0, 0, 0, alpha))
|
||||
else:
|
||||
new_data.append((0, 0, 0, 0))
|
||||
|
||||
menu_bar_full = Image.new("RGBA", img.size)
|
||||
menu_bar_full.putdata(new_data)
|
||||
|
||||
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
|
||||
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
|
||||
print("Done generating main PNGs")
|
||||
|
||||
if __name__ == '__main__':
|
||||
process_images(sys.argv[1])
|
||||