mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-12 22:45:08 +00:00
305 lines
14 KiB
Swift
305 lines
14 KiB
Swift
import Combine
|
||
import Foundation
|
||
|
||
// MARK: - Snapshot 投影
|
||
|
||
/// 可安全复制的 HTTP 导出能力(issue #47):shell 可直接 source 的 http/https
|
||
/// 双导出行。原始监听设置与内部端口语义不进 snapshot。复制是 UI 的副作用,
|
||
/// workflow 不触剪贴板。
|
||
struct HTTPExportCapability: Equatable, Sendable {
|
||
/// 已就绪的导出行(UI 原样复制,不自行拼装)。
|
||
let copyableLine: String
|
||
}
|
||
|
||
/// 首页可复制的 shell 环境变量设置命令。不同 shell 的语法由 workflow 一次投影,
|
||
/// 呈现层只选择格式并执行剪贴板副作用。
|
||
struct TerminalProxyEnvironmentCommands: Equatable, Sendable {
|
||
let zshBash: String
|
||
let fish: String
|
||
}
|
||
|
||
/// 活动目标安全事实(issue #47):存在性 + 可展示路径摘要。身份是不透明
|
||
/// `NodeID`(级联勾选用);不含凭据、原始配置 URL 或目录树结构。路径摘要
|
||
/// 是目录 projection 的显示名(无备注的服务器叶子以地址为显示名,与目录树
|
||
/// 呈现一致),不额外携带配置细节。
|
||
struct ProxyActiveTargetFacts: Equatable, Sendable {
|
||
let id: NodeID
|
||
/// 根 → 叶显示名路径(" / " 连接);目标已不在当前目录树中为 nil。
|
||
let pathSummary: String?
|
||
}
|
||
|
||
/// 代理控制的统一 UI-facing snapshot(issue #47/#60):一次 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
|
||
/// 规则模式子选项(issue #63):未匹配默认动作;仅规则模式呈现。
|
||
let ruleDefaultAction: RuleDefaultAction
|
||
/// 可用模式(issue #46 Domain 单点策略的投影;UI 不再自行判断可用性)。
|
||
let availableModes: [ProxyMode]
|
||
/// 活动目标事实;无活动目标为 nil。
|
||
let activeTarget: ProxyActiveTargetFacts?
|
||
/// 最近一次激活预检或目录收敛跳过的无效服务器数量。
|
||
let skippedInvalidServerCount: Int
|
||
/// HTTP 导出能力。
|
||
let httpExport: HTTPExportCapability
|
||
/// 首页命令地址选择器(issue #72):可见性、候选与生效选择。
|
||
let commandAddressPicker: TerminalCommandAddressPicker
|
||
/// 首页终端代理环境变量命令,分别适用于 zsh/bash 与 fish;端点地址为生效
|
||
/// 命令地址选择(issue #72),端口取已保存监听事实。
|
||
let terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands
|
||
}
|
||
|
||
// MARK: - 适配缝
|
||
|
||
/// 生产运行时适配缝(issue #47/#60):包装现有 `ProxyRuntimeController` 的
|
||
/// 能力与观察。不复制状态机、generation 与健康门禁;意图持久化先行、失败
|
||
/// 清理与系统代理生命周期语义全部留在控制器。测试注入确定性 fake
|
||
/// (story 31)。
|
||
@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 是否等待登录项批准(issue #71)。
|
||
var systemProxyApprovalRequired: Bool { get }
|
||
var systemProxyInspection: SystemProxyInspectionFacts { get }
|
||
/// 当前已应用的代理模式。
|
||
var proxyMode: ProxyMode { get }
|
||
/// 规则模式子选项(issue #63)。
|
||
var ruleDefaultAction: RuleDefaultAction { get }
|
||
/// 最近一次激活预检或目录收敛跳过的无效服务器数量。
|
||
var skippedInvalidServerCount: Int { get }
|
||
/// 当前活动目标身份;无目标为 nil(目录侧目标事实的唯一输入)。
|
||
var activeTargetID: NodeID? { get }
|
||
/// 可安全复制的 HTTP 导出能力。
|
||
var httpExportCapability: HTTPExportCapability { get }
|
||
/// 已保存监听事实(issue #72):命令地址候选过滤与命令生成的数据来源。
|
||
var listenFacts: RuntimeListenFacts { get }
|
||
/// 运行时事实变化通知:目录驱动、设置变更或运行时收敛导致事实变化后
|
||
/// 发值。生产实现带主队列 hop(willChange 语义 → 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 的登录项批准路径(issue #71)。
|
||
func openSystemProxyHelperApproval() async
|
||
func repairSystemProxy() async
|
||
func retrySystemProxyClear() async
|
||
func recheckSystemProxy() async
|
||
func setProxyMode(_ mode: ProxyMode) async
|
||
/// 规则模式子选项命令(issue #63)。
|
||
func setRuleDefaultAction(_ action: RuleDefaultAction) async
|
||
}
|
||
|
||
/// 窄的目录目标事实缝(issue #47,story 28):目录侧只向代理控制提供活动
|
||
/// 目标的存在性与安全路径摘要;完整目录树、订阅对象与凭据不出此接口。
|
||
@MainActor
|
||
protocol ProxyTargetFactsReading: AnyObject {
|
||
/// 活动目标安全事实:无活动目标为 nil;目标不在当前目录树中时摘要为 nil。
|
||
func activeTargetFacts(for targetID: NodeID?) -> ProxyActiveTargetFacts?
|
||
}
|
||
|
||
// MARK: - Workflow
|
||
|
||
/// 代理控制工作流 module(issue #47/#60):状态菜单与主窗口共用的唯一
|
||
/// 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
|
||
/// 会话内命令地址选择(issue #72):生命周期长于首页视图,不持久化;
|
||
/// 每次观察与最新候选对账,失效即回退默认回环。
|
||
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 批准路径并重试收敛;完成后整体重发布(issue #71)。
|
||
@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 语义由控制器既有入口裁定(issue #46);
|
||
/// 完成后整体重发布。持久化失败保留旧模式,失败以 typed fact 呈现。
|
||
@discardableResult
|
||
func setProxyMode(_ mode: ProxyMode) async -> ProxyControlSnapshot {
|
||
await runtime.setProxyMode(mode)
|
||
return republish()
|
||
}
|
||
|
||
/// 切换规则模式子选项(issue #63):完成后整体重发布。
|
||
@discardableResult
|
||
func setRuleDefaultAction(_ action: RuleDefaultAction) async -> ProxyControlSnapshot {
|
||
await runtime.setRuleDefaultAction(action)
|
||
return republish()
|
||
}
|
||
|
||
// MARK: - 首页命令地址(issue #72)
|
||
|
||
/// 下拉框选址:仅记录会话内选择并整体重发布;不触碰代理运行状态、监听
|
||
/// 设置与系统代理。
|
||
@discardableResult
|
||
func selectCommandAddress(_ address: TerminalCommandAddress) -> ProxyControlSnapshot {
|
||
commandAddressSelection = address.identity
|
||
return republish()
|
||
}
|
||
|
||
/// 刷新命令地址候选并整体重发布(进入首页与复制前调用;网络变化经接口
|
||
/// 事实变化通知自动刷新)。返回刷新后的两种 shell 命令供剪贴板副作用
|
||
/// 使用——失效选择已在本次观察中回退,提示投影与返回值一致。
|
||
@discardableResult
|
||
func refreshTerminalCommands() -> TerminalProxyEnvironmentCommands {
|
||
republish().terminalProxyEnvironmentCommands
|
||
}
|
||
|
||
// 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.listenerMode,
|
||
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)),
|
||
picker.selected.identity
|
||
)
|
||
}
|
||
}
|