mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-04 00:18:41 +00:00
feat: add native settings window
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
.build/
|
||||
build/
|
||||
DerivedData/
|
||||
.vscode/
|
||||
*.xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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))",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user