mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-25 18:17:21 +00:00
feat: add native settings window
This commit is contained in:
@@ -2,5 +2,6 @@
|
|||||||
.build/
|
.build/
|
||||||
build/
|
build/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
.vscode/
|
||||||
*.xcuserdata/
|
*.xcuserdata/
|
||||||
*.xcuserstate
|
*.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.
|
- Segmented downloads with 16-32 requested parts per file.
|
||||||
- Multiple files downloading at the same time.
|
- Multiple files downloading at the same time.
|
||||||
- Queue-based downloads with drag-and-drop priority ordering.
|
- 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`:
|
- Automatic save folders under `~/Downloads`:
|
||||||
- `Musics`
|
- `Musics`
|
||||||
- `Movies`
|
- `Movies`
|
||||||
@@ -17,9 +19,11 @@ This project is early, but it already has a working native prototype and an `ari
|
|||||||
- `Pictures`
|
- `Pictures`
|
||||||
- `Documents`
|
- `Documents`
|
||||||
- `Other`
|
- `Other`
|
||||||
|
- Custom download locations per file category.
|
||||||
- Broad file extension detection for audio, video, archive, image, and document formats.
|
- Broad file extension detection for audio, video, archive, image, and document formats.
|
||||||
- HTTP, HTTPS, FTP, and SFTP URL support through `aria2c`.
|
- 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.
|
- Pause, resume, cancel, delete, progress, speed, ETA, and connection count display.
|
||||||
- Release `.app` bundle script for local macOS builds.
|
- 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
|
## Roadmap
|
||||||
|
|
||||||
- Persist download history and queue state.
|
- 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 browser integration and URL capture.
|
||||||
- Add scheduler rules and speed limits.
|
- Add scheduler rules and speed limits.
|
||||||
- Add checksum verification.
|
- 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(
|
func start(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
|
perServerConnections: Int,
|
||||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||||
) throws -> Handle {
|
) throws -> Handle {
|
||||||
@@ -121,7 +122,7 @@ final class Aria2DownloadEngine {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
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.write(input)
|
||||||
}
|
}
|
||||||
inputPipe.fileHandleForWriting.closeFile()
|
inputPipe.fileHandleForWriting.closeFile()
|
||||||
@@ -150,8 +151,8 @@ final class Aria2DownloadEngine {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inputFileContent(for item: DownloadItem) -> String {
|
private func inputFileContent(for item: DownloadItem, perServerConnections: Int) -> String {
|
||||||
let sameHostConnections = min(item.parts, 16)
|
let sameHostConnections = min(max(perServerConnections, 1), item.parts)
|
||||||
var lines = [
|
var lines = [
|
||||||
sanitizedOptionValue(item.url.absoluteString),
|
sanitizedOptionValue(item.url.absoluteString),
|
||||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var controller: DownloadController
|
@EnvironmentObject private var controller: DownloadController
|
||||||
@State private var urlText = ""
|
@State private var urlText = ""
|
||||||
@State private var username = ""
|
|
||||||
@State private var password = ""
|
|
||||||
@State private var parts = 16.0
|
@State private var parts = 16.0
|
||||||
@State private var selection: DownloadItem.ID?
|
@State private var selection: DownloadItem.ID?
|
||||||
|
|
||||||
@@ -16,8 +14,6 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
AddDownloadBar(
|
AddDownloadBar(
|
||||||
urlText: $urlText,
|
urlText: $urlText,
|
||||||
username: $username,
|
|
||||||
password: $password,
|
|
||||||
parts: $parts,
|
parts: $parts,
|
||||||
addAction: addDownload
|
addAction: addDownload
|
||||||
)
|
)
|
||||||
@@ -34,6 +30,10 @@ struct ContentView: View {
|
|||||||
Label("Start Queue", systemImage: "play.fill")
|
Label("Start Queue", systemImage: "play.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsLink {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
|
||||||
if let selectedItem {
|
if let selectedItem {
|
||||||
if selectedItem.status == .downloading {
|
if selectedItem.status == .downloading {
|
||||||
Button {
|
Button {
|
||||||
@@ -68,14 +68,10 @@ struct ContentView: View {
|
|||||||
private func addDownload() {
|
private func addDownload() {
|
||||||
controller.add(
|
controller.add(
|
||||||
urlText: urlText,
|
urlText: urlText,
|
||||||
parts: Int(parts),
|
parts: Int(parts)
|
||||||
username: username,
|
|
||||||
password: password
|
|
||||||
)
|
)
|
||||||
if controller.engineMessage.hasPrefix("Added") {
|
if controller.engineMessage.hasPrefix("Added") {
|
||||||
urlText = ""
|
urlText = ""
|
||||||
username = ""
|
|
||||||
password = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,8 +113,6 @@ private struct SidebarView: View {
|
|||||||
|
|
||||||
private struct AddDownloadBar: View {
|
private struct AddDownloadBar: View {
|
||||||
@Binding var urlText: String
|
@Binding var urlText: String
|
||||||
@Binding var username: String
|
|
||||||
@Binding var password: String
|
|
||||||
@Binding var parts: Double
|
@Binding var parts: Double
|
||||||
let addAction: () -> Void
|
let addAction: () -> Void
|
||||||
|
|
||||||
@@ -146,16 +140,6 @@ private struct AddDownloadBar: View {
|
|||||||
.frame(width: 62, alignment: .trailing)
|
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
|||||||
@@ -7,8 +7,24 @@ final class DownloadController: ObservableObject {
|
|||||||
@Published var maxConcurrentDownloads = 3
|
@Published var maxConcurrentDownloads = 3
|
||||||
@Published var engineMessage = ""
|
@Published var engineMessage = ""
|
||||||
|
|
||||||
|
private let settings: AppSettings
|
||||||
private let engine = Aria2DownloadEngine()
|
private let engine = Aria2DownloadEngine()
|
||||||
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
|
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 {
|
var activeCount: Int {
|
||||||
downloads.filter { $0.status == .downloading }.count
|
downloads.filter { $0.status == .downloading }.count
|
||||||
@@ -30,7 +46,7 @@ final class DownloadController: ObservableObject {
|
|||||||
Aria2DownloadEngine.findExecutable() != nil
|
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)),
|
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
let scheme = url.scheme?.lowercased(),
|
let scheme = url.scheme?.lowercased(),
|
||||||
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
||||||
@@ -40,14 +56,13 @@ final class DownloadController: ObservableObject {
|
|||||||
|
|
||||||
let fileName = FileClassifier.fileName(from: url)
|
let fileName = FileClassifier.fileName(from: url)
|
||||||
let category = FileClassifier.category(forFileName: fileName)
|
let category = FileClassifier.category(forFileName: fileName)
|
||||||
let credentials = DownloadCredentials(username: username, password: password)
|
|
||||||
let item = DownloadItem(
|
let item = DownloadItem(
|
||||||
url: url,
|
url: url,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
category: category,
|
category: category,
|
||||||
destinationDirectory: FileClassifier.destinationDirectory(for: category),
|
destinationDirectory: settings.destinationDirectory(for: category),
|
||||||
parts: min(max(parts, 16), 32),
|
parts: min(max(parts, 16), 32),
|
||||||
credentials: credentials.isEmpty ? nil : credentials
|
credentials: settings.credentials(for: url)
|
||||||
)
|
)
|
||||||
|
|
||||||
downloads.append(item)
|
downloads.append(item)
|
||||||
@@ -66,6 +81,7 @@ final class DownloadController: ObservableObject {
|
|||||||
$0.status = .paused
|
$0.status = .paused
|
||||||
$0.message = "Paused. Resume will continue from the partial file."
|
$0.message = "Paused. Resume will continue from the partial file."
|
||||||
}
|
}
|
||||||
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +100,7 @@ final class DownloadController: ObservableObject {
|
|||||||
$0.status = .canceled
|
$0.status = .canceled
|
||||||
$0.message = "Canceled"
|
$0.message = "Canceled"
|
||||||
}
|
}
|
||||||
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +111,7 @@ final class DownloadController: ObservableObject {
|
|||||||
activeHandles[item.id] = nil
|
activeHandles[item.id] = nil
|
||||||
}
|
}
|
||||||
downloads.remove(atOffsets: offsets)
|
downloads.remove(atOffsets: offsets)
|
||||||
|
updateSleepActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
func move(from source: IndexSet, to destination: Int) {
|
func move(from source: IndexSet, to destination: Int) {
|
||||||
@@ -123,6 +141,7 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let handle = try engine.start(
|
let handle = try engine.start(
|
||||||
item: item,
|
item: item,
|
||||||
|
perServerConnections: settings.perServerConnections,
|
||||||
progress: { [weak self] progress in
|
progress: { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.update(item.id) {
|
self?.update(item.id) {
|
||||||
@@ -161,6 +180,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.pumpQueue()
|
self.pumpQueue()
|
||||||
|
self.updateSleepActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -168,11 +188,13 @@ final class DownloadController: ObservableObject {
|
|||||||
update(item.id) {
|
update(item.id) {
|
||||||
$0.message = "Process \(handle.processIdentifier)"
|
$0.message = "Process \(handle.processIdentifier)"
|
||||||
}
|
}
|
||||||
|
updateSleepActivity()
|
||||||
} catch {
|
} catch {
|
||||||
update(item.id) {
|
update(item.id) {
|
||||||
$0.status = .failed
|
$0.status = .failed
|
||||||
$0.message = error.localizedDescription
|
$0.message = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,4 +203,30 @@ final class DownloadController: ObservableObject {
|
|||||||
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
|
||||||
mutate(&downloads[index])
|
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
|
@main
|
||||||
struct FirelinkApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(controller)
|
.environmentObject(controller)
|
||||||
|
.environmentObject(settings)
|
||||||
.frame(minWidth: 980, minHeight: 640)
|
.frame(minWidth: 980, minHeight: 640)
|
||||||
}
|
}
|
||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
@@ -19,5 +27,10 @@ struct FirelinkApp: App {
|
|||||||
.keyboardShortcut("r", modifiers: [.command])
|
.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