Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ProxyRuntimeController.swift
T
Qiu Yuzhou ea801d5f38 feat(catalog): remove node enable state
Migrate legacy enabled fields to v3 without preserving their semantics.
Keep recoverable invalid imports visible and filter them during activation.
2026-09-22 08:40:13 +08:00

758 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftUI
/// 端点探测缝:注入以便控制器单测(真实探测走 EndpointHealthProbe)。
protocol EndpointProbing: Sendable {
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome
}
struct SystemEndpointProbe: EndpointProbing {
func probe(host: String, port: Int, timeout: TimeInterval) -> EndpointHealthProbe.Outcome {
EndpointHealthProbe.probe(host: host, port: port, timeout: timeout)
}
}
/// 代理运行时控制器(spec #21 D2/D5/D7/D9,issue #27/#28):把激活状态机的产出接到
/// 「GUI → LaunchAgent → wrapper → sslocal」链路。决策全部在纯域
/// `ProxyRuntimePlan`,本类按序执行动作并负责健康呈现;GUI 退出不影响任何
/// 一侧(agent 由 launchd 持有,构造上成立)。
@MainActor
final class ProxyRuntimeController: ObservableObject {
enum ProxyState: Equatable {
case off
case starting
case running
/// 代理在本机运行,但主机地址态的入站被 macOS 防火墙拒绝。
case firewallBlocked(detail: String)
/// 启动失败:携带点名端点与端口的事实(D8;端口语义细节 #30 接线)。
case launchFailed(detail: String)
/// 激活失败或活动目标清除(无静默回退族的呈现面)。
case activationFailed(reason: String)
/// 需要用户在系统设置-登录项中允许后台项。
case requiresApproval
/// 服务管理或运行时文件本身失败。
case serviceFailed(detail: String)
/// 运行时健康,但系统代理未能应用或恢复。
case systemProxyFailed(detail: String)
}
@Published private(set) var state: ProxyState = .off
@Published private(set) var settings: ProxySettings
@Published private(set) var pacURL: URL?
/// 当前活动目标(菜单栏状态摘要与级联只读呈现用,issue #31)。machine 是
/// 非发布值的普通结构体,代理关闭路径的激活动作不会触碰 state,菜单的
/// 「目标」行依赖这里的独立发布保持实时。
@Published private(set) var activeTargetID: NodeID?
/// 最近一次激活预检在分组中跳过的服务器;仅记录 app 已知的本地阻塞原因。
@Published private(set) var skippedServers: [SkippedServer] = []
private(set) var machine: ActivationStateMachine
private var catalog: ConfigurationCatalog
private let catalogFileStore: CatalogFileStore
private let activationFileStore: ActivationStateFileStore
private let runtimeFileStore: RuntimeFileStore
private let credentials: CredentialStoring
private let plugins: ManagedPluginProviding
private let settingsStore: ProxySettingsStoring
/// 监听设置不可读时的点名原因(D8「任何路径不静默改端口」);非 nil 时
/// 设置只是占位出厂默认,禁止部署(见 `deploy`)。
private var listenUnreadableReason: String?
/// 新版偏好不可读时同样禁止部署,不以出厂端口静默替代用户配置。
private var settingsUnreadableReason: String?
private let agent: LaunchAgentControlling
private let probe: EndpointProbing
private let pacProbe: PACHealthProbing
private let systemProxy: SystemProxyControlling
private let firewallChecker: FirewallStatusChecking
private let firewallExecutableURLs: [URL]
private let firewallPollIntervalNanoseconds: UInt64
/// 信号发送缝(默认 kill),单测观测 SIGUSR1 投递。
private let sendSignal: @Sendable (Int32, Int32) -> Int32
/// 并发流代际:停止请求可使进行中的启动探测立即失效。
private var flowGeneration = 0
private var lastDocument: SslocalRuntimeDocument?
private var firewallObservationTask: Task<Void, Never>?
@Published private(set) var proxyMode: ProxyMode
init(
catalogFileStore: CatalogFileStore = CatalogFileStore(
fileURL: CatalogFileStore.defaultFileURL()),
activationFileStore: ActivationStateFileStore = ActivationStateFileStore(
fileURL: ActivationStateFileStore.defaultFileURL()),
runtimeFileStore: RuntimeFileStore = RuntimeFileStore(),
credentials: CredentialStoring = KeychainCredentialStore(),
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
listenRestore: RestoredListenSettings = ListenSettingsFileStore.restored(),
settingsStore: ProxySettingsStoring = ProxySettingsFileStore(),
settingsRestore: RestoredProxySettings? = nil,
agent: LaunchAgentControlling = SMAppLaunchAgentService(),
probe: EndpointProbing = SystemEndpointProbe(),
pacProbe: PACHealthProbing = SystemPACHealthProbe(),
systemProxy: SystemProxyControlling = SystemConfigurationProxyController(),
proxyMode: ProxyMode? = nil,
firewallChecker: FirewallStatusChecking = SocketFilterFirewallChecker(),
firewallExecutableURLs: [URL]? = nil,
firewallPollIntervalNanoseconds: UInt64 = 2_000_000_000,
sendSignal: @escaping @Sendable (Int32, Int32) -> Int32 = { kill($0, $1) }
) {
self.catalogFileStore = catalogFileStore
self.activationFileStore = activationFileStore
self.runtimeFileStore = runtimeFileStore
self.credentials = credentials
self.plugins = plugins
self.settingsStore = settingsStore
let restoredSettings =
settingsRestore
?? RestoredProxySettings(
settings: ProxySettings(listen: listenRestore.settings),
unreadableError: listenRestore.unreadableError.map {
.legacyListenSettings($0)
})
settings = restoredSettings.settings
listenUnreadableReason =
settingsRestore == nil
? listenRestore.unreadableError?.presentedReason
: nil
settingsUnreadableReason = restoredSettings.unreadableError?.presentedReason
self.agent = agent
self.probe = probe
self.pacProbe = pacProbe
self.systemProxy = systemProxy
self.firewallChecker = firewallChecker
self.firewallExecutableURLs = firewallExecutableURLs ?? Self.defaultFirewallExecutableURLs
self.firewallPollIntervalNanoseconds = firewallPollIntervalNanoseconds
self.sendSignal = sendSignal
catalog = (try? catalogFileStore.load().catalog) ?? ConfigurationCatalog()
let persistedTarget = try? activationFileStore.loadActiveTargetID()
machine = ActivationStateMachine(activeTargetID: persistedTarget)
activeTargetID = machine.activeTargetID
self.proxyMode = proxyMode ?? Self.makeProxyMode(from: restoredSettings.settings)
}
var isActiveTargetPresent: Bool { machine.activeTargetID != nil }
private static var defaultFirewallExecutableURLs: [URL] {
let bundle = Bundle.main.bundleURL
return [
bundle.appendingPathComponent("Contents/MacOS/ShadowsocksX-NG2Agent"),
bundle.appendingPathComponent("Contents/Helpers/sslocal"),
]
}
/// 诊断只读面(issue #34):当前监听设置;监听地址在导出中只以回环/非回环
/// 两态呈现(D7)。
var listenSettings: SslocalListenSettings { settings.listen }
/// 运行时契约的脱敏摘要(数量与协议元数据,D5);契约缺失或无效返回 nil。
/// 诊断导出不读契约内容,只携带此摘要。
func runtimeDocumentSummary() -> String? {
runtimeFileStore.loadDocument().map { Redactor.documentSummary($0) }
}
/// 无活动目标时启用代理的点名原因(无静默回退族的呈现面)。
private static let noActiveTargetReason = "尚未激活任何服务器或分组,请先在主窗口激活后再启动代理"
// MARK: - 用户意图
/// 激活一个服务器或分组目标(目录 UI 工单复用入口):持久化目标;代理
/// 开启时立即把新档推到运行时。激活原子失败时状态完全不动(D3)。
func activate(_ target: NodeID) async {
reloadCatalog()
do {
let configuration = try machine.activate(
target, in: catalog, credentials: credentials, plugins: plugins, listen: settings.listen,
timeout: settings.timeoutSeconds, verbose: settings.verboseLogging,
pacUserRules: settings.pacUserRules)
activeTargetID = target
skippedServers = configuration.skippedServers
do {
try activationFileStore.save(activeTargetID: target)
} catch {
state = .serviceFailed(detail: String(describing: error))
return
}
if state != .off {
await deploy(configuration.document)
}
} catch let failure as ActivationFailure {
state = .activationFailed(reason: failure.presentedReason)
} catch {
state = .serviceFailed(detail: String(describing: error))
}
}
/// 代理开关。
func setProxyEnabled(_ enabled: Bool) async {
if enabled {
await enable()
} else {
let restoreError: Error?
do {
try systemProxy.restore()
restoreError = nil
} catch {
restoreError = error
}
_ = await execute(.stop, document: nil)
if let restoreError {
state = .systemProxyFailed(detail: systemProxyDetail(restoreError))
} else {
state = .off
}
pacURL = nil
lastDocument = nil
}
}
/// Changes the current mode without rebuilding the tunnel runtime. A mode
/// that writes system settings reuses the same endpoint health gate; manual
/// mode restores the snapshot immediately and leaves system settings alone.
func setProxyMode(_ mode: ProxyMode) async {
guard mode != proxyMode else { return }
proxyMode = mode
guard state != .off, let document = lastDocument ?? runtimeFileStore.loadDocument() else {
return
}
if case .manual = mode {
cancelFirewallObservation()
flowGeneration += 1
do {
try systemProxy.restore()
state = .running
} catch {
state = .systemProxyFailed(detail: systemProxyDetail(error))
}
return
}
state = .starting
await presentLaunchHealth(document)
}
/// 目录已提交变更后的立即重展开(D3/D5):有效非空且代理开启 → 原子更新
/// 运行时;目标失效 → 清除目标并停止代理。目录以磁盘为事实来源重载。
func catalogDidCommit() async {
reloadCatalog()
switch reexpand() {
case .deployed(let configuration):
if state != .off {
await deploy(configuration.document)
}
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
break
}
}
/// GUI 启动重同步(D5「GUI 下次启动重新校验同步」):注册态是代理意图的
/// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳
/// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。
func resyncOnLaunch() async {
reloadCatalog()
switch reexpand() {
case .deployed(let configuration):
let status = agent.status
if status == .registered || status == .requiresApproval {
await deploy(configuration.document)
} else {
// 未注册但可能残留运行时文件(上次异常退出):清理残留,保持停止态。
runtimeFileStore.deleteRuntimeFiles()
RuntimeLog.emit(.runtimeFilesDeleted)
let restoreError = restoreSystemProxyError()
state =
restoreError.map {
.systemProxyFailed(detail: systemProxyDetail($0))
} ?? .off
pacURL = nil
}
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
// 无活动目标:只剩清理残留(计划层只在已注册时注销)。
let restoreError = restoreSystemProxyError()
_ = await execute(.stop, document: nil)
state =
restoreError.map {
.systemProxyFailed(detail: systemProxyDetail($0))
} ?? .off
pacURL = nil
}
}
// MARK: - 动作执行
private func enable() async {
guard machine.activeTargetID != nil else {
presentNoActiveTarget()
return
}
reloadCatalog()
switch reexpand() {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
presentNoActiveTarget()
}
}
private func presentNoActiveTarget() {
RuntimeLog.emit(.activationFailed(reason: Self.noActiveTargetReason))
state = .activationFailed(reason: Self.noActiveTargetReason)
}
/// 把「运行定义档」意图推到运行时:计划动作 → 顺序执行 → 端点健康呈现。
private func deploy(_ document: SslocalRuntimeDocument) async {
if let reason = listenUnreadableReason ?? settingsUnreadableReason {
await refuseDeployForUnreadableListenSettings(reason)
return
}
pacURL = nil
lastDocument = document
if await execute(.run(document), document: document) {
state = .starting
await presentLaunchHealth(document)
}
}
/// D8「任何路径不静默改端口」:监听设置不可读时以占位出厂端口部署等于
/// 系统擅自改端口——停止运行时并点名呈现,等用户在设置区修复(#33 接线)。
private func refuseDeployForUnreadableListenSettings(_ reason: String) async {
RuntimeLog.emit(.activationFailed(reason: reason))
let restoreError = restoreSystemProxyError()
_ = await execute(.stop, document: nil)
pacURL = nil
lastDocument = nil
skippedServers = []
let detail =
"本地代理端口配置无法读取(\(reason)),已停止代理以避免静默改用出厂端口;请在设置区修复端口后重新启动"
if let restoreError {
state = .launchFailed(detail: "\(detail);\(systemProxyDetail(restoreError))")
} else {
state = .launchFailed(detail: detail)
}
}
private func handleCleared(_ failure: ActivationFailure) async {
RuntimeLog.emit(.activationFailed(reason: failure.presentedReason))
do {
try activationFileStore.save(activeTargetID: nil)
} catch {
// 清目标失败不阻断停止:下次重同步会再次收敛(目标已不在状态机中)。
RuntimeLog.emit(.runtimePersistFailed(detail: String(describing: error)))
}
let restoreError = restoreSystemProxyError()
_ = await execute(.stop, document: nil)
pacURL = nil
lastDocument = nil
skippedServers = []
if let restoreError {
state = .systemProxyFailed(
detail: "\(failure.presentedReason);\(systemProxyDetail(restoreError))")
} else {
state = .activationFailed(reason: failure.presentedReason)
}
}
/// 按计划顺序执行动作;返回 false 表示中途失败、状态已呈现(后续动作与
/// 健康探测都不应继续)。
private func execute(_ intent: RuntimeIntent, document: SslocalRuntimeDocument?) async -> Bool {
cancelFirewallObservation()
flowGeneration += 1
let actions = ProxyRuntimePlan.actions(
intent: intent,
agentStatus: agent.status,
wrapper: wrapperState(),
contractOnDisk: runtimeFileStore.readData())
if case .run = intent, actions.isEmpty {
RuntimeLog.emit(.contractUnchanged)
}
for action in actions {
guard await perform(action, document: document) else { return false }
}
return true
}
/// 执行单个动作;返回 false 表示应终止后续动作(状态已呈现)。
private func perform(_ action: RuntimeAction, document: SslocalRuntimeDocument?) async -> Bool {
switch action {
case .writeContract:
guard let document else {
state = .serviceFailed(detail: "缺少运行时文档")
return false
}
do {
try runtimeFileStore.write(document)
RuntimeLog.emit(.contractWritten(serverCount: document.servers.count))
} catch {
state = .serviceFailed(detail: String(describing: error))
return false
}
case .registerAgent:
do {
try agent.register()
} catch {
RuntimeLog.emit(.agentRegisterFailed(detail: describe(error)))
if agent.status == .requiresApproval {
// 注册请求已被系统接收,等待用户在登录项中批准。
state = .requiresApproval
return false
}
if agent.status != .registered {
state = .serviceFailed(detail: describe(error))
return false
}
// 注册与状态读取之间的竞态:已注册即达意图,不视为失败。
}
RuntimeLog.emit(.agentRegistered)
if agent.status == .requiresApproval {
state = .requiresApproval
return false
}
case .unregisterAgent:
do {
try agent.unregister()
} catch {
// 未注册竞态可忽略;其余错误记录后继续清理(尽力而为)。
RuntimeLog.emit(.agentUnregisterFailed(detail: describe(error)))
}
RuntimeLog.emit(.agentUnregistered)
await waitForWrapperExit()
case .signalReload(let pid):
_ = sendSignal(pid, SIGUSR1)
case .deleteRuntimeFiles:
runtimeFileStore.deleteRuntimeFiles()
RuntimeLog.emit(.runtimeFilesDeleted)
}
return true
}
}
extension ProxyRuntimeController {
/// Persists a fully validated settings snapshot and, when the proxy is
/// active, re-derives the same runtime path with the new snapshot.
func updateSettings(_ next: ProxySettings) async throws {
try settingsStore.save(next)
settings = next
listenUnreadableReason = nil
settingsUnreadableReason = nil
if case .externalPAC = proxyMode {
if let url = URL(string: next.externalPACURL), !next.externalPACURL.isEmpty {
try ProxyMode.validateExternalPACURL(url)
proxyMode = .externalPAC(url)
} else {
proxyMode = .pac
}
}
guard state != .off else { return }
switch reexpand() {
case .deployed(let configuration):
await deploy(configuration.document)
case .clearedAndStopped(let failure):
await handleCleared(failure)
case nil:
break
}
}
/// Restores factory defaults, removes the persisted snapshot and stops any
/// active runtime before the next user action can use the defaults.
func resetPreferences() async throws {
try settingsStore.reset()
settings = ProxySettings()
listenUnreadableReason = nil
settingsUnreadableReason = nil
proxyMode = .pac
if state != .off {
await setProxyEnabled(false)
}
}
/// Applies the post-import runtime boundary without touching SystemConfiguration.
/// Legacy import is intentionally separate from handoff: importing data must
/// leave the user's system proxy dictionary untouched, while any currently
/// running 2.0 runtime is stopped and the new target/settings are reloaded.
func legacyImportDidCommit() async {
cancelFirewallObservation()
flowGeneration += 1
_ = await execute(.stop, document: nil)
state = .off
pacURL = nil
lastDocument = nil
skippedServers = []
if let restored = try? settingsStore.load() {
settings = restored
settingsUnreadableReason = nil
listenUnreadableReason = nil
proxyMode = Self.makeProxyMode(from: restored)
}
reloadCatalog()
let persistedTarget = try? activationFileStore.loadActiveTargetID()
machine = ActivationStateMachine(activeTargetID: persistedTarget ?? nil)
activeTargetID = machine.activeTargetID
}
}
extension ProxyRuntimeController {
private struct LocalEndpointFailure {
let local: SslocalLocalDocument
let host: String
let outcome: EndpointHealthProbe.Outcome
}
/// 启动健康呈现(D2/D7/D8):先确认 sslocal 的 SOCKS/HTTP TCP 绑定,再
/// GET PAC endpoint;主机态额外查询应用防火墙拒绝记录。任一端点超时都点名呈现。
private func presentLaunchHealth(_ document: SslocalRuntimeDocument) async {
let generation = flowGeneration
let deadline = Date().addingTimeInterval(15)
var endpointFailure: LocalEndpointFailure?
var pacOutcome = PACHealthOutcome.failed(detail: "尚未探测")
while Date() < deadline {
if generation != flowGeneration { return }
endpointFailure = await unhealthyLocalEndpoint(in: document)
if endpointFailure == nil {
guard let healthURL = document.pac.healthURL else {
state = .launchFailed(detail: "PAC URL 无效")
return
}
pacOutcome = await pacProbe.probe(url: healthURL, timeout: 1.5)
if pacOutcome == .reachable {
if case .externalPAC(let externalURL) = proxyMode {
let externalOutcome = await pacProbe.probe(url: externalURL, timeout: 1.5)
if case .failed(let detail) = externalOutcome {
state = .systemProxyFailed(detail: "外部 PAC 未就绪(\(detail))")
return
}
}
guard generation == flowGeneration else { return }
pacURL = document.pac.publicURL
guard applySystemProxy(for: document) else { return }
await presentFirewallStatus(for: document)
return
}
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
if generation != flowGeneration { return }
if let endpointFailure {
presentEndpointFailure(endpointFailure)
return
}
let pacDetail: String
switch pacOutcome {
case .reachable:
pacDetail = "未知错误"
case .failed(let reason):
pacDetail = reason
}
state = .launchFailed(
detail: "PAC 端点 \(document.pac.healthURL?.absoluteString ?? "") 未就绪(\(pacDetail))")
}
private func unhealthyLocalEndpoint(
in document: SslocalRuntimeDocument
) async -> LocalEndpointFailure? {
for local in document.locals {
let outcome = await probeAsync(
host: local.probeHost, port: local.localPort, timeout: 1.5)
if outcome != .reachable {
return LocalEndpointFailure(local: local, host: local.probeHost, outcome: outcome)
}
}
return nil
}
private func presentEndpointFailure(_ failure: LocalEndpointFailure) {
let detail: String
switch failure.outcome {
case .reachable:
detail = "已连通"
case .refused(let reason):
detail = reason
case .timedOut:
detail = "连接超时"
}
RuntimeLog.emit(
.endpointProbeFailed(
host: failure.host, port: failure.local.localPort, detail: detail))
let endpointName = failure.local.inboundProtocol.uppercased()
state = .launchFailed(
detail:
"本地代理 \(endpointName) 端点 \(failure.host):\(failure.local.localPort) 未就绪(\(detail))")
}
private func presentFirewallStatus(for document: SslocalRuntimeDocument) async {
guard document.pac.listenScope == .host else {
state = .running
return
}
if let blocked = await blockedFirewallExecutable() {
presentFirewallBlocked(blocked)
return
}
state = .running
observeFirewall(generation: flowGeneration)
}
private func blockedFirewallExecutable() async -> URL? {
for executableURL in firewallExecutableURLs {
let status = await firewallStatus(for: executableURL)
if status == .blocked { return executableURL }
}
return nil
}
private func presentFirewallBlocked(_ executableURL: URL) {
let name = executableURL.lastPathComponent
state = .firewallBlocked(
detail:
"macOS 防火墙已阻止 \(name) 接受传入连接。请前往“系统设置”→“网络”→“防火墙”→“选项”,将 \(name) 设为“允许传入连接”,或移除该条目后重启代理。")
}
private func observeFirewall(generation: Int) {
firewallObservationTask?.cancel()
let interval = firewallPollIntervalNanoseconds
firewallObservationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: interval)
guard !Task.isCancelled, let self, generation == flowGeneration else { return }
if let blocked = await blockedFirewallExecutable() {
presentFirewallBlocked(blocked)
firewallObservationTask = nil
return
}
}
}
}
private func cancelFirewallObservation() {
firewallObservationTask?.cancel()
firewallObservationTask = nil
}
private func firewallStatus(for executableURL: URL) async -> FirewallBlockStatus {
let checker = firewallChecker
return await Task.detached(priority: .utility) {
checker.status(for: executableURL)
}.value
}
private func probeAsync(
host: String, port: Int, timeout: TimeInterval
) async -> EndpointHealthProbe.Outcome {
let probe = probe
return await Task.detached(priority: .utility) {
probe.probe(host: host, port: port, timeout: timeout)
}.value
}
/// 显式停止协议次序(D2):注销(SIGTERM wrapper → wrapper 停 sslocal 并
/// 等待)完成后才允许后续删文件动作。最多等 5 秒,超时也继续(launchd 会
/// 兜底结束进程)。
private func waitForWrapperExit() async {
let deadline = Date().addingTimeInterval(5)
while wrapperState() != .notRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
private func wrapperState() -> WrapperProcessState {
guard
let data = try? Data(contentsOf: runtimeFileStore.pidFileURL),
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines),
let pid = Int32(text)
else { return .notRunning }
guard sendSignal(pid, 0) == 0 else { return .notRunning }
return .running(pid: pid)
}
// MARK: - 目录同步
private func reloadCatalog() {
catalog = (try? catalogFileStore.load().catalog) ?? catalog
}
/// 目录以磁盘为事实来源重载后重展开;目标被清除时一并发布。
private func reexpand() -> ActivationEffect? {
let effect = machine.catalogDidCommit(
catalog, credentials: credentials, plugins: plugins, listen: settings.listen,
timeout: settings.timeoutSeconds, verbose: settings.verboseLogging,
pacUserRules: settings.pacUserRules)
activeTargetID = machine.activeTargetID
switch effect {
case .deployed(let configuration):
skippedServers = configuration.skippedServers
case .clearedAndStopped:
skippedServers = []
case nil:
break
}
return effect
}
private func describe(_ error: Error) -> String {
(error as? ActivationFailure)?.presentedReason ?? String(describing: error)
}
private func applySystemProxy(for document: SslocalRuntimeDocument) -> Bool {
do {
if let configuration = try proxyMode.systemProxyConfiguration(
for: document, exceptions: settings.proxyExceptionList)
{
try systemProxy.apply(configuration)
} else {
try systemProxy.restore()
}
return true
} catch {
state = .systemProxyFailed(detail: systemProxyDetail(error))
return false
}
}
private func restoreSystemProxyError() -> Error? {
do {
try systemProxy.restore()
return nil
} catch {
return error
}
}
private func systemProxyDetail(_ error: Error) -> String {
if let error = error as? SystemProxyError {
return error.presentedReason
}
if let error = error as? ProxyModeError {
return error.presentedReason
}
return String(describing: error)
}
}
extension ProxyRuntimeController {
fileprivate static func makeProxyMode(from settings: ProxySettings) -> ProxyMode {
switch settings.preferredMode {
case .pac:
return .pac
case .global:
return .global
case .manual:
return .manual
case .externalPAC:
guard let url = URL(string: settings.externalPACURL),
(try? ProxyMode.validateExternalPACURL(url)) != nil
else { return .pac }
return .externalPAC(url)
}
}
}