Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift
T

352 lines
15 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 Combine
import Foundation
// MARK: - Snapshot 投影
/// 可安全复制的 HTTP 导出能力:shell 可直接 source 的 http/https
/// 双导出行。原始监听设置与内部端口语义不进 snapshot。复制是 UI 的副作用,
/// workflow 不触剪贴板。
struct HTTPExportCapability: Equatable, Sendable {
/// 已就绪的导出行(UI 原样复制,不自行拼装)。
let copyableLine: String
}
/// 首页可复制的 shell 环境变量设置命令。不同 shell 的语法由 workflow 一次投影,
/// 呈现层只选择格式并执行剪贴板副作用。
enum TerminalCommandFailure: Error, Equatable, Sendable {
case credentialUnreadable
case addressUnavailable
}
struct TerminalProxyEnvironmentCommands: Equatable, Sendable {
let zshBash: String
let fish: String
let nushell: String
let windowsCMD: String
let windowsPowerShell: String
var failure: TerminalCommandFailure?
}
enum TerminalCommandShell: CaseIterable, Hashable, Sendable {
case zshBash, fish, nushell, windowsCMD, windowsPowerShell
func command(from commands: TerminalProxyEnvironmentCommands) -> String {
switch self {
case .zshBash: commands.zshBash
case .fish: commands.fish
case .nushell: commands.nushell
case .windowsCMD: commands.windowsCMD
case .windowsPowerShell: commands.windowsPowerShell
}
}
}
/// 活动目标安全事实:存在性 + 可展示路径摘要。身份是不透明
/// `NodeID`(级联勾选用);不含凭据、原始配置 URL 或目录树结构。路径摘要
/// 是目录 projection 的显示名(导入缺名的服务器叶子以地址端口为名称,与目录树
/// 呈现一致),不额外携带配置细节。
struct ProxyActiveTargetFacts: Equatable, Sendable {
let id: NodeID
/// 根 → 叶显示名路径(" / " 连接);目标已不在当前目录树中为 nil。
let pathSummary: String?
}
/// 代理控制的统一 UI-facing snapshot:一次 workflow
/// observation 产出的整体安全投影,所有字段来自同一时间点。agent 意图/运行
/// 状态与系统代理意图/实际应用是四个独立事实,两个开关互不代替;不含控制
/// 器内部状态枚举、`ProxySettings`、完整目录树、凭据、原始 URL、原始日志或
/// 本地化文案;失败保持 typed facts,成句呈现由 App presentation edge 负责。
struct ProxyControlSnapshot: Equatable, Sendable {
/// Agent 运行事实:状态类别、「在跑」投影与 typed 失败。
let runtime: ProxyRuntimeFacts
/// Agent 开关意图(持久化;默认开启)。
let agentIntentEnabled: Bool
/// 最近一次激活拒绝或目标清除的点名原因(独立于运行状态;agent 可能仍在
/// 监听)。
let activationFailure: ActivationFailure?
/// 系统代理开关意图(持久化;默认关闭)。
let systemProxyIntentEnabled: Bool
/// 系统代理实际应用状态(与 agent 运行状态分开呈现)。
let systemProxyApplication: SystemProxyApplicationFacts
/// Helper approval blockage, including failed cleanup after intent is off.
let systemProxyApprovalRequired: Bool
var systemProxyInspection = SystemProxyInspectionFacts()
/// 当前已应用的代理模式(持久化成功的模式)。
let proxyMode: ProxyMode
/// 规则模式子选项:未匹配默认动作;仅规则模式呈现。
let ruleDefaultAction: RuleDefaultAction
/// 可用模式由 Domain 单点策略投影;UI 不自行判断可用性。
let availableModes: [ProxyMode]
/// 活动目标事实;无活动目标为 nil。
let activeTarget: ProxyActiveTargetFacts?
/// 最近一次激活预检或目录收敛跳过的无效服务器数量。
let skippedInvalidServerCount: Int
/// HTTP 导出能力。
let httpExport: HTTPExportCapability
/// 首页命令地址选择器:可见性、候选与生效选择。
let commandAddressPicker: TerminalCommandAddressPicker
/// 首页终端代理环境变量命令;端点地址为生效
/// 命令地址选择,端口取已保存监听事实。
let terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands
/// Windows 格式供其他设备使用,只在局域网入口与网络地址都有效时提供。
var terminalCommandShells: [TerminalCommandShell] {
commandAddressPicker.isVisible && commandAddressPicker.selected.address != "127.0.0.1"
? TerminalCommandShell.allCases : [.zshBash, .fish, .nushell]
}
}
// MARK: - 适配缝
/// 生产运行时适配缝:包装现有 `ProxyRuntimeController` 的
/// 能力与观察。不复制状态机、generation 与健康门禁;意图持久化先行、失败
/// 清理与系统代理生命周期语义全部留在控制器;测试注入确定性 fake。
@MainActor
protocol ProxyRuntimeAdapting: AnyObject {
/// Agent 运行事实(状态类别 + 「在跑」投影 + typed 失败)。
var runtimeFacts: ProxyRuntimeFacts { get }
/// Agent 开关意图。
var agentIntentEnabled: Bool { get }
/// 最近一次激活拒绝/目标清除的点名原因。
var activationFailure: ActivationFailure? { get }
/// 系统代理开关意图。
var systemProxyIntentEnabled: Bool { get }
/// 系统代理实际应用状态。
var systemProxyApplication: SystemProxyApplicationFacts { get }
/// 特权 helper 是否等待登录项批准。
var systemProxyApprovalRequired: Bool { get }
var systemProxyInspection: SystemProxyInspectionFacts { get }
/// 当前已应用的代理模式。
var proxyMode: ProxyMode { get }
/// 规则模式子选项。
var ruleDefaultAction: RuleDefaultAction { get }
/// 最近一次激活预检或目录收敛跳过的无效服务器数量。
var skippedInvalidServerCount: Int { get }
/// 当前活动目标身份;无目标为 nil(目录侧目标事实的唯一输入)。
var activeTargetID: NodeID? { get }
/// 可安全复制的 HTTP 导出能力。
var httpExportCapability: HTTPExportCapability { get }
/// 已保存监听事实:命令地址候选过滤与命令生成的数据来源。
var listenFacts: RuntimeListenFacts { get }
func terminalLANPassword() throws -> String
/// 运行时事实变化通知:目录驱动、设置变更或运行时收敛导致事实变化后
/// 发值。生产实现合并控制器与观察机的 didChange 事实通道(同一主队列轮
/// 多次写合并为一次);fake 同步发值。workflow 以此触发整体重观察。
var changes: AnyPublisher<Void, Never> { get }
/// 启动重同步(GUI 重启后的注册态重校验)。
func resyncOnLaunch() async
/// Agent 开关命令。
func setAgentEnabled(_ enabled: Bool) async
/// 系统代理开关命令。
func setSystemProxyEnabled(_ enabled: Bool) async
/// 打开 helper 的登录项批准路径。
func openSystemProxyHelperApproval() async
func repairSystemProxy() async
func retrySystemProxyClear() async
func recheckSystemProxy() async
func setProxyMode(_ mode: ProxyMode) async
/// 规则模式子选项命令。
func setRuleDefaultAction(_ action: RuleDefaultAction) async
}
/// 窄的目录目标事实缝:目录侧只向代理控制提供活动
/// 目标的存在性与安全路径摘要;完整目录树、订阅对象与凭据不出此接口。
@MainActor
protocol ProxyTargetFactsReading: AnyObject {
/// 活动目标安全事实:无活动目标为 nil;目标不在当前目录树中时摘要为 nil。
func activeTargetFacts(for targetID: NodeID?) -> ProxyActiveTargetFacts?
}
// MARK: - Workflow
/// 代理控制工作流 module:状态菜单与主窗口共用的唯一
/// UI-facing 代理控制 seam。围绕现有 `ProxyRuntimeController` 建立稳定
/// typed interface:整体发布的 `ProxyControlSnapshot` 与 typed async
/// commands;命令完成后重新发布完整 snapshot,预期运行时失败以
/// `RuntimeFailureFacts` 进入 snapshot。不拆分控制器,也不持有第二套运行时
/// 状态机;活动目标树、目标激活与订阅刷新仍由 CatalogWorkflow 负责,安全
/// 诊断与导出仍由 DiagnosticsWorkflow 负责。
@MainActor
final class ProxyControlWorkflow: ObservableObject {
/// 最新整体 snapshot(唯一读取面;每次观察整体替换)。
@Published private(set) var snapshot: ProxyControlSnapshot
private let runtime: any ProxyRuntimeAdapting
private let targetFacts: any ProxyTargetFactsReading
private let interfaceFacts: any LocalInterfaceFactsReading
/// 会话内命令地址选择:生命周期长于首页视图,不持久化;
/// 每次观察与最新候选对账,失效即回退默认回环。
private var commandAddressSelection: TerminalCommandAddressIdentity?
private var cancellables: Set<AnyCancellable> = []
init(
runtime: any ProxyRuntimeAdapting,
targetFacts: any ProxyTargetFactsReading,
interfaceFacts: any LocalInterfaceFactsReading
) {
self.runtime = runtime
self.targetFacts = targetFacts
self.interfaceFacts = interfaceFacts
let observation = Self.makeSnapshot(
runtime: runtime, targetFacts: targetFacts, interfaceFacts: interfaceFacts,
selection: nil)
commandAddressSelection = observation.selection
snapshot = observation.snapshot
runtime.changes
.sink { [weak self] _ in self?.republish() }
.store(in: &cancellables)
interfaceFacts.changes
.sink { [weak self] _ in self?.republish() }
.store(in: &cancellables)
}
// MARK: - 观察与命令
/// 启动观察:GUI 重启后的注册态重校验 + 整体重发布(组合根在启动时调用)。
func resyncOnLaunch() async {
await runtime.resyncOnLaunch()
republish()
}
/// Agent 开关:命令完成后返回并发布完整新 snapshot。
@discardableResult
func setAgentEnabled(_ enabled: Bool) async -> ProxyControlSnapshot {
await runtime.setAgentEnabled(enabled)
return republish()
}
/// 系统代理开关:命令完成后返回并发布完整新 snapshot。
@discardableResult
func setSystemProxyEnabled(_ enabled: Bool) async -> ProxyControlSnapshot {
await runtime.setSystemProxyEnabled(enabled)
return republish()
}
/// 打开 helper 批准路径并重试收敛;完成后整体重发布。
@discardableResult
func openSystemProxyHelperApproval() async -> ProxyControlSnapshot {
await runtime.openSystemProxyHelperApproval()
return republish()
}
@discardableResult
func repairSystemProxy() async -> ProxyControlSnapshot {
await runtime.repairSystemProxy()
return republish()
}
@discardableResult
func retrySystemProxyClear() async -> ProxyControlSnapshot {
await runtime.retrySystemProxyClear()
return republish()
}
@discardableResult
func recheckSystemProxy() async -> ProxyControlSnapshot {
await runtime.recheckSystemProxy()
return republish()
}
/// 切换代理模式:可用性与 no-op 语义由控制器既有入口裁定;
/// 完成后整体重发布。持久化失败保留旧模式,失败以 typed fact 呈现。
@discardableResult
func setProxyMode(_ mode: ProxyMode) async -> ProxyControlSnapshot {
await runtime.setProxyMode(mode)
return republish()
}
/// 切换规则模式子选项:完成后整体重发布。
@discardableResult
func setRuleDefaultAction(_ action: RuleDefaultAction) async -> ProxyControlSnapshot {
await runtime.setRuleDefaultAction(action)
return republish()
}
// MARK: - 首页命令地址
/// 下拉框选址:仅记录会话内选择并整体重发布;不触碰代理运行状态、监听
/// 设置与系统代理。
@discardableResult
func selectCommandAddress(_ address: TerminalCommandAddress) -> ProxyControlSnapshot {
commandAddressSelection = address.identity
return republish()
}
/// 刷新命令地址候选并整体重发布(进入首页与复制前调用;网络变化经接口
/// 事实变化通知自动刷新)。失效选择回退到本地地址。
@discardableResult
func refreshTerminalCommands() -> TerminalProxyEnvironmentCommands {
republish().terminalProxyEnvironmentCommands
}
/// 复制前核实按钮呈现时的 IP 仍有效;回退只更新界面,不改写剪贴板。
/// 显式传入原地址身份,防止网络通知先回退 snapshot 后复制旧按钮。
func prepareTerminalCommandsForCopy(expectedAddress: TerminalCommandAddressIdentity) throws
-> TerminalProxyEnvironmentCommands
{
let commands = refreshTerminalCommands()
guard snapshot.commandAddressPicker.selected.identity == expectedAddress else {
throw TerminalCommandFailure.addressUnavailable
}
if let failure = commands.failure { throw failure }
return commands
}
// MARK: - 整体观察(implementation,UI 不可见)
/// 一次 observation 内整体拼装 snapshot;任何单一事实变化后整体替换,
/// 不暴露「新模式配旧状态」的混合结果。命令地址选择在观察内与最新候选
/// 对账,回退结果同步回会话状态(失效选择不随网络恢复复活)。
@discardableResult
private func republish() -> ProxyControlSnapshot {
let observation = Self.makeSnapshot(
runtime: runtime, targetFacts: targetFacts, interfaceFacts: interfaceFacts,
selection: commandAddressSelection)
commandAddressSelection = observation.selection
snapshot = observation.snapshot
return snapshot
}
/// 一次 observation:整体拼装 snapshot 并对账命令地址选择,返回生效选择
/// (失效选择已回退到当前监听方式的默认回环地址)。
private static func makeSnapshot(
runtime: any ProxyRuntimeAdapting,
targetFacts: any ProxyTargetFactsReading,
interfaceFacts: any LocalInterfaceFactsReading,
selection: TerminalCommandAddressIdentity?
) -> (snapshot: ProxyControlSnapshot, selection: TerminalCommandAddressIdentity) {
let listenFacts = runtime.listenFacts
let picker = TerminalCommandAddressPolicy.picker(
mode: listenFacts.lan.effectiveMode,
interfaces: interfaceFacts.interfaces,
selection: selection)
return (
ProxyControlSnapshot(
runtime: runtime.runtimeFacts,
agentIntentEnabled: runtime.agentIntentEnabled,
activationFailure: runtime.activationFailure,
systemProxyIntentEnabled: runtime.systemProxyIntentEnabled,
systemProxyApplication: runtime.systemProxyApplication,
systemProxyApprovalRequired: runtime.systemProxyApprovalRequired,
systemProxyInspection: runtime.systemProxyInspection,
proxyMode: runtime.proxyMode,
ruleDefaultAction: runtime.ruleDefaultAction,
availableModes: ProxyMode.availableModes,
activeTarget: targetFacts.activeTargetFacts(for: runtime.activeTargetID),
skippedInvalidServerCount: runtime.skippedInvalidServerCount,
httpExport: runtime.httpExportCapability,
commandAddressPicker: picker,
terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands(
listen: listenFacts, commandAddress: picker.selected,
password: { try runtime.terminalLANPassword() })),
picker.selected.identity
)
}
}
// 未提供凭据读取能力的 adapter 必须拒绝认证命令,不能复制无密码的替代值。
extension ProxyRuntimeAdapting {
func terminalLANPassword() throws -> String { throw TerminalCommandFailure.credentialUnreadable }
}