feat: add native settings window

This commit is contained in:
nimbold
2026-06-01 04:23:59 +03:30
parent 5e063f96e5
commit c8624c7c77
9 changed files with 498 additions and 31 deletions
+1
View File
@@ -2,5 +2,6 @@
.build/
build/
DerivedData/
.vscode/
*.xcuserdata/
*.xcuserstate
+6 -2
View File
@@ -10,6 +10,8 @@ This project is early, but it already has a working native prototype and an `ari
- Segmented downloads with 16-32 requested parts per file.
- Multiple files downloading at the same time.
- Queue-based downloads with drag-and-drop priority ordering.
- Native macOS Settings window, available from App menu > Settings and the main toolbar.
- Configurable per-server connection count.
- Automatic save folders under `~/Downloads`:
- `Musics`
- `Movies`
@@ -17,9 +19,11 @@ This project is early, but it already has a working native prototype and an `ari
- `Pictures`
- `Documents`
- `Other`
- Custom download locations per file category.
- Broad file extension detection for audio, video, archive, image, and document formats.
- HTTP, HTTPS, FTP, and SFTP URL support through `aria2c`.
- Optional per-download username/password support for servers that require authentication.
- Site login rules with URL pattern matching and Keychain-stored passwords.
- Optional prevention of system sleep while files are downloading, while still allowing display sleep.
- Pause, resume, cancel, delete, progress, speed, ETA, and connection count display.
- Release `.app` bundle script for local macOS builds.
@@ -60,7 +64,7 @@ Because the current machine only has Command Line Tools selected, this repositor
## Roadmap
- Persist download history and queue state.
- Add Keychain-backed credential storage.
- Improve site-login editing and migration tools.
- Add browser integration and URL capture.
- Add scheduler rules and speed limits.
- Add checksum verification.
+171
View File
@@ -0,0 +1,171 @@
import Foundation
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var urlPattern: String
var username: String
}
@MainActor
final class AppSettings: ObservableObject {
@Published var perServerConnections: Int {
didSet {
let clamped = min(max(perServerConnections, 1), 16)
if perServerConnections != clamped {
perServerConnections = clamped
}
save()
}
}
@Published var preventsSleepWhileDownloading: Bool {
didSet { save() }
}
@Published var downloadDirectories: [DownloadCategory: String] {
didSet { save() }
}
@Published var siteLogins: [SiteLogin] {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
private let storageKey = "Firelink.AppSettings.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
perServerConnections = min(max(stored.perServerConnections, 1), 16)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
perServerConnections = 16
preventsSleepWhileDownloading = true
siteLogins = []
downloadDirectories = Self.defaultDirectories()
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
downloadDirectories[category] = Self.defaultDirectory(for: category).path
}
}
func destinationDirectory(for category: DownloadCategory) -> URL {
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
}
func setDirectory(_ path: String, for category: DownloadCategory) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
}
func resetDirectories() {
downloadDirectories = Self.defaultDirectories()
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty, !cleanUsername.isEmpty else {
message = "Add a URL pattern and username."
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
message = "Could not save the password to Keychain."
return
}
siteLogins.append(login)
message = "Added login for \(pattern)."
}
func deleteSiteLogins(at offsets: IndexSet) {
for offset in offsets {
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
}
siteLogins.remove(atOffsets: offsets)
}
func credentials(for url: URL) -> DownloadCredentials? {
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
let password = KeychainCredentialStore.password(for: login.id) else {
return nil
}
return DownloadCredentials(username: login.username, password: password)
}
private func save() {
let stored = StoredSettings(
perServerConnections: perServerConnections,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
}
}
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !pattern.isEmpty else { return false }
let host = (url.host(percentEncoded: false) ?? "").lowercased()
let absolute = url.absoluteString.lowercased()
let normalizedPattern = URL(string: pattern)?.host ?? pattern
if normalizedPattern.hasPrefix("*.") {
let suffix = String(normalizedPattern.dropFirst(2))
return host == suffix || host.hasSuffix(".\(suffix)")
}
if normalizedPattern.contains("*") {
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
.replacingOccurrences(of: "\\*", with: ".*")
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
}
if normalizedPattern.contains("/") {
return absolute.contains(normalizedPattern)
}
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
}
private static func defaultDirectories() -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
}
private static func defaultDirectory(for category: DownloadCategory) -> URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
}
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
guard let category = DownloadCategory(rawValue: key) else { return nil }
return (category, value)
})
}
}
private struct StoredSettings: Codable {
var perServerConnections: Int
var preventsSleepWhileDownloading: Bool
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
}
+4 -3
View File
@@ -55,6 +55,7 @@ final class Aria2DownloadEngine {
func start(
item: DownloadItem,
perServerConnections: Int,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
@@ -121,7 +122,7 @@ final class Aria2DownloadEngine {
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
if let input = inputFileContent(for: item, perServerConnections: perServerConnections).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
@@ -150,8 +151,8 @@ final class Aria2DownloadEngine {
]
}
private func inputFileContent(for item: DownloadItem) -> String {
let sameHostConnections = min(item.parts, 16)
private func inputFileContent(for item: DownloadItem, perServerConnections: Int) -> String {
let sameHostConnections = min(max(perServerConnections, 1), item.parts)
var lines = [
sanitizedOptionValue(item.url.absoluteString),
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
+5 -21
View File
@@ -3,8 +3,6 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@State private var urlText = ""
@State private var username = ""
@State private var password = ""
@State private var parts = 16.0
@State private var selection: DownloadItem.ID?
@@ -16,8 +14,6 @@ struct ContentView: View {
VStack(spacing: 0) {
AddDownloadBar(
urlText: $urlText,
username: $username,
password: $password,
parts: $parts,
addAction: addDownload
)
@@ -34,6 +30,10 @@ struct ContentView: View {
Label("Start Queue", systemImage: "play.fill")
}
SettingsLink {
Label("Settings", systemImage: "gearshape")
}
if let selectedItem {
if selectedItem.status == .downloading {
Button {
@@ -68,14 +68,10 @@ struct ContentView: View {
private func addDownload() {
controller.add(
urlText: urlText,
parts: Int(parts),
username: username,
password: password
parts: Int(parts)
)
if controller.engineMessage.hasPrefix("Added") {
urlText = ""
username = ""
password = ""
}
}
}
@@ -117,8 +113,6 @@ private struct SidebarView: View {
private struct AddDownloadBar: View {
@Binding var urlText: String
@Binding var username: String
@Binding var password: String
@Binding var parts: Double
let addAction: () -> Void
@@ -146,16 +140,6 @@ private struct AddDownloadBar: View {
.frame(width: 62, alignment: .trailing)
}
Divider()
.frame(height: 22)
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.frame(width: 180)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(width: 180)
Spacer()
}
.font(.callout)
+52 -4
View File
@@ -7,8 +7,24 @@ final class DownloadController: ObservableObject {
@Published var maxConcurrentDownloads = 3
@Published var engineMessage = ""
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var sleepActivity: SleepActivityHandle?
private var settingsCancellable: AnyCancellable?
init(settings: AppSettings) {
self.settings = settings
settingsCancellable = settings.$preventsSleepWhileDownloading.sink { [weak self] _ in
Task { @MainActor in
self?.updateSleepActivity()
}
}
}
deinit {
sleepActivity?.end()
}
var activeCount: Int {
downloads.filter { $0.status == .downloading }.count
@@ -30,7 +46,7 @@ final class DownloadController: ObservableObject {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, parts: Int, username: String, password: String) {
func add(urlText: String, parts: Int) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
@@ -40,14 +56,13 @@ final class DownloadController: ObservableObject {
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
let credentials = DownloadCredentials(username: username, password: password)
let item = DownloadItem(
url: url,
fileName: fileName,
category: category,
destinationDirectory: FileClassifier.destinationDirectory(for: category),
destinationDirectory: settings.destinationDirectory(for: category),
parts: min(max(parts, 16), 32),
credentials: credentials.isEmpty ? nil : credentials
credentials: settings.credentials(for: url)
)
downloads.append(item)
@@ -66,6 +81,7 @@ final class DownloadController: ObservableObject {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
}
updateSleepActivity()
pumpQueue()
}
@@ -84,6 +100,7 @@ final class DownloadController: ObservableObject {
$0.status = .canceled
$0.message = "Canceled"
}
updateSleepActivity()
pumpQueue()
}
@@ -94,6 +111,7 @@ final class DownloadController: ObservableObject {
activeHandles[item.id] = nil
}
downloads.remove(atOffsets: offsets)
updateSleepActivity()
}
func move(from source: IndexSet, to destination: Int) {
@@ -123,6 +141,7 @@ final class DownloadController: ObservableObject {
do {
let handle = try engine.start(
item: item,
perServerConnections: settings.perServerConnections,
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
@@ -161,6 +180,7 @@ final class DownloadController: ObservableObject {
}
self.pumpQueue()
self.updateSleepActivity()
}
}
)
@@ -168,11 +188,13 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.message = "Process \(handle.processIdentifier)"
}
updateSleepActivity()
} catch {
update(item.id) {
$0.status = .failed
$0.message = error.localizedDescription
}
updateSleepActivity()
pumpQueue()
}
}
@@ -181,4 +203,30 @@ final class DownloadController: ObservableObject {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
}
private func updateSleepActivity() {
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
if shouldPreventSleep, sleepActivity == nil {
sleepActivity = SleepActivityHandle(activity: ProcessInfo.processInfo.beginActivity(
options: [.idleSystemSleepDisabled],
reason: "Firelink is downloading files."
))
} else if !shouldPreventSleep, let activity = sleepActivity {
activity.end()
sleepActivity = nil
}
}
}
private final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol
init(activity: NSObjectProtocol) {
self.activity = activity
}
func end() {
ProcessInfo.processInfo.endActivity(activity)
}
}
+14 -1
View File
@@ -2,12 +2,20 @@ import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var controller = DownloadController()
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
init() {
let settings = AppSettings()
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: DownloadController(settings: settings))
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.frame(minWidth: 980, minHeight: 640)
}
.windowStyle(.titleBar)
@@ -19,5 +27,10 @@ struct FirelinkApp: App {
.keyboardShortcut("r", modifiers: [.command])
}
}
Settings {
SettingsView()
.environmentObject(settings)
}
}
}
@@ -0,0 +1,50 @@
import Foundation
import Security
enum KeychainCredentialStore {
private static let service = "local.firelink.site-login"
static func password(for id: UUID) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
@discardableResult
static func setPassword(_ password: String, for id: UUID) -> Bool {
deletePassword(for: id)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
@discardableResult
static func deletePassword(for id: UUID) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
+195
View File
@@ -0,0 +1,195 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
TabView {
DownloadSettingsPane()
.tabItem {
Label("Downloads", systemImage: "arrow.down.circle")
}
LocationsSettingsPane()
.tabItem {
Label("Locations", systemImage: "folder")
}
SiteLoginsSettingsPane()
.tabItem {
Label("Site Logins", systemImage: "person.crop.circle.badge.key")
}
PowerSettingsPane()
.tabItem {
Label("Power", systemImage: "moon.zzz")
}
}
.padding(20)
.frame(width: 680, height: 470)
}
}
private struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used by aria2 for each server. Firelink still lets each download request 16-32 parts.")
.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)
}
}