mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
Add network proxy settings
This commit is contained in:
@@ -6,6 +6,63 @@ struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
|
|||||||
var username: String
|
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
|
@MainActor
|
||||||
final class AppSettings: ObservableObject {
|
final class AppSettings: ObservableObject {
|
||||||
@Published var perServerConnections: Int {
|
@Published var perServerConnections: Int {
|
||||||
@@ -32,6 +89,17 @@ final class AppSettings: ObservableObject {
|
|||||||
didSet { save() }
|
didSet { save() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var proxySettings: ProxySettings {
|
||||||
|
didSet {
|
||||||
|
let normalized = proxySettings.normalized
|
||||||
|
if proxySettings != normalized {
|
||||||
|
proxySettings = normalized
|
||||||
|
return
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Published var downloadDirectories: [DownloadCategory: String] {
|
@Published var downloadDirectories: [DownloadCategory: String] {
|
||||||
didSet { save() }
|
didSet { save() }
|
||||||
}
|
}
|
||||||
@@ -53,12 +121,14 @@ final class AppSettings: ObservableObject {
|
|||||||
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
||||||
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||||
|
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||||
siteLogins = stored.siteLogins
|
siteLogins = stored.siteLogins
|
||||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||||
} else {
|
} else {
|
||||||
perServerConnections = 16
|
perServerConnections = 16
|
||||||
maxConcurrentDownloads = 3
|
maxConcurrentDownloads = 3
|
||||||
preventsSleepWhileDownloading = true
|
preventsSleepWhileDownloading = true
|
||||||
|
proxySettings = ProxySettings()
|
||||||
siteLogins = []
|
siteLogins = []
|
||||||
downloadDirectories = Self.defaultDirectories()
|
downloadDirectories = Self.defaultDirectories()
|
||||||
}
|
}
|
||||||
@@ -83,6 +153,13 @@ final class AppSettings: ObservableObject {
|
|||||||
downloadDirectories = Self.defaultDirectories()
|
downloadDirectories = Self.defaultDirectories()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var downloadProxyConfiguration: DownloadProxyConfiguration {
|
||||||
|
DownloadProxyConfiguration(
|
||||||
|
mode: proxySettings.mode,
|
||||||
|
customProxyURI: proxySettings.customProxyURI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
func addSiteLogin(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)
|
||||||
@@ -131,6 +208,7 @@ final class AppSettings: ObservableObject {
|
|||||||
perServerConnections: perServerConnections,
|
perServerConnections: perServerConnections,
|
||||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||||
|
proxySettings: proxySettings.normalized,
|
||||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||||
siteLogins: siteLogins
|
siteLogins: siteLogins
|
||||||
)
|
)
|
||||||
@@ -188,6 +266,7 @@ private struct StoredSettings: Codable {
|
|||||||
var perServerConnections: Int
|
var perServerConnections: Int
|
||||||
var maxConcurrentDownloads: Int?
|
var maxConcurrentDownloads: Int?
|
||||||
var preventsSleepWhileDownloading: Bool
|
var preventsSleepWhileDownloading: Bool
|
||||||
|
var proxySettings: ProxySettings?
|
||||||
var downloadDirectories: [String: String]
|
var downloadDirectories: [String: String]
|
||||||
var siteLogins: [SiteLogin]
|
var siteLogins: [SiteLogin]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CFNetwork
|
||||||
|
|
||||||
final class Aria2DownloadEngine {
|
final class Aria2DownloadEngine {
|
||||||
struct Handle {
|
struct Handle {
|
||||||
@@ -9,6 +10,7 @@ final class Aria2DownloadEngine {
|
|||||||
enum EngineError: LocalizedError {
|
enum EngineError: LocalizedError {
|
||||||
case executableNotFound
|
case executableNotFound
|
||||||
case launchFailed(String)
|
case launchFailed(String)
|
||||||
|
case unsupportedProxy(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
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."
|
"aria2c was not found. Install it with `brew install aria2`, or bundle aria2c inside the app resources."
|
||||||
case .launchFailed(let details):
|
case .launchFailed(let details):
|
||||||
"Could not start aria2c: \(details)"
|
"Could not start aria2c: \(details)"
|
||||||
|
case .unsupportedProxy(let details):
|
||||||
|
details
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,7 @@ final class Aria2DownloadEngine {
|
|||||||
|
|
||||||
func start(
|
func start(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
|
proxyConfiguration: DownloadProxyConfiguration,
|
||||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||||
) throws -> Handle {
|
) throws -> Handle {
|
||||||
@@ -69,7 +74,7 @@ final class Aria2DownloadEngine {
|
|||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = executableURL
|
process.executableURL = executableURL
|
||||||
process.arguments = arguments(for: item)
|
process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration)
|
||||||
|
|
||||||
let inputPipe = Pipe()
|
let inputPipe = Pipe()
|
||||||
let outputPipe = 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",
|
"--continue=true",
|
||||||
"--allow-overwrite=false",
|
"--allow-overwrite=false",
|
||||||
"--auto-file-renaming=true",
|
"--auto-file-renaming=true",
|
||||||
@@ -148,6 +153,100 @@ final class Aria2DownloadEngine {
|
|||||||
"--min-split-size=1M",
|
"--min-split-size=1M",
|
||||||
"--input-file=-"
|
"--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 {
|
private func inputFileContent(for item: DownloadItem) -> String {
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let handle = try engine.start(
|
let handle = try engine.start(
|
||||||
item: item,
|
item: item,
|
||||||
|
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||||
progress: { [weak self] progress in
|
progress: { [weak self] progress in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.update(item.id) {
|
self?.update(item.id) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||||
case downloads = "Downloads"
|
case downloads = "Downloads"
|
||||||
|
case network = "Network"
|
||||||
case locations = "Locations"
|
case locations = "Locations"
|
||||||
case siteLogins = "Site Logins"
|
case siteLogins = "Site Logins"
|
||||||
case power = "Power"
|
case power = "Power"
|
||||||
@@ -9,6 +10,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
|
|||||||
var symbolName: String {
|
var symbolName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .downloads: "arrow.down.circle"
|
case .downloads: "arrow.down.circle"
|
||||||
|
case .network: "network"
|
||||||
case .locations: "folder"
|
case .locations: "folder"
|
||||||
case .siteLogins: "key.fill"
|
case .siteLogins: "key.fill"
|
||||||
case .power: "moon.zzz"
|
case .power: "moon.zzz"
|
||||||
@@ -63,6 +65,8 @@ struct SettingsView: View {
|
|||||||
switch selection {
|
switch selection {
|
||||||
case .downloads:
|
case .downloads:
|
||||||
DownloadSettingsPane()
|
DownloadSettingsPane()
|
||||||
|
case .network:
|
||||||
|
NetworkSettingsPane()
|
||||||
case .locations:
|
case .locations:
|
||||||
LocationsSettingsPane()
|
LocationsSettingsPane()
|
||||||
case .siteLogins:
|
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<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
|
||||||
|
Binding {
|
||||||
|
settings.proxySettings[keyPath: keyPath]
|
||||||
|
} set: { newValue in
|
||||||
|
var proxySettings = settings.proxySettings
|
||||||
|
proxySettings[keyPath: keyPath] = newValue
|
||||||
|
settings.proxySettings = proxySettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct DownloadSettingsPane: View {
|
private struct DownloadSettingsPane: View {
|
||||||
@EnvironmentObject private var settings: AppSettings
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user