mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(settings): align preferences with runtime behavior
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: f443e096c2...84eb776c43
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user