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 {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
case .https: "HTTPS (legacy)"
case .ftp: "FTP (legacy)"
case .socks5: "SOCKS5"
}
}
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
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
copy.port = min(max(copy.port, 1), 65_535)
if copy.type != .http {
copy.type = .http
}
return copy
}
@@ -195,6 +203,10 @@ final class AppSettings: ObservableObject {
}
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 cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -203,12 +215,39 @@ final class AppSettings: ObservableObject {
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
message = "Could not save the password to Keychain."
if let id,
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
message = "A login for \(pattern) already exists."
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)
message = "Added login for \(pattern)."
}
+2 -2
View File
@@ -212,7 +212,7 @@ final class Aria2DownloadEngine {
"id": UUID().uuidString,
"params": [
"token:\(handle.rpcSecret)",
["max-download-limit": limitValue]
["max-overall-download-limit": limitValue]
]
]
@@ -255,7 +255,7 @@ final class Aria2DownloadEngine {
]
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))
+45 -16
View File
@@ -10,6 +10,7 @@ final class DownloadController: ObservableObject {
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
@Published var pendingReferer: String?
@Published var extensionServerPort: UInt16?
var pendingAddQueueID: UUID?
private let settings: AppSettings
@@ -217,6 +218,7 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -242,6 +244,7 @@ final class DownloadController: ObservableObject {
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -262,6 +265,7 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
@@ -330,6 +334,7 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -353,6 +358,7 @@ final class DownloadController: ObservableObject {
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
@@ -511,6 +517,7 @@ final class DownloadController: ObservableObject {
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
}
@@ -522,9 +529,11 @@ final class DownloadController: ObservableObject {
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -569,25 +578,15 @@ final class DownloadController: ObservableObject {
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
SpeedLimitPolicy.normalized(value)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 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
}
SpeedLimitPolicy.effectiveLimit(
itemLimit: item.speedLimitKiBPerSecond,
globalLimit: settings.globalSpeedLimitKiBPerSecond,
activeDownloadCount: activeCount
)
}
private func applySpeedLimitsToActiveDownloads() {
@@ -886,6 +885,36 @@ private struct StoredDownloadState: Codable {
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 let activity: NSObjectProtocol
+1
View File
@@ -19,6 +19,7 @@ struct FirelinkApp: App {
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
var body: some Scene {
@@ -36,7 +36,7 @@ struct DownloadSettingsPane: View {
.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)
.foregroundStyle(.secondary)
}
@@ -2,6 +2,7 @@ import AppKit
import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
@@ -54,10 +55,32 @@ struct IntegrationSettingsPane: View {
.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)
.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") {
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
@State private var path = ""
@State private var message = ""
var body: some View {
LabeledContent {
HStack(spacing: 8) {
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))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("Folder path", text: $path)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
Button {
applyPath()
} label: {
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(category.rawValue, systemImage: category.symbolName)
}
.onAppear {
syncPathFromSettings()
}
.onChange(of: settings.downloadDirectories[category]) { _, _ in
syncPathFromSettings()
}
}
private func selectFolder() {
@@ -62,7 +79,70 @@ struct DirectoryPickerRow: View {
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
path = url.path
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") {
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)
.foregroundStyle(.secondary)
}
@@ -15,13 +15,6 @@ struct NetworkSettingsPane: View {
.pickerStyle(.radioGroup)
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) {
GridRow {
Text("IP or Host")
@@ -42,6 +35,12 @@ struct NetworkSettingsPane: View {
Text(networkSummary)
.font(.caption)
.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)
@@ -5,13 +5,14 @@ struct SiteLoginsSettingsPane: View {
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
@State private var editingLoginID: UUID?
var body: some View {
Form {
Section("Add Login") {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
HStack {
Text(settings.message)
@@ -19,15 +20,23 @@ struct SiteLoginsSettingsPane: View {
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if editingLoginID != nil {
Button("Cancel Edit") {
resetForm()
}
}
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
settings.saveSiteLogin(
id: editingLoginID,
urlPattern: urlPattern,
username: username,
password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
resetForm()
}
} label: {
Label("Add Login", systemImage: "plus")
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
}
.buttonStyle(.borderedProminent)
}
@@ -49,6 +58,13 @@ struct SiteLoginsSettingsPane: View {
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
Button {
edit(login)
} label: {
Label("Edit", systemImage: "pencil")
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
}
}
.onDelete(perform: settings.deleteSiteLogins)
@@ -59,4 +75,18 @@ struct SiteLoginsSettingsPane: View {
}
.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 = ""
}
}