From e65d7112f1ff2deff20885619e80eb4b19313c1e Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Wed, 23 Sep 2026 16:47:13 +0800 Subject: [PATCH] refactor(ng2): unify committed catalog source --- .../App/CatalogCommitCoordinator.swift | 78 +++++++++++-- ShadowsocksX-NG2/App/MainApp.swift | 10 +- .../App/ProxyRuntimeController.swift | 35 +++--- .../Tests/CatalogCommitCoordinatorTests.swift | 30 ++++- .../Tests/CatalogWorkflowRollbackTests.swift | 17 ++- .../ProxyRuntimeControllerModeTests.swift | 106 ++++++++++++++++++ .../Tests/ProxyRuntimeControllerTests.swift | 2 +- .../Tests/ProxyRuntimeFixture.swift | 6 + .../Tests/SettingsWorkflowTests.swift | 5 +- .../Tests/UnreadableListenSettingsTests.swift | 5 +- 10 files changed, 256 insertions(+), 38 deletions(-) diff --git a/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift b/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift index 416e1800..5ba4c683 100644 --- a/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift +++ b/ShadowsocksX-NG2/App/CatalogCommitCoordinator.swift @@ -8,6 +8,45 @@ struct CommittedCatalogSnapshot: Equatable, Sendable { let subscriptions: [SubscriptionRecord] } +/// Runtime commands read the latest committed catalog through this narrow seam. +/// The catalog remains a value snapshot; a command captures it once and uses it +/// for its full re-expansion operation. +@MainActor +protocol RuntimeCatalogSnapshotReading: AnyObject { + var catalogSnapshot: ConfigurationCatalog { get } +} + +/// Shared in-process catalog value. Only the commit coordinator can publish +/// after this file's bootstrap; runtime code receives the read-only protocol. +@MainActor +private final class CommittedCatalogState: RuntimeCatalogSnapshotReading { + private(set) var catalogSnapshot: ConfigurationCatalog + + init(catalog: ConfigurationCatalog) { + catalogSnapshot = catalog + } + + fileprivate func publish(_ catalog: ConfigurationCatalog) { + catalogSnapshot = catalog + } +} + +/// One-time startup load shared by the controller and coordinator. The reader +/// exposes only catalog data; subscription state remains coordinator-owned. +@MainActor +struct CatalogCommitBootstrap { + let catalogSnapshotReader: RuntimeCatalogSnapshotReading + fileprivate let committedCatalogState: CommittedCatalogState + fileprivate let subscriptions: [SubscriptionRecord] + + fileprivate init(document: CatalogDocument) { + let state = CommittedCatalogState(catalog: document.catalog) + catalogSnapshotReader = state + committedCatalogState = state + subscriptions = document.subscriptions + } +} + /// 目录提交后一次运行时收敛的结构化结果(issue #40)。协调器不生成本地化 /// 文案:失败细节原样携带平台运行时给出的点名事实,由上层 presentation 决定 /// 如何呈现。 @@ -56,17 +95,37 @@ final class CatalogCommitCoordinator: ObservableObject { let fileStore: CatalogFileStore private let runtime: CatalogRuntimeSyncing + private let catalogState: CommittedCatalogState /// 提交代次:每次提交递增,用于丢弃被更新提交取代的旧同步结果。 private var generation = 0 - /// 已提交状态(协调器持有的内存快照;`commit` 成功后整体替换)。 - private(set) var committedCatalog: ConfigurationCatalog + /// 目录在共享来源中只有一份;订阅状态仍由协调器持有。 + var committedCatalog: ConfigurationCatalog { catalogState.catalogSnapshot } private(set) var committedSubscriptions: [SubscriptionRecord] - init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) { + init( + fileStore: CatalogFileStore, + runtime: CatalogRuntimeSyncing, + bootstrap: CatalogCommitBootstrap + ) { self.fileStore = fileStore self.runtime = runtime - (committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore) + catalogState = bootstrap.committedCatalogState + committedSubscriptions = bootstrap.subscriptions + } + + /// Test and isolated-module convenience: still performs exactly one document + /// load, then gives the coordinator sole publication authority. + convenience init(fileStore: CatalogFileStore, runtime: CatalogRuntimeSyncing) { + self.init( + fileStore: fileStore, runtime: runtime, bootstrap: Self.bootstrap(fileStore: fileStore)) + } + + /// Loads the initial document once so the composition root can share its + /// catalog with the runtime controller before constructing the adapter. + /// Direct out-of-band file edits are not observed during a GUI session. + static func bootstrap(fileStore: CatalogFileStore) -> CatalogCommitBootstrap { + CatalogCommitBootstrap(document: loadCommitted(from: fileStore)) } /// 普通目录变更的唯一提交管线。任一同步步骤失败则已提交状态不动并原样 @@ -79,7 +138,7 @@ final class CatalogCommitCoordinator: ObservableObject { let result = try mutate(&workingCatalog, &workingSubscriptions) try fileStore.save( CatalogDocument(catalog: workingCatalog, subscriptions: workingSubscriptions)) - committedCatalog = workingCatalog + catalogState.publish(workingCatalog) committedSubscriptions = workingSubscriptions scheduleRuntimeSync( CommittedCatalogSnapshot(catalog: workingCatalog, subscriptions: workingSubscriptions)) @@ -89,7 +148,9 @@ final class CatalogCommitCoordinator: ObservableObject { /// Legacy 导入等独立路径直接落盘后对齐已提交状态;不触发运行时收敛 /// (导入的运行时边界由独立路径自行驱动,不经普通提交管线)。 func reloadCommittedStateFromStore() { - (committedCatalog, committedSubscriptions) = Self.loadCommitted(from: fileStore) + let document = Self.loadCommitted(from: fileStore) + catalogState.publish(document.catalog) + committedSubscriptions = document.subscriptions } /// 异步收敛调度:无活动目标不调用运行时;同步开始前与结束后都以代次 @@ -114,9 +175,8 @@ final class CatalogCommitCoordinator: ObservableObject { /// 用户经由导入/设置路径修复。 private static func loadCommitted( from fileStore: CatalogFileStore - ) -> (catalog: ConfigurationCatalog, subscriptions: [SubscriptionRecord]) { - let loaded = (try? fileStore.load()) ?? CatalogDocument() - return (loaded.catalog, loaded.subscriptions) + ) -> CatalogDocument { + (try? fileStore.load()) ?? CatalogDocument() } } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index d1f03488..e6f5a6b7 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -11,8 +11,13 @@ struct ShadowsocksXNG2App: App { init() { let dependencies = ApplicationDependencies.make() + // The catalog document is read exactly once at startup. The coordinator + // publishes later committed snapshots; the controller receives read-only + // access to this same in-process source. + let catalogBootstrap = CatalogCommitCoordinator.bootstrap( + fileStore: dependencies.catalogFileStore) let controller = ProxyRuntimeController( - catalogFileStore: dependencies.catalogFileStore, + catalogSnapshotReader: catalogBootstrap.catalogSnapshotReader, activationFileStore: dependencies.activationFileStore, runtimeFileStore: dependencies.runtimeFileStore, credentials: dependencies.credentials, @@ -27,7 +32,8 @@ struct ShadowsocksXNG2App: App { // 各 scene 不再重复设置提交回调;Legacy 导入后的 2.0 运行时边界同为一次性注入。 let coordinator = CatalogCommitCoordinator( fileStore: dependencies.catalogFileStore, - runtime: ProxyRuntimeSyncAdapter(controller: controller)) + runtime: ProxyRuntimeSyncAdapter(controller: controller), + bootstrap: catalogBootstrap) let catalogWorkflow = CatalogWorkflow( coordinator: coordinator, credentials: dependencies.credentials, diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index e4ad2933..7890afed 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -47,8 +47,7 @@ final class ProxyRuntimeController: ObservableObject { @Published private(set) var skippedServers: [SkippedServer] = [] private(set) var machine: ActivationStateMachine - private var catalog: ConfigurationCatalog - private let catalogFileStore: CatalogFileStore + private let catalogSnapshotReader: RuntimeCatalogSnapshotReading private let activationFileStore: ActivationStateFileStore private let runtimeFileStore: RuntimeFileStore private let credentials: CredentialStoring @@ -76,8 +75,7 @@ final class ProxyRuntimeController: ObservableObject { @Published private(set) var proxyMode: ProxyMode init( - catalogFileStore: CatalogFileStore = CatalogFileStore( - fileURL: CatalogFileStore.defaultFileURL()), + catalogSnapshotReader: RuntimeCatalogSnapshotReading, activationFileStore: ActivationStateFileStore = ActivationStateFileStore( fileURL: ActivationStateFileStore.defaultFileURL()), runtimeFileStore: RuntimeFileStore = RuntimeFileStore(), @@ -96,7 +94,7 @@ final class ProxyRuntimeController: ObservableObject { firewallPollIntervalNanoseconds: UInt64 = 2_000_000_000, sendSignal: @escaping @Sendable (Int32, Int32) -> Int32 = { kill($0, $1) } ) { - self.catalogFileStore = catalogFileStore + self.catalogSnapshotReader = catalogSnapshotReader self.activationFileStore = activationFileStore self.runtimeFileStore = runtimeFileStore self.credentials = credentials @@ -121,7 +119,6 @@ final class ProxyRuntimeController: ObservableObject { self.firewallExecutableURLs = firewallExecutableURLs ?? Self.defaultFirewallExecutableURLs self.firewallPollIntervalNanoseconds = firewallPollIntervalNanoseconds self.sendSignal = sendSignal - catalog = (try? catalogFileStore.load().catalog) ?? ConfigurationCatalog() let persistedTarget = try? activationFileStore.loadActiveTargetID() machine = ActivationStateMachine(activeTargetID: persistedTarget) activeTargetID = machine.activeTargetID @@ -161,7 +158,7 @@ final class ProxyRuntimeController: ObservableObject { /// `.rejectedActivation`;意外错误 throws 并进入 `serviceFailed`。 @discardableResult func activate(_ target: NodeID) async throws -> ActivationCommandOutcome { - reloadCatalog() + let catalog = catalogSnapshotReader.catalogSnapshot do { let configuration = try machine.activate( target, in: catalog, credentials: credentials, plugins: plugins, listen: settings.listen, @@ -255,8 +252,8 @@ final class ProxyRuntimeController: ObservableObject { /// 事实来源——注册过即视为开启并重校验;随后与磁盘契约对齐(相同内容跳 /// 过写入)。GUI 崩溃期间 agent 与 wrapper 均不受影响。 func resyncOnLaunch() async { - reloadCatalog() - switch reexpand() { + let catalog = catalogSnapshotReader.catalogSnapshot + switch reexpand(in: catalog) { case .deployed(let configuration): let status = agent.status if status == .registered || status == .requiresApproval { @@ -293,8 +290,8 @@ final class ProxyRuntimeController: ObservableObject { presentNoActiveTarget() return } - reloadCatalog() - switch reexpand() { + let catalog = catalogSnapshotReader.catalogSnapshot + switch reexpand(in: catalog) { case .deployed(let configuration): await deploy(configuration.document) case .clearedAndStopped(let failure): @@ -454,8 +451,7 @@ extension ProxyRuntimeController { /// → 原子更新运行时;目标失效 → 清除目标并停止代理。返回结构化收敛结果, /// 健康检查耗时属于本调用的异步收敛阶段,不改变「目录已提交」的事实。 func catalogDidCommit(snapshot catalog: ConfigurationCatalog) async -> RuntimeSyncOutcome { - self.catalog = catalog - switch reexpand() { + switch reexpand(in: catalog) { case .deployed(let configuration): guard state != .off else { return .revalidated @@ -497,6 +493,7 @@ extension ProxyRuntimeController { /// before persistence, so the mode kind and its URL commit as one logical /// change and a validation failure precedes the write. func updateSettings(_ proposed: ProxySettings) async throws { + let catalog = catalogSnapshotReader.catalogSnapshot var next = proposed var resolvedMode = proxyMode if case .externalPAC = proxyMode { @@ -515,7 +512,7 @@ extension ProxyRuntimeController { settingsUnreadable = false guard state != .off else { return } - switch reexpand() { + switch reexpand(in: catalog) { case .deployed(let configuration): await deploy(configuration.document) case .clearedAndStopped(let failure): @@ -557,7 +554,6 @@ extension ProxyRuntimeController { listenSettingsUnreadable = false proxyMode = Self.makeProxyMode(from: restored) } - reloadCatalog() let persistedTarget = try? activationFileStore.loadActiveTargetID() machine = ActivationStateMachine(activeTargetID: persistedTarget ?? nil) activeTargetID = machine.activeTargetID @@ -743,12 +739,9 @@ extension ProxyRuntimeController { // MARK: - 目录同步 - private func reloadCatalog() { - catalog = (try? catalogFileStore.load().catalog) ?? catalog - } - - /// 目录以磁盘为事实来源重载后重展开;目标被清除时一并发布。 - private func reexpand() -> ActivationEffect? { + /// Re-expands one command-start snapshot; a concurrently published catalog + /// is intentionally observed by the next command, not halfway through this one. + private func reexpand(in catalog: ConfigurationCatalog) -> ActivationEffect? { let effect = machine.catalogDidCommit( catalog, credentials: credentials, plugins: plugins, listen: settings.listen, timeout: settings.timeoutSeconds, verbose: settings.verboseLogging, diff --git a/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift b/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift index 8d4a51a7..80f55892 100644 --- a/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogCommitCoordinatorTests.swift @@ -69,6 +69,7 @@ final class CatalogCommitCoordinatorTests: XCTestCase { private var fileURL: URL! private var runtime: FakeCatalogRuntime! private var coordinator: CatalogCommitCoordinator! + private var bootstrap: CatalogCommitBootstrap! override func setUp() async throws { try await super.setUp() @@ -87,7 +88,9 @@ final class CatalogCommitCoordinatorTests: XCTestCase { } private func makeCoordinator() -> CatalogCommitCoordinator { - CatalogCommitCoordinator(fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) + let fileStore = CatalogFileStore(fileURL: fileURL) + bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) + return CatalogCommitCoordinator(fileStore: fileStore, runtime: runtime, bootstrap: bootstrap) } /// 预置目录后重建协调器(模拟已落盘的既有状态)。 @@ -112,6 +115,9 @@ final class CatalogCommitCoordinatorTests: XCTestCase { } XCTAssertTrue(coordinator.committedCatalog.contains(added), "提交后已发布新状态") + XCTAssertTrue( + bootstrap.catalogSnapshotReader.catalogSnapshot.contains(added), + "controller reader 与 coordinator 观察同一已提交目录") XCTAssertEqual(try persistedCatalog().rootChildren, [added], "变更已原子持久化") XCTAssertEqual(runtime.convergeCount, 0, "无活动目标不驱动运行时") XCTAssertEqual(coordinator.syncStatus, .idle) @@ -281,7 +287,29 @@ final class CatalogCommitCoordinatorTests: XCTestCase { coordinator.reloadCommittedStateFromStore() XCTAssertEqual(coordinator.committedCatalog.rootChildren, [groupID], "已提交状态对齐磁盘") + XCTAssertEqual( + bootstrap.catalogSnapshotReader.catalogSnapshot.rootChildren, [groupID], + "独立导入 reload 发布到共享目录来源") XCTAssertEqual(coordinator.committedSubscriptions.count, 1) XCTAssertEqual(runtime.convergeCount, 0, "对齐不触发运行时收敛") } + + func testFailedPersistenceDoesNotPublishSharedCatalog() throws { + let blocker = workDir.appendingPathComponent("not-a-directory") + try Data().write(to: blocker) + let blockedStore = CatalogFileStore(fileURL: blocker.appendingPathComponent("catalog.json")) + let blockedBootstrap = CatalogCommitCoordinator.bootstrap(fileStore: blockedStore) + let blockedCoordinator = CatalogCommitCoordinator( + fileStore: blockedStore, runtime: runtime, bootstrap: blockedBootstrap) + let original = blockedBootstrap.catalogSnapshotReader.catalogSnapshot + + XCTAssertThrowsError( + try blockedCoordinator.commit { catalog, _ in + try catalog.addTestServer("不能发布") + }) + + XCTAssertEqual(blockedBootstrap.catalogSnapshotReader.catalogSnapshot, original) + XCTAssertEqual(blockedCoordinator.committedCatalog, original) + XCTAssertEqual(runtime.convergeCount, 0, "持久化失败不驱动运行时") + } } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift index a70887c9..1bffa134 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift @@ -13,6 +13,8 @@ final class CatalogWorkflowRollbackTests: XCTestCase { private var activationURL: URL! private var credentials: InMemoryCredentialStore! private var runtime: FakeCatalogRuntime! + private var bootstrap: CatalogCommitBootstrap! + private var rootsSeenByLegacyPostImport: [NodeID]? override func setUp() async throws { try await super.setUp() @@ -46,8 +48,10 @@ final class CatalogWorkflowRollbackTests: XCTestCase { postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil ) -> CatalogWorkflow { runtime.hasActiveTarget = true + let fileStore = CatalogFileStore(fileURL: fileURL) + bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) let coordinator = CatalogCommitCoordinator( - fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime) + fileStore: fileStore, runtime: runtime, bootstrap: bootstrap) return CatalogWorkflow( coordinator: coordinator, credentials: credentials, @@ -221,7 +225,13 @@ final class CatalogWorkflowRollbackTests: XCTestCase { let service = try makeLegacyService() var postCalls = 0 - let workflow = makeWorkflow(legacyImportService: service) { _ in postCalls += 1 } + rootsSeenByLegacyPostImport = nil + let workflow = makeWorkflow(legacyImportService: service) { [weak self] _ in + postCalls += 1 + self?.rootsSeenByLegacyPostImport = + self?.bootstrap.catalogSnapshotReader + .catalogSnapshot.rootChildren + } XCTAssertTrue(workflow.legacyImportState.shouldOffer, "首启发现快照后应提供自动导入入口") // 有活动目标也不得触发运行时收敛(导入不启动代理、不写系统代理)。 XCTAssertTrue(runtime.hasActiveTarget) @@ -234,6 +244,9 @@ final class CatalogWorkflowRollbackTests: XCTestCase { XCTAssertTrue(group.isGroup) XCTAssertTrue(group.isManual) XCTAssertEqual(group.childCount, 1) + XCTAssertEqual( + rootsSeenByLegacyPostImport, [group.id], + "独立导入先 reload 并发布共享目录快照,再进入运行时清理回调") XCTAssertEqual(runtime.convergeCount, 0, "导入不经提交管线触发运行时收敛") XCTAssertEqual(postCalls, 1, "导入后的运行时边界只经注入闭包(独立于普通提交)") XCTAssertEqual(workflow.legacyImportState.completed, true, "成功导入写完成标记") diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerModeTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerModeTests.swift index a7e1c804..a777961e 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerModeTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerModeTests.swift @@ -105,4 +105,110 @@ extension ProxyRuntimeControllerTests { XCTAssertTrue(systemProxy.applied.isEmpty) XCTAssertEqual(agent.registerCount, 0) } + +} + +/// Coordinator/controller integration for the shared committed catalog source. +@MainActor +final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase { + private var runtime: ProxyRuntimeFixture.TemporaryRuntime! + private var catalogFileURL: URL! + private var activationFileURL: URL! + private var credentials: InMemoryCredentialStore! + private var agent: ProxyRuntimeFixture.FakeLaunchAgent! + private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! + + override func setUpWithError() throws { + try super.setUpWithError() + runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") + activationFileURL = runtime.directory.appendingPathComponent("activation.json") + credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() + systemProxy = ProxyRuntimeFixture.FakeSystemProxy() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: runtime.directory) + try super.tearDownWithError() + } + + func testCommitWithoutActiveTargetFeedsLaterActivationThroughSharedSnapshot() async throws { + let fileStore = CatalogFileStore(fileURL: catalogFileURL) + let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) + let controller = makeController(catalogSnapshotReader: bootstrap.catalogSnapshotReader) + let coordinator = CatalogCommitCoordinator( + fileStore: fileStore, + runtime: ProxyRuntimeSyncAdapter(controller: controller), + bootstrap: bootstrap) + + let server = try coordinator.commit { catalog, _ in + try ActivationFixture.addPlainServer( + "提交后激活", in: &catalog, credentials: credentials) + } + + XCTAssertEqual(coordinator.syncStatus, .idle, "无目标时目录只发布,不触发运行时") + XCTAssertNil(controller.activeTargetID) + try await controller.activate(server) + + XCTAssertEqual(controller.activeTargetID, server, "激活读取协调器刚发布的唯一快照") + XCTAssertEqual(controller.state, .off, "该命令仅选择目标,不启动代理") + } + + func testCoordinatorUsesProductionAdapterToClearRemovedActiveTarget() async throws { + let server = try makeSeededCatalog() + try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: server) + let fileStore = CatalogFileStore(fileURL: catalogFileURL) + let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore) + let controller = makeController(catalogSnapshotReader: bootstrap.catalogSnapshotReader) + let coordinator = CatalogCommitCoordinator( + fileStore: fileStore, + runtime: ProxyRuntimeSyncAdapter(controller: controller), + bootstrap: bootstrap) + + try coordinator.commit { catalog, _ in try catalog.remove(server) } + + await waitUntilRuntimeSettles( + { + if case .finished = coordinator.syncStatus { return true } + return false + }()) + + guard case .finished(_, .clearedAndStopped) = coordinator.syncStatus else { + return XCTFail("生产 adapter 应以刚提交快照清理无效目标") + } + XCTAssertNil(controller.activeTargetID) + XCTAssertNil( + try ActivationStateFileStore(fileURL: activationFileURL).loadActiveTargetID(), + "adapter 收敛后持久化清除失效目标") + } + + private func makeController( + catalogSnapshotReader: RuntimeCatalogSnapshotReading + ) -> ProxyRuntimeController { + ProxyRuntimeController( + catalogSnapshotReader: catalogSnapshotReader, + activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), + runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), + credentials: credentials, + plugins: ActivationFixture.plugins, + listenRestore: RestoredListenSettings( + settings: ActivationFixture.listen, unreadableError: nil), + settingsStore: InMemoryProxySettingsStore(), + agent: agent, + probe: ProxyRuntimeFixture.FakeProbe.reachable(), + pacProbe: ProxyRuntimeFixture.FakePACProbe(), + systemProxy: systemProxy, + firewallExecutableURLs: [URL(fileURLWithPath: "/bundle/Helpers/sslocal")], + firewallPollIntervalNanoseconds: 1_000_000, + sendSignal: { _, _ in 0 }) + } + + private func makeSeededCatalog() throws -> NodeID { + var catalog = ConfigurationCatalog() + let server = try ActivationFixture.addPlainServer( + "待删除", in: &catalog, credentials: credentials) + try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) + return server + } } diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index e7feb580..0bffe65e 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -78,7 +78,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { ) -> ProxyRuntimeController { agent.setStatus(agentStatus) return ProxyRuntimeController( - catalogFileStore: CatalogFileStore(fileURL: catalogFileURL), + catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL), activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), credentials: credentials, diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeFixture.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeFixture.swift index 6b774195..04ce9af4 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeFixture.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeFixture.swift @@ -25,6 +25,12 @@ enum ProxyRuntimeFixture { pidFile: directory.appendingPathComponent("agent.pid")) } + @MainActor + static func catalogSnapshotReader(at fileURL: URL) -> RuntimeCatalogSnapshotReading { + CatalogCommitCoordinator.bootstrap(fileStore: CatalogFileStore(fileURL: fileURL)) + .catalogSnapshotReader + } + static func unusedLoopbackPort() throws -> Int { let descriptor = socket(AF_INET, SOCK_STREAM, 0) guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) } diff --git a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift index 193c93e1..914290c9 100644 --- a/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/SettingsWorkflowTests.swift @@ -116,8 +116,11 @@ final class SettingsWorkflowTests: XCTestCase { // MARK: - 夹具 private func makePair() -> (controller: ProxyRuntimeController, workflow: SettingsWorkflow) { + let catalogSnapshotReader = CatalogCommitCoordinator.bootstrap( + fileStore: CatalogFileStore(fileURL: catalogFileURL) + ).catalogSnapshotReader let controller = ProxyRuntimeController( - catalogFileStore: CatalogFileStore(fileURL: catalogFileURL), + catalogSnapshotReader: catalogSnapshotReader, activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), credentials: credentials, diff --git a/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift index ffff1d5c..2541aa6f 100644 --- a/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift +++ b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift @@ -33,8 +33,11 @@ final class UnreadableListenSettingsTests: XCTestCase { listenUnreadable: ListenSettingsStoreError ) -> ProxyRuntimeController { agent.setStatus(agentStatus) + let catalogSnapshotReader = CatalogCommitCoordinator.bootstrap( + fileStore: CatalogFileStore(fileURL: catalogFileURL) + ).catalogSnapshotReader return ProxyRuntimeController( - catalogFileStore: CatalogFileStore(fileURL: catalogFileURL), + catalogSnapshotReader: catalogSnapshotReader, activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), credentials: credentials,