From c8624c7c77d661c464001706b36cfa3f50028cce Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 1 Jun 2026 04:23:59 +0330 Subject: [PATCH] feat: add native settings window --- .gitignore | 1 + README.md | 8 +- Sources/Firelink/AppSettings.swift | 171 +++++++++++++++ Sources/Firelink/Aria2DownloadEngine.swift | 7 +- Sources/Firelink/ContentView.swift | 26 +-- Sources/Firelink/DownloadController.swift | 56 ++++- Sources/Firelink/FirelinkApp.swift | 15 +- .../Firelink/KeychainCredentialStore.swift | 50 +++++ Sources/Firelink/SettingsView.swift | 195 ++++++++++++++++++ 9 files changed, 498 insertions(+), 31 deletions(-) create mode 100644 Sources/Firelink/AppSettings.swift create mode 100644 Sources/Firelink/KeychainCredentialStore.swift create mode 100644 Sources/Firelink/SettingsView.swift diff --git a/.gitignore b/.gitignore index 50c32fe..fdfc629 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .build/ build/ DerivedData/ +.vscode/ *.xcuserdata/ *.xcuserstate diff --git a/README.md b/README.md index 5bd047e..684681d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift new file mode 100644 index 0000000..509176b --- /dev/null +++ b/Sources/Firelink/AppSettings.swift @@ -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] +} diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index b93f932..a75c0fc 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -55,6 +55,7 @@ final class Aria2DownloadEngine { func start( item: DownloadItem, + perServerConnections: Int, progress: @escaping @Sendable (DownloadProgress) -> Void, completion: @escaping @Sendable (Result) -> 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))", diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 8d81227..ad801d4 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -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) diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 13ad4f3..96207c0 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -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) + } } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 0aa9cce..53f7e92 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -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) + } } } diff --git a/Sources/Firelink/KeychainCredentialStore.swift b/Sources/Firelink/KeychainCredentialStore.swift new file mode 100644 index 0000000..09f968f --- /dev/null +++ b/Sources/Firelink/KeychainCredentialStore.swift @@ -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 + } +} diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift new file mode 100644 index 0000000..a0353a5 --- /dev/null +++ b/Sources/Firelink/SettingsView.swift @@ -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) + } +}