From 22aeff4740e04b93185d48b2dac37a6fe54eabf5 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:15:53 +0330 Subject: [PATCH] Add network proxy settings --- Sources/Firelink/AppSettings.swift | 79 ++++++++++++++++ Sources/Firelink/Aria2DownloadEngine.swift | 105 ++++++++++++++++++++- Sources/Firelink/DownloadController.swift | 1 + Sources/Firelink/SettingsView.swift | 77 +++++++++++++++ 4 files changed, 259 insertions(+), 3 deletions(-) diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 5e521c4..c498265 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -6,6 +6,63 @@ struct SiteLogin: Identifiable, Codable, Equatable, Sendable { var username: String } +enum ProxyMode: String, Codable, CaseIterable, Sendable { + case none + case system + case custom + + var title: String { + switch self { + case .none: "No proxy" + case .system: "Use system proxy" + case .custom: "Set proxy" + } + } +} + +enum ProxyType: String, Codable, CaseIterable, Sendable { + case http + case https + case ftp + + var title: String { + switch self { + case .http: "HTTP" + case .https: "HTTPS" + case .ftp: "FTP" + } + } + + var uriScheme: String { + rawValue + } +} + +struct ProxySettings: Codable, Equatable, Sendable { + var mode: ProxyMode = .none + var type: ProxyType = .http + var host = "" + var port = 8080 + + var normalized: ProxySettings { + var copy = self + copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines) + copy.port = min(max(copy.port, 1), 65_535) + return copy + } + + var customProxyURI: String? { + let clean = normalized + guard !clean.host.isEmpty else { return nil } + return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)" + } +} + +struct DownloadProxyConfiguration: Equatable, Sendable { + var mode: ProxyMode + var customProxyURI: String? +} + @MainActor final class AppSettings: ObservableObject { @Published var perServerConnections: Int { @@ -32,6 +89,17 @@ final class AppSettings: ObservableObject { didSet { save() } } + @Published var proxySettings: ProxySettings { + didSet { + let normalized = proxySettings.normalized + if proxySettings != normalized { + proxySettings = normalized + return + } + save() + } + } + @Published var downloadDirectories: [DownloadCategory: String] { didSet { save() } } @@ -53,12 +121,14 @@ final class AppSettings: ObservableObject { perServerConnections = min(max(stored.perServerConnections, 1), 16) maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12) preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading + proxySettings = stored.proxySettings?.normalized ?? ProxySettings() siteLogins = stored.siteLogins downloadDirectories = Self.decodeDirectories(stored.downloadDirectories) } else { perServerConnections = 16 maxConcurrentDownloads = 3 preventsSleepWhileDownloading = true + proxySettings = ProxySettings() siteLogins = [] downloadDirectories = Self.defaultDirectories() } @@ -83,6 +153,13 @@ final class AppSettings: ObservableObject { downloadDirectories = Self.defaultDirectories() } + var downloadProxyConfiguration: DownloadProxyConfiguration { + DownloadProxyConfiguration( + mode: proxySettings.mode, + customProxyURI: proxySettings.customProxyURI + ) + } + func addSiteLogin(urlPattern: String, username: String, password: String) { let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines) let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) @@ -131,6 +208,7 @@ final class AppSettings: ObservableObject { perServerConnections: perServerConnections, maxConcurrentDownloads: maxConcurrentDownloads, preventsSleepWhileDownloading: preventsSleepWhileDownloading, + proxySettings: proxySettings.normalized, downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }), siteLogins: siteLogins ) @@ -188,6 +266,7 @@ private struct StoredSettings: Codable { var perServerConnections: Int var maxConcurrentDownloads: Int? var preventsSleepWhileDownloading: Bool + var proxySettings: ProxySettings? var downloadDirectories: [String: String] var siteLogins: [SiteLogin] } diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 4bbd99f..0938a4e 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -1,4 +1,5 @@ import Foundation +import CFNetwork final class Aria2DownloadEngine { struct Handle { @@ -9,6 +10,7 @@ final class Aria2DownloadEngine { enum EngineError: LocalizedError { case executableNotFound case launchFailed(String) + case unsupportedProxy(String) var errorDescription: String? { switch self { @@ -16,6 +18,8 @@ final class Aria2DownloadEngine { "aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources." case .launchFailed(let details): "Could not start aria2c: \(details)" + case .unsupportedProxy(let details): + details } } } @@ -55,6 +59,7 @@ final class Aria2DownloadEngine { func start( item: DownloadItem, + proxyConfiguration: DownloadProxyConfiguration, progress: @escaping @Sendable (DownloadProgress) -> Void, completion: @escaping @Sendable (Result) -> Void ) throws -> Handle { @@ -69,7 +74,7 @@ final class Aria2DownloadEngine { let process = Process() process.executableURL = executableURL - process.arguments = arguments(for: item) + process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration) let inputPipe = Pipe() let outputPipe = Pipe() @@ -136,8 +141,8 @@ final class Aria2DownloadEngine { } } - private func arguments(for item: DownloadItem) -> [String] { - [ + private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] { + var arguments = [ "--continue=true", "--allow-overwrite=false", "--auto-file-renaming=true", @@ -148,6 +153,100 @@ final class Aria2DownloadEngine { "--min-split-size=1M", "--input-file=-" ] + + arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration)) + return arguments + } + + private func proxyArguments(for item: DownloadItem, configuration: DownloadProxyConfiguration) throws -> [String] { + switch configuration.mode { + case .none: + return clearedProxyArguments() + case .system: + switch systemProxyResolution(for: item.url) { + case .direct: + return clearedProxyArguments() + case .proxy(let proxyURI): + return ["\(proxyArgumentName(for: item.url.scheme))=\(sanitizedOptionValue(proxyURI))"] + case .unsupported(let message): + throw EngineError.unsupportedProxy(message) + } + case .custom: + guard let proxyURI = configuration.customProxyURI else { return [] } + return ["--all-proxy=\(sanitizedOptionValue(proxyURI))"] + } + } + + private func clearedProxyArguments() -> [String] { + [ + "--all-proxy=", + "--http-proxy=", + "--https-proxy=", + "--ftp-proxy=" + ] + } + + private func proxyArgumentName(for urlScheme: String?) -> String { + switch urlScheme?.lowercased() { + case "http": "--http-proxy" + case "https": "--https-proxy" + case "ftp": "--ftp-proxy" + default: "--all-proxy" + } + } + + private enum SystemProxyResolution { + case direct + case proxy(String) + case unsupported(String) + } + + private func systemProxyResolution(for url: URL) -> SystemProxyResolution { + guard let systemSettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any], + let proxies = CFNetworkCopyProxiesForURL(url as CFURL, systemSettings as CFDictionary).takeRetainedValue() as? [[String: Any]] else { + return .direct + } + + for proxy in proxies { + guard let type = proxy[kCFProxyTypeKey as String] as? String else { continue } + if type == kCFProxyTypeNone as String { + return .direct + } + if type == kCFProxyTypeSOCKS as String { + return .unsupported("aria2c does not support SOCKS system proxies. Choose an HTTP, HTTPS, or FTP proxy in Network settings.") + } + if type == kCFProxyTypeAutoConfigurationURL as String || + type == kCFProxyTypeAutoConfigurationJavaScript as String { + return .unsupported("aria2c does not support automatic system proxy configuration. Choose a manual proxy in Network settings.") + } + if let uri = proxyURI(fromSystemProxy: proxy, type: type) { + return .proxy(uri) + } + } + + return .direct + } + + private func proxyURI(fromSystemProxy proxy: [String: Any], type: String) -> String? { + guard let host = proxy[kCFProxyHostNameKey as String] as? String, + !host.isEmpty else { + return nil + } + + let port = (proxy[kCFProxyPortNumberKey as String] as? NSNumber)?.intValue + let scheme: String + if type == kCFProxyTypeHTTPS as String { + scheme = "https" + } else if type == kCFProxyTypeFTP as String { + scheme = "ftp" + } else { + scheme = "http" + } + + guard let port else { + return "\(scheme)://\(host)" + } + return "\(scheme)://\(host):\(port)" } private func inputFileContent(for item: DownloadItem) -> String { diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 9c72a7b..8a20495 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -221,6 +221,7 @@ final class DownloadController: ObservableObject { do { let handle = try engine.start( item: item, + proxyConfiguration: settings.downloadProxyConfiguration, progress: { [weak self] progress in Task { @MainActor in self?.update(item.id) { diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index c6f989f..aa09202 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI private enum SettingsSection: String, CaseIterable, Hashable { case downloads = "Downloads" + case network = "Network" case locations = "Locations" case siteLogins = "Site Logins" case power = "Power" @@ -9,6 +10,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { var symbolName: String { switch self { case .downloads: "arrow.down.circle" + case .network: "network" case .locations: "folder" case .siteLogins: "key.fill" case .power: "moon.zzz" @@ -63,6 +65,8 @@ struct SettingsView: View { switch selection { case .downloads: DownloadSettingsPane() + case .network: + NetworkSettingsPane() case .locations: LocationsSettingsPane() case .siteLogins: @@ -73,6 +77,79 @@ struct SettingsView: View { } } +private struct NetworkSettingsPane: View { + @EnvironmentObject private var settings: AppSettings + + var body: some View { + Form { + Section { + Picker("Proxy", selection: proxyBinding(\.mode)) { + ForEach(ProxyMode.allCases, id: \.self) { mode in + Text(mode.title) + .tag(mode) + } + } + .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") + TextField("127.0.0.1", text: proxyBinding(\.host)) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + + GridRow { + Text("Port") + TextField("8080", value: proxyBinding(\.port), format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 110) + } + } + } + + Text(networkSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } + + private var networkSummary: String { + switch settings.proxySettings.mode { + case .none: + "Downloads ignore configured proxies." + case .system: + "Downloads use the matching macOS system proxy when one is configured." + case .custom: + if let proxyURI = settings.proxySettings.customProxyURI { + "Downloads use \(proxyURI)." + } else { + "Enter a proxy host and port to enable the custom proxy." + } + } + } + + private func proxyBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + settings.proxySettings[keyPath: keyPath] + } set: { newValue in + var proxySettings = settings.proxySettings + proxySettings[keyPath: keyPath] = newValue + settings.proxySettings = proxySettings + } + } +} + private struct DownloadSettingsPane: View { @EnvironmentObject private var settings: AppSettings