Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 062c4aec61 | |||
| 8e32762217 | |||
| fec18deb1a | |||
| bf0d1f6ce1 | |||
| d9c7da23e9 | |||
| 2f5e593d0b |
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.5.3] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
|
||||
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
|
||||
|
||||
### Changes
|
||||
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
|
||||
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
|
||||
|
||||
### Fixes
|
||||
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
|
||||
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
|
||||
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
|
||||
|
||||
## [0.5.2] - 2026-06-04
|
||||
|
||||
### Fixes
|
||||
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
|
||||
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
|
||||
|
||||
## [0.5.1] - 2026-06-04
|
||||
|
||||
### Changes
|
||||
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
|
||||
|
||||
## [0.5.0] - 2026-06-04
|
||||
|
||||
### New features
|
||||
|
||||
@@ -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,7 +45,9 @@ 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.
|
||||
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
|
||||
- 🖱️ **Drag-and-Drop:** Seamlessly import URLs or text files directly into the app or Dock icon.
|
||||
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
|
||||
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
|
||||
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, Speed Limiter, and system sleep prevention during active downloads.
|
||||
@@ -70,7 +72,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: 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 |
@@ -38,8 +38,8 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
.toolbar {
|
||||
actionBar
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
|
||||
@@ -218,16 +218,35 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
HStack {
|
||||
Text(actionMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
@ToolbarContentBuilder
|
||||
private var actionBar: some ToolbarContent {
|
||||
ToolbarItem(placement: .status) {
|
||||
HStack {
|
||||
Text(actionMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if metadataTask != nil {
|
||||
Button {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
addDownloads(start: false)
|
||||
} label: {
|
||||
@@ -243,8 +262,6 @@ struct AddDownloadsView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
@@ -414,6 +431,9 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
let rpcSecret: String
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> Int {
|
||||
var port: UInt16 = 6800
|
||||
let parameters = NWParameters.tcp
|
||||
for p in 6800...6900 {
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
|
||||
if let listener = try? NWListener(using: parameters) {
|
||||
listener.cancel()
|
||||
port = UInt16(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
return Int(port)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case executableNotFound
|
||||
case launchFailed(String)
|
||||
@@ -57,30 +74,38 @@ final class Aria2DownloadEngine {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func versionString() -> String? {
|
||||
static func versionString() async -> String? {
|
||||
guard let executableURL = findExecutable() else { return nil }
|
||||
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = Pipe()
|
||||
return await Task.detached {
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = nil
|
||||
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else { return nil }
|
||||
do {
|
||||
try process.run()
|
||||
// Close the write file handle in the parent process immediately
|
||||
// This guarantees readToEnd() won't hang waiting for the parent itself
|
||||
outputPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let data = try? outputPipe.fileHandleForReading.readToEnd()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0, let data = data else { return nil }
|
||||
|
||||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func start(
|
||||
@@ -99,12 +124,17 @@ final class Aria2DownloadEngine {
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
@@ -165,7 +195,7 @@ final class Aria2DownloadEngine {
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier) {
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
@@ -175,7 +205,9 @@ final class Aria2DownloadEngine {
|
||||
private func arguments(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
rpcPort: Int,
|
||||
rpcSecret: String
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--continue=true",
|
||||
@@ -191,7 +223,11 @@ final class Aria2DownloadEngine {
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-"
|
||||
"--input-file=-",
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-port=\(rpcPort)",
|
||||
"--rpc-secret=\(rpcSecret)",
|
||||
"--rpc-listen-all=false"
|
||||
]
|
||||
|
||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||
@@ -342,6 +378,11 @@ final class Aria2DownloadEngine {
|
||||
final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = Data()
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxBytes: Int = 512 * 1024) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
lock.withLock { storage }
|
||||
@@ -350,6 +391,9 @@ final class LockedDataBuffer: @unchecked Sendable {
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChunkMapView: View {
|
||||
let item: DownloadItem
|
||||
|
||||
@State private var bitfield: String = ""
|
||||
@State private var numPieces: Int = 0
|
||||
@State private var pollTask: Task<Void, Never>?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if numPieces > 0 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
|
||||
|
||||
pollTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await fetchStatus(port: port, secret: secret)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchStatus(port: Int, secret: String) async {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"method": "aria2.tellActive",
|
||||
"id": "1",
|
||||
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||
request.httpBody = data
|
||||
|
||||
do {
|
||||
let (responseData, _) = try await URLSession.shared.data(for: request)
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
let active = result.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedBitfield = active["bitfield"] as? String ?? ""
|
||||
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
|
||||
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
|
||||
|
||||
await MainActor.run {
|
||||
self.bitfield = fetchedBitfield
|
||||
self.numPieces = fetchedNumPieces
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
let boxSize: CGFloat = 6
|
||||
let spacing: CGFloat = 1
|
||||
let width = size.width
|
||||
|
||||
let x: CGFloat = 0
|
||||
let y: CGFloat = 0
|
||||
|
||||
let completedPath = Path { p in
|
||||
var cx = x
|
||||
var cy = y
|
||||
for piece in itemPieces {
|
||||
if piece {
|
||||
p.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
|
||||
}
|
||||
cx += boxSize
|
||||
if cx >= width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPath = Path { p in
|
||||
var cx: CGFloat = 0
|
||||
var cy: CGFloat = 0
|
||||
for piece in itemPieces {
|
||||
if !piece {
|
||||
p.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
|
||||
}
|
||||
cx += boxSize
|
||||
if cx >= width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.gray.opacity(0.3)))
|
||||
context.fill(completedPath, with: .color(Color.green))
|
||||
}
|
||||
.frame(minHeight: 80)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,30 @@ 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
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: String.self) {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -116,12 +140,6 @@ struct ContentView: View {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +496,8 @@ final class DownloadController: ObservableObject {
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
}
|
||||
saveDownloads()
|
||||
|
||||
@@ -158,6 +158,12 @@ struct DownloadPropertiesView: View {
|
||||
ProgressView(value: item.progress)
|
||||
InfoGrid(item: item)
|
||||
}
|
||||
|
||||
if item.status == .downloading && item.rpcPort != nil {
|
||||
Section("Chunk Map") {
|
||||
ChunkMapView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ struct FirelinkApp: App {
|
||||
.environmentObject(schedulerController)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.onOpenURL { url in
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
@@ -118,6 +118,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
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
|
||||
@@ -6,25 +6,31 @@ struct SettingsPaneContainer: View {
|
||||
|
||||
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: {
|
||||
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)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(activeTab == filter ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
|
||||
.clipShape(Capsule())
|
||||
.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)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -293,14 +299,12 @@ private struct AboutSettingsPane: View {
|
||||
}
|
||||
|
||||
private struct EngineSettingsPane: View {
|
||||
@State private var version = "Checking..."
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
private var version: String {
|
||||
Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
@@ -332,6 +336,9 @@ private struct EngineSettingsPane: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,18 +449,21 @@ private struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
Form {
|
||||
Section {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,25 +474,26 @@ private struct DirectoryPickerRow: View {
|
||||
@State private var path = ""
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.frame(width: 125, alignment: .leading)
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Folder path", text: Binding(
|
||||
get: { settings.downloadDirectories[category] ?? path },
|
||||
set: { newValue in
|
||||
path = newValue
|
||||
settings.setDirectory(newValue, for: category)
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
TextField("Folder path", text: Binding(
|
||||
get: { settings.downloadDirectories[category] ?? path },
|
||||
set: { newValue in
|
||||
path = newValue
|
||||
settings.setDirectory(newValue, for: category)
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,62 +518,56 @@ private struct SiteLoginsSettingsPane: View {
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("URL Pattern")
|
||||
TextField("*.github.com", text: $urlPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Form {
|
||||
Section("Add Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField("Password", text: $password)
|
||||
|
||||
GridRow {
|
||||
Text("Username")
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Password")
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
|
||||
if settings.message.hasPrefix("Added") {
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button {
|
||||
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
|
||||
if settings.message.hasPrefix("Added") {
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
} label: {
|
||||
Label("Add Login", systemImage: "plus")
|
||||
}
|
||||
} label: {
|
||||
Label("Add Login", systemImage: "plus")
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
Section("Saved Logins") {
|
||||
if settings.siteLogins.isEmpty {
|
||||
Text("No saved logins.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||