fix(settings): align preferences with runtime behavior

This commit is contained in:
nimbold
2026-06-05 11:41:56 +03:30
parent 2b820ec802
commit 4a9d4cab2f
11 changed files with 258 additions and 57 deletions
+45 -6
View File
@@ -29,14 +29,19 @@ enum ProxyType: String, Codable, CaseIterable, Sendable {
var title: String { var title: String {
switch self { switch self {
case .http: "HTTP" case .http: "HTTP"
case .https: "HTTPS" case .https: "HTTPS (legacy)"
case .ftp: "FTP" case .ftp: "FTP (legacy)"
case .socks5: "SOCKS5" case .socks5: "SOCKS5"
} }
} }
var uriScheme: String { var uriScheme: String {
rawValue switch self {
case .http, .https, .ftp:
"http"
case .socks5:
"socks5"
}
} }
} }
@@ -50,6 +55,9 @@ struct ProxySettings: Codable, Equatable, Sendable {
var copy = self var copy = self
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines) copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535) copy.port = min(max(copy.port, 1), 65_535)
if copy.type != .http {
copy.type = .http
}
return copy return copy
} }
@@ -195,6 +203,10 @@ final class AppSettings: ObservableObject {
} }
func addSiteLogin(urlPattern: String, username: String, password: String) { func addSiteLogin(urlPattern: String, username: String, password: String) {
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
}
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines) let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -203,12 +215,39 @@ final class AppSettings: ObservableObject {
return return
} }
let login = SiteLogin(urlPattern: pattern, username: cleanUsername) if let id,
guard KeychainCredentialStore.setPassword(password, for: login.id) else { siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
message = "Could not save the password to Keychain." message = "A login for \(pattern) already exists."
return return
} }
if let index = siteLogins.firstIndex(where: { login in
if let id {
return login.id == id
}
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
}) {
let loginID = siteLogins[index].id
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
message = "Could not save the password to Keychain."
return
}
siteLogins[index].urlPattern = pattern
siteLogins[index].username = cleanUsername
message = "Updated login for \(pattern)."
return
}
guard !password.isEmpty else {
message = "Add a password."
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
if !KeychainCredentialStore.setPassword(password, for: login.id) {
message = "Could not save the password to Keychain."
return
}
siteLogins.append(login) siteLogins.append(login)
message = "Added login for \(pattern)." message = "Added login for \(pattern)."
} }
+2 -2
View File
@@ -212,7 +212,7 @@ final class Aria2DownloadEngine {
"id": UUID().uuidString, "id": UUID().uuidString,
"params": [ "params": [
"token:\(handle.rpcSecret)", "token:\(handle.rpcSecret)",
["max-download-limit": limitValue] ["max-overall-download-limit": limitValue]
] ]
] ]
@@ -255,7 +255,7 @@ final class Aria2DownloadEngine {
] ]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 { if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K") arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
} }
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration)) arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
+45 -16
View File
@@ -10,6 +10,7 @@ final class DownloadController: ObservableObject {
@Published var engineMessage = "" @Published var engineMessage = ""
@Published var pendingPasteboardText: String? @Published var pendingPasteboardText: String?
@Published var pendingReferer: String? @Published var pendingReferer: String?
@Published var extensionServerPort: UInt16?
var pendingAddQueueID: UUID? var pendingAddQueueID: UUID?
private let settings: AppSettings private let settings: AppSettings
@@ -217,6 +218,7 @@ final class DownloadController: ObservableObject {
} }
automaticRetryCounts[item.id] = nil automaticRetryCounts[item.id] = nil
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -242,6 +244,7 @@ final class DownloadController: ObservableObject {
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")." engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -262,6 +265,7 @@ final class DownloadController: ObservableObject {
} }
automaticRetryCounts[item.id] = nil automaticRetryCounts[item.id] = nil
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
} }
@@ -330,6 +334,7 @@ final class DownloadController: ObservableObject {
} }
automaticRetryCounts[item.id] = nil automaticRetryCounts[item.id] = nil
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -353,6 +358,7 @@ final class DownloadController: ObservableObject {
downloads.removeAll { $0.id == item.id } downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil automaticRetryCounts[item.id] = nil
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
} }
@@ -511,6 +517,7 @@ final class DownloadController: ObservableObject {
} }
self.pumpQueue() self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity() self.updateSleepActivity()
} }
} }
@@ -522,9 +529,11 @@ final class DownloadController: ObservableObject {
$0.message = "Process \(handle.processIdentifier)" $0.message = "Process \(handle.processIdentifier)"
} }
saveDownloads() saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
} catch { } catch {
handleDownloadFailure(itemID: item.id, error: error) handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -569,25 +578,15 @@ final class DownloadController: ObservableObject {
} }
private func normalizedSpeedLimit(_ value: Int?) -> Int? { private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil } SpeedLimitPolicy.normalized(value)
return min(value, 10_485_760)
} }
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? { private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond) SpeedLimitPolicy.effectiveLimit(
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond) itemLimit: item.speedLimitKiBPerSecond,
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) } globalLimit: settings.globalSpeedLimitKiBPerSecond,
activeDownloadCount: activeCount
switch (itemLimit, globalLimit) { )
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
} }
private func applySpeedLimitsToActiveDownloads() { private func applySpeedLimitsToActiveDownloads() {
@@ -886,6 +885,36 @@ private struct StoredDownloadState: Codable {
var downloads: [DownloadItem] var downloads: [DownloadItem]
} }
enum SpeedLimitPolicy {
static let maximumKiBPerSecond = 10_485_760
static func normalized(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, maximumKiBPerSecond)
}
static func effectiveLimit(
itemLimit: Int?,
globalLimit: Int?,
activeDownloadCount: Int
) -> Int? {
let itemLimit = normalized(itemLimit)
let globalLimit = normalized(globalLimit)
.map { max(1, $0 / max(activeDownloadCount, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
}
private final class SleepActivityHandle: @unchecked Sendable { private final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol private let activity: NSObjectProtocol
+1
View File
@@ -19,6 +19,7 @@ struct FirelinkApp: App {
extensionServer = LocalExtensionServer(downloadController: controller) extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start() extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
} }
var body: some Scene { var body: some Scene {
@@ -36,7 +36,7 @@ struct DownloadSettingsPane: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Text("Set to 0 for unlimited speed. This limit is divided across all active parallel downloads.") Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -2,6 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
struct IntegrationSettingsPane: View { struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@State private var copiedExtensionURL: URL? @State private var copiedExtensionURL: URL?
@State private var installMessage = "" @State private var installMessage = ""
@@ -54,10 +55,32 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.") Text("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the approved add-on.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("Diagnostics") {
LabeledContent("Local receiver") {
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
LabeledContent("Copied folder") {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Not copied", systemImage: "folder.badge.questionmark")
.foregroundStyle(.secondary)
}
}
}
Section("Permissions & Privacy") { Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.") Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
@@ -28,29 +28,46 @@ struct DirectoryPickerRow: View {
let category: DownloadCategory let category: DownloadCategory
@State private var path = "" @State private var path = ""
@State private var message = ""
var body: some View { var body: some View {
LabeledContent { LabeledContent {
HStack(spacing: 8) { VStack(alignment: .leading, spacing: 4) {
TextField("Folder path", text: Binding( HStack(spacing: 8) {
get: { settings.downloadDirectories[category] ?? path }, TextField("Folder path", text: $path)
set: { newValue in .textFieldStyle(.roundedBorder)
path = newValue .font(.system(.body, design: .monospaced))
settings.setDirectory(newValue, for: category) .onSubmit {
} applyPath()
)) }
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button { Button {
selectFolder() applyPath()
} label: { } label: {
Label("Select", systemImage: "folder.badge.plus") Label("Apply", systemImage: "checkmark")
}
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
} }
Text(message.isEmpty ? statusMessage(for: path) : message)
.font(.caption)
.foregroundStyle(message.isEmpty ? .secondary : .primary)
} }
} label: { } label: {
Label(category.rawValue, systemImage: category.symbolName) Label(category.rawValue, systemImage: category.symbolName)
} }
.onAppear {
syncPathFromSettings()
}
.onChange(of: settings.downloadDirectories[category]) { _, _ in
syncPathFromSettings()
}
} }
private func selectFolder() { private func selectFolder() {
@@ -62,7 +79,70 @@ struct DirectoryPickerRow: View {
panel.directoryURL = settings.destinationDirectory(for: category) panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url { if panel.runModal() == .OK, let url = panel.url {
path = url.path
settings.setDirectory(url.path, for: category) settings.setDirectory(url.path, for: category)
message = "Saved."
} }
} }
private func syncPathFromSettings() {
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
message = ""
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
settings.setDirectory(expanded, for: category)
path = expanded
message = "Saved."
}
private func statusMessage(for path: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "Enter a folder path." }
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
if !isDirectory.boolValue {
return "This path points to a file, not a folder."
}
return FileManager.default.isWritableFile(atPath: expanded)
? "Ready."
: "Firelink cannot write to this folder."
}
return "Folder will be created when applied."
}
} }
@@ -37,7 +37,7 @@ struct LookAndFeelSettingsPane: View {
Section("Menu Bar") { Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon) Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.") Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -15,13 +15,6 @@ struct NetworkSettingsPane: View {
.pickerStyle(.radioGroup) .pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom { if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) { Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow { GridRow {
Text("IP or Host") Text("IP or Host")
@@ -42,6 +35,12 @@ struct NetworkSettingsPane: View {
Text(networkSummary) Text(networkSummary)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if settings.proxySettings.mode == .custom {
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
.font(.caption)
.foregroundStyle(.secondary)
}
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
@@ -5,13 +5,14 @@ struct SiteLoginsSettingsPane: View {
@State private var urlPattern = "" @State private var urlPattern = ""
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var editingLoginID: UUID?
var body: some View { var body: some View {
Form { Form {
Section("Add Login") { Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern) TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username) TextField("Username", text: $username)
SecureField("Password", text: $password) SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
HStack { HStack {
Text(settings.message) Text(settings.message)
@@ -19,15 +20,23 @@ struct SiteLoginsSettingsPane: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
if editingLoginID != nil {
Button("Cancel Edit") {
resetForm()
}
}
Button { Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password) settings.saveSiteLogin(
if settings.message.hasPrefix("Added") { id: editingLoginID,
urlPattern = "" urlPattern: urlPattern,
username = "" username: username,
password = "" password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
resetForm()
} }
} label: { } label: {
Label("Add Login", systemImage: "plus") Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
@@ -49,6 +58,13 @@ struct SiteLoginsSettingsPane: View {
Spacer() Spacer()
Text(login.username) Text(login.username)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button {
edit(login)
} label: {
Label("Edit", systemImage: "pencil")
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
} }
} }
.onDelete(perform: settings.deleteSiteLogins) .onDelete(perform: settings.deleteSiteLogins)
@@ -59,4 +75,18 @@ struct SiteLoginsSettingsPane: View {
} }
.formStyle(.grouped) .formStyle(.grouped)
} }
private func edit(_ login: SiteLogin) {
editingLoginID = login.id
urlPattern = login.urlPattern
username = login.username
password = ""
}
private func resetForm() {
editingLoginID = nil
urlPattern = ""
username = ""
password = ""
}
} }