mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
* feat: add GUI-scoped subscription automatic updates * fix: drain newly due subscription updates during a batch * style: use segmented subscription update pickers
393 lines
14 KiB
Swift
393 lines
14 KiB
Swift
import Foundation
|
||
import Security
|
||
|
||
// MARK: - 目录树 projection(非敏感)
|
||
|
||
/// 目录树节点快照(目录工作流 module 的 UI-facing projection):
|
||
/// 名称、来源、形态、渲染原子与子树计数;不含凭据引用、密码、插件参数等
|
||
/// 秘密值。身份为不透明 `NodeID`,重命名、移动或刷新后保持稳定。
|
||
/// 门禁/关系型事实(删除档位、移动资格、激活资格)走 module 查询方法,
|
||
/// 不在节点上堆政策字段。
|
||
struct CatalogTreeNode: Identifiable, Equatable {
|
||
let id: NodeID
|
||
let name: String
|
||
let isGroup: Bool
|
||
let source: NodeSource
|
||
/// 父节点身份;目录根层节点为 `nil`。
|
||
let parentID: NodeID?
|
||
let createdAt: Date?
|
||
let updatedAt: Date?
|
||
/// 服务器叶子的已知阻塞原因(typed,无成句文案);分组为空。
|
||
let invalidReasons: [LeafInvalidationReason]
|
||
/// 直接子节点数(分组)。
|
||
let childCount: Int
|
||
/// 子树全部后代节点数(分组;不含自身;删除确认的递归规模)。
|
||
let subtreeNodeCount: Int
|
||
/// 子树中已知无效的服务器数量(不含当前叶子自身;仅供 tooltip 展示)。
|
||
let invalidDescendantCount: Int
|
||
/// 分组持有子树快照;服务器叶子为 `nil`。
|
||
let children: [CatalogTreeNode]?
|
||
|
||
var isInvalid: Bool { !invalidReasons.isEmpty }
|
||
var isManual: Bool { source == .manual }
|
||
/// 子树快照;服务器叶子为空(与 `children` 的 nil 区分叶子语义并存)。
|
||
var childNodes: [CatalogTreeNode] { children ?? [] }
|
||
|
||
/// 子树服务器配置数,包含存在已知激活阻塞的服务器。
|
||
var serverConfigurationCount: Int {
|
||
isGroup ? childNodes.reduce(0) { $0 + $1.serverConfigurationCount } : 1
|
||
}
|
||
|
||
/// 是否包含服务器配置(含当前节点本身);激活资格与结构性可导出性分离。
|
||
var containsServerConfiguration: Bool {
|
||
if !isGroup { return true }
|
||
return childNodes.contains(where: \.containsServerConfiguration)
|
||
}
|
||
}
|
||
|
||
extension CatalogTreeNode {
|
||
/// 深度优先查找子树中的节点(含自身)。
|
||
func find(_ id: NodeID) -> CatalogTreeNode? {
|
||
if id == self.id { return self }
|
||
for child in children ?? [] {
|
||
if let found = child.find(id) { return found }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// 子树全部节点身份(含自身)。
|
||
var subtreeIDs: Set<NodeID> {
|
||
var ids: Set<NodeID> = [id]
|
||
for child in children ?? [] { ids.formUnion(child.subtreeIDs) }
|
||
return ids
|
||
}
|
||
|
||
/// 子树内已知无效服务器总数(含自身叶子)。
|
||
var subtreeInvalidServerCount: Int {
|
||
(isInvalid ? 1 : 0) + invalidDescendantCount
|
||
}
|
||
}
|
||
|
||
/// 侧栏树的可见行投影:节点 + 呈现深度;身份即节点身份(折叠只影响可见
|
||
/// 集合,不影响行身份与选中)。服务器管理侧栏与首页目标树共用同一类型,
|
||
/// 折叠集合由各自的浏览状态 owner 给出。
|
||
struct CatalogTreeRow: Identifiable {
|
||
let node: CatalogTreeNode
|
||
let depth: Int
|
||
var id: NodeID { node.id }
|
||
}
|
||
|
||
/// 目录树快照:侧栏、活动目标级联、移动目的地与删除确认共用的同一份
|
||
/// 非敏感 projection;UI 不再持有原始目录副本。
|
||
struct CatalogTreeSnapshot: Equatable {
|
||
let roots: [CatalogTreeNode]
|
||
|
||
var isEmpty: Bool { roots.isEmpty }
|
||
|
||
/// 深度优先查找节点(含子树)。
|
||
func node(withID id: NodeID) -> CatalogTreeNode? {
|
||
for node in roots {
|
||
if let found = node.find(id) { return found }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// 节点是否在树中(拖拽负载存在性校验)。
|
||
func containsNode(_ id: NodeID) -> Bool { node(withID: id) != nil }
|
||
|
||
/// 活动目标的显示名路径(根 → 节点," / " 连接);目标不在树中为 nil。
|
||
/// 代理控制窄缝经此取安全路径摘要,树结构不出目录 module。
|
||
func pathSummary(for id: NodeID) -> String? {
|
||
pathComponents(for: id)?.joined(separator: " / ")
|
||
}
|
||
|
||
/// 节点的名称路径;分隔符与来源标签由呈现层决定。
|
||
func pathComponents(for id: NodeID) -> [String]? {
|
||
guard var node = self.node(withID: id) else { return nil }
|
||
var names = [node.name]
|
||
while let parentID = node.parentID, let parent = self.node(withID: parentID) {
|
||
names.insert(parent.name, at: 0)
|
||
node = parent
|
||
}
|
||
return names
|
||
}
|
||
|
||
/// 节点的严格祖先身份(根 → 父,不含自身)。行级"子树含活动目标"标记经
|
||
/// `ancestors(of: activeTargetID)` 推导;目标为空或不在树中时为空集合。
|
||
func ancestors(of id: NodeID?) -> Set<NodeID> {
|
||
guard let id, var current = node(withID: id) else { return [] }
|
||
var ancestors = Set<NodeID>()
|
||
while let parentID = current.parentID, let parent = node(withID: parentID) {
|
||
ancestors.insert(parentID)
|
||
current = parent
|
||
}
|
||
return ancestors
|
||
}
|
||
|
||
/// 全树已知无效服务器总数(诊断计数)。
|
||
var invalidServerCount: Int {
|
||
roots.reduce(0) { $0 + $1.subtreeInvalidServerCount }
|
||
}
|
||
}
|
||
|
||
extension CatalogTreeSnapshot {
|
||
/// 折叠投影后的可见行(深度优先):收起分组的子树不出现,其余保持目录
|
||
/// 序。行序与深度是纯树事实;折叠集合由调用方按自己的持久化策略持有。
|
||
func visibleRows(source: NodeSource? = nil, collapsed: Set<NodeID>) -> [CatalogTreeRow] {
|
||
var rows: [CatalogTreeRow] = []
|
||
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
|
||
for node in nodes {
|
||
rows.append(CatalogTreeRow(node: node, depth: depth))
|
||
if node.isGroup, !collapsed.contains(node.id) {
|
||
walk(node.childNodes, depth: depth + 1)
|
||
}
|
||
}
|
||
}
|
||
// 来源不跨越子树,根层筛选保留完整层级与目录顺序。
|
||
walk(source.map { source in roots.filter { $0.source == source } } ?? roots, depth: 0)
|
||
return rows
|
||
}
|
||
|
||
/// 全树服务器叶子数(工作区侧栏角标)。
|
||
var serverLeafCount: Int {
|
||
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
|
||
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
|
||
}
|
||
return leaves(roots)
|
||
}
|
||
}
|
||
|
||
extension CatalogTreeSnapshot {
|
||
/// 从已提交目录构建 projection(module 内部推导;测试同 target 可直接调用)。
|
||
/// 每个服务器叶子求值一次激活校验(与运行时口径一致)。
|
||
static func build(
|
||
from catalog: ConfigurationCatalog,
|
||
plugins: PluginExecutableResolving
|
||
) -> CatalogTreeSnapshot {
|
||
let plugins = plugins.catalogSnapshot()
|
||
func buildNode(_ id: NodeID, entry: CatalogEntry, parentID: NodeID?) -> CatalogTreeNode {
|
||
switch entry.kind {
|
||
case .group(let fields):
|
||
let children = fields.children.compactMap { childID -> CatalogTreeNode? in
|
||
guard let childEntry = catalog.entry(for: childID) else { return nil }
|
||
return buildNode(childID, entry: childEntry, parentID: id)
|
||
}
|
||
return CatalogTreeNode(
|
||
id: id,
|
||
name: entry.displayName,
|
||
isGroup: true,
|
||
source: entry.source,
|
||
parentID: parentID,
|
||
createdAt: entry.createdAt,
|
||
updatedAt: entry.updatedAt,
|
||
invalidReasons: [],
|
||
childCount: children.count,
|
||
subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount },
|
||
invalidDescendantCount: children.reduce(0) { $0 + $1.subtreeInvalidServerCount },
|
||
children: children)
|
||
case .server(let fields):
|
||
return CatalogTreeNode(
|
||
id: id,
|
||
name: entry.displayName,
|
||
isGroup: false,
|
||
source: entry.source,
|
||
parentID: parentID,
|
||
createdAt: entry.createdAt,
|
||
updatedAt: entry.updatedAt,
|
||
invalidReasons: LeafPreparation.invalidReasons(
|
||
fields, plugins: plugins),
|
||
childCount: 0,
|
||
subtreeNodeCount: 0,
|
||
invalidDescendantCount: 0,
|
||
children: nil)
|
||
}
|
||
}
|
||
let roots = catalog.rootChildren.compactMap { id -> CatalogTreeNode? in
|
||
guard let entry = catalog.entry(for: id) else { return nil }
|
||
return buildNode(id, entry: entry, parentID: nil)
|
||
}
|
||
return CatalogTreeSnapshot(roots: roots)
|
||
}
|
||
}
|
||
|
||
// MARK: - 目录政策事实(UI 写句子;本 module 不产成句文案)
|
||
|
||
/// 删除确认档位(GLOSSARY.md 删除不变量的结构化事实)。
|
||
enum DeleteConfirmKind: Equatable, Sendable {
|
||
/// 服务器叶子:单次确认。
|
||
case leaf
|
||
/// 空手动分组:单次确认。
|
||
case emptyGroup
|
||
/// 非空手动分组:二次确认,点名子树规模与凭据移除。
|
||
case subtree(count: Int, includesCredentials: Bool)
|
||
}
|
||
|
||
/// 激活资格事实(点前门禁;与 `ActivationCommandOutcome` 的点后结果区分)。
|
||
struct ActivationEligibility: Equatable, Sendable {
|
||
enum Ineligibility: Equatable, Sendable {
|
||
case emptyGroup
|
||
case noCandidates
|
||
}
|
||
|
||
let canActivate: Bool
|
||
let candidateCount: Int
|
||
let skippedInvalidCount: Int
|
||
let ineligibility: Ineligibility?
|
||
}
|
||
|
||
/// 激活命令的结构化结果(意外错误仍 throws)。
|
||
enum ActivationCommandOutcome: Equatable, Sendable {
|
||
/// 成功;组展开时跳过的已知无效叶子数。
|
||
case activated(skippedInvalid: Int)
|
||
/// 原子拒绝(无 activation candidate / 目标失效);携带 typed 点名原因,
|
||
/// 状态完全不动。结果自含全部事实,调用方不必回读运行时发布事实拼原因。
|
||
case rejectedActivation(ActivationFailure)
|
||
}
|
||
|
||
/// 激活缝:目录命令面经此发出激活意图。生产 adapter 为
|
||
/// `ProxyRuntimeController`;测试注入假 adapter 观察目标传递。
|
||
@MainActor
|
||
protocol Activating: AnyObject {
|
||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome
|
||
}
|
||
|
||
// MARK: - 服务器详情与编辑
|
||
|
||
/// 服务器编辑面状态:用户打开编辑器时经显式命令解析,凭据明文只在此出现,
|
||
/// 不属于普通 projection。
|
||
struct ServerEditForm: Equatable {
|
||
let address: String
|
||
let port: Int
|
||
let encryptionMethod: String
|
||
let password: String
|
||
let remark: String
|
||
let plugin: PluginSectionState
|
||
let isEditable: Bool
|
||
}
|
||
|
||
/// 服务器编辑的提交载荷(typed command):配置与凭据字段作为一个逻辑变更
|
||
/// 提交;任一持久化步骤失败时旧配置与旧凭据继续生效。
|
||
struct ServerEditDraft: Equatable {
|
||
var address: String
|
||
var port: Int
|
||
var encryptionMethod: String
|
||
var password: String
|
||
var remark: String
|
||
var plugin: PluginSelection
|
||
var pluginOptions: String?
|
||
}
|
||
|
||
/// 插件选择器选中态:无、目录中的名称引用、未解析的导入引用。
|
||
/// 托管或用户来源由有效目录解释,不参与选中值身份。
|
||
/// Hashable 以直接充当 SwiftUI Picker 的选中值。
|
||
enum PluginSelection: Hashable {
|
||
case none
|
||
case named(program: String)
|
||
case unknown(program: String)
|
||
}
|
||
|
||
/// 表单所需的目录事实,不包含本机路径或托管发布信息。
|
||
struct PluginSectionState: Equatable {
|
||
struct Program: Equatable {
|
||
let program: String
|
||
let source: PluginCatalogSnapshot.Source
|
||
let availability: PluginCatalogSnapshot.Availability
|
||
}
|
||
|
||
let selection: PluginSelection
|
||
let programs: [Program]
|
||
let mappingsUnreadable: Bool
|
||
let optionsPresent: Bool
|
||
var options: String
|
||
|
||
/// 当前草稿引用可能与已保存选择不同,目录移除不能让它从表单消失。
|
||
func unresolvedProgram(for selection: PluginSelection) -> String? {
|
||
switch selection {
|
||
case .none: return nil
|
||
case .named(let program), .unknown(let program):
|
||
return programs.contains { $0.program == program } ? nil : program
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 订阅 projection
|
||
|
||
/// 订阅卡片 projection(非敏感):完整 URL 不出现,只含 host;
|
||
/// 状态为结构化值,本地化文案与日期格式由 UI 呈现层派生。
|
||
struct SubscriptionSummary: Identifiable, Equatable {
|
||
let id: NodeID
|
||
let groupID: NodeID
|
||
let name: String
|
||
let host: String
|
||
let status: SubscriptionRefreshStatus
|
||
let serverCount: Int
|
||
var information: SubscriptionInformation?
|
||
var lastSucceededAt: Date?
|
||
var autoUpdate = SubscriptionAutoUpdate()
|
||
}
|
||
|
||
/// 非持久化的刷新回执。每次操作有独立身份,失败事实不含秘密值。
|
||
struct SubscriptionRefreshCompletion: Identifiable, Equatable {
|
||
let id: UUID
|
||
let completedAt: Date
|
||
let failure: SubscriptionRefreshFailure?
|
||
|
||
init(id: UUID = UUID(), completedAt: Date = Date(), failure: SubscriptionRefreshFailure? = nil) {
|
||
self.id = id
|
||
self.completedAt = completedAt
|
||
self.failure = failure
|
||
}
|
||
}
|
||
|
||
extension SubscriptionRefreshFailure {
|
||
/// Single workflow conversion point from transient refresh errors to durable
|
||
/// safe facts. Presentation is intentionally not involved here.
|
||
static func from(error: Error) -> Self {
|
||
switch error {
|
||
case let error as SubscriptionFetchError:
|
||
return from(fetchError: error)
|
||
case let error as SubscriptionParseError:
|
||
return from(parseError: error)
|
||
case let error as SubscriptionFormError:
|
||
if case .invalidURL = error { return .invalidURL }
|
||
return .unknown
|
||
case let error as SubscriptionRefreshFailure:
|
||
return error
|
||
default:
|
||
return .unknown
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 删除结果:被删节点身份集合。UI 据此清除失效选择(selection invalidation,
|
||
/// 不自动改选相邻节点,活动目标语义不受影响。
|
||
struct RemovalOutcome: Equatable, Sendable {
|
||
let removedNodeIDs: Set<NodeID>
|
||
}
|
||
|
||
// MARK: - 表单级 typed error(本地化由 UI 呈现层负责)
|
||
|
||
/// 表单级校验失败(地址/端口/名称);领域拒绝仍以 `CatalogError` 上抛。
|
||
enum ServerFormError: Error, Equatable {
|
||
case invalidAddress
|
||
case invalidPort
|
||
case missingEncryptionMethod
|
||
case unsupportedEncryptionMethod(String)
|
||
case invalidPassword
|
||
case emptyName
|
||
/// 提交了有效目录之外的插件选择;拒绝陈旧或未知的表单选择。
|
||
case pluginUnknown(String)
|
||
}
|
||
|
||
/// 订阅表单级失败(URL 门禁、订阅不存在);获取/解析失败仍以原错误上抛。
|
||
enum SubscriptionFormError: Error, Equatable {
|
||
case invalidURL
|
||
case notFound
|
||
}
|
||
|
||
/// Safe load failure facts; no credential reference or underlying error detail escapes.
|
||
enum ServerFormLoadError: Error, Equatable {
|
||
case credentialsUnavailable
|
||
case notFound
|
||
}
|