diff --git a/Extensions/Firefox b/Extensions/Firefox index f443e09..84eb776 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit f443e096c2fac58cb3d06f97cd792a602582ef14 +Subproject commit 84eb776c437e98d54f58df45940549eaed008b44 diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 26c9edc..27fc258 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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)." } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 1cd5bc1..e61c704 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -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)) diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index a106deb..d340b59 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -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 diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index af5f06c..2a613db 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -19,6 +19,7 @@ struct FirelinkApp: App { extensionServer = LocalExtensionServer(downloadController: controller) extensionServer?.start() + controller.extensionServerPort = extensionServer?.port } var body: some Scene { diff --git a/Sources/Firelink/Settings/DownloadSettingsPane.swift b/Sources/Firelink/Settings/DownloadSettingsPane.swift index 3070308..b6568bf 100644 --- a/Sources/Firelink/Settings/DownloadSettingsPane.swift +++ b/Sources/Firelink/Settings/DownloadSettingsPane.swift @@ -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) } diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index 25f4b12..8e45957 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -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.") diff --git a/Sources/Firelink/Settings/LocationsSettingsPane.swift b/Sources/Firelink/Settings/LocationsSettingsPane.swift index 72a9eb7..8721558 100644 --- a/Sources/Firelink/Settings/LocationsSettingsPane.swift +++ b/Sources/Firelink/Settings/LocationsSettingsPane.swift @@ -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." + } } diff --git a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift index b30e4a2..7d0df1d 100644 --- a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift +++ b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift @@ -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) } diff --git a/Sources/Firelink/Settings/NetworkSettingsPane.swift b/Sources/Firelink/Settings/NetworkSettingsPane.swift index a92b6f4..e2ae35f 100644 --- a/Sources/Firelink/Settings/NetworkSettingsPane.swift +++ b/Sources/Firelink/Settings/NetworkSettingsPane.swift @@ -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) diff --git a/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift index 3467f93..de6313f 100644 --- a/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift +++ b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift @@ -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 = "" + } }