mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 21:53:43 +00:00
refactor(ng2): deepen catalog policy into workflow seam
New UI must bind projections and send typed commands only. Views were re-deriving delete confirm, move destinations, activation eligibility, and drag payload from domain state. Empty manual-group delete is no longer instant — always one confirmation.
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@
|
||||
- **Configuration catalog**: The non-user-visible root of the 2.0 configuration tree. Its ordered top-level children may be server configurations or configuration groups.
|
||||
- **Server configuration**: The user-visible connection data for one Shadowsocks server. It is a leaf with an opaque, persistent UUID and a source owner.
|
||||
- **Configuration group**: A named, ordered container that may contain server configurations and nested configuration groups. A node has at most one parent, and group membership forms an acyclic tree.
|
||||
- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants, with a second confirmation when it is non-empty.
|
||||
- **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants. Deletion is always user-confirmed: one confirmation for an empty manual group, and a second confirmation naming the recursive subtree scale when it is non-empty.
|
||||
- **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity.
|
||||
- **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription node's user-visible name/remark is one remote-derived value from `remarks`, falling back to the server address when absent; it has no local alias or note override.
|
||||
- **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server.
|
||||
@@ -46,7 +46,7 @@
|
||||
- The active target remains the selected node ID and is not silently replaced by a descendant chosen by `sslocal`. If it is deleted, has no activation candidate, or is otherwise invalid, the target is cleared and proxying stops rather than falling back silently.
|
||||
- After a committed edit to the active target's subtree, the target is re-expanded immediately: a non-empty set of activation candidates updates the runtime atomically; no activation candidate clears the target and stops proxying without fallback.
|
||||
- Manual nodes may move between the catalog root and manual groups, carrying their subtree and child order without changing identity or ownership; cross-source moves, shared parents, and cycles are rejected.
|
||||
- Empty groups may persist and remain editable, but cannot be activated. Deleting a non-empty manual group recursively deletes its descendants and requires a second confirmation.
|
||||
- Empty groups may persist and remain editable, but cannot be activated. Deleting a server configuration or an empty manual group takes one user confirmation. Deleting a non-empty manual group recursively deletes its descendants and takes a second confirmation that names the subtree scale and that the subtree's server credentials are removed.
|
||||
- A subscription owns its subscription group and its refreshed members; manual groups remain user-owned. Remote connection fields, membership, structure, order, and `remarks` are authoritative; subscription nodes have no local eligibility overlay.
|
||||
- Subscription server and nested-group identities use provider-supplied stable IDs scoped to that subscription. When a remote record has no stable ID, continuity is allowed only for an exactly matching canonical record; endpoint, name, or path heuristics must not merge identities.
|
||||
- Subscription-owned nodes and nested groups permit no local connection-field, name/remark, membership, parent, order, or eligibility override.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
/// 目录政策查询与激活命令(issue #41):删除确认档位、移动目的地、拖放资格
|
||||
/// 与激活资格/命令的唯一 UI-facing 出口。视图不得重推 ownership、删除确认或
|
||||
/// ADR-0001 激活预检;领域拒绝仍是不变量防线。
|
||||
extension CatalogWorkflow {
|
||||
// MARK: - 删除确认事实
|
||||
|
||||
/// 删除确认档位(CONTEXT.md:空手动组/服务器单次;非空手动组二次并点名规模)。
|
||||
/// 节点不存在时为 `nil`。
|
||||
func deleteFacts(for id: NodeID) -> DeleteConfirmKind? {
|
||||
guard let node = tree.node(withID: id) else { return nil }
|
||||
guard node.isGroup, node.isManual else { return .leaf }
|
||||
if node.childNodes.isEmpty { return .emptyGroup }
|
||||
return .subtree(
|
||||
count: node.subtreeNodeCount,
|
||||
includesCredentials: subtreeHasServers(node))
|
||||
}
|
||||
|
||||
private func subtreeHasServers(_ node: CatalogTreeNode) -> Bool {
|
||||
if !node.isGroup { return true }
|
||||
return node.childNodes.contains { subtreeHasServers($0) }
|
||||
}
|
||||
|
||||
// MARK: - 移动目的地与拖放资格
|
||||
|
||||
/// 「移动到」目的地:目录根 + 除自身子树外的全部手动组(depth 供展示缩进)。
|
||||
func moveDestinations(for id: NodeID) -> [MoveDestination] {
|
||||
var result = [MoveDestination(id: nil, name: "目录根", depth: 0)]
|
||||
let excluded = tree.node(withID: id)?.subtreeIDs ?? [id]
|
||||
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
|
||||
for node in nodes {
|
||||
guard node.isGroup, node.isManual else { continue }
|
||||
guard !excluded.contains(node.id) else { continue }
|
||||
result.append(MoveDestination(id: node.id, name: node.name, depth: depth))
|
||||
walk(node.childNodes, depth: depth + 1)
|
||||
}
|
||||
}
|
||||
walk(tree.roots, depth: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
/// 移动/落点资格:存在性、非自身、被拖节点可拖、目标为根或不在被拖子树内
|
||||
/// 的手动组。跨来源与成环仍由领域拒绝(不变量防线,不在此复制)。
|
||||
func canMove(_ id: NodeID, to parent: NodeID?) -> Bool {
|
||||
guard let node = tree.node(withID: id) else { return false }
|
||||
guard id != parent else { return false }
|
||||
guard node.isManual else { return false }
|
||||
guard let parent else { return true }
|
||||
guard let parentNode = tree.node(withID: parent), parentNode.isGroup, parentNode.isManual else {
|
||||
return false
|
||||
}
|
||||
return !node.subtreeIDs.contains(parent)
|
||||
}
|
||||
|
||||
/// 拖拽载荷:手动节点携带身份;订阅节点结构只读,返回 `nil`(空载荷)。
|
||||
func dragPayload(for id: NodeID) -> String? {
|
||||
guard let node = tree.node(withID: id), node.isManual else { return nil }
|
||||
return id.rawValue
|
||||
}
|
||||
|
||||
// MARK: - 激活资格与命令
|
||||
|
||||
/// 激活资格(点前门禁,ADR-0001):空组与无 candidate 原子不可激活;
|
||||
/// 组内已知无效叶子计入跳过数。
|
||||
func activationEligibility(for id: NodeID) -> ActivationEligibility? {
|
||||
guard let node = tree.node(withID: id) else { return nil }
|
||||
if node.isGroup {
|
||||
if node.childNodes.isEmpty {
|
||||
return ActivationEligibility(
|
||||
canActivate: false, candidateCount: 0, skippedInvalidCount: 0,
|
||||
ineligibility: .emptyGroup)
|
||||
}
|
||||
var candidateCount = 0
|
||||
var skipped = 0
|
||||
countEligibility(of: node, candidates: &candidateCount, skipped: &skipped)
|
||||
if candidateCount == 0 {
|
||||
return ActivationEligibility(
|
||||
canActivate: false, candidateCount: 0, skippedInvalidCount: skipped,
|
||||
ineligibility: .noCandidates)
|
||||
}
|
||||
return ActivationEligibility(
|
||||
canActivate: true, candidateCount: candidateCount, skippedInvalidCount: skipped,
|
||||
ineligibility: nil)
|
||||
}
|
||||
if node.isInvalid {
|
||||
return ActivationEligibility(
|
||||
canActivate: false, candidateCount: 0, skippedInvalidCount: 1,
|
||||
ineligibility: .noCandidates)
|
||||
}
|
||||
return ActivationEligibility(
|
||||
canActivate: true, candidateCount: 1, skippedInvalidCount: 0, ineligibility: nil)
|
||||
}
|
||||
|
||||
private func countEligibility(
|
||||
of node: CatalogTreeNode,
|
||||
candidates: inout Int,
|
||||
skipped: inout Int
|
||||
) {
|
||||
if !node.isGroup {
|
||||
if node.isInvalid {
|
||||
skipped += 1
|
||||
} else {
|
||||
candidates += 1
|
||||
}
|
||||
return
|
||||
}
|
||||
for child in node.childNodes {
|
||||
countEligibility(of: child, candidates: &candidates, skipped: &skipped)
|
||||
}
|
||||
}
|
||||
|
||||
/// 激活命令(typed command surface):经 `Activating` port 发出;编排与
|
||||
/// 运行时收敛留在 port 背后。意外错误 throws。
|
||||
func activate(_ id: NodeID) async throws -> ActivationCommandOutcome {
|
||||
try await activator.activate(id)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ extension CatalogWorkflow {
|
||||
status: .never)
|
||||
try credentials.save(url.absoluteString, for: record.urlRef)
|
||||
do {
|
||||
try commitDocument { catalog, subscriptions in
|
||||
try commitSubscriptionDocument { catalog, subscriptions in
|
||||
// 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host
|
||||
// 兜底,首次成功刷新后跟随远端。
|
||||
try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID)
|
||||
@@ -81,7 +81,7 @@ extension CatalogWorkflow {
|
||||
throw SubscriptionFormError.notFound
|
||||
}
|
||||
var removedEntries: [CatalogEntry] = []
|
||||
try commitDocument { catalog, subscriptions in
|
||||
try commitSubscriptionDocument { catalog, subscriptions in
|
||||
removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID)
|
||||
subscriptions.removeAll { $0.id == id }
|
||||
}
|
||||
@@ -126,7 +126,7 @@ extension CatalogWorkflow {
|
||||
var removedServers: [CatalogEntry] = []
|
||||
var credentialJournal = CredentialWriteJournal(credentials: credentials)
|
||||
do {
|
||||
try commitDocument { [self] catalog, subscriptions in
|
||||
try commitSubscriptionDocument { [self] catalog, subscriptions in
|
||||
// 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用;
|
||||
// journal 保证快照提交失败时恢复旧秘密(story 29)。
|
||||
var reusedRefs: [NodeID: ServerCredentialRefs] = [:]
|
||||
@@ -160,7 +160,7 @@ extension CatalogWorkflow {
|
||||
/// 不含订阅 URL),不是弹窗文案;其文案化沿用在同一 presentation 边缘的
|
||||
/// `Error.presentableMessage`,独立编译 target 拆分时随该边缘外移。
|
||||
private func markRefreshFailed(subscriptionID: NodeID, reason: String) async {
|
||||
try? commitDocument { _, subscriptions in
|
||||
try? commitSubscriptionDocument { _, subscriptions in
|
||||
if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) {
|
||||
subscriptions[index].status = .failed(at: Date(), reason: reason)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ final class CatalogWorkflow: ObservableObject {
|
||||
/// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。
|
||||
let postLegacyImport: ((LegacyImportOutcome) async -> Void)?
|
||||
let legacyImportService: LegacyImportService
|
||||
/// 激活缝(issue #41):生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。
|
||||
let activator: Activating
|
||||
|
||||
init(
|
||||
coordinator: CatalogCommitCoordinator,
|
||||
@@ -51,7 +53,8 @@ final class CatalogWorkflow: ObservableObject {
|
||||
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
|
||||
subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(),
|
||||
legacyImportService: LegacyImportService? = nil,
|
||||
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil
|
||||
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil,
|
||||
activator: Activating = RejectingActivator()
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
fileStore = coordinator.fileStore
|
||||
@@ -59,6 +62,7 @@ final class CatalogWorkflow: ObservableObject {
|
||||
self.plugins = plugins
|
||||
self.subscriptionFetcher = subscriptionFetcher
|
||||
self.postLegacyImport = postLegacyImport
|
||||
self.activator = activator
|
||||
self.legacyImportService =
|
||||
legacyImportService
|
||||
?? LegacyImportService(
|
||||
@@ -81,9 +85,9 @@ final class CatalogWorkflow: ObservableObject {
|
||||
coordinator.committedCatalog.entry(for: id)?.displayName ?? ""
|
||||
}
|
||||
|
||||
/// 服务器激活校验结果(非敏感点名原因;编辑面「激活状态」区)。
|
||||
func serverValidation(for id: NodeID) -> ServerValidation? {
|
||||
tree.node(withID: id)?.validation
|
||||
/// 服务器叶子的已知阻塞原因(typed;编辑面「激活状态」区)。
|
||||
func serverInvalidReasons(for id: NodeID) -> [LeafInvalidationReason] {
|
||||
tree.node(withID: id)?.invalidReasons ?? []
|
||||
}
|
||||
|
||||
/// 服务器编辑面(显式命令,story 11):解析密码与受管插件参数明文。
|
||||
@@ -288,10 +292,10 @@ final class CatalogWorkflow: ObservableObject {
|
||||
).encode()
|
||||
}
|
||||
|
||||
// MARK: - 提交管线(module 内部)
|
||||
// MARK: - 提交管线(module 内部;UI 不得调用,拆独立 target 前靠约定)
|
||||
|
||||
/// 仅目录变更的提交便捷入口(订阅扩展使用双参 `commitDocument`)。
|
||||
func commit<T>(
|
||||
/// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。
|
||||
private func commit<T>(
|
||||
_ mutate: (inout ConfigurationCatalog) throws -> T
|
||||
) throws -> T {
|
||||
try commitDocument { catalog, _ in
|
||||
@@ -301,7 +305,7 @@ final class CatalogWorkflow: ObservableObject {
|
||||
|
||||
/// 副本变更 → 协调器落盘 → 重新发布 projection;任一步失败则已发布状态
|
||||
/// 不动。运行时收敛由协调器异步调度(issue #40),不阻塞也不回滚本提交。
|
||||
func commitDocument<T>(
|
||||
private func commitDocument<T>(
|
||||
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
|
||||
) throws -> T {
|
||||
let result = try coordinator.commit(mutate)
|
||||
@@ -309,6 +313,13 @@ final class CatalogWorkflow: ObservableObject {
|
||||
return result
|
||||
}
|
||||
|
||||
/// 订阅扩展的唯一写路径(module 内部窄缝,替代直接摸 `commitDocument`)。
|
||||
func commitSubscriptionDocument<T>(
|
||||
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
|
||||
) throws -> T {
|
||||
try commitDocument(mutate)
|
||||
}
|
||||
|
||||
/// 提交成功后的 projection 重建(树 + 订阅卡片)。
|
||||
func republishCommittedState() {
|
||||
tree = .build(
|
||||
@@ -431,6 +442,13 @@ final class CatalogWorkflow: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认激活 adapter:未接线时原子拒绝(测试不触激活的场景)。
|
||||
final class RejectingActivator: Activating {
|
||||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
|
||||
.rejectedActivation
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭据写入日志:目录文件只有在全部凭据写入成功后才替换;如果任何凭据或
|
||||
/// 目录写入失败,则恢复改动前的每个引用值,避免旧秘密被覆盖或丢失
|
||||
/// (story 13/29)。
|
||||
|
||||
@@ -3,8 +3,10 @@ import Foundation
|
||||
// MARK: - 目录树 projection(非敏感)
|
||||
|
||||
/// 目录树节点快照(目录工作流 module 的 UI-facing projection,issue #41):
|
||||
/// 名称、来源、形态、校验事实与子树计数;不含凭据引用、密码、插件参数等
|
||||
/// 名称、来源、形态、渲染原子与子树计数;不含凭据引用、密码、插件参数等
|
||||
/// 秘密值。身份为不透明 `NodeID`,重命名、移动或刷新后保持稳定(story 3)。
|
||||
/// 门禁/关系型事实(删除档位、移动资格、激活资格)走 module 查询方法,
|
||||
/// 不在节点上堆政策字段。
|
||||
struct CatalogTreeNode: Identifiable, Equatable {
|
||||
let id: NodeID
|
||||
let name: String
|
||||
@@ -12,21 +14,18 @@ struct CatalogTreeNode: Identifiable, Equatable {
|
||||
let source: NodeSource
|
||||
/// 父节点身份;目录根层节点为 `nil`。
|
||||
let parentID: NodeID?
|
||||
/// 服务器叶子的 app 可知校验结果;分组为 `nil`。
|
||||
let validation: ServerValidation?
|
||||
/// 服务器叶子的已知阻塞原因(typed,无成句文案);分组为空。
|
||||
let invalidReasons: [LeafInvalidationReason]
|
||||
/// 直接子节点数(分组)。
|
||||
let childCount: Int
|
||||
/// 子树全部后代节点数(分组;不含自身;删除确认的递归规模)。
|
||||
let subtreeNodeCount: Int
|
||||
/// 子树中已知无效的服务器数量(不含当前叶子自身)。
|
||||
/// 子树中已知无效的服务器数量(不含当前叶子自身;仅供 tooltip 展示)。
|
||||
let invalidDescendantCount: Int
|
||||
/// 子树中服务器叶子总数(叶子为 1)。
|
||||
let serverCount: Int
|
||||
/// 分组持有子树快照;服务器叶子为 `nil`。
|
||||
let children: [CatalogTreeNode]?
|
||||
|
||||
var isInvalid: Bool { validation?.isValid == false }
|
||||
var invalidServerCount: Int { (isInvalid ? 1 : 0) + invalidDescendantCount }
|
||||
var isInvalid: Bool { !invalidReasons.isEmpty }
|
||||
var isManual: Bool { source == .manual }
|
||||
/// 子树快照;服务器叶子为空(与 `children` 的 nil 区分叶子语义并存)。
|
||||
var childNodes: [CatalogTreeNode] { children ?? [] }
|
||||
@@ -48,6 +47,11 @@ extension CatalogTreeNode {
|
||||
for child in children ?? [] { ids.formUnion(child.subtreeIDs) }
|
||||
return ids
|
||||
}
|
||||
|
||||
/// 子树内已知无效服务器总数(含自身叶子)。
|
||||
var subtreeInvalidServerCount: Int {
|
||||
(isInvalid ? 1 : 0) + invalidDescendantCount
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树快照:侧栏、活动目标级联、移动目的地与删除确认共用的同一份
|
||||
@@ -69,7 +73,9 @@ struct CatalogTreeSnapshot: Equatable {
|
||||
func containsNode(_ id: NodeID) -> Bool { node(withID: id) != nil }
|
||||
|
||||
/// 全树已知无效服务器总数(诊断计数)。
|
||||
var invalidServerCount: Int { roots.reduce(0) { $0 + $1.invalidServerCount } }
|
||||
var invalidServerCount: Int {
|
||||
roots.reduce(0) { $0 + $1.subtreeInvalidServerCount }
|
||||
}
|
||||
}
|
||||
|
||||
extension CatalogTreeSnapshot {
|
||||
@@ -93,24 +99,24 @@ extension CatalogTreeSnapshot {
|
||||
isGroup: true,
|
||||
source: entry.source,
|
||||
parentID: parentID,
|
||||
validation: nil,
|
||||
invalidReasons: [],
|
||||
childCount: children.count,
|
||||
subtreeNodeCount: children.reduce(0) { $0 + 1 + $1.subtreeNodeCount },
|
||||
invalidDescendantCount: children.reduce(0) { $0 + $1.invalidServerCount },
|
||||
serverCount: children.reduce(0) { $0 + $1.serverCount },
|
||||
invalidDescendantCount: children.reduce(0) { $0 + $1.subtreeInvalidServerCount },
|
||||
children: children)
|
||||
case .server(let fields):
|
||||
let validation = ServerValidation.evaluate(
|
||||
fields, credentials: credentials, plugins: plugins)
|
||||
return CatalogTreeNode(
|
||||
id: id,
|
||||
name: entry.displayName,
|
||||
isGroup: false,
|
||||
source: entry.source,
|
||||
parentID: parentID,
|
||||
validation: ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins),
|
||||
invalidReasons: validation.issues,
|
||||
childCount: 0,
|
||||
subtreeNodeCount: 0,
|
||||
invalidDescendantCount: 0,
|
||||
serverCount: 1,
|
||||
children: nil)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +128,54 @@ extension CatalogTreeSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 目录政策事实(UI 写句子;本 module 不产成句文案)
|
||||
|
||||
/// 删除确认档位(CONTEXT.md 删除不变量的结构化事实)。
|
||||
enum DeleteConfirmKind: Equatable, Sendable {
|
||||
/// 服务器叶子:单次确认。
|
||||
case leaf
|
||||
/// 空手动分组:单次确认。
|
||||
case emptyGroup
|
||||
/// 非空手动分组:二次确认,点名子树规模与凭据移除。
|
||||
case subtree(count: Int, includesCredentials: Bool)
|
||||
}
|
||||
|
||||
/// 「移动到」目的地(目录根 + 除自身子树外的全部手动组)。
|
||||
struct MoveDestination: Identifiable, Equatable, Sendable {
|
||||
/// `nil` = 目录根。
|
||||
let id: NodeID?
|
||||
let name: String
|
||||
/// 展示缩进深度(根为 0)。
|
||||
let depth: Int
|
||||
}
|
||||
|
||||
/// 激活资格事实(点前门禁;与 `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 / 目标失效);状态完全不动。
|
||||
case rejectedActivation
|
||||
}
|
||||
|
||||
/// 激活缝(issue #41):目录命令面经此发出激活意图。生产 adapter 为
|
||||
/// `ProxyRuntimeController`;测试注入假 adapter 观察目标传递。
|
||||
protocol Activating: AnyObject {
|
||||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome
|
||||
}
|
||||
|
||||
// MARK: - 服务器详情与编辑
|
||||
|
||||
/// 服务器编辑面状态:用户打开编辑器时经显式命令解析,凭据明文只在此出现
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 分组详情(issue #32/#41):名称编辑(手动组)、直接子节点数与激活入口。
|
||||
/// 数据来自目录工作流 module 的树 projection;订阅固定分组名称由远端管理,
|
||||
/// 只读。空组保留可编辑(可先搭结构再填内容)。
|
||||
/// 数据来自目录工作流 module 的树 projection 与激活资格查询;订阅固定分组
|
||||
/// 名称由远端管理,只读。空组保留可编辑(可先搭结构再填内容)。
|
||||
struct GroupDetailView: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let groupID: NodeID
|
||||
let proxyController: ProxyRuntimeController
|
||||
let errors: ErrorAlertPresenter
|
||||
|
||||
@State private var name = ""
|
||||
@@ -14,6 +13,10 @@ struct GroupDetailView: View {
|
||||
private var node: CatalogTreeNode? { workflow.tree.node(withID: groupID) }
|
||||
private var isManual: Bool { node?.isManual ?? false }
|
||||
|
||||
private var eligibility: ActivationEligibility? {
|
||||
workflow.activationEligibility(for: groupID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("分组") {
|
||||
@@ -25,26 +28,32 @@ struct GroupDetailView: View {
|
||||
}
|
||||
Section {
|
||||
LabeledContent("直接子节点", value: "\(directChildCount)")
|
||||
if invalidServerCount > 0 {
|
||||
LabeledContent("已知无效服务器", value: "\(invalidServerCount)")
|
||||
if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
LabeledContent("已知无效服务器", value: "\(eligibility.skippedInvalidCount)")
|
||||
}
|
||||
LabeledContent("来源", value: isManual ? "手动" : "订阅")
|
||||
Button {
|
||||
Task { await proxyController.activate(groupID) }
|
||||
Task {
|
||||
do {
|
||||
_ = try await workflow.activate(groupID)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("激活此分组", systemImage: "bolt.fill")
|
||||
}
|
||||
.disabled(validServerCount == 0)
|
||||
.disabled(!(eligibility?.canActivate ?? false))
|
||||
if directChildCount == 0 {
|
||||
Text("空分组保留可编辑,但不能激活。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if validServerCount == 0 {
|
||||
} else if eligibility?.canActivate == false {
|
||||
Text("分组中没有可激活的有效服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if invalidServerCount > 0 {
|
||||
Text("激活时将跳过 \(invalidServerCount) 个存在已知阻塞问题的服务器。")
|
||||
} else if let eligibility, eligibility.skippedInvalidCount > 0 {
|
||||
Text("激活时将跳过 \(eligibility.skippedInvalidCount) 个存在已知阻塞问题的服务器。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -57,9 +66,6 @@ struct GroupDetailView: View {
|
||||
}
|
||||
|
||||
private var directChildCount: Int { node?.childCount ?? 0 }
|
||||
private var invalidServerCount: Int { node?.invalidServerCount ?? 0 }
|
||||
private var serverCount: Int { node?.serverCount ?? 0 }
|
||||
private var validServerCount: Int { serverCount - invalidServerCount }
|
||||
|
||||
private func loadName() {
|
||||
name = workflow.displayName(for: groupID)
|
||||
|
||||
@@ -178,8 +178,8 @@ struct QRImportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「移动到」表单(issue #41):目的地由树 projection 派生——目录根与除自身
|
||||
/// 子树外的全部手动组;跨来源与成环由领域层最终拒绝。
|
||||
/// 「移动到」表单(issue #41):目的地由 seam 的 `moveDestinations` 提供——
|
||||
/// 目录根与除自身子树外的全部手动组;跨来源与成环由领域层最终拒绝。
|
||||
struct MoveNodeSheet: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
@@ -194,19 +194,10 @@ struct MoveNodeSheet: View {
|
||||
}
|
||||
|
||||
private var destinations: [Destination] {
|
||||
var result = [Destination(id: nil, title: "目录根")]
|
||||
let excluded = workflow.tree.node(withID: nodeID)?.subtreeIDs ?? [nodeID]
|
||||
func walk(_ nodes: [CatalogTreeNode], depth: Int) {
|
||||
for node in nodes {
|
||||
guard node.isGroup, node.isManual else { continue }
|
||||
guard !excluded.contains(node.id) else { continue }
|
||||
let indent = String(repeating: " ", count: depth)
|
||||
result.append(Destination(id: node.id, title: indent + node.name))
|
||||
walk(node.children ?? [], depth: depth + 1)
|
||||
}
|
||||
workflow.moveDestinations(for: nodeID).map { item in
|
||||
let indent = String(repeating: " ", count: item.depth)
|
||||
return Destination(id: item.id, title: indent + item.name)
|
||||
}
|
||||
walk(workflow.tree.roots, depth: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -29,7 +29,8 @@ struct ShadowsocksXNG2App: App {
|
||||
coordinator: coordinator,
|
||||
postLegacyImport: { _ in
|
||||
await controller.legacyImportDidCommit()
|
||||
})
|
||||
},
|
||||
activator: controller)
|
||||
_catalogWorkflow = StateObject(wrappedValue: catalogWorkflow)
|
||||
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次。
|
||||
_settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(controller: controller))
|
||||
|
||||
@@ -15,6 +15,7 @@ struct MainWindowView: View {
|
||||
}
|
||||
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
/// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。
|
||||
@ObservedObject var proxyController: ProxyRuntimeController
|
||||
/// 诊断工作流 module(issue #43):诊断侧栏与详情共用的唯一 seam。
|
||||
@ObservedObject var diagnostics: DiagnosticsWorkflow
|
||||
@@ -158,7 +159,7 @@ struct MainWindowView: View {
|
||||
SidebarRow(
|
||||
node: node,
|
||||
workflow: workflow,
|
||||
proxyController: proxyController,
|
||||
activeTargetID: proxyController.activeTargetID,
|
||||
errors: errors,
|
||||
onRename: { id in
|
||||
renameTarget = id
|
||||
@@ -169,14 +170,13 @@ struct MainWindowView: View {
|
||||
newGroupName = ""
|
||||
},
|
||||
onMove: { moveTarget = $0 },
|
||||
onDelete: { deleteTarget = $0 },
|
||||
onRemoved: { clearSelectionIfInvalidated($0) }
|
||||
onDelete: { deleteTarget = $0 }
|
||||
)
|
||||
.onDrag {
|
||||
// 订阅节点结构只读:携带空负载,落点校验节点存在性后自动忽略。
|
||||
node.isManual
|
||||
? NSItemProvider(object: node.id.rawValue as NSString)
|
||||
: NSItemProvider()
|
||||
guard let payload = workflow.dragPayload(for: node.id) else {
|
||||
return NSItemProvider()
|
||||
}
|
||||
return NSItemProvider(object: payload as NSString)
|
||||
}
|
||||
.dropDestination(for: String.self) { payload, _ in
|
||||
handleDrop(payload, onto: node.id)
|
||||
@@ -211,11 +211,11 @@ struct MainWindowView: View {
|
||||
}
|
||||
|
||||
/// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。
|
||||
/// 跨来源由视图过滤 + 领域拒绝双重保证;无效负载不接收。
|
||||
/// 资格事实由 seam 提供;跨来源/成环仍由领域拒绝(不变量防线)。
|
||||
private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool {
|
||||
guard let raw = payload.first else { return false }
|
||||
let dragged = NodeID(rawValue: raw)
|
||||
guard dragged != target, workflow.tree.containsNode(dragged) else { return false }
|
||||
guard workflow.canMove(dragged, to: target) else { return false }
|
||||
Task {
|
||||
do {
|
||||
try await workflow.move(dragged, to: target)
|
||||
@@ -241,11 +241,11 @@ struct MainWindowView: View {
|
||||
if let id = selection, let node = workflow.tree.node(withID: id) {
|
||||
if node.isGroup {
|
||||
GroupDetailView(
|
||||
workflow: workflow, groupID: id, proxyController: proxyController,
|
||||
workflow: workflow, groupID: id,
|
||||
errors: errors)
|
||||
} else {
|
||||
ServerDetailView(
|
||||
workflow: workflow, serverID: id, proxyController: proxyController,
|
||||
workflow: workflow, serverID: id,
|
||||
errors: errors)
|
||||
}
|
||||
} else {
|
||||
@@ -332,27 +332,29 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var deleteTargetNode: CatalogTreeNode? {
|
||||
deleteTarget.flatMap { workflow.tree.node(withID: $0) }
|
||||
}
|
||||
|
||||
/// 删除语义:非空手动组点名子树规模(二次确认),空组与服务器单次确认。
|
||||
/// 删除确认文案:档位与规模事实来自 seam;句子由 UI 拼(Q3-A)。
|
||||
private var deleteTitle: String {
|
||||
guard let node = deleteTargetNode, node.isGroup, node.isManual else {
|
||||
return "删除「\(deleteTargetName)」?"
|
||||
guard let id = deleteTarget else { return "" }
|
||||
let name = workflow.displayName(for: id)
|
||||
switch workflow.deleteFacts(for: id) {
|
||||
case .subtree:
|
||||
return "删除分组「\(name)」及其整棵子树?"
|
||||
case .leaf, .emptyGroup, nil:
|
||||
return "删除「\(name)」?"
|
||||
}
|
||||
return "删除分组「\(deleteTargetName)」及其整棵子树?"
|
||||
}
|
||||
|
||||
private var deleteMessage: String {
|
||||
guard let node = deleteTargetNode, node.isGroup, node.isManual else {
|
||||
guard let id = deleteTarget else { return "" }
|
||||
switch workflow.deleteFacts(for: id) {
|
||||
case .subtree(let count, let includesCredentials):
|
||||
if includesCredentials {
|
||||
return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。"
|
||||
}
|
||||
return "将递归删除 \(count) 个节点,此操作不可撤销。"
|
||||
case .leaf, .emptyGroup, nil:
|
||||
return "此操作不可撤销。"
|
||||
}
|
||||
return "将递归删除 \(node.subtreeNodeCount) 个节点(含其中的服务器与凭据),此操作不可撤销。"
|
||||
}
|
||||
|
||||
private var deleteTargetName: String {
|
||||
deleteTarget.map { workflow.displayName(for: $0) } ?? ""
|
||||
}
|
||||
|
||||
private func commitDelete() {
|
||||
|
||||
@@ -156,8 +156,10 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
// MARK: - 用户意图
|
||||
|
||||
/// 激活一个服务器或分组目标(目录 UI 工单复用入口):持久化目标;代理
|
||||
/// 开启时立即把新档推到运行时。激活原子失败时状态完全不动(D3)。
|
||||
func activate(_ target: NodeID) async {
|
||||
/// 开启时立即把新档推到运行时。激活原子失败时状态完全不动(D3),返回
|
||||
/// `.rejectedActivation`;意外错误 throws 并进入 `serviceFailed`。
|
||||
@discardableResult
|
||||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
|
||||
reloadCatalog()
|
||||
do {
|
||||
let configuration = try machine.activate(
|
||||
@@ -170,15 +172,18 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
try activationFileStore.save(activeTargetID: target)
|
||||
} catch {
|
||||
state = .serviceFailed(detail: String(describing: error))
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if state != .off {
|
||||
await deploy(configuration.document)
|
||||
}
|
||||
return .activated(skippedInvalid: configuration.skippedServers.count)
|
||||
} catch let failure as ActivationFailure {
|
||||
state = .activationFailed(reason: failure.presentedReason)
|
||||
return .rejectedActivation
|
||||
} catch {
|
||||
state = .serviceFailed(detail: String(describing: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +447,8 @@ extension ProxyRuntimeController.ProxyState {
|
||||
}
|
||||
}
|
||||
|
||||
extension ProxyRuntimeController: Activating {}
|
||||
|
||||
extension ProxyRuntimeController {
|
||||
/// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开,
|
||||
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启
|
||||
|
||||
@@ -8,7 +8,6 @@ import SwiftUI
|
||||
struct ServerDetailView: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let serverID: NodeID
|
||||
let proxyController: ProxyRuntimeController
|
||||
let errors: ErrorAlertPresenter
|
||||
|
||||
@State private var address = ""
|
||||
@@ -74,10 +73,10 @@ struct ServerDetailView: View {
|
||||
.disabled(!isEditable)
|
||||
}
|
||||
|
||||
if let validation = node?.validation, !validation.isValid {
|
||||
if let node, node.isInvalid {
|
||||
Section("激活状态") {
|
||||
ForEach(Array(validation.issues.enumerated()), id: \.offset) { _, issue in
|
||||
Label(issue.presentedReason, systemImage: "exclamationmark.triangle.fill")
|
||||
ForEach(Array(node.invalidReasons.enumerated()), id: \.offset) { _, reason in
|
||||
Label(reason.presentedReason, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 侧栏树行(issue #41):来源标识、有效性提示、活动目标标记与右键菜单。
|
||||
/// 数据来自目录工作流 module 的树 projection;服务器/分组不再拥有独立的启
|
||||
/// 用/停用状态,激活时由状态机按有效性展开。
|
||||
/// 数据来自目录工作流 module 的树 projection;激活经 seam 的 typed command,
|
||||
/// 活动目标标记由父视图传入(运行时事实,不进目录 projection)。删除一律
|
||||
/// 交父视图确认弹窗(空手动组单次、非空二次,CONTEXT.md)。
|
||||
struct SidebarRow: View {
|
||||
let node: CatalogTreeNode
|
||||
let workflow: CatalogWorkflow
|
||||
let proxyController: ProxyRuntimeController
|
||||
let activeTargetID: NodeID?
|
||||
let errors: ErrorAlertPresenter
|
||||
let onRename: (NodeID) -> Void
|
||||
let onNewGroup: (NodeID?) -> Void
|
||||
let onMove: (NodeID) -> Void
|
||||
let onDelete: (NodeID) -> Void
|
||||
/// 直接删除完成后的回调(携带被删身份集合;UI 据此清除失效选择)。
|
||||
let onRemoved: (Set<NodeID>) -> Void
|
||||
|
||||
private var isSubscription: Bool { node.source == .subscription }
|
||||
|
||||
@@ -26,7 +25,7 @@ struct SidebarRow: View {
|
||||
if node.isInvalid {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.help(node.validation?.issues.first?.presentedReason ?? "服务器存在已知阻塞问题")
|
||||
.help(node.invalidReasons.first?.presentedReason ?? "服务器存在已知阻塞问题")
|
||||
} else if node.invalidDescendantCount > 0 {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
@@ -37,7 +36,7 @@ struct SidebarRow: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
.help("订阅节点:由远端管理")
|
||||
}
|
||||
if proxyController.activeTargetID == node.id {
|
||||
if activeTargetID == node.id {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.help("活动目标")
|
||||
@@ -46,15 +45,16 @@ struct SidebarRow: View {
|
||||
.contextMenu { contextMenu }
|
||||
}
|
||||
|
||||
/// 空手动组直接删除;服务器与非空手动组走确认弹窗(issue #32)。
|
||||
private var isEmptyManualGroup: Bool {
|
||||
node.isGroup && node.isManual && node.childNodes.isEmpty
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextMenu: some View {
|
||||
Button("激活") {
|
||||
Task { await proxyController.activate(node.id) }
|
||||
Task {
|
||||
do {
|
||||
_ = try await workflow.activate(node.id)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isSubscription {
|
||||
Divider()
|
||||
@@ -68,18 +68,7 @@ struct SidebarRow: View {
|
||||
Button("移动到…") { onMove(node.id) }
|
||||
Divider()
|
||||
Button("删除…", role: .destructive) {
|
||||
if isEmptyManualGroup {
|
||||
Task {
|
||||
do {
|
||||
let outcome = try await workflow.remove(node.id)
|
||||
onRemoved(outcome.removedNodeIDs)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onDelete(node.id)
|
||||
}
|
||||
onDelete(node.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import XCTest
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 目录政策 seam 的 interface 测试(issue #41):删除确认档位、移动目的地、
|
||||
/// 拖放/移动资格、激活资格与激活命令经假 adapter 的目标传递。只观察结构化
|
||||
/// fact 与 typed command outcome,不锁定 SwiftUI。
|
||||
@MainActor
|
||||
final class CatalogWorkflowPolicyTests: XCTestCase {
|
||||
private var workDir: URL!
|
||||
private var fileURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var activator: FakeActivator!
|
||||
private var workflow: CatalogWorkflow!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
workDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("catalog-policy-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||||
credentials = InMemoryCredentialStore()
|
||||
runtime = FakeCatalogRuntime()
|
||||
activator = FakeActivator()
|
||||
workflow = makeWorkflow()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try? FileManager.default.removeItem(at: workDir)
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
private func makeWorkflow() -> CatalogWorkflow {
|
||||
runtime.hasActiveTarget = true
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
return CatalogWorkflow(
|
||||
coordinator: coordinator,
|
||||
credentials: credentials,
|
||||
plugins: NoManagedPluginProvider(),
|
||||
activator: activator)
|
||||
}
|
||||
|
||||
private func importServer(into parent: NodeID? = nil) async throws -> NodeID {
|
||||
let before = Set(workflow.tree.roots.flatMap(\.subtreeIDs))
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: parent)
|
||||
if let parent {
|
||||
let node = try XCTUnwrap(workflow.tree.node(withID: parent))
|
||||
return try XCTUnwrap(node.childNodes.last?.id)
|
||||
}
|
||||
let added = workflow.tree.roots.map(\.id).filter { !before.contains($0) }
|
||||
return try XCTUnwrap(added.last)
|
||||
}
|
||||
|
||||
// MARK: - 删除确认档位
|
||||
|
||||
func testDeleteFactsLeafIsEmptySingleConfirm() async throws {
|
||||
let serverID = try await importServer()
|
||||
XCTAssertEqual(workflow.deleteFacts(for: serverID), .leaf)
|
||||
}
|
||||
|
||||
func testDeleteFactsEmptyManualGroupIsSingleConfirm() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "空组", into: nil)
|
||||
XCTAssertEqual(workflow.deleteFacts(for: groupID), .emptyGroup)
|
||||
}
|
||||
|
||||
func testDeleteFactsNonEmptyGroupNamesSubtreeAndCredentials() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: groupID)
|
||||
let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID)
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.8:8388", into: nestedID)
|
||||
// 组内:服务器 + 嵌套组 + 嵌套服务器 = 3 个后代节点(不含自身)。
|
||||
XCTAssertEqual(
|
||||
workflow.deleteFacts(for: groupID),
|
||||
.subtree(count: 3, includesCredentials: true))
|
||||
}
|
||||
|
||||
// MARK: - 移动目的地
|
||||
|
||||
func testMoveDestinationsAreRootAndManualGroupsExcludingOwnSubtree() async throws {
|
||||
let outer = try await workflow.createGroup(named: "外层", into: nil)
|
||||
let inner = try await workflow.createGroup(named: "内层", into: outer)
|
||||
let sibling = try await workflow.createGroup(named: "兄弟", into: nil)
|
||||
let serverID = try await importServer()
|
||||
|
||||
let destinations = workflow.moveDestinations(for: outer)
|
||||
XCTAssertEqual(destinations.map(\.id), [nil, sibling], "排除自身子树(内层)")
|
||||
XCTAssertEqual(destinations.map(\.depth), [0, 0])
|
||||
XCTAssertEqual(destinations.map(\.name), ["目录根", "兄弟"])
|
||||
|
||||
let forServer = workflow.moveDestinations(for: serverID)
|
||||
XCTAssertTrue(forServer.map(\.id).contains(outer), "服务器可移入手动组")
|
||||
XCTAssertTrue(forServer.map(\.id).contains(inner), "含嵌套手动组")
|
||||
}
|
||||
|
||||
// MARK: - 移动/拖放资格
|
||||
|
||||
func testCanMoveRejectsSelfCycleAndSubscriptionTarget() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
||||
let nestedID = try await workflow.createGroup(named: "嵌套", into: groupID)
|
||||
let serverID = try await importServer(into: groupID)
|
||||
|
||||
XCTAssertFalse(workflow.canMove(groupID, to: groupID), "非自身")
|
||||
XCTAssertTrue(workflow.canMove(groupID, to: nil), "可回根")
|
||||
XCTAssertFalse(workflow.canMove(groupID, to: nestedID), "目标在被拖子树内")
|
||||
XCTAssertTrue(workflow.canMove(nestedID, to: nil))
|
||||
XCTAssertFalse(workflow.canMove(nestedID, to: nestedID))
|
||||
XCTAssertTrue(workflow.canMove(serverID, to: nestedID))
|
||||
|
||||
let fixture = try CatalogFixtures.makeSubscriptionFixture()
|
||||
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog))
|
||||
workflow = makeWorkflow()
|
||||
XCTAssertFalse(workflow.canMove(fixture.serverIDs[0], to: nil), "订阅节点不可拖")
|
||||
XCTAssertFalse(workflow.canMove(serverID, to: fixture.groupID), "目标非手动组")
|
||||
XCTAssertFalse(workflow.canMove(NodeID(rawValue: "ghost"), to: nil), "不存在")
|
||||
}
|
||||
|
||||
func testDragPayloadManualHasIDSubscriptionNil() async throws {
|
||||
let serverID = try await importServer()
|
||||
XCTAssertEqual(workflow.dragPayload(for: serverID), serverID.rawValue)
|
||||
|
||||
let fixture = try CatalogFixtures.makeSubscriptionFixture()
|
||||
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: fixture.catalog))
|
||||
workflow = makeWorkflow()
|
||||
XCTAssertNil(workflow.dragPayload(for: fixture.serverIDs[0]))
|
||||
XCTAssertNil(workflow.dragPayload(for: fixture.groupID))
|
||||
}
|
||||
|
||||
// MARK: - 激活资格
|
||||
|
||||
func testActivationEligibilityEmptyGroup() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "空组", into: nil)
|
||||
XCTAssertEqual(
|
||||
workflow.activationEligibility(for: groupID),
|
||||
ActivationEligibility(
|
||||
canActivate: false, candidateCount: 0, skippedInvalidCount: 0,
|
||||
ineligibility: .emptyGroup))
|
||||
}
|
||||
|
||||
func testActivationEligibilityCountsCandidatesAndSkips() async throws {
|
||||
let groupID = try await workflow.createGroup(named: "组", into: nil)
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: groupID)
|
||||
// 无效叶子(不支持的加密方法)。
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388", into: groupID)
|
||||
let eligibility = try XCTUnwrap(workflow.activationEligibility(for: groupID))
|
||||
XCTAssertTrue(eligibility.canActivate)
|
||||
XCTAssertEqual(eligibility.candidateCount, 1)
|
||||
XCTAssertEqual(eligibility.skippedInvalidCount, 1)
|
||||
XCTAssertNil(eligibility.ineligibility)
|
||||
}
|
||||
|
||||
func testActivationEligibilityAllInvalidIsNoCandidates() async throws {
|
||||
_ = try await workflow.createServers(
|
||||
fromURIs: "ss://ZnV0dXJlLWNpcGhlcjp4eHg=@203.0.113.9:8388", into: nil)
|
||||
let serverID = try XCTUnwrap(workflow.tree.roots.first?.id)
|
||||
let eligibility = try XCTUnwrap(workflow.activationEligibility(for: serverID))
|
||||
XCTAssertEqual(eligibility.ineligibility, .noCandidates)
|
||||
XCTAssertFalse(eligibility.canActivate)
|
||||
XCTAssertEqual(eligibility.skippedInvalidCount, 1)
|
||||
}
|
||||
|
||||
// MARK: - 激活命令经假 adapter
|
||||
|
||||
func testActivateForwardsTargetThroughActivatingPort() async throws {
|
||||
let serverID = try await importServer()
|
||||
activator.result = .activated(skippedInvalid: 2)
|
||||
let outcome = try await workflow.activate(serverID)
|
||||
XCTAssertEqual(outcome, .activated(skippedInvalid: 2))
|
||||
XCTAssertEqual(activator.activated, [serverID])
|
||||
}
|
||||
|
||||
func testActivateRejectedOutcomeIsNotThrown() async throws {
|
||||
let serverID = try await importServer()
|
||||
activator.result = .rejectedActivation
|
||||
let outcome = try await workflow.activate(serverID)
|
||||
XCTAssertEqual(outcome, .rejectedActivation)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeActivator: Activating {
|
||||
var activated: [NodeID] = []
|
||||
var result: ActivationCommandOutcome = .activated(skippedInvalid: 0)
|
||||
var error: Error?
|
||||
|
||||
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
|
||||
activated.append(target)
|
||||
if let error { throw error }
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ final class CatalogWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(roots[0].name, "香港 01", "备注优先的显示名")
|
||||
XCTAssertEqual(
|
||||
workflow.serverEditForm(for: roots[0].id)?.password, "password123", "密码入凭据存储")
|
||||
XCTAssertTrue(roots[0].validation?.isValid ?? false)
|
||||
XCTAssertTrue(roots[0].invalidReasons.isEmpty)
|
||||
// 两个身份均为全新 UUID 形态(不按内容去重)。
|
||||
XCTAssertNotEqual(roots[0].id, roots[1].id)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ final class CatalogWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(outcome.addedCount, 1)
|
||||
let node = try XCTUnwrap(workflow.tree.roots.first)
|
||||
XCTAssertEqual(
|
||||
node.validation?.issues, [.unsupportedEncryptionMethod("future-cipher")],
|
||||
node.invalidReasons, [.unsupportedEncryptionMethod("future-cipher")],
|
||||
"不支持的方法保留并明确标记为无效(story 9)")
|
||||
}
|
||||
|
||||
@@ -134,14 +134,12 @@ final class CatalogWorkflowTests: XCTestCase {
|
||||
XCTAssertEqual(group.parentID, nil)
|
||||
XCTAssertEqual(group.childCount, 1)
|
||||
XCTAssertEqual(group.subtreeNodeCount, 2, "嵌套分组 + 服务器叶子")
|
||||
XCTAssertEqual(group.serverCount, 1)
|
||||
XCTAssertEqual(group.invalidServerCount, 0)
|
||||
let nested = try XCTUnwrap(group.children?.first)
|
||||
XCTAssertEqual(nested.id, nestedID)
|
||||
XCTAssertEqual(nested.parentID, groupID)
|
||||
let rootServer = try XCTUnwrap(workflow.tree.roots.last)
|
||||
XCTAssertFalse(rootServer.isGroup)
|
||||
XCTAssertEqual(rootServer.serverCount, 1)
|
||||
XCTAssertFalse(rootServer.isInvalid)
|
||||
XCTAssertNil(rootServer.children)
|
||||
XCTAssertTrue(workflow.tree.containsNode(groupID))
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let cancellable = controller.$activeTargetID.dropFirst().sink { observed = $0 }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
|
||||
XCTAssertEqual(controller.activeTargetID, seeded.server)
|
||||
XCTAssertEqual(observed, seeded.server, "代理关闭路径的激活也要发布目标变更")
|
||||
@@ -133,7 +133,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
|
||||
let controller = makeController(probe: probe)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
@@ -166,7 +166,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
pacProbe: pacProbe,
|
||||
proxyMode: .externalPAC(externalURL))
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
guard case .systemProxyFailed(let detail) = controller.state else {
|
||||
@@ -187,7 +187,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), proxyMode: .global)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
XCTAssertEqual(
|
||||
systemProxy.applied,
|
||||
@@ -220,7 +220,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.refusing(), agentStatus: .notRegistered)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
guard case .launchFailed(let detail) = controller.state else {
|
||||
@@ -236,7 +236,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
func testDisableUnregistersAndCleansRuntimeFiles() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
await controller.setProxyEnabled(false)
|
||||
@@ -253,7 +253,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), firewallChecker: firewall)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
@@ -275,7 +275,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
listen: listen,
|
||||
firewallChecker: firewall)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
guard case .firewallBlocked(let detail) = controller.state else {
|
||||
@@ -297,7 +297,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
listen: listen,
|
||||
firewallChecker: firewall)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
let deadline = Date().addingTimeInterval(1)
|
||||
@@ -317,7 +317,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
func testCatalogEditOfActiveTargetRewritesAndSignalsRunningWrapper() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
// 模拟 wrapper 在跑:pid 指向本测试进程(kill(pid, 0) 判活通过)。
|
||||
try Data("\(ProcessInfo.processInfo.processIdentifier)".utf8).write(to: runtime.pidFile)
|
||||
@@ -353,7 +353,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let seeded = try makeSeededCatalog()
|
||||
try Data("\(ProcessInfo.processInfo.processIdentifier)".utf8).write(to: runtime.pidFile)
|
||||
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
// 删除活动目标 → 重展开失败 → 清除并停止。
|
||||
@@ -381,7 +381,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), agentStatus: .notRegistered)
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
XCTAssertTrue(agent.registerCount >= 1)
|
||||
let contractMtime =
|
||||
@@ -414,7 +414,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
try Data("99999".utf8).write(to: runtime.pidFile)
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), agentStatus: .notRegistered)
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: seeded.server)
|
||||
|
||||
await controller.resyncOnLaunch()
|
||||
@@ -499,7 +499,7 @@ extension ProxyRuntimeControllerTests {
|
||||
settingsStore: settingsStore,
|
||||
systemProxy: systemProxy)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
XCTAssertEqual(systemProxy.applied.count, 1)
|
||||
@@ -539,7 +539,7 @@ extension ProxyRuntimeControllerTests {
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(), settingsStore: settingsStore)
|
||||
|
||||
await controller.activate(seeded.server)
|
||||
try await controller.activate(seeded.server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
var next = controller.settings
|
||||
|
||||
@@ -186,7 +186,7 @@ final class SettingsWorkflowTests: XCTestCase {
|
||||
"香港 01", in: &catalog, credentials: credentials)
|
||||
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
||||
let pair = makePair()
|
||||
await pair.controller.activate(server)
|
||||
try await pair.controller.activate(server)
|
||||
await pair.controller.setProxyEnabled(true)
|
||||
await waitUntil(pair.controller.state == .running)
|
||||
return pair
|
||||
|
||||
@@ -64,7 +64,7 @@ final class UnreadableListenSettingsTests: XCTestCase {
|
||||
.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086)
|
||||
]))
|
||||
|
||||
await controller.activate(server)
|
||||
try await controller.activate(server)
|
||||
await controller.setProxyEnabled(true)
|
||||
|
||||
guard case .launchFailed(let detail) = controller.state else {
|
||||
|
||||
Reference in New Issue
Block a user