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:
Qiu Yuzhou
2026-09-22 20:41:18 +08:00
parent fd9be86149
commit cdf1c8ab8f
17 changed files with 514 additions and 135 deletions
+2 -2
View File
@@ -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: - 服务器详情与编辑
/// 服务器编辑面状态:用户打开编辑器时经显式命令解析,凭据明文只在此出现
+19 -13
View File
@@ -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)
+5 -14
View File
@@ -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 {
+2 -1
View File
@@ -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))
+27 -25
View File
@@ -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):以刚提交的内存快照重展开,
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启
+3 -4
View File
@@ -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)
}
}
+14 -25
View File
@@ -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 {