refactor(ng2): deepen CatalogWorkflow into application module

Issue #49: introduce CatalogWorkflowDependencies as the composition-only
adapter bundle and stop exposing implementation collaborators as workflow
properties. MainApp now assembles all production adapters explicitly (the
workflow creates no production defaults); tests assemble hermetic fakes via
a shared fixture in CatalogFixtures. Subscription tests swap fetch behavior
on the injected fake instead of mutating the workflow. An architecture
deletion check fails when UI source files reference implementation
collaborators or raw catalog/credential storage types, and a hermetic
assembly test proves the workflow runs entirely on injected fakes.
This commit is contained in:
Qiu Yuzhou
2026-09-24 01:44:01 +08:00
parent c76ba93221
commit 63dac7a01b
18 changed files with 416 additions and 195 deletions
@@ -7,7 +7,7 @@ extension CatalogWorkflow {
/// 当前目录的聚合诊断事实(服务器/分组/来源/插件计数与已知无效数)。
var diagnosticCatalogFacts: DiagnosticCatalogFacts {
DiagnosticCatalogFacts(
counts: DiagnosticReportBuilder.counts(in: coordinator.committedCatalog),
counts: DiagnosticReportBuilder.counts(in: dependencies.coordinator.committedCatalog),
knownInvalidServerCount: tree.invalidServerCount)
}
}
@@ -12,33 +12,34 @@ extension CatalogWorkflow {
func importLegacy(reimport: Bool = false) async throws -> LegacyImportReport {
let snapshot: LegacySnapshot
if reimport {
guard let current = try legacyImportService.readSnapshot() else {
guard let current = try dependencies.legacyImportService.readSnapshot() else {
throw LegacyImportError.noSnapshot
}
snapshot = current
} else if let discoveredLegacySnapshot {
snapshot = discoveredLegacySnapshot
} else {
guard let current = try legacyImportService.readSnapshot() else {
guard let current = try dependencies.legacyImportService.readSnapshot() else {
throw LegacyImportError.noSnapshot
}
snapshot = current
}
let outcome = try legacyImportService.importSnapshot(snapshot, reimport: reimport)
let outcome = try dependencies.legacyImportService.importSnapshot(
snapshot, reimport: reimport)
// 导入服务独立落盘各 store 后对齐内存已提交状态;不经普通提交管线,
// 因此不触发运行时收敛。
coordinator.reloadCommittedStateFromStore()
dependencies.coordinator.reloadCommittedStateFromStore()
republishCommittedState()
publishLegacyImportReport(outcome.report)
refreshLegacyImportState()
await postLegacyImport?(outcome)
await dependencies.postLegacyImport?(outcome)
return outcome.report
}
/// 重读磁盘发现状态,不写任何标记或 Legacy 数据。
func refreshLegacyImportState() {
let completed = (try? legacyImportService.isCompleted()) ?? false
discoveredLegacySnapshot = try? legacyImportService.readSnapshot()
let completed = (try? dependencies.legacyImportService.isCompleted()) ?? false
discoveredLegacySnapshot = try? dependencies.legacyImportService.readSnapshot()
publishLegacyImportState(
LegacyImportAvailability(
snapshotFound: discoveredLegacySnapshot != nil, completed: completed))
@@ -113,6 +113,6 @@ extension CatalogWorkflow {
/// 激活命令(typed command surface):经 `Activating` port 发出;编排与
/// 运行时收敛留在 port 背后。意外错误 throws。
func activate(_ id: NodeID) async throws -> ActivationCommandOutcome {
try await activator.activate(id)
try await dependencies.activator.activate(id)
}
}
@@ -22,7 +22,7 @@ extension CatalogWorkflow {
groupID: .fresh(),
urlRef: .fresh(),
status: .never)
try credentials.save(url.absoluteString, for: record.urlRef)
try dependencies.credentials.save(url.absoluteString, for: record.urlRef)
do {
try commitSubscriptionDocument { catalog, subscriptions in
// 固定分组挂在目录根(CONTEXT.md「Subscription group」);名称以 host
@@ -31,7 +31,7 @@ extension CatalogWorkflow {
subscriptions.append(record)
}
} catch {
try? credentials.delete(record.urlRef)
try? dependencies.credentials.delete(record.urlRef)
throw error
}
await refreshSubscription(record.id)
@@ -77,7 +77,7 @@ extension CatalogWorkflow {
let url = try Self.validatedSubscriptionURL(urlString)
// 覆盖写同一引用;URL 编辑不落目录文档(引用与身份不变),新地址刷新
// 成功才有新快照——写入失败不触碰目录状态。
try credentials.save(url.absoluteString, for: record.urlRef)
try dependencies.credentials.save(url.absoluteString, for: record.urlRef)
await refreshSubscription(record.id)
}
@@ -94,7 +94,7 @@ extension CatalogWorkflow {
subscriptions.removeAll { $0.id == id }
}
for ref in [record.urlRef] + Self.credentialRefs(of: removedEntries) {
try? credentials.delete(ref)
try? dependencies.credentials.delete(ref)
}
return RemovalOutcome(removedNodeIDs: Set(removedEntries.map(\.id)))
}
@@ -104,7 +104,7 @@ extension CatalogWorkflow {
guard let record = subscriptionRecord(withID: id) else {
throw SubscriptionFormError.notFound
}
guard let stored = try credentials.secret(for: record.urlRef) else {
guard let stored = try dependencies.credentials.secret(for: record.urlRef) else {
throw CredentialStoreError.keychainStatus(errSecItemNotFound)
}
return stored
@@ -118,7 +118,7 @@ extension CatalogWorkflow {
// 缺凭据是命名失败(区别于地址格式无效),不静默当作空 URL。
let urlString = try subscriptionURL(for: record.id)
let url = try Self.validatedSubscriptionURL(urlString)
let data = try await subscriptionFetcher.fetch(url)
let data = try await dependencies.subscriptionFetcher.fetch(url)
let snapshot = try SubscriptionDocumentParser.parse(data, subscriptionID: record.id)
let summary =
subscriptions.first(where: { $0.id == record.id })
@@ -132,7 +132,7 @@ extension CatalogWorkflow {
_ snapshot: SubscriptionSnapshot, summary: SubscriptionSummary, fallbackName: String
) async throws {
var removedServers: [CatalogEntry] = []
var credentialJournal = CredentialWriteJournal(credentials: credentials)
var credentialJournal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
try commitSubscriptionDocument { catalog, subscriptions in
// 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用;
@@ -164,7 +164,7 @@ extension CatalogWorkflow {
}
// 被移除节点的凭据引用不会被新树复用(身份已不在),提交成功后清理。
for ref in Self.credentialRefs(of: removedServers) {
try? credentials.delete(ref)
try? dependencies.credentials.delete(ref)
}
}
@@ -194,7 +194,7 @@ extension CatalogWorkflow {
/// 订阅原始记录查询(module 内部;投影不含凭据引用等原始形态)。
private func subscriptionRecord(withID id: NodeID) -> SubscriptionRecord? {
coordinator.committedSubscriptions.first { $0.id == id }
dependencies.coordinator.committedSubscriptions.first { $0.id == id }
}
/// 订阅卡片 projection 推导(module 内部):名称、URL host、结构化状态、
@@ -268,7 +268,7 @@ extension CatalogWorkflow {
}
/// 订阅地址门禁:必须是带主机的 HTTPS URL(宽松模式永不提供)。
static func validatedSubscriptionURL(_ string: String) throws -> URL {
private static func validatedSubscriptionURL(_ string: String) throws -> URL {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
@@ -280,8 +280,8 @@ extension CatalogWorkflow {
}
}
/// 一台服务器叶子的凭据引用对(密码 + 可选插件参数)。
struct ServerCredentialRefs: Sendable {
/// 一台服务器叶子的凭据引用对(密码 + 可选插件参数;仅本实现文件使用)。
private struct ServerCredentialRefs: Sendable {
var password: CredentialReference
var options: CredentialReference?
}
@@ -1,17 +1,20 @@
import Combine
import Foundation
/// 目录工作流 module(issue #41):主窗口与配置 UI 的唯一 UI-facing seam。
/// 隐藏配置目录、凭据存储、订阅快照、Legacy 导入细节、持久化协调与运行时
/// 收敛,向 UI 提供不含秘密值的树/详情/订阅 projection、opaque 节点身份查询、
/// typed command、typed error 与 structured outcome。选择、pane、sheet、alert
/// 等窗口状态仍由 UI 持有;本类不生成用户可见文案。
/// 目录工作流 module(issue #41,#49 深化为 application module):主窗口与
/// 配置 UI 的唯一 UI-facing seam。隐藏配置目录、凭据存储、订阅快照、Legacy
/// 导入细节、持久化协调与运行时收敛,向 UI 提供不含秘密值的树/详情/订阅
/// projection、opaque 节点身份查询、typed command、typed error 与 structured
/// outcome。选择、pane、sheet、alert 等窗口状态仍由 UI 持有;本类不生成
/// 用户可见文案。
///
/// 内部沿用既有深 module:`ConfigurationCatalog` 的结构约束、
/// `ActivationStateMachine` 经 `CatalogCommitCoordinator` 的激活语义与目录
/// 持久化/runtime sync 协调,以及既有凭据、订阅获取与 Legacy 导入缝。
/// 「目录已提交」与「运行时已收敛」是分离的观察面(story 38/39):提交在
/// 持久化成功即完成,`runtimeSync` 单独呈现收敛阶段,失败不回滚目录。
/// 实现 adapter 全部经组合根注入的 `CatalogWorkflowDependencies` 装配
/// (issue #49),本类不自行创建生产默认实现。
@MainActor
final class CatalogWorkflow: ObservableObject {
/// 目录树 projection(侧栏、级联、移动目的地、删除确认共用)。
@@ -31,52 +34,25 @@ final class CatalogWorkflow: ObservableObject {
snapshotFound: false, completed: false)
/// 最近一次 Legacy 导入报告(story 31)。
@Published private(set) var legacyImportReport: LegacyImportReport?
/// 组合根与测试装配的全部实现 adapter(issue #49):coordinator、凭据、
/// 插件、订阅获取、Legacy 导入服务、导入后回调与激活缝只经此束持有,
/// 不再作为 workflow 属性暴露。仅供组合根与测试构造;视图不得访问——
/// 独立 target 拆分前由 architecture deletion check 守护残余可见性。
let dependencies: CatalogWorkflowDependencies
/// 启动时发现的 Legacy 快照(导入编排用;秘密值不进 projection)。
var discoveredLegacySnapshot: LegacySnapshot?
// module 内部协作成员:UI-facing seam 之外的实现细节(issue #41)。在拆出
// 独立编译 target 之前访问级别只到 internal,seam 纪律由约定与 deletion
// test 维护——视图不得直接触碰目录、凭据与协调器。
/// 提交管线与运行时收敛的唯一入口(组合根注入,沿用 #40)。
let coordinator: CatalogCommitCoordinator
private let fileStore: CatalogFileStore
/// 凭据存储只在 module 内出现(story 11);UI 不得持有。
let credentials: CredentialStoring
let plugins: ManagedPluginProviding
/// 订阅获取缝(默认 URLSession 实现;测试注入夹具)。
var subscriptionFetcher: SubscriptionFetching
/// Legacy 导入提交后的运行时边界(组合根一次性接线;独立于普通提交管线)。
let postLegacyImport: ((LegacyImportOutcome) async -> Void)?
let legacyImportService: LegacyImportService
/// 激活缝(issue #41):生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。
let activator: Activating
init(
coordinator: CatalogCommitCoordinator,
credentials: CredentialStoring = KeychainCredentialStore(),
plugins: ManagedPluginProviding = BundleManagedPluginProvider(),
subscriptionFetcher: SubscriptionFetching = HTTPSSubscriptionFetcher(),
legacyImportService: LegacyImportService? = nil,
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil,
activator: Activating = RejectingActivator()
) {
self.coordinator = coordinator
fileStore = coordinator.fileStore
self.credentials = credentials
self.plugins = plugins
self.subscriptionFetcher = subscriptionFetcher
self.postLegacyImport = postLegacyImport
self.activator = activator
self.legacyImportService =
legacyImportService
?? LegacyImportService(
catalogStore: fileStore,
credentials: credentials)
/// 组合根注入依赖束;workflow 不自行创建任何生产 adapter(issue #49)。
init(dependencies: CatalogWorkflowDependencies) {
self.dependencies = dependencies
let coordinator = dependencies.coordinator
tree = .build(
from: coordinator.committedCatalog, credentials: credentials, plugins: plugins)
from: coordinator.committedCatalog,
credentials: dependencies.credentials, plugins: dependencies.plugins)
subscriptions = Self.subscriptionSummaries(
coordinator.committedSubscriptions, catalog: coordinator.committedCatalog,
credentials: credentials)
credentials: dependencies.credentials)
runtimeSync = coordinator.syncStatus
coordinator.$syncStatus.assign(to: &$runtimeSync)
refreshLegacyImportState()
@@ -86,7 +62,7 @@ final class CatalogWorkflow: ObservableObject {
/// 行显示名(导航标题、重命名预填等);节点不存在为空串。
func displayName(for id: NodeID) -> String {
coordinator.committedCatalog.entry(for: id)?.displayName ?? ""
dependencies.coordinator.committedCatalog.entry(for: id)?.displayName ?? ""
}
/// 服务器叶子的已知阻塞原因(typed;编辑面「激活状态」区)。
@@ -97,10 +73,10 @@ final class CatalogWorkflow: ObservableObject {
/// 服务器编辑面(显式命令,story 11):解析密码与受管插件参数明文。
/// 节点不存在或不是服务器叶子为 `nil`。
func serverEditForm(for id: NodeID) -> ServerEditForm? {
guard let entry = coordinator.committedCatalog.entry(for: id),
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
case .server(let fields) = entry.kind
else { return nil }
let password = (try? credentials.secret(for: fields.passwordRef)) ?? ""
let password = (try? dependencies.credentials.secret(for: fields.passwordRef)) ?? ""
return ServerEditForm(
address: fields.address,
port: fields.port,
@@ -126,8 +102,10 @@ final class CatalogWorkflow: ObservableObject {
var provided = false
var options = ""
if case .managed(let program) = selection {
provided = plugins.executablePath(forProgram: program) != nil
options = fields.pluginOptionsRef.flatMap { (try? credentials.secret(for: $0)) ?? "" } ?? ""
provided = dependencies.plugins.executablePath(forProgram: program) != nil
options =
fields.pluginOptionsRef.flatMap { (try? dependencies.credentials.secret(for: $0)) ?? "" }
?? ""
}
return PluginSectionState(
selection: selection,
@@ -139,17 +117,14 @@ final class CatalogWorkflow: ObservableObject {
/// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。
func importTargetParent(for selection: NodeID?) -> NodeID? {
guard let selection, let entry = coordinator.committedCatalog.entry(for: selection) else {
return nil
}
let catalog = dependencies.coordinator.committedCatalog
guard let selection, let entry = catalog.entry(for: selection) else { return nil }
switch entry.kind {
case .group:
return entry.source == .manual ? selection : nil
case .server:
guard let parent = (try? coordinator.committedCatalog.parentID(of: selection)) ?? nil else {
return nil
}
return coordinator.committedCatalog.entry(for: parent)?.source == .manual ? parent : nil
guard let parent = (try? catalog.parentID(of: selection)) ?? nil else { return nil }
return catalog.entry(for: parent)?.source == .manual ? parent : nil
}
}
@@ -166,7 +141,7 @@ final class CatalogWorkflow: ObservableObject {
for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() {
do {
let uri = try SsUri.decode(String(line))
let fields = try Self.serverFields(from: uri, credentials: credentials)
let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials)
prepared.append((uri: uri, fields: fields))
} catch {
let reason: ImportLineFailureReason
@@ -189,7 +164,7 @@ final class CatalogWorkflow: ObservableObject {
}
} catch {
for item in prepared {
Self.deleteCredentialRefs(for: item.fields, credentials: credentials)
Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials)
}
throw error
}
@@ -230,7 +205,7 @@ final class CatalogWorkflow: ObservableObject {
try catalog.remove(id)
}
for ref in Self.credentialRefs(of: removed) {
try? credentials.delete(ref)
try? dependencies.credentials.delete(ref)
}
return RemovalOutcome(removedNodeIDs: Set(removed.map(\.id)))
}
@@ -255,7 +230,7 @@ final class CatalogWorkflow: ObservableObject {
{
throw ServerFormError.pluginNotManaged(program)
}
var journal = CredentialWriteJournal(credentials: credentials)
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
try commit { [self] catalog in
guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else {
@@ -268,7 +243,7 @@ final class CatalogWorkflow: ObservableObject {
fields.remark = draft.remark.trimmingCharacters(in: .whitespaces)
try Self.applyPluginSelection(
draft.plugin, options: draft.pluginOptions, to: &fields,
credentials: credentials, journal: &journal)
credentials: dependencies.credentials, journal: &journal)
try catalog.updateServer(id, with: fields)
}
} catch {
@@ -281,15 +256,15 @@ final class CatalogWorkflow: ObservableObject {
/// 服务器 → ss://(SIP002,story 22):凭据从存储解析;无凭据即点名失败
/// (不分享空档)。仅在用户明确请求分享时调用。
func shareURI(for id: NodeID) throws -> String {
guard let entry = coordinator.committedCatalog.entry(for: id),
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
case .server(let fields) = entry.kind
else { throw CatalogError.notAServer(id) }
guard let password = try credentials.secret(for: fields.passwordRef) else {
guard let password = try dependencies.credentials.secret(for: fields.passwordRef) else {
throw CredentialStoreError.keychainStatus(errSecItemNotFound)
}
var pluginOptions: String?
if let optionsRef = fields.pluginOptionsRef {
pluginOptions = try credentials.secret(for: optionsRef) ?? ""
pluginOptions = try dependencies.credentials.secret(for: optionsRef) ?? ""
}
return SsUri(
method: fields.encryptionMethod,
@@ -302,7 +277,7 @@ final class CatalogWorkflow: ObservableObject {
).encode()
}
// MARK: - 提交管线(module 内部;UI 不得调用,拆独立 target 前靠约定)
// MARK: - 提交管线(module 内部;UI 不得调用,独立 target 拆分前靠 deletion check)
/// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。
private func commit<T>(
@@ -318,7 +293,7 @@ final class CatalogWorkflow: ObservableObject {
private func commitDocument<T>(
_ mutate: (inout ConfigurationCatalog, inout [SubscriptionRecord]) throws -> T
) throws -> T {
let result = try coordinator.commit(mutate)
let result = try dependencies.coordinator.commit(mutate)
republishCommittedState()
return result
}
@@ -332,11 +307,13 @@ final class CatalogWorkflow: ObservableObject {
/// 提交成功后的 projection 重建(树 + 订阅卡片)。
func republishCommittedState() {
let coordinator = dependencies.coordinator
tree = .build(
from: coordinator.committedCatalog, credentials: credentials, plugins: plugins)
from: coordinator.committedCatalog,
credentials: dependencies.credentials, plugins: dependencies.plugins)
subscriptions = Self.subscriptionSummaries(
coordinator.committedSubscriptions, catalog: coordinator.committedCatalog,
credentials: credentials)
credentials: dependencies.credentials)
}
// MARK: - module 内部发布缝(扩展文件经此更新只读投影,setter 保持 private)
@@ -411,7 +388,7 @@ final class CatalogWorkflow: ObservableObject {
}
}
static func deleteCredentialRefs(
private static func deleteCredentialRefs(
for fields: ServerFields, credentials: CredentialStoring
) {
try? credentials.delete(fields.passwordRef)
@@ -459,10 +436,3 @@ final class CatalogWorkflow: ObservableObject {
}
}
}
/// 默认激活 adapter:未接线时原子拒绝(测试不触激活的场景)。
final class RejectingActivator: Activating {
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
.rejectedActivation
}
}
@@ -0,0 +1,25 @@
import Foundation
/// 组合根专用依赖束(issue #49):目录工作流全部实现 adapter 的唯一装配缝。
/// 生产 adapter 由组合根(MainApp)装配,测试注入 hermetic fake;
/// CatalogWorkflow 不自行创建钥匙串存储、网络获取器或提交协调器。
/// 本类型不是 UI-facing facade:视图不得引用——独立 target 拆分前,
/// 同 target 的访问纪律由 architecture deletion check 守护。
@MainActor
struct CatalogWorkflowDependencies {
/// 提交管线与运行时收敛的唯一入口;与代理运行时路径共享同一
/// committed catalog source(组合根创建并注入)。
let coordinator: CatalogCommitCoordinator
/// 凭据存储(story 11):只在 module 内出现,UI 不持有。
let credentials: CredentialStoring
/// 受管插件提供缝(编辑面集内事实与可执行文件存在性)。
let plugins: ManagedPluginProviding
/// 订阅获取缝。
let subscriptionFetcher: SubscriptionFetching
/// Legacy 导入服务(快照发现、完成标记与原子导入)。
let legacyImportService: LegacyImportService
/// Legacy 导入提交后的 2.0 运行时边界(组合根一次性接线;独立于普通提交管线)。
let postLegacyImport: ((LegacyImportOutcome) async -> Void)?
/// 激活缝:生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。
let activator: Activating
}
+40 -28
View File
@@ -29,20 +29,25 @@ struct ShadowsocksXNG2App: App {
_proxyController = StateObject(wrappedValue: controller)
let loginController = LaunchAtLoginController(service: dependencies.loginService)
_loginController = StateObject(wrappedValue: loginController)
// 组合根(issue #40/#41):目录提交协调器与生产运行时适配器只在此接线一次,
// 各 scene 不再重复设置提交回调;Legacy 导入后的 2.0 运行时边界同为一次性注入。
// 组合根(issue #40/#41/#49):目录提交协调器与全部生产运行时适配器只在
// 此接线一次,各 scene 不再重复设置提交回调;目录工作流的实现依赖经
// CatalogWorkflowDependencies 显式装配(workflow 不自行创建生产默认实现);
// Legacy 导入后的 2.0 运行时边界同为一次性注入。
let coordinator = CatalogCommitCoordinator(
fileStore: dependencies.catalogFileStore,
runtime: ProxyRuntimeSyncAdapter(controller: controller),
bootstrap: catalogBootstrap)
let catalogWorkflow = CatalogWorkflow(
coordinator: coordinator,
credentials: dependencies.credentials,
legacyImportService: dependencies.legacyImportService,
postLegacyImport: { _ in
await controller.legacyImportDidCommit()
},
activator: controller)
dependencies: CatalogWorkflowDependencies(
coordinator: coordinator,
credentials: dependencies.credentials,
plugins: BundleManagedPluginProvider(),
subscriptionFetcher: HTTPSSubscriptionFetcher(),
legacyImportService: dependencies.legacyImportService,
postLegacyImport: { _ in
await controller.legacyImportDidCommit()
},
activator: controller))
_catalogWorkflow = StateObject(wrappedValue: catalogWorkflow)
// 代理控制工作流 module(issue #47):状态菜单等 UI 表面的唯一代理控制
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
@@ -108,7 +113,7 @@ private struct ApplicationDependencies {
let settingsStore: ProxySettingsFileStore
let settingsRestore: RestoredProxySettings
let listenRestore: RestoredListenSettings
let legacyImportService: LegacyImportService?
let legacyImportService: LegacyImportService
let launchAgent: LaunchAgentControlling
let loginService: LaunchAtLoginControlling
@@ -119,25 +124,32 @@ private struct ApplicationDependencies {
}
static func make() -> ApplicationDependencies {
guard isUnitTesting else {
let credentials = KeychainCredentialStore()
let settingsStore = ProxySettingsFileStore(credentials: credentials)
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
return ApplicationDependencies(
credentials: credentials,
catalogFileStore: CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()),
activationFileStore: ActivationStateFileStore(
fileURL: ActivationStateFileStore.defaultFileURL()),
runtimeFileStore: RuntimeFileStore(),
settingsStore: settingsStore,
settingsRestore: restoredSettings,
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
legacyImportService: nil,
launchAgent: SMAppLaunchAgentService(),
loginService: SMAppLaunchAtLoginService())
}
guard isUnitTesting else { return makeProduction() }
return makeTesting()
}
private static func makeProduction() -> ApplicationDependencies {
let credentials = KeychainCredentialStore()
let catalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL())
let settingsStore = ProxySettingsFileStore(credentials: credentials)
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
return ApplicationDependencies(
credentials: credentials,
catalogFileStore: catalogFileStore,
activationFileStore: ActivationStateFileStore(
fileURL: ActivationStateFileStore.defaultFileURL()),
runtimeFileStore: RuntimeFileStore(),
settingsStore: settingsStore,
settingsRestore: restoredSettings,
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
legacyImportService: LegacyImportService(
catalogStore: catalogFileStore, credentials: credentials),
launchAgent: SMAppLaunchAgentService(),
loginService: SMAppLaunchAtLoginService())
}
private static func makeTesting() -> ApplicationDependencies {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ShadowsocksX-NG2-test-host-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -100,3 +100,70 @@ func expectThrowsAsync(
errorHandler(error)
}
}
// MARK: - 目录工作流测试夹具(issue #49)
/// 中性激活替身:不关心激活语义的测试场景,激活命令原子拒绝。
final class RejectingActivator: Activating {
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome {
.rejectedActivation
}
}
/// 永不提供快照的 Legacy 来源替身(测试不读用户 Legacy 偏好)。
struct EmptyLegacySnapshotProvider: LegacySnapshotProviding {
func readSnapshot() throws -> LegacySnapshot? { nil }
}
/// 固定快照提供缝替身(可中途改写快照,供重读场景断言)。
final class FixedLegacySnapshotProvider: LegacySnapshotProviding {
var snapshot: LegacySnapshot?
init(snapshot: LegacySnapshot?) {
self.snapshot = snapshot
}
func readSnapshot() throws -> LegacySnapshot? { snapshot }
}
/// 内存完成标记替身(测试不写 UserDefaults)。
final class InMemoryLegacyImportMarker: LegacyImportMarkerStoring {
private(set) var completed = false
func isCompleted() throws -> Bool { completed }
func setCompleted(_ completed: Bool) throws {
self.completed = completed
}
}
/// 目录工作流测试组装缝(issue #49):经依赖束显式装配全部 hermetic fake,
/// 不触碰生产 Keychain、UserDefaults、网络或激活边界。各测试按需覆盖任一
/// adapter;未指定 Legacy 服务时用「空快照 + 内存标记」的组合(工作流 init
/// 的发现状态读取仍被观察,但只触替身)。
@MainActor
func makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator,
credentials: CredentialStoring,
plugins: ManagedPluginProviding = NoManagedPluginProvider(),
subscriptionFetcher: SubscriptionFetching = FakeSubscriptionFetcher(
behavior: .success(Data())),
legacyImportService: LegacyImportService? = nil,
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil,
activator: Activating = RejectingActivator()
) -> CatalogWorkflow {
CatalogWorkflow(
dependencies: CatalogWorkflowDependencies(
coordinator: coordinator,
credentials: credentials,
plugins: plugins,
subscriptionFetcher: subscriptionFetcher,
legacyImportService: legacyImportService
?? LegacyImportService(
source: EmptyLegacySnapshotProvider(),
catalogStore: coordinator.fileStore,
credentials: credentials,
marker: InMemoryLegacyImportMarker()),
postLegacyImport: postLegacyImport,
activator: activator))
}
@@ -45,7 +45,7 @@ final class CatalogWorkflowPluginTests: XCTestCase {
}
private func makeWorkflow() -> CatalogWorkflow {
CatalogWorkflow(
makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
credentials: credentials,
@@ -35,7 +35,7 @@ final class CatalogWorkflowPolicyTests: XCTestCase {
runtime.hasActiveTarget = true
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
return CatalogWorkflow(
return makeCatalogWorkflow(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider(),
@@ -52,7 +52,7 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
let coordinator = CatalogCommitCoordinator(
fileStore: fileStore, runtime: runtime, bootstrap: bootstrap)
return CatalogWorkflow(
return makeCatalogWorkflow(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider(),
@@ -288,25 +288,3 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
marker: InMemoryLegacyImportMarker())
}
}
/// 固定快照提供缝(与 LegacyImportTests 同型的最小替身)。
private final class FixedLegacySnapshotProvider: LegacySnapshotProviding {
let snapshot: LegacySnapshot?
init(snapshot: LegacySnapshot?) {
self.snapshot = snapshot
}
func readSnapshot() throws -> LegacySnapshot? { snapshot }
}
/// 内存完成标记替身。
private final class InMemoryLegacyImportMarker: LegacyImportMarkerStoring {
private(set) var completed = false
func isCompleted() throws -> Bool { completed }
func setCompleted(_ completed: Bool) throws {
self.completed = completed
}
}
@@ -33,16 +33,16 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
try await super.tearDown()
}
private func makeWorkflow() -> CatalogWorkflow {
private func makeWorkflow(fetcher override: SubscriptionFetching? = nil) -> CatalogWorkflow {
// 计数即提交计数:提交后的运行时收敛由协调器异步调度(issue #40)。
runtime.hasActiveTarget = true
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
return CatalogWorkflow(
return makeCatalogWorkflow(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider(),
subscriptionFetcher: fetcher!)
subscriptionFetcher: override ?? fetcher!)
}
/// 文档稳定 ID 后缀在树 projection 中定位节点(订阅前缀随机)。
@@ -127,8 +127,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
let convergesBefore = runtime.convergeCount
let treeBefore = workflow.tree
fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503)))
workflow.subscriptionFetcher = fetcher
fetcher.setBehavior(.failure(.httpStatus(code: 503)))
await workflow.refreshSubscription(record.id)
// 快照原样保留。
@@ -148,8 +147,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
let record = try await seedSuccessfulSubscription()
let treeBefore = workflow.tree
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.duplicateIDs()))
fetcher.setBehavior(.success(SubscriptionDocs.duplicateIDs()))
await workflow.refreshSubscription(record.id)
guard case .failed(_, let failure) = workflow.subscriptions[0].status else {
@@ -163,8 +161,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
let record = try await seedSuccessfulSubscription()
let treeBefore = workflow.tree
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.invalidRecord()))
fetcher.setBehavior(.success(SubscriptionDocs.invalidRecord()))
await workflow.refreshSubscription(record.id)
guard case .failed(_, let failure) = workflow.subscriptions[0].status else {
@@ -179,8 +176,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
func testEmptySnapshotIsSuccessAndClearsSubtree() async throws {
let record = try await seedSuccessfulSubscription()
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.flat(serverCount: 0)))
fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 0)))
await workflow.refreshSubscription(record.id)
let group = try XCTUnwrap(workflow.tree.node(withID: record.groupID))
@@ -194,8 +190,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
func testCycleDocumentFallsBackToFlatAndSucceeds() async throws {
let record = try await seedSuccessfulSubscription()
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.cycleExtension()))
fetcher.setBehavior(.success(SubscriptionDocs.cycleExtension()))
await workflow.refreshSubscription(record.id)
let group = try XCTUnwrap(workflow.tree.node(withID: record.groupID))
@@ -212,8 +207,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc))
workflow = makeWorkflow()
let record = try await workflow.createSubscription(urlString: "https://p.example.com/s.json")
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(changedTreeDoc))
fetcher.setBehavior(.success(changedTreeDoc))
await workflow.refreshSubscription(record.id)
// 身份不变;名称/位置/顺序跟随远端。
@@ -235,8 +229,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
XCTAssertEqual(onlyServerChildID(ofGroup: record.groupID), serverID)
// 端口变化 → 新身份:旧节点删除(无墓碑)。
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.idLess(port: 9999)))
fetcher.setBehavior(.success(SubscriptionDocs.idLess(port: 9999)))
await workflow.refreshSubscription(record.id)
XCTAssertNil(workflow.tree.node(withID: serverID), "无稳定 ID 且记录变化即不同节点")
let newID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID))
@@ -251,20 +244,18 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
let originalGroupID = record.groupID
// 新地址先失败:快照保留、身份不动。
let failing = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 404)))
workflow.subscriptionFetcher = failing
fetcher.setBehavior(.failure(.httpStatus(code: 404)))
try await workflow.editSubscriptionURL(
record.id, urlString: "https://other.example.com/v2.json")
XCTAssertEqual(workflow.subscriptions[0].id, originalRecordID, "订阅身份保留")
guard case .failed = workflow.subscriptions[0].status else {
return XCTFail("新地址失败应记失败态")
}
XCTAssertEqual(failing.lastURL?.host, "other.example.com")
XCTAssertEqual(fetcher.lastURL?.host, "other.example.com")
XCTAssertNotNil(findNode(withSuffix: "aaaaaaaa-0000-4000-8000-00000000000a"), "失败保留最后成功快照")
// 新地址成功:同一身份命名空间下应用新内容。
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 1)))
await workflow.refreshSubscription(record.id)
XCTAssertEqual(workflow.subscriptions[0].groupID, originalGroupID)
guard case .succeeded = workflow.subscriptions[0].status else {
@@ -327,11 +318,10 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
func testSubscriptionsPersistAcrossWorkflowReload() async throws {
let record = try await seedSuccessfulSubscription()
let reloaded = CatalogWorkflow(
let reloaded = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime()),
credentials: credentials,
plugins: NoManagedPluginProvider())
credentials: credentials)
XCTAssertEqual(reloaded.subscriptions.count, 1)
XCTAssertEqual(reloaded.subscriptions[0].id, record.id)
guard case .succeeded = reloaded.subscriptions[0].status else {
@@ -344,8 +334,9 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
func testConcurrentRefreshOnSameSubscriptionDoesNotReenter() async throws {
let record = try await seedSuccessfulSubscription()
// 用门控获取器重建工作流:刷新停在 fetch 内部,验证并发守卫。
let gated = GatedFetcher()
workflow.subscriptionFetcher = gated
workflow = makeWorkflow(fetcher: gated)
async let first: Void = workflow.refreshSubscription(record.id)
// 轮询而非阻塞等待:主线程阻塞会饿死继承 MainActor 的 async let 子任务。
@@ -410,7 +401,7 @@ extension CatalogWorkflowSubscriptionTests {
),
] {
let treeBefore = workflow.tree
workflow.subscriptionFetcher = FakeSubscriptionFetcher(behavior: behavior)
fetcher.setBehavior(behavior)
await workflow.refreshSubscription(record.id)
guard case .failed(_, let failure) = workflow.subscriptions[0].status else {
@@ -429,8 +420,7 @@ extension CatalogWorkflowSubscriptionTests {
guard case .succeeded = healthy.status else { return XCTFail("应记成功态") }
XCTAssertEqual(healthy.serverCount, 2)
workflow.subscriptionFetcher = FakeSubscriptionFetcher(
behavior: .failure(.transport(detail: "URLError.cannotConnectToHost")))
fetcher.setBehavior(.failure(.transport(detail: "URLError.cannotConnectToHost")))
await workflow.refreshSubscription(healthy.id)
let failed = try XCTUnwrap(workflow.subscriptions.first)
@@ -446,11 +436,10 @@ extension CatalogWorkflowSubscriptionTests {
func testPartialCredentialRollbackIsTransientAndPersistedCoarsely() async throws {
let partialStore = SubscriptionRollbackCredentialStore()
runtime.hasActiveTarget = true
let partialWorkflow = CatalogWorkflow(
let partialWorkflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime),
credentials: partialStore,
plugins: NoManagedPluginProvider(),
subscriptionFetcher: FakeSubscriptionFetcher(
behavior: .success(SubscriptionDocs.tree())))
let record = try await partialWorkflow.createSubscription(
@@ -37,7 +37,7 @@ final class CatalogWorkflowTests: XCTestCase {
runtime.hasActiveTarget = true
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
return CatalogWorkflow(
return makeCatalogWorkflow(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider())
@@ -327,3 +327,80 @@ final class CatalogWorkflowTests: XCTestCase {
XCTAssertEqual(reloaded.displayName(for: groupID), "持久组")
}
}
/// 组合根 fakes 装配覆盖(issue #49 story 11):工作流可以完全经依赖束以
/// hermetic fake 组装——init 的发现状态、目录命令、激活命令与 Legacy 导入
/// 全部命中注入替身,不触碰生产 Keychain、app 数据、LaunchAgent、代理进程
/// 或 SystemConfiguration。
@MainActor
final class CatalogWorkflowHermeticAssemblyTests: XCTestCase {
private var workDir: URL!
override func setUp() async throws {
try await super.setUp()
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("catalog-assembly-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
}
override func tearDown() async throws {
try? FileManager.default.removeItem(at: workDir)
try await super.tearDown()
}
func testWorkflowAssemblesFromFakesWithoutProductionSideEffects() async throws {
let fileURL = workDir.appendingPathComponent("catalog.json")
let credentials = InMemoryCredentialStore()
let runtime = FakeCatalogRuntime()
let marker = InMemoryLegacyImportMarker()
let dependencies = CatalogWorkflowDependencies(
coordinator: CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime),
credentials: credentials,
plugins: NoManagedPluginProvider(),
subscriptionFetcher: FakeSubscriptionFetcher(behavior: .success(Data())),
legacyImportService: LegacyImportService(
source: FixedLegacySnapshotProvider(snapshot: try makeLegacySnapshot()),
catalogStore: CatalogFileStore(fileURL: fileURL),
credentials: credentials,
marker: marker),
postLegacyImport: nil,
activator: RejectingActivator())
let workflow = CatalogWorkflow(dependencies: dependencies)
// init 的发现状态来自注入的 Legacy 服务,而非生产缺省实现。
XCTAssertEqual(
workflow.legacyImportState,
LegacyImportAvailability(snapshotFound: true, completed: false))
// 目录命令落在注入的内存凭据与文件存储上;激活命令命中注入替身。
let groupID = try await workflow.createGroup(named: "组装组", into: nil)
XCTAssertTrue(workflow.tree.containsNode(groupID))
let activation = try await workflow.activate(groupID)
XCTAssertEqual(activation, .rejectedActivation)
// Legacy 导入走注入服务:报告、完成标记与凭据写入都落在注入的 fake 上;
// 不经提交管线触发运行时收敛。
let report = try await workflow.importLegacy()
XCTAssertEqual(report.importedServerCount, 1)
XCTAssertEqual(try marker.isCompleted(), true)
XCTAssertEqual(
credentials.storageSnapshot.values.contains("legacy-pw"), true,
"导入凭据写入注入的内存存储,而非生产 Keychain")
XCTAssertEqual(runtime.convergeCount, 0, "导入不经提交管线触发运行时收敛")
}
private func makeLegacySnapshot() throws -> LegacySnapshot {
let profile: [String: Any] = [
"Id": "11111111-2222-4333-8444-555555555555",
"ServerHost": "203.0.113.9",
"ServerPort": 8388,
"Method": "aes-256-gcm",
"Password": "legacy-pw",
"Remark": "旧服务器",
]
return try LegacySnapshot(
propertyList: ["ServerProfiles": [profile], "ShadowsocksRunningMode": "manual"])
}
}
@@ -135,7 +135,7 @@ final class DiagnosticsWorkflowLifecycleTests: DiagnosticsWorkflowTestCase {
let fileURL = workDir.appendingPathComponent("catalog.json")
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: FakeCatalogRuntime())
let catalogWorkflow = CatalogWorkflow(
let catalogWorkflow = makeCatalogWorkflow(
coordinator: coordinator,
credentials: InMemoryCredentialStore(),
plugins: ActivationFixture.TestManagedPlugins(pathsByProgram: [:]))
@@ -245,16 +245,6 @@ extension LegacyImportTests {
}
}
private final class FixedLegacySnapshotProvider: LegacySnapshotProviding {
var snapshot: LegacySnapshot?
init(snapshot: LegacySnapshot?) {
self.snapshot = snapshot
}
func readSnapshot() throws -> LegacySnapshot? { snapshot }
}
private final class TestLegacyImportMarker: LegacyImportMarkerStoring {
var completed = false
var failWhenSettingCompleted = false
@@ -71,7 +71,7 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase {
sendSignal: { _, _ in 0 })
let fileStore = CatalogFileStore(fileURL: catalogFileURL)
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
let catalogWorkflow = CatalogWorkflow(
let catalogWorkflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: fileStore,
runtime: ProxyRuntimeSyncAdapter(controller: controller),
+105
View File
@@ -20,3 +20,108 @@ final class SkeletonTests: XCTestCase {
XCTAssertEqual(Bundle.main.object(forInfoDictionaryKey: "LSUIElement") as? Bool, true)
}
}
/// 架构 deletion check(issue #49):UI 源文件不得引用目录工作流的实现
/// 协作者或原始目录/凭据存储类型。组合根(MainApp)与各 workflow module
/// 目录豁免;新增视图文件自动纳入扫描,新增运行时/系统适配器需显式登记
/// 豁免名单。规则只点名实现缝,不锁定实现内部的文件划分或私有 helper。
final class CatalogWorkflowArchitectureTests: XCTestCase {
func testUISourceFilesDoNotReferenceCatalogWorkflowImplementation() throws {
let appDirectory = Self.testTargetRoot.appendingPathComponent("App")
let uiFiles = try Self.uiSourceFiles(in: appDirectory)
XCTAssertFalse(uiFiles.isEmpty, "UI 源文件集合不应为空(App 目录缺失?)")
var violations: [String] = []
for file in uiFiles {
let content = try String(contentsOf: file, encoding: .utf8)
for (index, line) in content.components(separatedBy: .newlines).enumerated() {
for pattern in Self.forbiddenPatterns
where pattern.regex.firstMatch(
in: line, range: NSRange(line.startIndex..., in: line)) != nil
{
violations.append("\(file.lastPathComponent):\(index + 1) → \(pattern.label)")
}
}
}
XCTAssertTrue(
violations.isEmpty,
"""
UI 源文件不得引用目录工作流实现协作者或原始目录/凭据存储类型;
经 CatalogWorkflow 的 UI-facing interface 表达(issue #49):
\(violations.joined(separator: "\n"))
""")
}
// MARK: - 扫描范围与禁令表
private static let testTargetRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent()
/// 豁免文件:组合根(生产 adapter 唯一装配点)、应用生命周期与运行时/
/// 系统适配器(合法接触原始目录与凭据类型)。
private static let exemptedFiles: Set<String> = [
"MainApp.swift",
"AppDelegate.swift",
"CatalogCommitCoordinator.swift",
"ProxyRuntimeController.swift",
"SystemConfigurationProxyController.swift",
"LaunchAgentService.swift",
"LoginAtLoginService.swift",
"FirewallStatusChecker.swift",
"PACHealthProbe.swift",
]
/// 豁免目录:workflow module 实现(含被测的目录工作流自身)。
private static let exemptedDirectories: Set<String> = [
"CatalogWorkflow", "ProxyControlWorkflow", "SettingsWorkflow", "DiagnosticsWorkflow",
]
/// 禁令:实现协作者类型、原始目录/凭据存储类型与 workflow 内部成员缝。
private static let forbiddenPatterns = makeForbiddenPatterns()
private static func makeForbiddenPatterns() -> [(label: String, regex: NSRegularExpression)] {
let typeTokens = [
"CatalogWorkflowDependencies", "CatalogCommitCoordinator", "CatalogFileStore",
"ConfigurationCatalog", "CatalogEntry", "CommittedCatalogSnapshot",
"CredentialStoring", "KeychainCredentialStore", "CredentialWriteJournal",
"CredentialReference", "ManagedPluginProviding", "BundleManagedPluginProvider",
"SubscriptionFetching", "HTTPSSubscriptionFetcher", "SubscriptionRecord",
"LegacyImportService", "LegacySnapshot", "Activating", "ServerFields",
]
// workflow 内部成员缝(不作为可访问属性/方法暴露给 UI)。
let memberTokens = [
"\\.dependencies\\b", "\\bfileStore\\b", "discoveredLegacySnapshot",
"republishCommittedState", "commitSubscriptionDocument", "publishLegacyImportState",
"publishLegacyImportReport", "setRefreshInFlight", "setSubscriptionRefreshFailure",
"subscriptionSummaries", "credentialRefs",
]
return (typeTokens + memberTokens).map { token in
do {
return (label: token, regex: try NSRegularExpression(pattern: token))
} catch {
preconditionFailure("禁令正则编译失败:\(token)")
}
}
}
/// UI 源文件 = App 下全部 Swift 文件,除豁免名单与 workflow module 目录。
private static func uiSourceFiles(in directory: URL) throws -> [URL] {
guard
let enumerator = FileManager.default.enumerator(
at: directory, includingPropertiesForKeys: nil)
else {
XCTFail("无法枚举 App 源码目录:\(directory.path)")
return []
}
var result: [URL] = []
for case let url as URL in enumerator {
let name = url.lastPathComponent
guard name.hasSuffix(".swift"), !exemptedFiles.contains(name) else { continue }
let relativePath = url.path.replacingOccurrences(of: directory.path + "/", with: "")
guard !exemptedDirectories.contains(where: { relativePath.hasPrefix("\($0)/") })
else { continue }
result.append(url)
}
return result
}
}
@@ -149,6 +149,13 @@ final class FakeSubscriptionFetcher: SubscriptionFetching, @unchecked Sendable {
self.behavior = behavior
}
/// 中途切换预设结果(工作流不再暴露可替换的获取器属性,issue #49)。
func setBehavior(_ behavior: Behavior) {
lock.lock()
defer { lock.unlock() }
self.behavior = behavior
}
var lastURL: URL? {
lock.lock()
defer { lock.unlock() }