Files

119 lines
5.6 KiB
Swift

import Foundation
import Network
public struct ApplicationSettings: Codable, Equatable, Sendable {
public var userAgent = ""
public var proxy = ""
public var hosts = ""
public var timeoutSeconds = 0
public var concurrentSources = 32
public var chapterCacheMB = 256
public var prefetchChapters = 1
public var recordLog = false
public var ignoreCertificateErrors = true
public init() {}
public var effectiveUserAgent: String {
userAgent.isEmpty ? LegadoURLBuilder.defaultUserAgent : userAgent
}
public func validated() throws -> Self {
var value = self
value.userAgent = userAgent.trimmingCharacters(in: .whitespacesAndNewlines)
value.proxy = proxy.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.userAgent.contains(where: { $0.isNewline || $0 == "\0" }) else {
throw SettingsError.invalid("User-Agent 不能包含换行。")
}
if !value.proxy.isEmpty { _ = try Self.proxyDictionary(value.proxy) }
_ = try Self.parseHosts(hosts)
guard timeoutSeconds == 0 || (5...300).contains(timeoutSeconds),
(1...64).contains(concurrentSources), (16...4096).contains(chapterCacheMB),
(0...20).contains(prefetchChapters) else {
throw SettingsError.invalid("网络或缓存设置超出允许范围。")
}
return value
}
public func configure(_ configuration: URLSessionConfiguration, proxyOverride: String? = nil) throws {
configuration.httpMaximumConnectionsPerHost = concurrentSources
let address = proxyOverride ?? proxy
configuration.connectionProxyDictionary = address.isEmpty ? nil : try Self.proxyDictionary(address)
if timeoutSeconds > 0 {
configuration.timeoutIntervalForRequest = Double(timeoutSeconds)
configuration.timeoutIntervalForResource = Double(timeoutSeconds)
}
}
public func configure(_ request: URLRequest) -> URLRequest {
var request = request
if timeoutSeconds > 0 { request.timeoutInterval = Double(timeoutSeconds) }
if request.value(forHTTPHeaderField: "User-Agent") == nil,
URLProtocol.property(forKey: "LegadoSuppressUserAgent", in: request) as? Bool != true {
request.setValue(effectiveUserAgent, forHTTPHeaderField: "User-Agent")
}
return request
}
public static func proxyDictionary(_ address: String) throws -> [AnyHashable: Any] {
guard let url = URL(string: address), let host = url.host, !host.isEmpty,
let scheme = url.scheme?.lowercased(), ["http", "https", "socks", "socks4", "socks5"].contains(scheme),
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
url.path.isEmpty || url.path == "/", (1...65535).contains(url.port ?? 8080) else {
throw SettingsError.invalid("代理格式应为 http://主机:端口、https://主机:端口或 socks5://主机:端口,不含账号和密码。")
}
let port = url.port ?? (scheme.hasPrefix("socks") ? 1080 : scheme == "https" ? 443 : 8080)
if scheme.hasPrefix("socks") {
return ["SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port]
}
return ["HTTPEnable": 1, "HTTPProxy": host, "HTTPPort": port,
"HTTPSEnable": 1, "HTTPSProxy": host, "HTTPSPort": port]
}
public static func parseHosts(_ text: String) throws -> [String: String] {
var values: [String: String] = [:]
for (index, line) in text.components(separatedBy: .newlines).enumerated() {
let fields = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.split(whereSeparator: { $0.isWhitespace }).map(String.init)
guard !fields.isEmpty else { continue }
guard fields.count >= 2, IPv4Address(fields[0]) != nil || IPv6Address(fields[0]) != nil else {
throw SettingsError.invalid("Hosts 第 \(index + 1) 行应为 IP地址 域名,可添加 # 注释。")
}
for domain in fields.dropFirst() {
let domain = domain.lowercased()
guard domain.utf8.count <= 253, domain.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ label in
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
&& label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 }
}) else { throw SettingsError.invalid("Hosts 第 \(index + 1) 行的域名无效。") }
values[domain] = fields[0]
}
}
return values
}
}
public enum SettingsError: Error, LocalizedError {
case invalid(String)
public var errorDescription: String? { if case .invalid(let message) = self { message } else { nil } }
}
public final class ApplicationSettingsStore: @unchecked Sendable {
public static let shared = ApplicationSettingsStore()
public static let key = "legado.applicationSettings.v1"
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard) { self.defaults = defaults }
public var settings: ApplicationSettings {
guard let data = defaults.data(forKey: Self.key),
let value = try? JSONDecoder().decode(ApplicationSettings.self, from: data),
let validated = try? value.validated() else { return .init() }
return validated
}
public func save(_ settings: ApplicationSettings) throws {
let validated = try settings.validated()
defaults.set(try JSONEncoder().encode(validated), forKey: Self.key)
}
}