mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 22:23:43 +00:00
feat(ng2): decouple catalog commit from proxy runtime sync (#40)
Add an application-layer CatalogCommitCoordinator as the single commit entry for normal configuration changes: copy -> mutate -> atomic persist -> publish committed state -> async runtime convergence toward the just committed in-memory snapshot. Commits complete at persist; convergence reports structured outcomes (converged / revalidated / failed / clearedAndStopped) and never blocks or rolls back the commit. Each commit bumps a generation; superseded syncs end before starting or have their late results discarded, so only the latest snapshot can win. No automatic retry. CatalogRuntimeSyncing is the one new replacement seam: the production ProxyRuntimeSyncAdapter drives the existing ProxyRuntimeController via a snapshot-based catalogDidCommit; tests use a deterministic fake. When no active target exists a commit no longer touches the runtime at all. CatalogViewModel stays as a compatibility facade with unchanged published state; the optional postCommit callback and the per-scene callback wiring in MainApp are removed - the composition root wires the coordinator, adapter and the (independent) Legacy import boundary exactly once.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// 已提交目录的内存快照(issue #40):协调器与运行时缝之间的传值。运行时
|
||||
/// 收敛以此为唯一目标,不回读磁盘;磁盘仍是重启与跨进程恢复的权威来源。
|
||||
struct CommittedCatalogSnapshot: Equatable, Sendable {
|
||||
let catalog: ConfigurationCatalog
|
||||
let subscriptions: [SubscriptionRecord]
|
||||
}
|
||||
|
||||
/// 目录提交后一次运行时收敛的结构化结果(issue #40)。协调器不生成本地化
|
||||
/// 文案:失败细节原样携带平台运行时给出的点名事实,由上层 presentation 决定
|
||||
/// 如何呈现。
|
||||
enum RuntimeSyncOutcome: Equatable, Sendable {
|
||||
/// 代理未开启:活动目标已按提交快照重校验,未部署。
|
||||
case revalidated
|
||||
/// 运行时已向提交快照收敛(部署已执行且启动健康通过)。
|
||||
case converged(skippedServers: [SkippedServer])
|
||||
/// 部署已执行但运行时未收敛;细节为平台运行时的点名事实(nil 表示等待
|
||||
/// 用户在系统设置批准等无额外细节的终态)。
|
||||
case failed(detail: String?)
|
||||
/// 活动目标失效:目标已清除且代理已停止(无静默回退)。
|
||||
case clearedAndStopped(ActivationFailure)
|
||||
}
|
||||
|
||||
/// 运行时收敛的可观察阶段(issue #40):目录已提交 → 运行时同步中 → 结束。
|
||||
/// generation 为提交代次;被更新提交取代的旧代次不写最终状态。
|
||||
enum RuntimeSyncStatus: Equatable {
|
||||
/// 无进行中或已完成的同步(无活动目标时提交保持此状态)。
|
||||
case idle
|
||||
/// 正在向该代次提交快照收敛。
|
||||
case syncing(generation: Int)
|
||||
/// 该代次收敛已结束。
|
||||
case finished(generation: Int, outcome: RuntimeSyncOutcome)
|
||||
}
|
||||
|
||||
/// 运行时同步缝(issue #40):目录提交协调器与代理运行时之间的窄接口。
|
||||
/// 生产实现适配现有代理运行时控制器;测试注入确定性 fake。
|
||||
@MainActor
|
||||
protocol CatalogRuntimeSyncing: AnyObject {
|
||||
/// 当前是否存在活动目标(无目标时目录提交不驱动运行时)。
|
||||
var hasActiveTarget: Bool { get }
|
||||
/// 让运行时向刚提交的目录快照收敛。健康检查、LaunchAgent 与端点启动的
|
||||
/// 耗时属于本调用的异步收敛阶段。
|
||||
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome
|
||||
}
|
||||
|
||||
/// 目录提交—运行时同步协调器(issue #40):普通配置变更的唯一提交入口。
|
||||
/// 管线为「副本变更 → 校验并原子持久化 → 发布已提交状态 → 异步运行时收敛」;
|
||||
/// 目录提交在持久化成功后立即完成,不因运行时失败回滚目录,也不被健康检查
|
||||
/// 或启动耗时阻塞。每次提交递增代次:旧代次自然结束,但不得覆盖最新代次的
|
||||
/// 最终状态;没有自动重试,后续提交、显式激活或启动代理会发起新的同步尝试。
|
||||
@MainActor
|
||||
final class CatalogCommitCoordinator: ObservableObject {
|
||||
/// 最新提交的运行时收敛阶段(结构化;呈现由上层决定)。
|
||||
@Published private(set) var syncStatus: RuntimeSyncStatus = .idle
|
||||
|
||||
let fileStore: CatalogFileStore
|
||||
private let runtime: CatalogRuntimeSyncing
|
||||
/// 提交代次:每次提交递增,用于丢弃被更新提交取代的旧同步结果。
|
||||
private var generation = 0
|
||||
|
||||
/// 已提交状态(协调器持有的内存快照;`commit` 成功后整体替换)。
|
||||
private(set) var committedCatalog: ConfigurationCatalog
|
||||
private(set) var committedSubscriptions: [SubscriptionRecord]
|
||||
|
||||
init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) {
|
||||
self.fileStore = fileStore
|
||||
self.runtime = runtime
|
||||
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
|
||||
}
|
||||
|
||||
/// 普通目录变更的唯一提交管线。任一同步步骤失败则已提交状态不动并原样
|
||||
/// 上抛(凭据等外部副作用由调用方回滚);运行时收敛在持久化成功后异步开始。
|
||||
func commit<T>(
|
||||
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
|
||||
) throws -> T {
|
||||
var workingCatalog = committedCatalog
|
||||
var workingSubscriptions = committedSubscriptions
|
||||
let result = try mutate(&workingCatalog, &workingSubscriptions)
|
||||
try fileStore.save(
|
||||
CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions))
|
||||
committedCatalog = workingCatalog
|
||||
committedSubscriptions = workingSubscriptions
|
||||
scheduleRuntimeSync(
|
||||
CommittedCatalogSnapshot(catalog: workingCatalog, subscriptions: workingSubscriptions))
|
||||
return result
|
||||
}
|
||||
|
||||
/// Legacy 导入等独立路径直接落盘后对齐已提交状态;不触发运行时收敛
|
||||
/// (导入的运行时边界由独立路径自行驱动,不经普通提交管线)。
|
||||
func reloadCommittedStateFromStore() {
|
||||
(committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore)
|
||||
}
|
||||
|
||||
/// 异步收敛调度:无活动目标不调用运行时;同步开始前与结束后都以代次
|
||||
/// 校验——被更新提交取代的旧同步直接结束或丢弃结果,不覆盖最新状态。
|
||||
private func scheduleRuntimeSync(_ snapshot: CommittedCatalogSnapshot) {
|
||||
generation += 1
|
||||
let generation = generation
|
||||
guard runtime.hasActiveTarget else {
|
||||
syncStatus = .idle
|
||||
return
|
||||
}
|
||||
syncStatus = .syncing(generation: generation)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.generation == generation else { return }
|
||||
let outcome = await self.runtime.converge(to: snapshot)
|
||||
guard !Task.isCancelled, self.generation == generation else { return }
|
||||
self.syncStatus = .finished(generation: generation, outcome: outcome)
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁盘读取的容错口径与首次加载一致:损坏文档回退全新空文档,等待
|
||||
/// 用户经由导入/设置路径修复。
|
||||
private static func loadCommitted(
|
||||
from fileStore: CatalogFileStore
|
||||
) -> (catalog: ConfigurationCatalog, subscriptions: [SubscriptionRecord]) {
|
||||
let loaded = (try? fileStore.load()) ?? CatalogDocument()
|
||||
return (loaded.catalog, loaded.subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产运行时适配器(issue #40):协调器与现有代理运行时控制器之间的唯一
|
||||
/// 生产接线,由应用组合根建立。
|
||||
@MainActor
|
||||
final class ProxyRuntimeSyncAdapter: CatalogRuntimeSyncing {
|
||||
private let controller: ProxyRuntimeController
|
||||
|
||||
init(controller: ProxyRuntimeController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
var hasActiveTarget: Bool { controller.isActiveTargetPresent }
|
||||
|
||||
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome {
|
||||
await controller.catalogDidCommit(snapshot: snapshot.catalog)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import Foundation
|
||||
/// 订阅生命周期(spec #21 D4,issue #35):粘贴 HTTPS URL 创建、单个/全部
|
||||
/// 立即更新、编辑 URL(保留身份)、删除(递归清除)。刷新 = 完整获取、解析、
|
||||
/// 校验后同文档原子提交快照与刷新状态;任何失败保留最后成功快照、活动目标与
|
||||
/// 运行状态,仅标记来源失败。每次提交都触发 postCommit
|
||||
/// 重展开(订阅子树变更 → 活动目标原子跟随或清除停止)。
|
||||
/// 运行状态,仅标记来源失败。每次提交都经目录提交协调器异步触发运行时收敛
|
||||
/// (issue #40;订阅子树变更 → 活动目标原子跟随或清除停止)。
|
||||
extension CatalogViewModel {
|
||||
/// 创建订阅:校验 HTTPS URL → 落订阅记录与空固定分组 → 立即首次刷新。
|
||||
/// 首次刷新失败留空分组加错误态(状态在刷新内标记,创建本身总是成功)。
|
||||
@@ -17,7 +17,7 @@ extension CatalogViewModel {
|
||||
urlRef: .fresh(),
|
||||
status: .never)
|
||||
try credentials.save(url.absoluteString, for: record.urlRef)
|
||||
try await commitDocument { catalog, subscriptions in
|
||||
try commitDocument { catalog, subscriptions in
|
||||
// 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host
|
||||
// 兜底,首次成功刷新后跟随远端。
|
||||
try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID)
|
||||
@@ -73,7 +73,7 @@ extension CatalogViewModel {
|
||||
throw SubscriptionFormError.notFound
|
||||
}
|
||||
var removedEntries: [CatalogEntry] = []
|
||||
try await commitDocument { catalog, subscriptions in
|
||||
try commitDocument { catalog, subscriptions in
|
||||
removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID)
|
||||
subscriptions.removeAll { $0.id == id }
|
||||
}
|
||||
@@ -148,7 +148,7 @@ extension CatalogViewModel {
|
||||
var removedServers: [CatalogEntry] = []
|
||||
var credentialJournal = CredentialWriteJournal(credentials: credentials)
|
||||
do {
|
||||
try await commitDocument { catalog, subscriptions in
|
||||
try commitDocument { catalog, subscriptions in
|
||||
// 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用;
|
||||
// journal 保证快照提交失败时恢复旧秘密。
|
||||
var reusedRefs: [NodeID: ServerCredentialRefs] = [:]
|
||||
@@ -182,7 +182,7 @@ extension CatalogViewModel {
|
||||
}
|
||||
|
||||
private func markRefreshFailed(subscriptionID: NodeID, reason: String) async {
|
||||
try? await commitDocument { _, subscriptions in
|
||||
try? commitDocument { _, subscriptions in
|
||||
if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) {
|
||||
subscriptions[index].status = .failed(at: Date(), reason: reason)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import SwiftUI
|
||||
|
||||
/// 主窗口视图模型(issue #32/#35):把 ConfigurationCatalog 领域语义接到树
|
||||
/// 操作、详情表单、添加三入口、分享与订阅生命周期。所有变更走「副本变更 →
|
||||
/// 落盘 → 发布 → postCommit 重展开」,失败即整体不变更;凭据读写只在模型层
|
||||
/// 出现。
|
||||
/// 落盘 → 发布」,失败即整体不变更;凭据读写只在模型层出现。提交管线、
|
||||
/// 运行时收敛与并发代次由注入的 `CatalogCommitCoordinator` 负责(issue #40),
|
||||
/// 本类在迁移期间保留为兼容 facade,向现有 SwiftUI 调用方提供已发布状态。
|
||||
@MainActor
|
||||
final class CatalogViewModel: ObservableObject {
|
||||
@Published private(set) var catalog: ConfigurationCatalog
|
||||
@@ -21,29 +22,32 @@ final class CatalogViewModel: ObservableObject {
|
||||
@Published private(set) var legacyImportReport: LegacyImportReport?
|
||||
private var discoveredLegacySnapshot: LegacySnapshot?
|
||||
|
||||
/// 提交管线与运行时收敛的唯一入口(组合根注入,issue #40)。
|
||||
let coordinator: CatalogCommitCoordinator
|
||||
private let fileStore: CatalogFileStore
|
||||
/// 订阅扩展(CatalogViewModel+Subscriptions)同样经此读写凭据。
|
||||
let credentials: CredentialStoring
|
||||
private let plugins: ManagedPluginProviding
|
||||
/// 订阅获取缝(默认 URLSession 实现;测试注入夹具,issue #35)。
|
||||
var subscriptionFetcher: SubscriptionFetching
|
||||
/// 目录提交后的运行时重展开(生产接线 `ProxyRuntimeController.catalogDidCommit`)。
|
||||
var postCommit: (() async -> Void)?
|
||||
/// Legacy 导入提交后的运行时边界(生产接线为不写系统代理的停止/重载)。
|
||||
var postLegacyImport: ((LegacyImportOutcome) async -> Void)?
|
||||
/// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。
|
||||
private let postLegacyImport: ((LegacyImportOutcome) async -> Void)?
|
||||
private let legacyImportService: LegacyImportService
|
||||
|
||||
init(
|
||||
fileStore: CatalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
|
||||
coordinator: CatalogCommitCoordinator,
|
||||
credentials: CredentialStoring = KeychainCredentialStore(),
|
||||
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
|
||||
subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(),
|
||||
legacyImportService: LegacyImportService? = nil
|
||||
legacyImportService: LegacyImportService? = nil,
|
||||
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil
|
||||
) {
|
||||
self.fileStore = fileStore
|
||||
self.coordinator = coordinator
|
||||
fileStore = coordinator.fileStore
|
||||
self.credentials = credentials
|
||||
self.plugins = plugins
|
||||
self.subscriptionFetcher = subscriptionFetcher
|
||||
self.postLegacyImport = postLegacyImport
|
||||
self.legacyImportService =
|
||||
legacyImportService
|
||||
?? LegacyImportService(
|
||||
@@ -52,9 +56,8 @@ final class CatalogViewModel: ObservableObject {
|
||||
activationStore: ActivationStateFileStore(
|
||||
fileURL: ActivationStateFileStore.defaultFileURL()),
|
||||
credentials: credentials)
|
||||
let loaded = (try? fileStore.load()) ?? CatalogDocument()
|
||||
catalog = loaded.catalog
|
||||
subscriptions = loaded.subscriptions
|
||||
catalog = coordinator.committedCatalog
|
||||
subscriptions = coordinator.committedSubscriptions
|
||||
refreshLegacyImportState()
|
||||
}
|
||||
|
||||
@@ -87,9 +90,9 @@ final class CatalogViewModel: ObservableObject {
|
||||
snapshot = current
|
||||
}
|
||||
let outcome = try legacyImportService.importSnapshot(snapshot, reimport: reimport)
|
||||
let loaded = try fileStore.load()
|
||||
catalog = loaded.catalog
|
||||
subscriptions = loaded.subscriptions
|
||||
coordinator.reloadCommittedStateFromStore()
|
||||
catalog = coordinator.committedCatalog
|
||||
subscriptions = coordinator.committedSubscriptions
|
||||
legacyImportReport = outcome.report
|
||||
refreshLegacyImportState()
|
||||
await postLegacyImport?(outcome)
|
||||
@@ -140,7 +143,7 @@ final class CatalogViewModel: ObservableObject {
|
||||
}
|
||||
guard !prepared.isEmpty else { return (0, failures) }
|
||||
do {
|
||||
try await commit { catalog in
|
||||
try commit { catalog in
|
||||
for item in prepared {
|
||||
try catalog.addServer(item.fields, to: parent)
|
||||
}
|
||||
@@ -158,7 +161,7 @@ final class CatalogViewModel: ObservableObject {
|
||||
func addGroup(named name: String, into parent: NodeID?) async throws -> NodeID {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
|
||||
return try await commit { catalog in
|
||||
return try commit { catalog in
|
||||
try catalog.addGroup(trimmed, to: parent)
|
||||
}
|
||||
}
|
||||
@@ -166,21 +169,21 @@ final class CatalogViewModel: ObservableObject {
|
||||
func renameGroup(_ id: NodeID, to name: String) async throws {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
|
||||
try await commit { catalog in
|
||||
try commit { catalog in
|
||||
try catalog.renameGroup(id, to: trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 拖拽/移动菜单落点。跨来源、成环、移动进自身由领域拒绝,原样上抛呈现。
|
||||
func move(_ id: NodeID, to parent: NodeID?) async throws {
|
||||
try await commit { catalog in
|
||||
try commit { catalog in
|
||||
try catalog.move(id, to: parent)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除(手动分组递归删整棵子树),并尽力清理被删节点的 Keychain 秘密。
|
||||
func remove(_ id: NodeID) async throws {
|
||||
let removed: [CatalogEntry] = try await commit { catalog in
|
||||
let removed: [CatalogEntry] = try commit { catalog in
|
||||
try catalog.remove(id)
|
||||
}
|
||||
if let selection = selectedNodeID, removed.contains(where: { $0.id == selection }) {
|
||||
@@ -215,7 +218,7 @@ final class CatalogViewModel: ObservableObject {
|
||||
throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod)
|
||||
}
|
||||
guard !password.isEmpty else { throw ServerFormError.invalidPassword }
|
||||
try await commit { catalog in
|
||||
try commit { catalog in
|
||||
guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else {
|
||||
throw CatalogError.notAServer(id)
|
||||
}
|
||||
@@ -312,28 +315,23 @@ final class CatalogViewModel: ObservableObject {
|
||||
|
||||
// MARK: - 提交管线
|
||||
|
||||
/// 副本变更 → 落盘 → 发布 → postCommit;任一步失败则已发布状态不动。
|
||||
/// 仅目录变更的提交便捷入口(订阅扩展使用双参 `commitDocument`)。
|
||||
private func commit<T>(
|
||||
_ mutate: (inout ConfigurationCatalog) throws -> T
|
||||
) async throws -> T {
|
||||
try await commitDocument { catalog, _ in
|
||||
) throws -> T {
|
||||
try commitDocument { catalog, _ in
|
||||
try mutate(&catalog)
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录 + 订阅记录同文档提交(订阅刷新/创建/删除共用):任一步失败则已
|
||||
/// 发布状态不动,成功才依次发布并触发运行时重展开。
|
||||
/// 副本变更 → 协调器落盘 → 发布已提交状态;任一步失败则已发布状态不动。
|
||||
/// 运行时收敛由协调器异步调度(issue #40),不阻塞也不回滚本提交。
|
||||
func commitDocument<T>(
|
||||
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
|
||||
) async throws -> T {
|
||||
var workingCatalog = catalog
|
||||
var workingSubscriptions = subscriptions
|
||||
let result = try mutate(&workingCatalog, &workingSubscriptions)
|
||||
try fileStore.save(
|
||||
CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions))
|
||||
catalog = workingCatalog
|
||||
subscriptions = workingSubscriptions
|
||||
await postCommit?()
|
||||
) throws -> T {
|
||||
let result = try coordinator.commit(mutate)
|
||||
catalog = coordinator.committedCatalog
|
||||
subscriptions = coordinator.committedSubscriptions
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,32 @@ import SwiftUI
|
||||
struct ShadowsocksXNG2App: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var proxyController: ProxyRuntimeController
|
||||
@StateObject private var catalogViewModel = CatalogViewModel()
|
||||
@StateObject private var catalogViewModel: CatalogViewModel
|
||||
@StateObject private var loginController: LaunchAtLoginController
|
||||
|
||||
init() {
|
||||
let settingsStore = ProxySettingsFileStore()
|
||||
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
|
||||
_proxyController = StateObject(
|
||||
wrappedValue: ProxyRuntimeController(
|
||||
listenRestore: RestoredListenSettings(
|
||||
settings: restoredSettings.settings.listen, unreadableError: nil),
|
||||
settingsStore: settingsStore,
|
||||
settingsRestore: restoredSettings))
|
||||
let controller = ProxyRuntimeController(
|
||||
listenRestore: RestoredListenSettings(
|
||||
settings: restoredSettings.settings.listen, unreadableError: nil),
|
||||
settingsStore: settingsStore,
|
||||
settingsRestore: restoredSettings)
|
||||
_proxyController = StateObject(wrappedValue: controller)
|
||||
let loginController = LaunchAtLoginController()
|
||||
_loginController = StateObject(wrappedValue: loginController)
|
||||
// 组合根(issue #40):目录提交协调器与生产运行时适配器只在此接线一次,
|
||||
// 各 scene 不再重复设置提交回调;Legacy 导入边界同为一次性注入。
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
|
||||
runtime: ProxyRuntimeSyncAdapter(controller: controller))
|
||||
_catalogViewModel = StateObject(
|
||||
wrappedValue: CatalogViewModel(
|
||||
coordinator: coordinator,
|
||||
postLegacyImport: { outcome in
|
||||
await controller.legacyImportDidCommit()
|
||||
loginController.applyImportedValue(outcome.loginAtLogin)
|
||||
}))
|
||||
Task { @MainActor in
|
||||
loginController.syncAtLaunch()
|
||||
}
|
||||
@@ -30,17 +42,8 @@ struct ShadowsocksXNG2App: App {
|
||||
MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") {
|
||||
ProxyStatusMenu(controller: proxyController, catalogViewModel: catalogViewModel)
|
||||
.task {
|
||||
// postCommit 在应用启动即接线(不只主窗口打开时):菜单栏「立即更新
|
||||
// 全部订阅」(issue #35)等未开窗路径的目录提交同样立即重展开运行时。
|
||||
let controller = proxyController
|
||||
catalogViewModel.postCommit = { await controller.catalogDidCommit() }
|
||||
let loginController = loginController
|
||||
catalogViewModel.postLegacyImport = { outcome in
|
||||
await controller.legacyImportDidCommit()
|
||||
loginController.applyImportedValue(outcome.loginAtLogin)
|
||||
}
|
||||
// 全局快捷键(issue #31):开关代理、切换模式,与菜单项同一入口。
|
||||
GlobalShortcuts.wire(controller: controller)
|
||||
GlobalShortcuts.wire(controller: proxyController)
|
||||
await proxyController.resyncOnLaunch()
|
||||
}
|
||||
}
|
||||
@@ -51,16 +54,6 @@ struct ShadowsocksXNG2App: App {
|
||||
viewModel: catalogViewModel, proxyController: proxyController,
|
||||
eventStore: .shared
|
||||
)
|
||||
.task {
|
||||
// 目录提交 → 代理运行时立即重展开(spec #21 D3「已提交编辑立即跟随」)。
|
||||
let controller = proxyController
|
||||
catalogViewModel.postCommit = { await controller.catalogDidCommit() }
|
||||
let loginController = loginController
|
||||
catalogViewModel.postLegacyImport = { outcome in
|
||||
await controller.legacyImportDidCommit()
|
||||
loginController.applyImportedValue(outcome.loginAtLogin)
|
||||
}
|
||||
}
|
||||
}
|
||||
.defaultLaunchBehavior(
|
||||
catalogViewModel.shouldOfferLegacyImport ? .automatic : .suppressed)
|
||||
|
||||
@@ -231,22 +231,6 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
await presentLaunchHealth(document)
|
||||
}
|
||||
|
||||
/// 目录已提交变更后的立即重展开(D3/D5):有效非空且代理开启 → 原子更新
|
||||
/// 运行时;目标失效 → 清除目标并停止代理。目录以磁盘为事实来源重载。
|
||||
func catalogDidCommit() async {
|
||||
reloadCatalog()
|
||||
switch reexpand() {
|
||||
case .deployed(let configuration):
|
||||
if state != .off {
|
||||
await deploy(configuration.document)
|
||||
}
|
||||
case .clearedAndStopped(let failure):
|
||||
await handleCleared(failure)
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI 启动重同步(D5「GUI 下次启动重新校验同步」):注册态是代理意图的
|
||||
/// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳
|
||||
/// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。
|
||||
@@ -433,6 +417,44 @@ final class ProxyRuntimeController: ObservableObject {
|
||||
}
|
||||
|
||||
extension ProxyRuntimeController {
|
||||
/// 目录提交协调器的生产适配入口(issue #40):以刚提交的内存快照重展开,
|
||||
/// 不回读磁盘(磁盘仍是重启与跨进程恢复的权威来源)。有效非空且代理开启
|
||||
/// → 原子更新运行时;目标失效 → 清除目标并停止代理。返回结构化收敛结果,
|
||||
/// 健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。
|
||||
func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome {
|
||||
self.catalog = catalog
|
||||
switch reexpand() {
|
||||
case .deployed(let configuration):
|
||||
guard state != .off else {
|
||||
return .revalidated
|
||||
}
|
||||
await deploy(configuration.document)
|
||||
return Self.syncOutcome(for: state, skippedServers: configuration.skippedServers)
|
||||
case .clearedAndStopped(let failure):
|
||||
await handleCleared(failure)
|
||||
return .clearedAndStopped(failure)
|
||||
case nil:
|
||||
return .revalidated
|
||||
}
|
||||
}
|
||||
|
||||
/// 部署后的控制器状态 → 结构化收敛结果:健康通过(含防火墙受阻的运行态)
|
||||
/// 视为已收敛;启动、服务、系统代理与激活拒绝都视为未收敛并携带点名细节。
|
||||
private static func syncOutcome(
|
||||
for state: ProxyState, skippedServers: [SkippedServer]
|
||||
) -> RuntimeSyncOutcome {
|
||||
switch state {
|
||||
case .running, .firewallBlocked:
|
||||
.converged(skippedServers: skippedServers)
|
||||
case .launchFailed(let detail), .serviceFailed(let detail), .systemProxyFailed(let detail):
|
||||
.failed(detail: detail)
|
||||
case .activationFailed(let reason):
|
||||
.failed(detail: reason)
|
||||
case .requiresApproval, .off, .starting:
|
||||
.failed(detail: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a fully validated settings snapshot and, when the proxy is
|
||||
/// active, re-derives the same runtime path with the new snapshot.
|
||||
func updateSettings(_ next: ProxySettings) async throws {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import XCTest
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 确定性运行时同步替身(issue #40 测试缝):记录 converge 快照与次序,
|
||||
/// 可编程每次结果;门闩让同步在收敛中挂起,用于验证提交代次语义。
|
||||
@MainActor
|
||||
final class FakeCatalogRuntime: CatalogRuntimeSyncing {
|
||||
var hasActiveTarget = false
|
||||
/// 未编程结果时的默认返回。
|
||||
var defaultOutcome: RuntimeSyncOutcome = .converged(skippedServers: [])
|
||||
private var outcomeQueue: [RuntimeSyncOutcome] = []
|
||||
private(set) var convergeSnapshots: [CommittedCatalogSnapshot] = []
|
||||
|
||||
/// 门闩:武装后接下来的 converge 在记录调用后挂起,直到 openGate。
|
||||
private var armedGateCount = 0
|
||||
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
var convergeCount: Int { convergeSnapshots.count }
|
||||
|
||||
/// 预编程按次序返回的结果(耗尽后回退 `defaultOutcome`)。
|
||||
func enqueueOutcomes(_ outcomes: [RuntimeSyncOutcome]) {
|
||||
outcomeQueue.append(contentsOf: outcomes)
|
||||
}
|
||||
|
||||
func armGate(count: Int = 1) {
|
||||
armedGateCount = count
|
||||
}
|
||||
|
||||
func openGate() {
|
||||
armedGateCount = 0
|
||||
gateWaiters.forEach { $0.resume() }
|
||||
gateWaiters.removeAll()
|
||||
}
|
||||
|
||||
func converge(to snapshot: CommittedCatalogSnapshot) async -> RuntimeSyncOutcome {
|
||||
convergeSnapshots.append(snapshot)
|
||||
let result = outcomeQueue.isEmpty ? defaultOutcome : outcomeQueue.removeFirst()
|
||||
if armedGateCount > 0 {
|
||||
armedGateCount -= 1
|
||||
await withCheckedContinuation { gateWaiters.append($0) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// 让出 MainActor 直到条件成立(被测同步任务在同一线程上排队执行)。供
|
||||
/// 协调器与视图模型测试共享:提交后的运行时收敛不再内联等待(issue #40)。
|
||||
@MainActor
|
||||
func waitUntilRuntimeSettles(
|
||||
_ condition: @autoclosure () -> Bool,
|
||||
timeout: TimeInterval = 2,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while !condition() && Date() < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
XCTAssertTrue(condition(), "等待条件超时", file: file, line: line)
|
||||
}
|
||||
|
||||
/// 目录提交—运行时同步协调器(issue #40):只断言外部行为——持久化与发布、
|
||||
/// 运行时是否被调用及使用的快照、阶段与失败结果、并发提交的最终赢家、无效
|
||||
/// 活动目标的停止语义。全部走临时目录文件存储,不触碰真实运行时。
|
||||
@MainActor
|
||||
final class CatalogCommitCoordinatorTests: XCTestCase {
|
||||
private var workDir: URL!
|
||||
private var fileURL: URL!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var coordinator: CatalogCommitCoordinator!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
// 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。
|
||||
workDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("catalog-commit-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||||
runtime = FakeCatalogRuntime()
|
||||
coordinator = makeCoordinator()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try? FileManager.default.removeItem(at: workDir)
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
private func makeCoordinator() -> CatalogCommitCoordinator {
|
||||
CatalogCommitCoordinator(fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
}
|
||||
|
||||
/// 预置目录后重建协调器(模拟已落盘的既有状态)。
|
||||
private func seedCatalog(_ mutate: (inout ConfigurationCatalog) throws -> Void) throws {
|
||||
var catalog = ConfigurationCatalog()
|
||||
try mutate(&catalog)
|
||||
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog))
|
||||
coordinator = makeCoordinator()
|
||||
}
|
||||
|
||||
private func persistedCatalog() throws -> ConfigurationCatalog {
|
||||
try CatalogFileStore(fileURL: fileURL).load().catalog
|
||||
}
|
||||
|
||||
// MARK: - 无活动目标
|
||||
|
||||
func testCommitWithoutActiveTargetPersistsAndPublishesWithoutRuntimeCall() throws {
|
||||
runtime.hasActiveTarget = false
|
||||
|
||||
let added = try coordinator.commit { catalog, _ in
|
||||
try catalog.addTestServer("香港 01")
|
||||
}
|
||||
|
||||
XCTAssertTrue(coordinator.committedCatalog.contains(added), "提交后已发布新状态")
|
||||
XCTAssertEqual(try persistedCatalog().rootChildren, [added], "变更已原子持久化")
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "无活动目标不驱动运行时")
|
||||
XCTAssertEqual(coordinator.syncStatus, .idle)
|
||||
}
|
||||
|
||||
// MARK: - 活动目标提交
|
||||
|
||||
func testCommitWithActiveServerTargetConvergesToCommittedSnapshot() async throws {
|
||||
try seedCatalog { try $0.addTestServer("已有节点") }
|
||||
runtime.hasActiveTarget = true
|
||||
|
||||
let added = try coordinator.commit { catalog, _ in
|
||||
try catalog.addTestServer("新节点")
|
||||
}
|
||||
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
|
||||
XCTAssertEqual(runtime.convergeCount, 1, "一次提交恰好一次同步")
|
||||
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
|
||||
XCTAssertTrue(snapshot.catalog.contains(added), "同步使用刚提交的内存快照")
|
||||
XCTAssertEqual(snapshot.subscriptions, [])
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 1, outcome: .converged(skippedServers: [])))
|
||||
}
|
||||
|
||||
func testCommitWithActiveGroupTargetUsesSamePipeline() async throws {
|
||||
try seedCatalog { catalog in
|
||||
let group = try catalog.addGroup("手动组")
|
||||
_ = try catalog.addTestServer("成员", to: group)
|
||||
}
|
||||
runtime.hasActiveTarget = true
|
||||
let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
|
||||
|
||||
_ = try coordinator.commit { catalog, _ in
|
||||
try catalog.addTestServer("新成员", to: groupID)
|
||||
}
|
||||
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
|
||||
XCTAssertEqual(runtime.convergeCount, 1, "分组目标与服务器目标共用同一提交语义")
|
||||
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
|
||||
let group = try XCTUnwrap(snapshot.catalog.entry(for: groupID))
|
||||
guard case .group(let fields) = group.kind else {
|
||||
return XCTFail("快照中的目标应为分组")
|
||||
}
|
||||
XCTAssertEqual(fields.children.count, 2, "同步快照携带刚提交的新子树")
|
||||
}
|
||||
|
||||
func testMoveRemoveAndRenameShareCommitSemantics() async throws {
|
||||
try seedCatalog { catalog in
|
||||
let group = try catalog.addGroup("组")
|
||||
_ = try catalog.addTestServer("成员", to: group)
|
||||
}
|
||||
runtime.hasActiveTarget = true
|
||||
let groupID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
|
||||
let serverID = try XCTUnwrap(coordinator.committedCatalog.children(of: groupID).first)
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.move(serverID, to: nil) }
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
|
||||
try coordinator.commit { catalog, _ in try catalog.renameGroup(groupID, to: "改名") }
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
|
||||
try coordinator.commit { catalog, _ in try catalog.remove(serverID) }
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 3)
|
||||
|
||||
XCTAssertEqual(runtime.convergeCount, 3, "移动、改名、删除各触发一次同步")
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 3, outcome: .converged(skippedServers: [])),
|
||||
"最新代次的收敛结果是最终状态")
|
||||
}
|
||||
|
||||
// MARK: - 无效活动目标
|
||||
|
||||
func testRemovedActiveTargetClearsAndStopsWithoutSilentFallback() async throws {
|
||||
try seedCatalog { try $0.addTestServer("活动节点") }
|
||||
let targetID = try XCTUnwrap(coordinator.committedCatalog.rootChildren.first)
|
||||
runtime.hasActiveTarget = true
|
||||
runtime.enqueueOutcomes([.clearedAndStopped(.targetNotFound(targetID))])
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.remove(targetID) }
|
||||
|
||||
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1))
|
||||
XCTAssertEqual(runtime.convergeCount, 1)
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 1, outcome: .clearedAndStopped(.targetNotFound(targetID))),
|
||||
"清除停止以结构化结果呈现")
|
||||
XCTAssertFalse(try persistedCatalog().contains(targetID), "运行时失败不回滚目录")
|
||||
}
|
||||
|
||||
// MARK: - 目录与运行时结果区分
|
||||
|
||||
func testRuntimeFailureKeepsCommittedCatalogAndDistinguishesResult() async throws {
|
||||
runtime.hasActiveTarget = true
|
||||
runtime.defaultOutcome = .failed(detail: "agent register failed")
|
||||
|
||||
let added = try coordinator.commit { catalog, _ in
|
||||
try catalog.addTestServer("香港 01")
|
||||
}
|
||||
|
||||
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1))
|
||||
XCTAssertTrue(try persistedCatalog().contains(added), "运行时失败不回滚已提交目录")
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 1, outcome: .failed(detail: "agent register failed")),
|
||||
"结果明确区分目录已提交与运行时未收敛")
|
||||
}
|
||||
|
||||
// MARK: - 提交代次
|
||||
|
||||
func testSupersededSyncBeforeStartNeverCallsRuntime() async throws {
|
||||
runtime.hasActiveTarget = true
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
|
||||
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
|
||||
// 同一 MainActor 回合内的两次提交:旧代次同步尚未启动即被取代。
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
|
||||
|
||||
XCTAssertEqual(runtime.convergeCount, 1, "被取代的旧代次不触碰运行时")
|
||||
let snapshot = try XCTUnwrap(runtime.convergeSnapshots.first)
|
||||
XCTAssertEqual(snapshot.catalog.rootChildren.count, 2, "最终同步采用最新提交快照")
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 2, outcome: .converged(skippedServers: [])))
|
||||
}
|
||||
|
||||
func testDelayedSyncCannotOverrideLatestSnapshotOrResult() async throws {
|
||||
runtime.hasActiveTarget = true
|
||||
runtime.enqueueOutcomes([
|
||||
.converged(skippedServers: []),
|
||||
.failed(detail: "最新代次的失败结果"),
|
||||
])
|
||||
runtime.armGate()
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
|
||||
|
||||
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
|
||||
runtime.openGate()
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
|
||||
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 2))
|
||||
|
||||
XCTAssertEqual(runtime.convergeCount, 2)
|
||||
XCTAssertEqual(
|
||||
runtime.convergeSnapshots[1].catalog.rootChildren.count, 2, "最新代次使用最新快照")
|
||||
XCTAssertEqual(
|
||||
coordinator.syncStatus,
|
||||
.finished(generation: 2, outcome: .failed(detail: "最新代次的失败结果")),
|
||||
"迟到的旧代次结果不得覆盖最新代次")
|
||||
}
|
||||
|
||||
// MARK: - 独立路径对齐
|
||||
|
||||
func testReloadCommittedStateAdoptsExternalWritesWithoutRuntimeCall() throws {
|
||||
runtime.hasActiveTarget = true
|
||||
|
||||
// Legacy 导入等独立路径绕过协调器直接落盘(订阅固定分组须在目录内)。
|
||||
var external = ConfigurationCatalog()
|
||||
let groupID = NodeID(rawValue: "import:group")
|
||||
try external.addGroup("导入订阅组", source: .subscription, id: groupID)
|
||||
try CatalogFileStore(fileURL: fileURL).save(
|
||||
CatalogDocument(
|
||||
catalog: external,
|
||||
subscriptions: [
|
||||
SubscriptionRecord(id: .fresh(), groupID: groupID, urlRef: .fresh(), status: .never)
|
||||
]))
|
||||
|
||||
coordinator.reloadCommittedStateFromStore()
|
||||
|
||||
XCTAssertEqual(coordinator.committedCatalog.rootChildren, [groupID], "已提交状态对齐磁盘")
|
||||
XCTAssertEqual(coordinator.committedSubscriptions.count, 1)
|
||||
XCTAssertEqual(runtime.convergeCount, 0, "对齐不触发运行时收敛")
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,11 @@ final class CatalogViewModelPluginTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeViewModel() -> CatalogViewModel {
|
||||
let model = CatalogViewModel(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL),
|
||||
CatalogViewModel(
|
||||
coordinator: CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
|
||||
credentials: credentials,
|
||||
plugins: BundleManagedPluginProvider(bundleURL: bundleRoot))
|
||||
model.postCommit = {}
|
||||
return model
|
||||
}
|
||||
|
||||
/// 直建目录(不经 URI 导入)后重载视图模型,返回该服务器身份。
|
||||
|
||||
@@ -3,16 +3,17 @@ import XCTest
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 订阅端到端语义(issue #35 验收):创建门禁、刷新失败保留快照、overlay 与
|
||||
/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与 postCommit 接线。
|
||||
/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests)。
|
||||
/// 身份连续性、URL 编辑保身份、删除递归清除、UI 数据面与提交接线。
|
||||
/// 获取走 FakeSubscriptionFetcher(传输侧契约在 SubscriptionFetcherTests);
|
||||
/// 提交后的运行时收敛以确定性 fake 计数(issue #40)。
|
||||
@MainActor
|
||||
final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
private var workDir: URL!
|
||||
private var fileURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var fetcher: FakeSubscriptionFetcher!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var viewModel: CatalogViewModel!
|
||||
private var commitCounter: CommitCounter!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
@@ -21,7 +22,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||||
credentials = InMemoryCredentialStore()
|
||||
commitCounter = CommitCounter()
|
||||
runtime = FakeCatalogRuntime()
|
||||
fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
|
||||
viewModel = makeViewModel()
|
||||
}
|
||||
@@ -32,13 +33,15 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeViewModel() -> CatalogViewModel {
|
||||
// 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。
|
||||
runtime.hasActiveTarget = true
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
let model = CatalogViewModel(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL),
|
||||
coordinator: coordinator,
|
||||
credentials: credentials,
|
||||
plugins: NoManagedPluginProvider())
|
||||
model.subscriptionFetcher = fetcher!
|
||||
let counter = commitCounter!
|
||||
model.postCommit = { await counter.increment() }
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc))
|
||||
viewModel = makeViewModel()
|
||||
let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json")
|
||||
let commitsBefore = await commitCounter.count
|
||||
let convergesBefore = runtime.convergeCount
|
||||
|
||||
fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503)))
|
||||
viewModel.subscriptionFetcher = fetcher
|
||||
@@ -139,8 +142,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(reason.contains("503"))
|
||||
// 仅状态提交了一次(快照未动)。
|
||||
let commitsAfter = await commitCounter.count
|
||||
XCTAssertEqual(commitsAfter - commitsBefore, 1)
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1)
|
||||
XCTAssertEqual(runtime.convergeCount - convergesBefore, 1)
|
||||
}
|
||||
|
||||
func testDecodeAndSchemaFailuresMarkFailedWithoutTouchingSnapshot() async throws {
|
||||
@@ -301,7 +304,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
let record = try await seedSuccessfulSubscription()
|
||||
viewModel.selectedNodeID = serverAID
|
||||
let passwordRef = try XCTUnwrap(serverFields(of: serverAID)).passwordRef
|
||||
let commitsBefore = await commitCounter.count
|
||||
let convergesBefore = runtime.convergeCount
|
||||
|
||||
try await viewModel.removeSubscription(record.id)
|
||||
|
||||
@@ -312,8 +315,8 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
XCTAssertNil(viewModel.selectedNodeID, "选中节点随子树清除")
|
||||
XCTAssertNil(try credentials.secret(for: passwordRef), "远端成员凭据一并清理")
|
||||
XCTAssertNil(try credentials.secret(for: record.urlRef), "订阅 URL 凭据一并清理")
|
||||
let commitsAfter = await commitCounter.count
|
||||
XCTAssertEqual(commitsAfter - commitsBefore, 1, "删除经 postCommit 触发活动目标重展开接线")
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1)
|
||||
XCTAssertEqual(runtime.convergeCount - convergesBefore, 1, "删除经提交协调器触发活动目标重展开")
|
||||
}
|
||||
|
||||
// MARK: 批量刷新与持久化
|
||||
@@ -322,7 +325,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
_ = try await seedSuccessfulSubscription()
|
||||
_ = try await createOnly(urlString: "https://q.example.com/t.json")
|
||||
let requestsBefore = fetcher.requestCount
|
||||
let commitsBefore = await commitCounter.count
|
||||
let convergesBefore = runtime.convergeCount
|
||||
|
||||
await viewModel.refreshAllSubscriptions()
|
||||
|
||||
@@ -332,15 +335,16 @@ final class CatalogViewModelSubscriptionTests: XCTestCase {
|
||||
return XCTFail("全部订阅都应记成功态:\(record.id)")
|
||||
}
|
||||
}
|
||||
let commitsAfter = await commitCounter.count
|
||||
XCTAssertEqual(commitsAfter - commitsBefore, 2)
|
||||
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 2)
|
||||
XCTAssertEqual(runtime.convergeCount - convergesBefore, 2)
|
||||
}
|
||||
|
||||
func testSubscriptionsPersistAcrossViewModelReload() async throws {
|
||||
let record = try await seedSuccessfulSubscription()
|
||||
|
||||
let reloaded = CatalogViewModel(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL),
|
||||
coordinator: CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
|
||||
credentials: credentials,
|
||||
plugins: NoManagedPluginProvider())
|
||||
XCTAssertEqual(reloaded.subscriptions.count, 1)
|
||||
|
||||
@@ -3,14 +3,15 @@ import XCTest
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 主窗口视图模型(issue #32):三入口导入、凭据生命周期、订阅子树只读夹具、
|
||||
/// 分享互逆与 postCommit 接线。全部走内存凭据存储与临时目录文件存储。
|
||||
/// 分享互逆与提交接线。全部走内存凭据存储与临时目录文件存储;提交后的运行
|
||||
/// 时收敛以确定性 fake 计数(issue #40)。
|
||||
@MainActor
|
||||
final class CatalogViewModelTests: XCTestCase {
|
||||
private var workDir: URL!
|
||||
private var fileURL: URL!
|
||||
private var credentials: InMemoryCredentialStore!
|
||||
private var runtime: FakeCatalogRuntime!
|
||||
private var viewModel: CatalogViewModel!
|
||||
private var commitCounter: CommitCounter!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
@@ -20,7 +21,7 @@ final class CatalogViewModelTests: XCTestCase {
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||||
credentials = InMemoryCredentialStore()
|
||||
commitCounter = CommitCounter()
|
||||
runtime = FakeCatalogRuntime()
|
||||
viewModel = makeViewModel()
|
||||
}
|
||||
|
||||
@@ -30,13 +31,14 @@ final class CatalogViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeViewModel() -> CatalogViewModel {
|
||||
let model = CatalogViewModel(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL),
|
||||
// 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。
|
||||
runtime.hasActiveTarget = true
|
||||
let coordinator = CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
|
||||
return CatalogViewModel(
|
||||
coordinator: coordinator,
|
||||
credentials: credentials,
|
||||
plugins: NoManagedPluginProvider())
|
||||
let counter = commitCounter!
|
||||
model.postCommit = { await counter.increment() }
|
||||
return model
|
||||
}
|
||||
|
||||
private func makeSubscriptionCatalog() throws {
|
||||
@@ -137,8 +139,14 @@ final class CatalogViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(fields.remark, "改过的")
|
||||
XCTAssertEqual(fields.passwordRef, oldRef, "同一引用始终对应最新秘密")
|
||||
XCTAssertEqual(try credentials.secret(for: oldRef), "新密码")
|
||||
let commitCount = await commitCounter.count
|
||||
XCTAssertEqual(commitCount, 2, "导入 + 更新各一次提交")
|
||||
// 连续提交合并为最新代次的收敛(issue #40):旧代次在启动前被取代,
|
||||
// 最终结果必为最新提交快照。
|
||||
await waitUntilRuntimeSettles(viewModel.coordinator.syncStatus != .syncing(generation: 2))
|
||||
XCTAssertEqual(
|
||||
viewModel.coordinator.syncStatus,
|
||||
.finished(generation: 2, outcome: .converged(skippedServers: [])))
|
||||
XCTAssertFalse(runtime.convergeSnapshots.isEmpty)
|
||||
XCTAssertEqual(runtime.convergeSnapshots.last?.catalog, viewModel.catalog)
|
||||
}
|
||||
|
||||
func testUpdateServerRejectsInvalidAddressAndPort() async throws {
|
||||
@@ -251,12 +259,6 @@ final class CatalogViewModelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// postCommit 触发计数。
|
||||
actor CommitCounter {
|
||||
private(set) var count = 0
|
||||
func increment() { count += 1 }
|
||||
}
|
||||
|
||||
/// `XCTAssertThrowsError` 的 async 版本(表达式在 await 之后才能检查)。
|
||||
func expectThrowsAsync(
|
||||
_ expression: () async throws -> Void,
|
||||
|
||||
@@ -336,7 +336,9 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
try catalog.updateServer(seeded.server, with: fields)
|
||||
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
||||
|
||||
await controller.catalogDidCommit()
|
||||
// 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。
|
||||
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
|
||||
await controller.catalogDidCommit(snapshot: committed)
|
||||
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
let expectedRemark = "新加坡 01"
|
||||
@@ -359,7 +361,9 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
try catalog.remove(seeded.server)
|
||||
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
||||
|
||||
await controller.catalogDidCommit()
|
||||
// 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。
|
||||
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
|
||||
await controller.catalogDidCommit(snapshot: committed)
|
||||
|
||||
guard case .activationFailed = controller.state else {
|
||||
XCTFail("应呈现点名原因,实际 \(controller.state)")
|
||||
|
||||
Reference in New Issue
Block a user