From ea801d5f386f01eb13ccd31c8317119590f60d1f Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 22 Sep 2026 08:40:13 +0800 Subject: [PATCH] feat(catalog): remove node enable state Migrate legacy enabled fields to v3 without preserving their semantics. Keep recoverable invalid imports visible and filter them during activation. --- CONTEXT.md | 24 ++-- .../App/ActivationFailure+Presentation.swift | 12 +- .../App/CatalogViewModel+Subscriptions.swift | 87 +++++++---- ShadowsocksX-NG2/App/CatalogViewModel.swift | 135 +++++++++++------- .../App/CatalogViewModelTypes.swift | 12 +- ShadowsocksX-NG2/App/DiagnosticsView.swift | 3 + ShadowsocksX-NG2/App/ErrorPresentation.swift | 8 +- ShadowsocksX-NG2/App/GroupDetailView.swift | 46 +++++- ShadowsocksX-NG2/App/MainWindowView.swift | 4 +- .../App/ProxyRuntimeController.swift | 14 ++ ShadowsocksX-NG2/App/ProxyStatusMenu.swift | 6 + ShadowsocksX-NG2/App/ServerDetailView.swift | 23 +-- ShadowsocksX-NG2/App/SidebarRow.swift | 37 ++--- ShadowsocksX-NG2/App/StatusMenuModel.swift | 2 +- ShadowsocksX-NG2/App/SubscriptionsView.swift | 4 +- .../Domain/ActivationFailure.swift | 14 +- .../Domain/ActivationStateMachine.swift | 65 +++++---- ShadowsocksX-NG2/Domain/CatalogError.swift | 4 +- .../Domain/CatalogFileStore.swift | 9 +- ShadowsocksX-NG2/Domain/CatalogModels.swift | 28 +++- .../Domain/ConfigurationCatalog.swift | 48 ++----- .../Domain/DiagnosticReport.swift | 17 ++- .../Domain/RuntimeConfiguration.swift | 17 +++ .../Domain/ServerValidation.swift | 105 ++++++++++++++ ShadowsocksX-NG2/Domain/SsUri.swift | 23 ++- .../Tests/ActivationReexpansionTests.swift | 48 ++++--- .../Tests/ActivationStateMachineTests.swift | 60 ++++---- .../Tests/CatalogFileStoreTests.swift | 24 +++- .../CatalogViewModelSubscriptionTests.swift | 25 ++-- .../Tests/CatalogViewModelTests.swift | 31 ++-- .../ConfigurationCatalogRejectionTests.swift | 10 +- .../Tests/ConfigurationCatalogTests.swift | 1 - .../Tests/DiagnosticReportTests.swift | 11 +- .../Tests/EffectiveEnablementTests.swift | 72 ---------- .../Tests/ServerValidationTests.swift | 75 ++++++++++ ShadowsocksX-NG2/Tests/SsUriTests.swift | 9 +- ...SubscriptionSnapshotApplicationTests.swift | 21 +-- docs/adr/0001-remove-node-enable-state.md | 19 +++ 38 files changed, 750 insertions(+), 403 deletions(-) create mode 100644 ShadowsocksX-NG2/Domain/ServerValidation.swift delete mode 100644 ShadowsocksX-NG2/Tests/EffectiveEnablementTests.swift create mode 100644 ShadowsocksX-NG2/Tests/ServerValidationTests.swift create mode 100644 docs/adr/0001-remove-node-enable-state.md diff --git a/CONTEXT.md b/CONTEXT.md index 98de1d90..d3bd6b7b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,9 +7,9 @@ - **Configuration group**: A named, ordered container that may contain server configurations and nested configuration groups. A node has at most one parent, and group membership forms an acyclic tree. - **Manual group**: A configuration group owned by the user. It may contain only manually owned nodes; deleting it recursively deletes its descendants, with a second confirmation when it is non-empty. - **Subscription**: A remote configuration source with a persistent opaque UUID and a fixed subscription-group identity. Its URL is a mutable endpoint/credential reference; editing the URL keeps the source identity, while deleting and creating a subscription establishes a new source identity. -- **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription node's user-visible name/remark is one remote-derived value from `remarks`, falling back to the server address when absent; it has no local alias or note override. Local enablement remains a separate eligibility state. +- **Subscription group**: The fixed, source-owned configuration subtree provided by one subscription. Users cannot move or structurally edit its nodes. A subscription node's user-visible name/remark is one remote-derived value from `remarks`, falling back to the server address when absent; it has no local alias or note override. - **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server. -- **Enabled**: A local eligibility state on a server configuration or configuration group. A node is effectively enabled only when it and every group ancestor are enabled. +- **Activation candidate**: A server configuration with no known local blocking problem for proxy configuration generation. This status does not guarantee remote reachability or correctness of opaque plugin options. - **Managed plugin**: A SIP003 plugin executable that the product packages, hash-verifies, re-signs, and distributes only inside the app bundle. The managed set changes only with app releases; users cannot supply their own plugin executables. - **Proxy mode**: The user-facing way traffic is routed through the local proxy, such as PAC, global, manual, or an external PAC configuration. - **HTTP proxy mode**: A local proxy mode that accepts HTTP/HTTPS proxy requests directly through the Shadowsocks runtime; it does not require a separate adapter process. @@ -31,21 +31,21 @@ - Each group stores an explicit child order. A node cannot be shared by multiple groups, and group edges must remain acyclic. - Manual and subscription ownership form separate subtrees; a manual group cannot adopt subscription-owned nodes, and subscription-owned nodes cannot be moved into manual groups. - New manual servers always receive a new UUID rather than being deduplicated by endpoint or serialized content; Legacy migration preserves an available legacy UUID. -- Effective enablement excludes a disabled group and its subtree or a disabled server leaf from activation. -- A server configuration persists its SIP003 plugin program reference and plugin options verbatim. Editing offers only managed plugins as selections; a reference to a program outside the managed set remains stored but makes the server an invalid activation candidate, so activation including it is rejected atomically with a named reason and no silent fallback. +- Server configurations and configuration groups have no separate node-level enable/disable state. A server configuration is an activation candidate only when no known local blocking problem prevents proxy configuration generation. +- A server configuration persists its SIP003 plugin program reference and plugin options verbatim. Editing offers only managed plugins as selections; a non-empty but unsupported encryption method or a reference to a program outside the managed set remains stored but makes the server an invalid activation candidate. Opaque plugin options are retained without being declared valid or invalid by the app. - Plugin programs are distributed only as managed plugins; their availability changes only with app releases. If an active target's plugin becomes unavailable, the target is cleared and proxying stops without fallback. -- Activating a server selects that one server. Activating a group recursively expands effectively enabled server leaves in explicit child order and passes them to the external proxy service; an empty result or an invalid enabled leaf rejects activation atomically. -- The active target remains the selected node ID and is not silently replaced by a descendant chosen by `sslocal`. If it is deleted, disabled, empty, or invalid, the target is cleared and proxying stops rather than falling back silently. -- After a committed edit to the active target's subtree, the target is re-expanded immediately: a valid non-empty result updates the runtime atomically; an empty or invalid result clears the target and stops proxying without fallback. +- Activating a server selects that one server. Activating a group recursively expands activation-candidate server leaves in explicit child order and passes them to the external proxy service; invalid leaves are omitted and surfaced, while a group with no activation candidate rejects activation atomically. +- The active target remains the selected node ID and is not silently replaced by a descendant chosen by `sslocal`. If it is deleted, has no activation candidate, or is otherwise invalid, the target is cleared and proxying stops rather than falling back silently. +- After a committed edit to the active target's subtree, the target is re-expanded immediately: a non-empty set of activation candidates updates the runtime atomically; no activation candidate clears the target and stops proxying without fallback. - Manual nodes may move between the catalog root and manual groups, carrying their subtree and child order without changing identity or ownership; cross-source moves, shared parents, and cycles are rejected. - Empty groups may persist and remain editable, but cannot be activated. Deleting a non-empty manual group recursively deletes its descendants and requires a second confirmation. -- A subscription owns its subscription group and its refreshed members; manual groups remain user-owned. Remote connection fields, membership, structure, order, and `remarks` are authoritative; only local enablement may be retained as a per-node overlay for a matched subscription identity. +- A subscription owns its subscription group and its refreshed members; manual groups remain user-owned. Remote connection fields, membership, structure, order, and `remarks` are authoritative; subscription nodes have no local eligibility overlay. - Subscription server and nested-group identities use provider-supplied stable IDs scoped to that subscription. When a remote record has no stable ID, continuity is allowed only for an exactly matching canonical record; endpoint, name, or path heuristics must not merge identities. -- Subscription-owned nodes and nested groups permit no local connection-field, name/remark, membership, parent, or order override. A matched remote node or group may retain only its local `enabled` state; new records default to enabled, and removed records lose their overlays rather than leaving tombstones. -- Each subscription refresh is a complete, validated snapshot committed atomically. A valid empty snapshot replaces the remote subtree with an empty group; transport, HTTP/authentication, decoding, schema, duplicate-ID, or record-validation failures leave the last successful snapshot, local enablement, active target, and runtime state unchanged while marking the source stale. An initial failed refresh leaves an empty group with an error state. +- Subscription-owned nodes and nested groups permit no local connection-field, name/remark, membership, parent, order, or eligibility override. +- Each subscription refresh is a complete, structurally valid snapshot committed atomically. A valid empty snapshot replaces the remote subtree with an empty group; transport, HTTP/authentication, decoding, schema, duplicate-ID, or required-field validation failures leave the last successful snapshot, active target, and runtime state unchanged while marking the source stale. A structurally complete record with an unsupported method or plugin remains in the committed snapshot as an invalid activation candidate. An initial failed refresh leaves an empty group with an error state. - The private `x_shadowsocksx_ng` extension is optional decoration on the standard SIP-008 server list. When it is absent, uses an unsupported schema version, or fails validation — unresolvable child references, duplicate or ID-colliding groups, shared parents, or cycles — the refresh still succeeds: the standard flat server list becomes the direct children of the subscription group and only the extension is discarded. -- Editing a subscription URL keeps the subscription, fixed group, and remote identity namespace; the last successful snapshot remains available until the new URL succeeds. To isolate a different source, delete the old subscription and create a new one. Deleting a subscription removes its source, fixed group, remote members, and local overlays after explicit confirmation; an active target is cleared and proxying stops without fallback. -- A Legacy import reads a current snapshot and commits atomically; skipping leaves an import entry available, success prevents automatic repetition, and an explicitly requested re-import creates a separate manual group. +- Editing a subscription URL keeps the subscription, fixed group, and remote identity namespace; the last successful snapshot remains available until the new URL succeeds. To isolate a different source, delete the old subscription and create a new one. Deleting a subscription removes its source, fixed group, and remote members after explicit confirmation; an active target is cleared and proxying stops without fallback. +- A Legacy import reads a current snapshot and commits atomically; structurally invalid records are omitted with a report, while unsupported methods or plugins are preserved as invalid activation candidates. Skipping leaves an import entry available, success prevents automatic repetition, and an explicitly requested re-import creates a separate manual group. - A unique, syntactically valid Legacy server UUID is preserved. An otherwise valid server with a missing, malformed, or duplicate UUID receives a fresh UUID; invalid records are omitted with a user-visible report, and an active target is kept only when it maps unambiguously. - Imported mode and target preferences never start proxying or write system proxy settings. Legacy passwords, plugin options, and user-provided remote URLs move to credential references; generated configurations, caches, diagnostics, logs, and binaries do not migrate. The imported plugin program reference is preserved verbatim even when 2.0 does not provide that plugin. - A Legacy handoff is an explicit second phase, not an import side effect. It may stop only positively identified Legacy runtime jobs after confirmation and must not broadly kill processes or automatically delete Legacy data. diff --git a/ShadowsocksX-NG2/App/ActivationFailure+Presentation.swift b/ShadowsocksX-NG2/App/ActivationFailure+Presentation.swift index 0fcda073..85e02230 100644 --- a/ShadowsocksX-NG2/App/ActivationFailure+Presentation.swift +++ b/ShadowsocksX-NG2/App/ActivationFailure+Presentation.swift @@ -5,10 +5,8 @@ extension ActivationFailure { switch self { case .targetNotFound(let target): return "活动目标已被删除(\(shortNodeID(target)))" - case .targetDisabled(let node): - return "活动目标自身或其分组已被停用(\(shortNodeID(node)))" case .targetExpandsToNothing(let target): - return "活动目标没有可代理的启用服务器(\(shortNodeID(target)))" + return "活动目标没有可激活的有效服务器(\(shortNodeID(target)))" case .invalidLeaf(let node, let reason): return "服务器不可用:\(reason.presentedReason)(\(shortNodeID(node)))" } @@ -18,6 +16,14 @@ extension ActivationFailure { extension LeafInvalidationReason { var presentedReason: String { switch self { + case .invalidAddress: + return "服务器地址为空" + case .invalidPort(let port): + return "端口无效(\(port))" + case .missingEncryptionMethod: + return "未指定加密方法" + case .unsupportedEncryptionMethod(let method): + return "当前 sslocal 不支持加密方法「\(method)」" case .pluginNotProvided(let program): return "本版本未提供插件「\(program)」" case .credentialUnresolved: diff --git a/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift b/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift index 66d1dd89..785ba972 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModel+Subscriptions.swift @@ -2,8 +2,8 @@ import Foundation /// 订阅生命周期(spec #21 D4,issue #35):粘贴 HTTPS URL 创建、单个/全部 /// 立即更新、编辑 URL(保留身份)、删除(递归清除)。刷新 = 完整获取、解析、 -/// 校验后同文档原子提交快照与刷新状态;任何失败保留最后成功快照、本地 -/// enabled、活动目标与运行状态,仅标记来源失败。每次提交都触发 postCommit +/// 校验后同文档原子提交快照与刷新状态;任何失败保留最后成功快照、活动目标与 +/// 运行状态,仅标记来源失败。每次提交都触发 postCommit /// 重展开(订阅子树变更 → 活动目标原子跟随或清除停止)。 extension CatalogViewModel { /// 创建订阅:校验 HTTPS URL → 落订阅记录与空固定分组 → 立即首次刷新。 @@ -66,7 +66,7 @@ extension CatalogViewModel { await refreshSubscription(record.id) } - /// 删除订阅:递归清除订阅源、固定分组、远端成员与本地 overlay;活动目标 + /// 删除订阅:递归清除订阅源、固定分组与远端成员;活动目标 /// 的清除与代理停止由 postCommit 的既有激活语义处理(无静默回退)。 func removeSubscription(_ id: NodeID) async throws { guard let record = subscriptions.first(where: { $0.id == id }) else { @@ -146,24 +146,31 @@ extension CatalogViewModel { _ snapshot: SubscriptionSnapshot, record: SubscriptionRecord, fallbackName: String ) async throws { var removedServers: [CatalogEntry] = [] - try await commitDocument { catalog, subscriptions in - // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用。 - var reusedRefs: [NodeID: ServerCredentialRefs] = [:] - for entry in (try? catalog.subscriptionSubtree(of: record.groupID)) ?? [] { - if case .server(let fields) = entry.kind { - reusedRefs[entry.id] = ServerCredentialRefs( - password: fields.passwordRef, options: fields.pluginOptionsRef) + var credentialJournal = CredentialWriteJournal(credentials: credentials) + do { + try await commitDocument { catalog, subscriptions in + // 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用; + // journal 保证快照提交失败时恢复旧秘密。 + var reusedRefs: [NodeID: ServerCredentialRefs] = [:] + for entry in (try? catalog.subscriptionSubtree(of: record.groupID)) ?? [] { + if case .server(let fields) = entry.kind { + reusedRefs[entry.id] = ServerCredentialRefs( + password: fields.passwordRef, options: fields.pluginOptionsRef) + } + } + let resolved = try Self.resolveCredentials( + snapshot.root, catalogGroupID: record.groupID, reuse: reusedRefs, + journal: &credentialJournal) + let name = snapshot.root.name.isEmpty ? fallbackName : snapshot.root.name + let document = CatalogSubscriptionSnapshot(name: name, root: resolved) + removedServers = try catalog.applySubscriptionSnapshot(document, into: record.groupID) + if let index = subscriptions.firstIndex(where: { $0.id == record.id }) { + subscriptions[index].status = .succeeded(at: Date()) } } - let resolved = try Self.resolveCredentials( - snapshot.root, catalogGroupID: record.groupID, reuse: reusedRefs, - credentials: credentials) - let name = snapshot.root.name.isEmpty ? fallbackName : snapshot.root.name - let document = CatalogSubscriptionSnapshot(name: name, root: resolved) - removedServers = try catalog.applySubscriptionSnapshot(document, into: record.groupID) - if let index = subscriptions.firstIndex(where: { $0.id == record.id }) { - subscriptions[index].status = .succeeded(at: Date()) - } + } catch { + credentialJournal.rollback() + throw error } // 被移除节点的凭据引用不会被新树复用(身份已不在),提交成功后清理。 for entry in removedServers { @@ -196,7 +203,7 @@ extension CatalogViewModel { _ group: SubscriptionSnapshot.Group, catalogGroupID: NodeID, reuse: [NodeID: ServerCredentialRefs], - credentials: CredentialStoring + journal: inout CredentialWriteJournal ) throws -> CatalogSubscriptionSnapshot.Group { var children: [CatalogSubscriptionSnapshot.Child] = [] for child in group.children { @@ -204,11 +211,11 @@ extension CatalogViewModel { case .server(let leaf): let existing = reuse[leaf.id] let passwordRef = existing?.password ?? .fresh() - try credentials.save(leaf.record.password, for: passwordRef) + try journal.save(leaf.record.password, for: passwordRef) var optionsRef: CredentialReference? - if let options = leaf.record.pluginOptions { + if let options = leaf.record.pluginOptions, !options.isEmpty { let ref = existing?.options ?? .fresh() - try credentials.save(options, for: ref) + try journal.save(options, for: ref) optionsRef = ref } children.append( @@ -227,7 +234,7 @@ extension CatalogViewModel { children.append( .group( try resolveCredentials( - nested, catalogGroupID: nested.id, reuse: reuse, credentials: credentials))) + nested, catalogGroupID: nested.id, reuse: reuse, journal: &journal))) } } return CatalogSubscriptionSnapshot.Group( @@ -253,6 +260,38 @@ struct ServerCredentialRefs: Sendable { var options: CredentialReference? } +/// 订阅刷新期间的凭据写入日志。目录文件只有在全部写入成功后才替换;如果 +/// 任何凭据或目录写入失败,则恢复刷新前的每个引用,避免旧快照的秘密被覆盖。 +private struct CredentialWriteJournal { + private struct OriginalValue { + let secret: String? + } + + let credentials: CredentialStoring + private var originals: [CredentialReference: OriginalValue] = [:] + + init(credentials: CredentialStoring) { + self.credentials = credentials + } + + mutating func save(_ secret: String, for reference: CredentialReference) throws { + if originals[reference] == nil { + originals[reference] = OriginalValue(secret: try credentials.secret(for: reference)) + } + try credentials.save(secret, for: reference) + } + + func rollback() { + for (reference, original) in originals { + if let secret = original.secret { + try? credentials.save(secret, for: reference) + } else { + try? credentials.delete(reference) + } + } + } +} + /// 订阅表单级失败(URL 门禁、订阅不存在);获取/解析失败仍以原错误上抛。 enum SubscriptionFormError: Error, Equatable { case invalidURL diff --git a/ShadowsocksX-NG2/App/CatalogViewModel.swift b/ShadowsocksX-NG2/App/CatalogViewModel.swift index 783861b7..538a1404 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModel.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModel.swift @@ -111,40 +111,11 @@ final class CatalogViewModel: ObservableObject { func parentID(of id: NodeID) -> NodeID? { (try? catalog.parentID(of: id)) ?? nil } - func isEffectivelyEnabled(_ id: NodeID) -> Bool { - (try? catalog.isEffectivelyEnabled(id)) ?? false - } - /// 行显示名:委托目录条目的共用口径(`CatalogEntry.displayName`)。 func displayName(for id: NodeID) -> String { catalog.entry(for: id)?.displayName ?? "" } - // MARK: - 侧栏树快照 - - func sidebarNodes() -> [SidebarNode] { - sidebarChildren(of: nil) - } - - private func sidebarChildren(of parent: NodeID?) -> [SidebarNode] { - let ids = (try? catalog.children(of: parent)) ?? [] - return ids.compactMap { id in - guard let entry = catalog.entry(for: id) else { return nil } - let isGroup: Bool = { - if case .group = entry.kind { return true } - return false - }() - return SidebarNode( - id: id, - name: displayName(for: id), - isGroup: isGroup, - source: entry.source, - enabled: entry.enabled, - effectivelyEnabled: isEffectivelyEnabled(id), - children: isGroup ? sidebarChildren(of: id) : nil) - } - } - // MARK: - 变更面(CatalogError / ServerFormError / CredentialStoreError 上抛) /// ss:// 批量导入(剪贴板、导入 URL、二维码识别三入口的共同落点)。 @@ -153,24 +124,34 @@ final class CatalogViewModel: ObservableObject { func addServers(fromURIs text: String, into parent: NodeID?) async throws -> ( added: Int, failures: [String] ) { - var parsed: [SsUri] = [] + var prepared: [(uri: SsUri, fields: ServerFields)] = [] var failures: [String] = [] for line in text.split(whereSeparator: \.isNewline) { do { - parsed.append(try SsUri.decode(String(line))) + let uri = try SsUri.decode(String(line)) + let fields = try Self.serverFields(from: uri, credentials: credentials) + prepared.append((uri: uri, fields: fields)) } catch { let digest = line.count > 24 ? "\(line.prefix(24))…" : line - let reason = (error as? SsUriError).map { String(describing: $0) } ?? "未知错误" + let reason = + (error as? SsUriError).map { String(describing: $0) } ?? error.presentableMessage failures.append("「\(digest)」:\(reason)") } } - try await commit { catalog in - for uri in parsed { - let fields = try Self.serverFields(from: uri, credentials: credentials) - try catalog.addServer(fields, to: parent) + guard !prepared.isEmpty else { return (0, failures) } + do { + try await commit { catalog in + for item in prepared { + try catalog.addServer(item.fields, to: parent) + } } + } catch { + for item in prepared { + Self.deleteCredentialRefs(for: item.fields, credentials: credentials) + } + throw error } - return (parsed.count, failures) + return (prepared.count, failures) } @discardableResult @@ -190,12 +171,6 @@ final class CatalogViewModel: ObservableObject { } } - func setEnabled(_ id: NodeID, _ enabled: Bool) async throws { - try await commit { catalog in - try catalog.setEnabled(id, enabled) - } - } - /// 拖拽/移动菜单落点。跨来源、成环、移动进自身由领域拒绝,原样上抛呈现。 func move(_ id: NodeID, to parent: NodeID?) async throws { try await commit { catalog in @@ -234,6 +209,12 @@ final class CatalogViewModel: ObservableObject { let trimmedAddress = address.trimmingCharacters(in: .whitespaces) guard !trimmedAddress.isEmpty else { throw ServerFormError.invalidAddress } guard (1...65_535).contains(port) else { throw ServerFormError.invalidPort } + let trimmedMethod = encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMethod.isEmpty else { throw ServerFormError.missingEncryptionMethod } + guard EncryptionMethodCatalog.isSupported(trimmedMethod) else { + throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod) + } + guard !password.isEmpty else { throw ServerFormError.invalidPassword } try await commit { catalog in guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else { throw CatalogError.notAServer(id) @@ -241,7 +222,7 @@ final class CatalogViewModel: ObservableObject { try credentials.save(password, for: fields.passwordRef) fields.address = trimmedAddress fields.port = port - fields.encryptionMethod = encryptionMethod + fields.encryptionMethod = trimmedMethod fields.remark = remark.trimmingCharacters(in: .whitespaces) try Self.applyPluginSelection( plugin, options: pluginOptions, to: &fields, credentials: credentials) @@ -361,16 +342,18 @@ final class CatalogViewModel: ObservableObject { from uri: SsUri, credentials: CredentialStoring ) throws -> ServerFields { let passwordRef = CredentialReference.fresh() - try credentials.save(uri.password, for: passwordRef) - var pluginProgram: String? var pluginOptionsRef: CredentialReference? - if let program = uri.pluginProgram { - pluginProgram = program - if let options = uri.pluginOptions { + do { + try credentials.save(uri.password, for: passwordRef) + if uri.pluginProgram != nil, let options = uri.pluginOptions { let ref = CredentialReference.fresh() try credentials.save(options, for: ref) pluginOptionsRef = ref } + } catch { + try? credentials.delete(passwordRef) + if let pluginOptionsRef { try? credentials.delete(pluginOptionsRef) } + throw error } return ServerFields( address: uri.host, @@ -378,10 +361,17 @@ final class CatalogViewModel: ObservableObject { encryptionMethod: uri.method, passwordRef: passwordRef, remark: uri.remark ?? "", - pluginProgram: pluginProgram, + pluginProgram: uri.pluginProgram, pluginOptionsRef: pluginOptionsRef) } + private static func deleteCredentialRefs( + for fields: ServerFields, credentials: CredentialStoring + ) { + try? credentials.delete(fields.passwordRef) + if let optionsRef = fields.pluginOptionsRef { try? credentials.delete(optionsRef) } + } + /// 插件选择落盘(issue #38,D10/CONTEXT.md 不变量):「无」整体清除引用与 /// 参数秘密;受管程序写引用、参数按空/非空写删钥匙串;集外引用原样保留 /// (不因打开或保存表单而漂移,激活语义由状态机点名拒绝)。 @@ -417,6 +407,9 @@ final class CatalogViewModel: ObservableObject { fields.pluginOptionsRef = reference } case .unknown: + // Imported manual records may contain an unsupported plugin. Keep the opaque + // reference unchanged while the user repairs another field or selects a + // supported plugin explicitly. break } } @@ -427,10 +420,52 @@ final class CatalogViewModel: ObservableObject { } } +extension CatalogViewModel { + func validation(for id: NodeID) -> ServerValidation? { + guard let entry = catalog.entry(for: id), case .server(let fields) = entry.kind else { + return nil + } + return ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins) + } + + // MARK: - 侧栏树快照 + + func sidebarNodes() -> [SidebarNode] { + sidebarChildren(of: nil) + } + + private func sidebarChildren(of parent: NodeID?) -> [SidebarNode] { + let ids = (try? catalog.children(of: parent)) ?? [] + return ids.compactMap { id in + guard let entry = catalog.entry(for: id) else { return nil } + let isGroup: Bool = { + if case .group = entry.kind { return true } + return false + }() + let children = isGroup ? sidebarChildren(of: id) : nil + let validation: ServerValidation? = { + guard case .server(let fields) = entry.kind else { return nil } + return ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins) + }() + return SidebarNode( + id: id, + name: displayName(for: id), + isGroup: isGroup, + source: entry.source, + validation: validation, + invalidDescendantCount: children?.reduce(0) { $0 + $1.invalidServerCount } ?? 0, + children: children) + } + } +} + /// 表单级校验失败(地址/端口/名称);领域拒绝仍以 CatalogError 上抛。 enum ServerFormError: Error, Equatable { case invalidAddress case invalidPort + case missingEncryptionMethod + case unsupportedEncryptionMethod(String) + case invalidPassword case emptyName /// 提交了受管集之外的插件选择(表单只能产生受管集内的选择,此为程序错误防线的显式拒绝)。 case pluginNotManaged(String) diff --git a/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift b/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift index bbd68c1a..88a3a146 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift @@ -6,10 +6,18 @@ struct SidebarNode: Identifiable { let name: String let isGroup: Bool let source: NodeSource - let enabled: Bool - let effectivelyEnabled: Bool + /// 服务器叶子的 app 可知校验结果;分组为 nil。 + let validation: ServerValidation? + /// 子树中已知无效的服务器数量,不包含当前叶子。 + let invalidDescendantCount: Int /// 服务器叶子为 `nil`;分组持有子树快照。 let children: [SidebarNode]? + + var isInvalid: Bool { validation?.isValid == false } + + var invalidServerCount: Int { + (isInvalid ? 1 : 0) + invalidDescendantCount + } } /// 服务器详情表单状态:凭据已解析为明文(仅本窗口内呈现);插件区为受管 diff --git a/ShadowsocksX-NG2/App/DiagnosticsView.swift b/ShadowsocksX-NG2/App/DiagnosticsView.swift index 475b4228..ee183f21 100644 --- a/ShadowsocksX-NG2/App/DiagnosticsView.swift +++ b/ShadowsocksX-NG2/App/DiagnosticsView.swift @@ -173,6 +173,9 @@ struct DiagnosticsView: View { snapshot.listen = proxyController.listenSettings snapshot.runtimeDocumentSummary = proxyController.runtimeDocumentSummary() snapshot.catalog = viewModel.catalog + snapshot.knownInvalidServerCount = viewModel.sidebarNodes().reduce(0) { + $0 + $1.invalidServerCount + } snapshot.fileFacts = Self.fileFacts() snapshot.managedPlugins = Self.managedPluginFacts() snapshot.eventLines = eventStore.snapshot.suffix(200).map(\.renderedLine) diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index 14238c3c..76677859 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -13,6 +13,12 @@ extension Error { return "服务器地址不能为空" case .invalidPort: return "端口必须是 1–65535 之间的整数" + case .missingEncryptionMethod: + return "必须选择加密方法" + case .unsupportedEncryptionMethod(let method): + return "当前 sslocal 不支持加密方法「\(method)」" + case .invalidPassword: + return "密码不能为空" case .emptyName: return "名称不能为空" case .pluginNotManaged(let program): @@ -69,7 +75,7 @@ extension CatalogError { ? "不能把手动节点移入订阅子树" : "不能把订阅节点移入手动子树" case .subscriptionNodeImmutable: - return "订阅节点由远端管理:结构、连接字段与名称只读,仅启用开关可调" + return "订阅节点由远端管理:结构、连接字段与名称只读" case .subscriptionServerAtRoot: return "订阅服务器不能移出其订阅分组" case .cycleDetected: diff --git a/ShadowsocksX-NG2/App/GroupDetailView.swift b/ShadowsocksX-NG2/App/GroupDetailView.swift index 13e05f95..dfaea918 100644 --- a/ShadowsocksX-NG2/App/GroupDetailView.swift +++ b/ShadowsocksX-NG2/App/GroupDetailView.swift @@ -23,17 +23,28 @@ struct GroupDetailView: View { } Section { LabeledContent("直接子节点", value: "\(directChildCount)") + if invalidServerCount > 0 { + LabeledContent("已知无效服务器", value: "\(invalidServerCount)") + } LabeledContent("来源", value: isManual ? "手动" : "订阅") Button { Task { await proxyController.activate(groupID) } } label: { Label("激活此分组", systemImage: "bolt.fill") } - .disabled(directChildCount == 0) + .disabled(validServerCount == 0) if directChildCount == 0 { Text("空分组保留可编辑,但不能激活。") .font(.footnote) .foregroundStyle(.secondary) + } else if validServerCount == 0 { + Text("分组中没有可激活的有效服务器。") + .font(.footnote) + .foregroundStyle(.secondary) + } else if invalidServerCount > 0 { + Text("激活时将跳过 \(invalidServerCount) 个存在已知阻塞问题的服务器。") + .font(.footnote) + .foregroundStyle(.secondary) } } } @@ -47,6 +58,39 @@ struct GroupDetailView: View { (try? viewModel.catalog.children(of: groupID))?.count ?? 0 } + private var invalidServerCount: Int { + groupNode?.invalidServerCount ?? 0 + } + + private var validServerCount: Int { + serverCount - invalidServerCount + } + + private var serverCount: Int { + guard let node = groupNode else { + return 0 + } + return countServers(in: node) + } + + private var groupNode: SidebarNode? { + func find(_ nodes: [SidebarNode]) -> SidebarNode? { + for node in nodes { + if node.id == groupID { return node } + if let match = find(node.children ?? []) { return match } + } + return nil + } + return find(viewModel.sidebarNodes()) + } + + private func countServers(in node: SidebarNode) -> Int { + if node.isGroup { + return (node.children ?? []).reduce(0) { $0 + countServers(in: $1) } + } + return 1 + } + private func loadName() { name = viewModel.displayName(for: groupID) } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 9923571e..4ca59f18 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -2,7 +2,7 @@ import SwiftUI /// 主窗口(spec #21 D11,issue #32/#34/#35):NavigationSplitView 分区。侧栏 /// 顶部分区切换「服务器 / 订阅 / 诊断」;服务器分区是配置目录分组树(订阅子 -/// 树只读、仅启用开关)与详情编辑;订阅分区是订阅卡片与刷新/编辑/删除;诊断 +/// 树只读)与详情编辑;订阅分区是订阅卡片与刷新/编辑/删除;诊断 /// 分区是日志查看与脱敏导出(D11「诊断收进主窗口」)。编辑类操作只在主窗口 /// (菜单栏仅保留 D11 白名单内的「立即更新全部订阅」快速动作)。 struct MainWindowView: View { @@ -157,7 +157,7 @@ struct MainWindowView: View { VStack(alignment: .leading, spacing: 8) { Text("订阅") .font(.headline) - Text("每个订阅是一张卡片:远端 SIP-008 文档经解析校验后原子提交;刷新失败保留最后一次成功内容。远端名称、结构与成员权威,本地仅保留启用开关。") + Text("每个订阅是一张卡片:远端 SIP-008 文档经解析校验后原子提交;刷新失败保留最后一次成功内容。远端名称、结构与成员权威;不支持的服务器配置会保留并在激活时跳过。") .font(.callout) .foregroundStyle(.secondary) Spacer() diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index d6a74dc4..cdde5f39 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -43,6 +43,8 @@ final class ProxyRuntimeController: ObservableObject { /// 非发布值的普通结构体,代理关闭路径的激活动作不会触碰 state,菜单的 /// 「目标」行依赖这里的独立发布保持实时。 @Published private(set) var activeTargetID: NodeID? + /// 最近一次激活预检在分组中跳过的服务器;仅记录 app 已知的本地阻塞原因。 + @Published private(set) var skippedServers: [SkippedServer] = [] private(set) var machine: ActivationStateMachine private var catalog: ConfigurationCatalog @@ -163,6 +165,7 @@ final class ProxyRuntimeController: ObservableObject { timeout: settings.timeoutSeconds, verbose: settings.verboseLogging, pacUserRules: settings.pacUserRules) activeTargetID = target + skippedServers = configuration.skippedServers do { try activationFileStore.save(activeTargetID: target) } catch { @@ -324,6 +327,7 @@ final class ProxyRuntimeController: ObservableObject { _ = await execute(.stop, document: nil) pacURL = nil lastDocument = nil + skippedServers = [] let detail = "本地代理端口配置无法读取(\(reason)),已停止代理以避免静默改用出厂端口;请在设置区修复端口后重新启动" if let restoreError { @@ -345,6 +349,7 @@ final class ProxyRuntimeController: ObservableObject { _ = await execute(.stop, document: nil) pacURL = nil lastDocument = nil + skippedServers = [] if let restoreError { state = .systemProxyFailed( detail: "\(failure.presentedReason);\(systemProxyDetail(restoreError))") @@ -480,6 +485,7 @@ extension ProxyRuntimeController { state = .off pacURL = nil lastDocument = nil + skippedServers = [] if let restored = try? settingsStore.load() { settings = restored @@ -681,6 +687,14 @@ extension ProxyRuntimeController { timeout: settings.timeoutSeconds, verbose: settings.verboseLogging, pacUserRules: settings.pacUserRules) activeTargetID = machine.activeTargetID + switch effect { + case .deployed(let configuration): + skippedServers = configuration.skippedServers + case .clearedAndStopped: + skippedServers = [] + case nil: + break + } return effect } diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index 41296a6b..7f80b7cd 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -26,6 +26,12 @@ struct ProxyStatusMenu: View { .font(.caption) .foregroundStyle(.secondary) } + if !controller.skippedServers.isEmpty { + Text("已跳过 \(controller.skippedServers.count) 个无效服务器") + .font(.caption) + .foregroundStyle(.orange) + .help("激活时跳过了存在已知本地阻塞问题的服务器") + } Divider() diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift index 752f5c3b..15e800e4 100644 --- a/ShadowsocksX-NG2/App/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -3,7 +3,7 @@ import SwiftUI /// 服务器详情表单(issue #32/#38):连接字段与插件选择可编辑(仅手动节点)。 /// 插件区为受管选择器(D10)——「无」+ 受管列表,选中受管项才显示参数输入; /// 集外引用以显式「本版本未提供」呈现并原样保留;分享区(二维码 + 复制 ss://)。 -/// 订阅服务器整表只读,仅树中启用开关可调。 +/// 订阅服务器整表只读。 struct ServerDetailView: View { let viewModel: CatalogViewModel let serverID: NodeID @@ -28,13 +28,9 @@ struct ServerDetailView: View { formState?.isEditable ?? false } - /// 常用加密方法(sslocal v1.25 支持);目录里既有的非常见方法原样追加显示。 + /// 当前 sslocal 能力目录;目录中既有的未知方法原样追加显示,便于用户修复。 private var methodChoices: [String] { - var choices = [ - "aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", - "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", - "2022-blake3-chacha20-poly1305", "none", - ] + var choices = EncryptionMethodCatalog.supported.sorted() if !encryptionMethod.isEmpty && !choices.contains(encryptionMethod) { choices.append(encryptionMethod) } @@ -72,6 +68,15 @@ struct ServerDetailView: View { .disabled(!isEditable) } + if let validation = viewModel.validation(for: serverID), !validation.isValid { + Section("激活状态") { + ForEach(Array(validation.issues.enumerated()), id: \.offset) { _, issue in + Label(issue.presentedReason, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + } + Section("插件") { pluginSection } @@ -82,7 +87,7 @@ struct ServerDetailView: View { } } else { Section { - Label("订阅节点由远端管理:连接字段只读,仅树中的启用开关可调。", systemImage: "info.circle") + Label("订阅节点由远端管理:连接字段只读。", systemImage: "info.circle") .font(.footnote) .foregroundStyle(.secondary) } @@ -166,7 +171,7 @@ struct ServerDetailView: View { } } - /// 集外引用(Legacy 导入/订阅带入):显式「本版本未提供」状态,原样保留。 + /// 集外引用(Legacy 导入/订阅带入):原样保留并明确指出当前 app 无法提供该插件。 @ViewBuilder private func unknownPluginNotice(program: String, plugin: PluginSectionState) -> some View { Label( diff --git a/ShadowsocksX-NG2/App/SidebarRow.swift b/ShadowsocksX-NG2/App/SidebarRow.swift index dcb8ccff..f9d0ac2a 100644 --- a/ShadowsocksX-NG2/App/SidebarRow.swift +++ b/ShadowsocksX-NG2/App/SidebarRow.swift @@ -1,7 +1,7 @@ import SwiftUI -/// 侧栏树行:行内启用 checkbox、来源标识、活动目标标记与右键菜单。 -/// 订阅行只暴露激活/启停(结构操作整体缺席,领域层兜底拒绝)。 +/// 侧栏树行:来源标识、有效性提示、活动目标标记与右键菜单。 +/// 服务器/分组不再拥有独立的启用/停用状态;激活时由状态机按有效性展开。 struct SidebarRow: View { let node: SidebarNode let viewModel: CatalogViewModel @@ -15,17 +15,23 @@ struct SidebarRow: View { var body: some View { HStack(spacing: 6) { - Toggle("", isOn: enableBinding) - .labelsHidden() - .toggleStyle(.checkbox) Image(systemName: node.isGroup ? "folder" : "server.rack") .foregroundStyle(.secondary) Text(node.name) - .foregroundStyle(node.effectivelyEnabled ? .primary : .secondary) + .foregroundStyle(node.isInvalid ? .secondary : .primary) + if node.isInvalid { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .help(node.validation?.issues.first?.presentedReason ?? "服务器存在已知阻塞问题") + } else if node.invalidDescendantCount > 0 { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + .help("包含 \(node.invalidDescendantCount) 个存在已知阻塞问题的服务器") + } if isSubscription { Image(systemName: "arrow.triangle.2.circlepath") .foregroundStyle(.tertiary) - .help("订阅节点:由远端管理,仅启用开关可调") + .help("订阅节点:由远端管理") } if proxyController.machine.activeTargetID == node.id { Image(systemName: "bolt.fill") @@ -36,20 +42,6 @@ struct SidebarRow: View { .contextMenu { contextMenu } } - private var enableBinding: Binding { - Binding( - get: { viewModel.entry(for: node.id)?.enabled ?? false }, - set: { newValue in - Task { - do { - try await viewModel.setEnabled(node.id, newValue) - } catch { - viewModel.presentedError = error.presentableMessage - } - } - }) - } - /// 空手动组直接删除;服务器与非空手动组走确认弹窗(issue #32)。 private var isEmptyManualGroup: Bool { node.isGroup && node.source == .manual && (node.children?.isEmpty ?? true) @@ -60,9 +52,6 @@ struct SidebarRow: View { Button("激活") { Task { await proxyController.activate(node.id) } } - Button(node.enabled ? "停用" : "启用") { - enableBinding.wrappedValue.toggle() - } if !isSubscription { Divider() if node.isGroup { diff --git a/ShadowsocksX-NG2/App/StatusMenuModel.swift b/ShadowsocksX-NG2/App/StatusMenuModel.swift index 7199cb6a..90934be7 100644 --- a/ShadowsocksX-NG2/App/StatusMenuModel.swift +++ b/ShadowsocksX-NG2/App/StatusMenuModel.swift @@ -101,7 +101,7 @@ enum StatusMenuModel { } } - /// 活动目标级联子菜单的只读树快照:结构与配置树一致(含停用节点),活动 + /// 活动目标级联子菜单的只读树快照:结构与配置树一致(含全部节点),活动 /// 目标以勾选呈现。分组节点持有子树,服务器叶子无子。`id` 供 SwiftUI /// ForEach 稳定标识(导入不去重,同名兄弟节点合法)。 struct TargetNode: Equatable { diff --git a/ShadowsocksX-NG2/App/SubscriptionsView.swift b/ShadowsocksX-NG2/App/SubscriptionsView.swift index 886ad765..fc95830f 100644 --- a/ShadowsocksX-NG2/App/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/SubscriptionsView.swift @@ -49,7 +49,7 @@ struct SubscriptionsView: View { Button("取消", role: .cancel) { deleteTarget = nil } } message: { Text( - "将移除订阅源、固定分组、全部远端成员与本地启用状态;若代理正走此订阅,代理会停止。此操作不可撤销。" + "将移除订阅源、固定分组与全部远端成员;若代理正走此订阅,代理会停止。此操作不可撤销。" ) } } @@ -225,7 +225,7 @@ private struct EditSubscriptionURLSheet: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("编辑订阅地址。订阅身份、固定分组与本地启用状态全部保留;新地址刷新成功前保留最后一次成功内容。") + Text("编辑订阅地址。订阅身份与固定分组全部保留;新地址刷新成功前保留最后一次成功内容。") .font(.callout) .foregroundStyle(.secondary) TextField("https://example.com/subscription.json", text: $urlString) diff --git a/ShadowsocksX-NG2/Domain/ActivationFailure.swift b/ShadowsocksX-NG2/Domain/ActivationFailure.swift index 88afd972..26c17292 100644 --- a/ShadowsocksX-NG2/Domain/ActivationFailure.swift +++ b/ShadowsocksX-NG2/Domain/ActivationFailure.swift @@ -3,16 +3,22 @@ enum ActivationFailure: Error, Equatable, Sendable { /// 活动目标已被删除(或从未存在)。 case targetNotFound(NodeID) - /// 目标自身或某祖先被禁用;携带目标→根路径上第一个禁用节点。 - case targetDisabled(NodeID) - /// 组目标展开后没有有效启用的服务器叶子(空组或全部子叶停用)。 + /// 组目标展开后没有可激活的服务器叶子(空组或全部候选无效)。 case targetExpandsToNothing(NodeID) /// 展开结果包含无效叶子;携带叶节点与具体原因,激活整体原子拒绝。 case invalidLeaf(node: NodeID, reason: LeafInvalidationReason) } /// 服务器叶子无效的具体原因(点名到可行动的事实)。 -enum LeafInvalidationReason: Equatable, Sendable { +enum LeafInvalidationReason: Error, Equatable, Sendable { + /// 地址为空或只有空白。 + case invalidAddress + /// 端口不在 1–65535 范围内。 + case invalidPort(Int) + /// 加密方式为空。 + case missingEncryptionMethod + /// 加密方式非空但不在当前 sslocal 能力目录中。 + case unsupportedEncryptionMethod(String) /// 引用本版本未提供的插件程序(真实受管集由 #38 从供应链清单接通)。 case pluginNotProvided(program: String) /// 凭据引用在 Keychain 无对应条目。 diff --git a/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift b/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift index a3361881..eba43b60 100644 --- a/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift +++ b/ShadowsocksX-NG2/Domain/ActivationStateMachine.swift @@ -45,7 +45,7 @@ struct ActivationStateMachine: Equatable, Sendable { } /// 目录已提交变更后的立即重展开。返回 `nil` 表示无事可做(无活动目标); - /// 有效非空 → `.deployed`(原子更新);目标被删除/禁用/变空/含无效叶子 → + /// 有效非空 → `.deployed`(原子更新);目标被删除/变空/无有效叶子 → /// 清除目标并返回 `.clearedAndStopped`(点名原因)。任何目录提交(含与活动 /// 目标无关的编辑)都整体重校验重展开:产出对相同输入幂等,由 #27 决定是否 /// 跳过相同内容的运行时写入。 @@ -73,8 +73,9 @@ struct ActivationStateMachine: Equatable, Sendable { // MARK: - 展开、校验与派生 - /// 单缝派生:目标存在 → 有效启用 → 显式子序深度优先展开叶子 → 非空 → - /// 逐叶校验并解析凭据。任何一步失败都返回点名原因,不产出部分结果。 + /// 单缝派生:目标存在 → 显式子序深度优先展开全部叶子 → 逐叶进行 app + /// 可知的激活预检 → 解析凭据。单个服务器目标遇到阻塞原因时整体拒绝; + /// 分组目标则跳过已知无效叶子,至少保留一台服务器才产出运行时文档。 private func derive( target: NodeID, in catalog: ConfigurationCatalog, @@ -86,26 +87,45 @@ struct ActivationStateMachine: Equatable, Sendable { pacUserRules: String ) -> Result { guard catalog.contains(target) else { return .failure(.targetNotFound(target)) } - if let disabledNode = firstDisabledNode(from: target, in: catalog) { - return .failure(.targetDisabled(disabledNode)) - } + let isGroup: Bool = { + guard let entry = catalog.entry(for: target) else { return false } + if case .group = entry.kind { return true } + return false + }() var leaves: [NodeID] = [] - if case .server = catalog.entry(for: target)?.kind { - leaves = [target] + if isGroup { + collectServerLeaves(of: target, in: catalog, into: &leaves) } else { - collectEnabledLeaves(of: target, in: catalog, into: &leaves) + leaves = [target] } guard !leaves.isEmpty else { return .failure(.targetExpandsToNothing(target)) } var servers: [SslocalServerDocument] = [] + var skippedServers: [SkippedServer] = [] for leafID in leaves { + guard let entry = catalog.entry(for: leafID), case .server(let fields) = entry.kind else { + preconditionFailure("展开结果只含服务器叶子") + } + let validation = ServerValidation.evaluate(fields, credentials: credentials, plugins: plugins) + if let issue = validation.issues.first { + if isGroup { + skippedServers.append(SkippedServer(id: leafID, reason: issue)) + continue + } + return .failure(.invalidLeaf(node: leafID, reason: issue)) + } switch derivedServer(leafID, in: catalog, credentials: credentials, plugins: plugins) { case .success(let server): servers.append(server) case .failure(let failure): - return .failure(failure) + if isGroup, case .invalidLeaf(let node, let reason) = failure { + skippedServers.append(SkippedServer(id: node, reason: reason)) + } else { + return .failure(failure) + } } } + guard !servers.isEmpty else { return .failure(.targetExpandsToNothing(target)) } return .success( RuntimeConfiguration( targetID: target, @@ -114,19 +134,13 @@ struct ActivationStateMachine: Equatable, Sendable { listen: listen, timeout: timeout, verbose: verbose, - pacUserRules: pacUserRules))) + pacUserRules: pacUserRules), + skippedServers: skippedServers)) } - /// 目标→根路径上第一个禁用节点;全部启用返回 `nil`。 - private func firstDisabledNode(from target: NodeID, in catalog: ConfigurationCatalog) -> NodeID? { - ([target] + catalog.ancestors(of: target)).first { - catalog.entry(for: $0)?.enabled == false - } - } - - /// 按显式子序深度优先收集有效启用的服务器叶子;禁用分组整棵跳过。 + /// 按显式子序深度优先收集全部服务器叶子;有效性在派生阶段统一预检。 /// 目录结构不变量(子引用必存在、目标必为分组)由构造校验保证,失约即程序错误。 - private func collectEnabledLeaves( + private func collectServerLeaves( of groupID: NodeID, in catalog: ConfigurationCatalog, into leaves: inout [NodeID] @@ -138,17 +152,16 @@ struct ActivationStateMachine: Equatable, Sendable { guard let entry = catalog.entry(for: child) else { preconditionFailure("目录结构不变量:子引用必存在") } - guard entry.enabled else { continue } switch entry.kind { case .server: leaves.append(child) case .group: - collectEnabledLeaves(of: child, in: catalog, into: &leaves) + collectServerLeaves(of: child, in: catalog, into: &leaves) } } } - /// 校验单个有效启用叶子并解析为上游条目;凭据在派生时从凭据存储解析(D5)。 + /// 校验单个有效候选叶子并解析为上游条目;凭据在派生时从凭据存储解析(D5)。 private func derivedServer( _ leafID: NodeID, in catalog: ConfigurationCatalog, @@ -185,10 +198,10 @@ struct ActivationStateMachine: Equatable, Sendable { SslocalServerDocument( id: leafID.rawValue, remarks: fields.remark, - server: fields.address, + server: fields.address.trimmingCharacters(in: .whitespacesAndNewlines), serverPort: fields.port, password: password, - method: fields.encryptionMethod, + method: fields.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines), plugin: pluginPath, pluginOpts: pluginOpts)) } @@ -200,7 +213,7 @@ struct ActivationStateMachine: Equatable, Sendable { credentials: CredentialStoring ) -> Result { do { - guard let secret = try credentials.secret(for: reference) else { + guard let secret = try credentials.secret(for: reference), !secret.isEmpty else { return .failure(.invalidLeaf(node: leafID, reason: .credentialUnresolved(reference))) } return .success(secret) diff --git a/ShadowsocksX-NG2/Domain/CatalogError.swift b/ShadowsocksX-NG2/Domain/CatalogError.swift index e81c54a0..a7b6f159 100644 --- a/ShadowsocksX-NG2/Domain/CatalogError.swift +++ b/ShadowsocksX-NG2/Domain/CatalogError.swift @@ -15,8 +15,8 @@ enum CatalogError: Error, Equatable, Sendable { case duplicateID(NodeID) /// 手动与订阅来源严格分离:不允许把节点放进另一来源的子树。 case crossSourcePlacement(node: NodeSource, container: NodeSource) - /// 订阅子树由远端权威所有:本地结构操作(移动/删除/改连接字段/改名)一律拒绝, - /// 仅 `enabled` 作为本地资格状态可写;整棵订阅子树的移除属于订阅票(#35)。 + /// 订阅子树由远端权威所有:本地结构操作(移动/删除/改连接字段/改名)一律拒绝; + /// 整棵订阅子树的移除属于订阅票(#35)。 case subscriptionNodeImmutable(NodeID) /// 订阅服务器叶子必须位于其订阅固定分组内,不得直挂目录根(CONTEXT.md /// 「Subscription group」:根层只允许订阅固定分组本身)。 diff --git a/ShadowsocksX-NG2/Domain/CatalogFileStore.swift b/ShadowsocksX-NG2/Domain/CatalogFileStore.swift index 0a3bd05a..578ce452 100644 --- a/ShadowsocksX-NG2/Domain/CatalogFileStore.swift +++ b/ShadowsocksX-NG2/Domain/CatalogFileStore.swift @@ -12,9 +12,10 @@ struct CatalogFileStore { case ioFailure(detail: String) } - /// v2 起携带订阅记录;v1(无订阅字段)仍可读取以兼容存量开发数据。 - private static let supportedVersions: Set = [1, 2] - private static let currentVersion = 2 + /// v2 起携带订阅记录;v1(无订阅字段)仍可读取。v3 移除节点级 enabled + /// 语义;旧字段由 CatalogEntry 解码时丢弃,下一次保存统一写出 v3。 + private static let supportedVersions: Set = [1, 2, 3] + private static let currentVersion = 3 private static let jsonEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -110,7 +111,7 @@ struct CatalogFileStore { } /// 落盘文档形态:版本号 + 根子序 + 全量节点表(顺序语义在根序与分组显子序中) -/// + 订阅记录(v2 起携带;v1 缺省为空)。 +/// + 订阅记录(v2 起携带;v1 缺省为空)。v3 节点条目不再写出 `enabled`。 private struct CatalogFilePayload: Codable { var version: Int var rootChildren: [NodeID] diff --git a/ShadowsocksX-NG2/Domain/CatalogModels.swift b/ShadowsocksX-NG2/Domain/CatalogModels.swift index fa1ccad3..7406973a 100644 --- a/ShadowsocksX-NG2/Domain/CatalogModels.swift +++ b/ShadowsocksX-NG2/Domain/CatalogModels.swift @@ -48,8 +48,34 @@ struct CatalogEntry: Codable, Equatable, Sendable { let id: NodeID var source: NodeSource - var enabled: Bool var kind: Kind + + private enum CodingKeys: String, CodingKey { + case id, source, enabled, kind + } + + init(id: NodeID, source: NodeSource, kind: Kind) { + self.id = id + self.source = source + self.kind = kind + } + + /// Reads the removed `enabled` field from v1/v2 documents only for migration; + /// its value has no meaning in the current domain model. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(NodeID.self, forKey: .id) + source = try container.decode(NodeSource.self, forKey: .source) + _ = try container.decodeIfPresent(Bool.self, forKey: .enabled) + kind = try container.decode(Kind.self, forKey: .kind) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(source, forKey: .source) + try container.encode(kind, forKey: .kind) + } } extension CatalogEntry { diff --git a/ShadowsocksX-NG2/Domain/ConfigurationCatalog.swift b/ShadowsocksX-NG2/Domain/ConfigurationCatalog.swift index 1d7a14e1..5703f159 100644 --- a/ShadowsocksX-NG2/Domain/ConfigurationCatalog.swift +++ b/ShadowsocksX-NG2/Domain/ConfigurationCatalog.swift @@ -87,17 +87,6 @@ extension ConfigurationCatalog { return parents } - /// 有效启用 = 节点及其全部祖先启用(CONTEXT.md「Enabled」)。 - func isEffectivelyEnabled(_ id: NodeID) throws -> Bool { - guard entries[id] != nil else { throw CatalogError.nodeNotFound(id) } - let parents = parentIndex() - var current: NodeID? = id - while let nodeID = current { - guard let entry = entries[nodeID], entry.enabled else { return false } - current = parents[nodeID] - } - return true - } } // MARK: - 变更 @@ -117,7 +106,7 @@ extension ConfigurationCatalog { throw CatalogError.subscriptionServerAtRoot(id) } try place(id, source: source, parent: parent, index: index, excluding: nil) - entries[id] = CatalogEntry(id: id, source: source, enabled: true, kind: .server(fields)) + entries[id] = CatalogEntry(id: id, source: source, kind: .server(fields)) return id } @@ -132,8 +121,7 @@ extension ConfigurationCatalog { let id = proposedID ?? .fresh() guard entries[id] == nil else { throw CatalogError.duplicateID(id) } try place(id, source: source, parent: parent, index: index, excluding: nil) - entries[id] = CatalogEntry( - id: id, source: source, enabled: true, kind: .group(GroupFields(name: name))) + entries[id] = CatalogEntry(id: id, source: source, kind: .group(GroupFields(name: name))) return id } @@ -154,13 +142,6 @@ extension ConfigurationCatalog { entries[id] = entry } - /// 本地资格状态开关;订阅节点同样允许(CONTEXT.md 仅保留 enabled 的本地覆盖)。 - mutating func setEnabled(_ id: NodeID, _ enabled: Bool) throws { - guard var entry = entries[id] else { throw CatalogError.nodeNotFound(id) } - entry.enabled = enabled - entries[id] = entry - } - /// 移动节点(携带整棵子树):目录根与手动组之间、手动组相互之间。 /// 同父移动即重排。跨来源、共享父、成环一律拒绝。 mutating func move(_ id: NodeID, to parent: NodeID?, index: Int? = nil) throws { @@ -272,10 +253,8 @@ extension ConfigurationCatalog { } /// 订阅快照原子应用(CONTEXT.md 刷新契约):以快照整体重建固定分组子树; - /// `enabled` 仅按节点身份精确匹配延续(无稳定 ID 的记录由解析器保证只有 - /// 完全相同内容才得同身份);固定分组自身身份与 `enabled` 保持不变,名称 - /// 跟随远端(扩展缺失时由调用方给 URL host 兜底)。返回被移除的旧服务器 - /// 叶子(供调用方清理凭据)。 + /// 名称、结构、顺序和连接字段全部跟随远端(扩展缺失时由调用方给 URL host + /// 兜底)。返回被移除的旧服务器叶子(供调用方清理凭据)。 @discardableResult mutating func applySubscriptionSnapshot( _ snapshot: CatalogSubscriptionSnapshot, into groupID: NodeID @@ -287,12 +266,10 @@ extension ConfigurationCatalog { throw CatalogError.crossSourcePlacement(node: .subscription, container: fixed.source) } - // 先整树摘除旧成员(固定分组本身保留),收集 overlay 与被移除的服务器。 + // 先整树摘除旧成员(固定分组本身保留),收集被移除的服务器。 var removedServers: [CatalogEntry] = [] - var oldEnabled: [NodeID: Bool] = [:] let oldSubtree = try subscriptionSubtree(of: groupID) for entry in oldSubtree { - oldEnabled[entry.id] = entry.enabled if case .server = entry.kind { removedServers.append(entry) } } for entry in oldSubtree where entry.id != groupID { @@ -300,9 +277,9 @@ extension ConfigurationCatalog { } setChildren([], of: groupID) - // 远端权威重建:名称、结构、顺序、字段全按快照;本地只回填 enabled。 + // 远端权威重建:名称、结构、顺序、字段全按快照。 var fixedFields = GroupFields(name: snapshot.name, children: []) - try insertSnapshotChildren(of: snapshot.root, into: &fixedFields, overlay: oldEnabled) + try insertSnapshotChildren(of: snapshot.root, into: &fixedFields) entries[groupID]?.kind = .group(fixedFields) return removedServers } @@ -311,8 +288,7 @@ extension ConfigurationCatalog { /// 分组的显子序。身份已由解析器按订阅作用域限定,与既有节点冲突即程序错误。 private mutating func insertSnapshotChildren( of group: CatalogSubscriptionSnapshot.Group, - into fields: inout GroupFields, - overlay: [NodeID: Bool] + into fields: inout GroupFields ) throws { var childIDs: [NodeID] = [] for child in group.children { @@ -320,16 +296,14 @@ extension ConfigurationCatalog { case .server(let leaf): guard entries[leaf.id] == nil else { throw CatalogError.duplicateID(leaf.id) } entries[leaf.id] = CatalogEntry( - id: leaf.id, source: .subscription, - enabled: overlay[leaf.id] ?? true, kind: .server(leaf.fields)) + id: leaf.id, source: .subscription, kind: .server(leaf.fields)) childIDs.append(leaf.id) case .group(let nested): guard entries[nested.id] == nil else { throw CatalogError.duplicateID(nested.id) } var nestedFields = GroupFields(name: nested.name, children: []) - try insertSnapshotChildren(of: nested, into: &nestedFields, overlay: overlay) + try insertSnapshotChildren(of: nested, into: &nestedFields) entries[nested.id] = CatalogEntry( - id: nested.id, source: .subscription, - enabled: overlay[nested.id] ?? true, kind: .group(nestedFields)) + id: nested.id, source: .subscription, kind: .group(nestedFields)) childIDs.append(nested.id) } } diff --git a/ShadowsocksX-NG2/Domain/DiagnosticReport.swift b/ShadowsocksX-NG2/Domain/DiagnosticReport.swift index dc530522..68013286 100644 --- a/ShadowsocksX-NG2/Domain/DiagnosticReport.swift +++ b/ShadowsocksX-NG2/Domain/DiagnosticReport.swift @@ -92,6 +92,8 @@ struct DiagnosticSnapshot: Sendable { /// `Redactor.documentSummary` 产出的契约脱敏摘要(数量与模式)。 var runtimeDocumentSummary: String? var catalog: ConfigurationCatalog? + /// 由应用层用当前凭据与插件供应事实计算的已知无效服务器数。 + var knownInvalidServerCount: Int? var fileFacts: [DiagnosticFileFacts] = [] /// 渲染好的运行事件行(旧→新,调用方负责封顶)。 var eventLines: [String] = [] @@ -118,7 +120,6 @@ enum DiagnosticReportBuilder { struct CatalogCounts: Equatable, Sendable { var servers = 0 var groups = 0 - var effectivelyEnabledServers = 0 var serversWithPlugin = 0 var manualServers = 0 var subscriptionServers = 0 @@ -138,9 +139,6 @@ enum DiagnosticReportBuilder { if fields.pluginProgram != nil { counts.serversWithPlugin += 1 } - if (try? catalog.isEffectivelyEnabled(entry.id)) == true { - counts.effectivelyEnabledServers += 1 - } case .group: counts.groups += 1 } @@ -197,7 +195,9 @@ enum DiagnosticReportBuilder { lines.append("## 配置目录(数量)") lines.append("") if let catalog = snapshot.catalog { - lines.append(contentsOf: catalogCountLines(catalog)) + lines.append( + contentsOf: catalogCountLines( + catalog, knownInvalidServerCount: snapshot.knownInvalidServerCount)) } else { lines.append("- 配置目录不可用") } @@ -229,10 +229,13 @@ enum DiagnosticReportBuilder { return lines } - private static func catalogCountLines(_ catalog: ConfigurationCatalog) -> [String] { + private static func catalogCountLines( + _ catalog: ConfigurationCatalog, knownInvalidServerCount: Int? + ) -> [String] { let counts = counts(in: catalog) + let invalid = knownInvalidServerCount.map { "已知无效 \($0);" } ?? "" return [ - "- 服务器:\(counts.servers)(有效启用 \(counts.effectivelyEnabledServers);" + "- 服务器:\(counts.servers)(\(invalid)" + "配置插件 \(counts.serversWithPlugin);" + "手动 \(counts.manualServers) / 订阅 \(counts.subscriptionServers))", "- 分组:\(counts.groups)", diff --git a/ShadowsocksX-NG2/Domain/RuntimeConfiguration.swift b/ShadowsocksX-NG2/Domain/RuntimeConfiguration.swift index f9ae8284..e3fc1463 100644 --- a/ShadowsocksX-NG2/Domain/RuntimeConfiguration.swift +++ b/ShadowsocksX-NG2/Domain/RuntimeConfiguration.swift @@ -1,6 +1,23 @@ +/// 组激活时因已知本地阻塞问题被跳过的服务器。 +struct SkippedServer: Equatable, Sendable { + let id: NodeID + let reason: LeafInvalidationReason +} + /// 激活产出:活动目标身份 + 完整派生文档。组目标保持组 UUID(CONTEXT.md /// 「Active target」),不被 sslocal 实际选中的后代替换。 struct RuntimeConfiguration: Equatable, Sendable { let targetID: NodeID let document: SslocalRuntimeDocument + let skippedServers: [SkippedServer] + + init( + targetID: NodeID, + document: SslocalRuntimeDocument, + skippedServers: [SkippedServer] = [] + ) { + self.targetID = targetID + self.document = document + self.skippedServers = skippedServers + } } diff --git a/ShadowsocksX-NG2/Domain/ServerValidation.swift b/ShadowsocksX-NG2/Domain/ServerValidation.swift new file mode 100644 index 00000000..4514fbf5 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/ServerValidation.swift @@ -0,0 +1,105 @@ +import Foundation + +/// 加密方式能力目录(与当前随 app 打包的 sslocal 版本保持一致)。 +/// 目录值是本地可验证的能力事实,不代表远端服务器一定可达。 +enum EncryptionMethodCatalog { + static let supported: Set = [ + "aes-128-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", + "none", + ] + + static func isSupported(_ method: String) -> Bool { + supported.contains(method) + } +} + +/// 服务器配置的 app 可知校验结果。插件参数保持 opaque,不在此处判断其语法或 +/// 远端握手;空 issues 只表示没有已知的本地激活阻塞原因。 +struct ServerValidation: Equatable, Sendable { + let issues: [LeafInvalidationReason] + + var isValid: Bool { issues.isEmpty } + + static func evaluate( + _ fields: ServerFields, + credentials: CredentialStoring, + plugins: ManagedPluginProviding + ) -> ServerValidation { + var issues: [LeafInvalidationReason] = [] + appendAddressIssue(fields.address, to: &issues) + appendPortIssue(fields.port, to: &issues) + appendMethodIssue(fields.encryptionMethod, to: &issues) + appendCredentialIssue(fields.passwordRef, credentials: credentials, to: &issues) + appendPluginIssues(fields, credentials: credentials, plugins: plugins, to: &issues) + return ServerValidation(issues: issues) + } + + private static func appendAddressIssue( + _ address: String, to issues: inout [LeafInvalidationReason] + ) { + if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + issues.append(.invalidAddress) + } + } + + private static func appendPortIssue( + _ port: Int, to issues: inout [LeafInvalidationReason] + ) { + if !(1...65_535).contains(port) { issues.append(.invalidPort(port)) } + } + + private static func appendMethodIssue( + _ method: String, to issues: inout [LeafInvalidationReason] + ) { + let trimmed = method.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + issues.append(.missingEncryptionMethod) + } else if !EncryptionMethodCatalog.isSupported(trimmed) { + issues.append(.unsupportedEncryptionMethod(trimmed)) + } + } + + private static func appendCredentialIssue( + _ reference: CredentialReference, + credentials: CredentialStoring, + to issues: inout [LeafInvalidationReason] + ) { + if case .failure(let issue) = resolveCredential(reference, credentials: credentials) { + issues.append(issue) + } + } + + private static func appendPluginIssues( + _ fields: ServerFields, + credentials: CredentialStoring, + plugins: ManagedPluginProviding, + to issues: inout [LeafInvalidationReason] + ) { + guard let program = fields.pluginProgram else { return } + if plugins.executablePath(forProgram: program) == nil { + issues.append(.pluginNotProvided(program: program)) + } + if let optionsRef = fields.pluginOptionsRef { + appendCredentialIssue(optionsRef, credentials: credentials, to: &issues) + } + } + + private static func resolveCredential( + _ reference: CredentialReference, + credentials: CredentialStoring + ) -> Result { + do { + guard let secret = try credentials.secret(for: reference), !secret.isEmpty else { + return .failure(.credentialUnresolved(reference)) + } + return .success(secret) + } catch { + return .failure(.credentialReadFailed(reference)) + } + } +} diff --git a/ShadowsocksX-NG2/Domain/SsUri.swift b/ShadowsocksX-NG2/Domain/SsUri.swift index 46b2734e..71d649e1 100644 --- a/ShadowsocksX-NG2/Domain/SsUri.swift +++ b/ShadowsocksX-NG2/Domain/SsUri.swift @@ -98,13 +98,23 @@ extension SsUri { /// 不含分隔冒号时回退百分号编码明文(SIP022 形态)。 private static func parseUserInfo(_ userInfo: String) throws -> (String, String) { if let decoded = base64FlexibleDecode(userInfo), let separator = decoded.firstIndex(of: ":") { - return (String(decoded[.. ConfigurationCatalog { var catalog = try CatalogFixtures.makeSubscriptionFixture(prefix: "sub9").catalog let group = NodeID(rawValue: "manual:group") @@ -208,8 +208,7 @@ final class CatalogFileStoreTests: XCTestCase { id: server, to: group ) - let disabled = try catalog.addTestServer("停用", to: group) - try catalog.setEnabled(disabled, false) + _ = try catalog.addTestServer("无效", to: group) try catalog.addGroup("空组", id: NodeID(rawValue: "manual:empty")) try catalog.move(server, to: group, index: 0) return catalog @@ -247,6 +246,23 @@ final class CatalogFileStoreTests: XCTestCase { XCTAssertTrue(loaded.catalog.isEmpty) } + func testLegacyEnabledFieldIsIgnoredAndNextSaveWritesV3WithoutIt() throws { + let payload = """ + {"version": 1, "rootChildren": ["g"], "entries": [ + {"id": "g", "source": "manual", "enabled": false, + "kind": {"group": {"_0": {"name": "G", "children": []}}}} + ]} + """ + try payload.write(to: store.fileURL, atomically: true, encoding: .utf8) + + let loaded = try store.load() + try store.save(loaded) + + let raw = try String(contentsOf: store.fileURL, encoding: .utf8) + XCTAssertTrue(raw.contains("\"version\" : 3")) + XCTAssertFalse(raw.contains("\"enabled\""), "v3 不再写出节点级 enabled") + } + /// 订阅记录 JSON 用真编码器生成:枚举 Codable 形状是实现细节,不在测试里手写。 private func subscriptionJSON( id: String, groupID: String, urlRef: String = "ref-sub-url" diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift index e79f52f4..ccf47f29 100644 --- a/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelSubscriptionTests.swift @@ -62,7 +62,7 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { ?? "missing-b") } - /// b 停用 + a 改名 + b 移到根分组(远端改名/移动/排序跟随的对照文档)。 + /// b 移到根 + a 改名 + 重排(远端改名/移动/排序跟随的对照文档)。 private var treeDoc: Data { SubscriptionDocs.tree() } private var changedTreeDoc: Data { SubscriptionDocs.treeRenamedAndReordered() } @@ -119,21 +119,19 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { // MARK: 失败保留语义 - func testFailedRefreshKeepsSnapshotOverlayAndStatus() async throws { + func testFailedRefreshKeepsSnapshotAndStatus() async throws { fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc)) viewModel = makeViewModel() let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json") - try await viewModel.setEnabled(serverBID, false) let commitsBefore = await commitCounter.count fetcher = FakeSubscriptionFetcher(behavior: .failure(.httpStatus(code: 503))) viewModel.subscriptionFetcher = fetcher await viewModel.refreshSubscription(record.id) - // 快照与 overlay 原样保留。 + // 快照原样保留。 XCTAssertNotNil(viewModel.entry(for: serverAID)) XCTAssertNotNil(viewModel.entry(for: serverBID)) - XCTAssertFalse(try viewModel.entry(for: serverBID)!.enabled) XCTAssertEqual(viewModel.displayName(for: serverAID), "香港 01") // 状态点名失败且原因脱敏。 guard case .failed(_, let reason) = viewModel.subscriptions[0].status else { @@ -231,20 +229,17 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { } } - // MARK: overlay 与身份连续性 + // MARK: 身份连续性 - func testRemoteRenameMoveReorderFollowsWithOverlayRetained() async throws { + func testRemoteRenameMoveReorderFollowsWithoutLocalOverlay() async throws { fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc)) viewModel = makeViewModel() let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json") - try await viewModel.setEnabled(serverBID, false) - viewModel.subscriptionFetcher = FakeSubscriptionFetcher( behavior: .success(changedTreeDoc)) await viewModel.refreshSubscription(record.id) - // 身份不变:b 停用保留;名称/位置/顺序跟随远端。 - XCTAssertFalse(try viewModel.entry(for: serverBID)!.enabled, "仅本地 enabled 延续") + // 身份不变;名称/位置/顺序跟随远端。 XCTAssertEqual(viewModel.displayName(for: serverAID), "香港 01(新名)") guard case .group(let fields) = viewModel.entry(for: record.groupID)?.kind else { return XCTFail("固定分组应存在") @@ -259,21 +254,17 @@ final class CatalogViewModelSubscriptionTests: XCTestCase { viewModel = makeViewModel() let record = try await viewModel.createSubscription(urlString: "https://p.example.com/s.json") let serverID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID)) - try await viewModel.setEnabled(serverID, false) - - // 完全相同记录 → 身份延续,停用保留。 + // 完全相同记录 → 身份延续。 await viewModel.refreshSubscription(record.id) XCTAssertEqual(onlyServerChildID(ofGroup: record.groupID), serverID) - XCTAssertFalse(try viewModel.entry(for: serverID)!.enabled) - // 端口变化 → 新身份:旧节点删除(无墓碑),新节点默认启用。 + // 端口变化 → 新身份:旧节点删除(无墓碑)。 viewModel.subscriptionFetcher = FakeSubscriptionFetcher( behavior: .success(SubscriptionDocs.idLess(port: 9999))) await viewModel.refreshSubscription(record.id) XCTAssertNil(viewModel.entry(for: serverID), "无稳定 ID 且记录变化即不同节点") let newID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID)) XCTAssertNotEqual(newID, serverID) - XCTAssertTrue(try viewModel.entry(for: newID)!.enabled, "新记录默认启用") } // MARK: URL 编辑与删除 diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift index bfe07e34..cd21f33e 100644 --- a/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift @@ -87,6 +87,20 @@ final class CatalogViewModelTests: XCTestCase { XCTAssertEqual(try credentials.secret(for: optionsRef), "mode=websocket;host=example.com") } + func testImportUnsupportedMethodKeepsServerAsInvalidCandidate() async throws { + let uri = SsUri( + method: "future-cipher", password: "password", host: "203.0.113.7", port: 8388 + ).encode() + + let outcome = try await viewModel.addServers(fromURIs: uri, into: nil) + + XCTAssertEqual(outcome.added, 1) + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + XCTAssertEqual( + viewModel.validation(for: id)?.issues, + [.unsupportedEncryptionMethod("future-cipher")]) + } + func testImportIntoSelectedManualGroup() async throws { let groupID = try await viewModel.addGroup(named: "手动组", into: nil) let target = try XCTUnwrap(viewModel.importTargetParent(for: groupID)) @@ -177,7 +191,7 @@ final class CatalogViewModelTests: XCTestCase { XCTAssertTrue(viewModel.catalog.isEmpty) } - // MARK: - 订阅子树只读(夹具验证:结构操作被拒、启用开关可用) + // MARK: - 订阅子树只读(夹具验证:结构操作被拒) func testSubscriptionStructureOperationsRejected() async throws { try makeSubscriptionCatalog() @@ -197,21 +211,6 @@ final class CatalogViewModelTests: XCTestCase { await expectThrowsAsync { try await viewModel.move(manualGroupID, to: fixture.groupID) } } - func testSubscriptionEnableToggleAllowedAndPersisted() async throws { - try makeSubscriptionCatalog() - let fixture = try CatalogFixtures.makeSubscriptionFixture() - let server = fixture.serverIDs[0] - try await viewModel.setEnabled(server, false) - let entry = try XCTUnwrap(viewModel.entry(for: server)) - XCTAssertFalse(entry.enabled, "启用开关是订阅节点唯一可写状态") - let commitCount = await commitCounter.count - XCTAssertEqual(commitCount, 1) - // 持久化到磁盘可读回。 - let reloaded = try CatalogFileStore(fileURL: fileURL).load().catalog - XCTAssertFalse(try XCTUnwrap(reloaded.entry(for: server)).enabled) - XCTAssertTrue(try reloaded.isEffectivelyEnabled(fixture.serverIDs[1]), "兄弟节点不受影响") - } - // MARK: - 分享(与添加互逆) func testShareSsUriRoundTripsBackThroughImport() async throws { diff --git a/ShadowsocksX-NG2/Tests/ConfigurationCatalogRejectionTests.swift b/ShadowsocksX-NG2/Tests/ConfigurationCatalogRejectionTests.swift index 9c9849fa..72fb159a 100644 --- a/ShadowsocksX-NG2/Tests/ConfigurationCatalogRejectionTests.swift +++ b/ShadowsocksX-NG2/Tests/ConfigurationCatalogRejectionTests.swift @@ -37,7 +37,7 @@ final class ConfigurationCatalogRejectionTests: XCTestCase { XCTAssertTrue(catalog.contains(fixture.groupID), "被拒删除不得产生任何变更") } - func testSubscriptionNodesRejectRenameAndFieldEditButAllowEnabledToggle() throws { + func testSubscriptionNodesRejectRenameAndFieldEdit() throws { let fixture = try CatalogFixtures.makeSubscriptionFixture() var catalog = fixture.catalog let subscriptionServer = fixture.serverIDs[0] @@ -50,8 +50,6 @@ final class ConfigurationCatalogRejectionTests: XCTestCase { ) { error in XCTAssertEqual(error as? CatalogError, .subscriptionNodeImmutable(subscriptionServer)) } - try catalog.setEnabled(subscriptionServer, false) - XCTAssertEqual(catalog.entry(for: subscriptionServer)?.enabled, false, "enabled 是唯一允许的本地覆盖") } func testMoveGroupIntoItsOwnSubtreeIsRejected() throws { @@ -149,17 +147,11 @@ final class ConfigurationCatalogRejectionTests: XCTestCase { ) { error in XCTAssertEqual(error as? CatalogError, .nodeNotFound(missing)) } - XCTAssertThrowsError(try catalog.setEnabled(missing, false)) { error in - XCTAssertEqual(error as? CatalogError, .nodeNotFound(missing)) - } XCTAssertThrowsError(try catalog.move(missing, to: nil)) { error in XCTAssertEqual(error as? CatalogError, .nodeNotFound(missing)) } XCTAssertThrowsError(try catalog.remove(missing)) { error in XCTAssertEqual(error as? CatalogError, .nodeNotFound(missing)) } - XCTAssertThrowsError(try catalog.isEffectivelyEnabled(missing)) { error in - XCTAssertEqual(error as? CatalogError, .nodeNotFound(missing)) - } } } diff --git a/ShadowsocksX-NG2/Tests/ConfigurationCatalogTests.swift b/ShadowsocksX-NG2/Tests/ConfigurationCatalogTests.swift index 1d38867f..285ff0d8 100644 --- a/ShadowsocksX-NG2/Tests/ConfigurationCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/ConfigurationCatalogTests.swift @@ -14,7 +14,6 @@ final class ConfigurationCatalogTests: XCTestCase { XCTAssertEqual(try catalog.children(of: nil), [first, second], "根子序按插入顺序") let entry = try XCTUnwrap(catalog.entry(for: first)) XCTAssertEqual(entry.source, .manual) - XCTAssertEqual(entry.enabled, true, "新节点默认启用") guard case .server(let fields) = entry.kind else { return XCTFail("应为服务器叶子") } XCTAssertEqual(fields.remark, "香港 01") XCTAssertEqual(fields.address, "203.0.113.7") diff --git a/ShadowsocksX-NG2/Tests/DiagnosticReportTests.swift b/ShadowsocksX-NG2/Tests/DiagnosticReportTests.swift index c49c1536..26604a8d 100644 --- a/ShadowsocksX-NG2/Tests/DiagnosticReportTests.swift +++ b/ShadowsocksX-NG2/Tests/DiagnosticReportTests.swift @@ -18,7 +18,7 @@ final class DiagnosticReportTests: XCTestCase { } /// 投毒目录:分组名、服务器地址、备注、插件参数全为敏感值;含订阅子树、 - /// 停用祖先与插件节点,用于覆盖数量统计分支。 + /// 插件节点,用于覆盖数量统计分支。 private func poisonedCatalog() throws -> ConfigurationCatalog { var catalog = ConfigurationCatalog() let group = try catalog.addGroup(groupName) @@ -44,7 +44,6 @@ final class DiagnosticReportTests: XCTestCase { ), source: .subscription, to: subscriptionGroup) - try catalog.setEnabled(group, false) return catalog } @@ -111,7 +110,7 @@ final class DiagnosticReportTests: XCTestCase { XCTAssertTrue(report.contains("HTTP 端口 1087")) XCTAssertTrue(report.contains("PAC 端口 1089")) XCTAssertTrue(report.contains("servers=1 protocols=socks mode=tcp_only"), "缺少契约脱敏摘要") - XCTAssertTrue(report.contains("服务器:2(有效启用 1;配置插件 1;手动 1 / 订阅 1)")) + XCTAssertTrue(report.contains("服务器:2(配置插件 1;手动 1 / 订阅 1)")) XCTAssertTrue(report.contains("分组:2")) XCTAssertTrue(report.contains("| sslocal-active.json | 是 | 0600 | 512 |")) XCTAssertTrue(report.contains("| agent.pid | 否 | — | — |")) @@ -202,7 +201,7 @@ final class DiagnosticReportTests: XCTestCase { assertNoSecrets(report) } - func testCountsCoverEffectiveEnablementAndPluginPresence() throws { + func testCountsCoverServerAndPluginPresence() throws { var catalog = ConfigurationCatalog() let group = try catalog.addGroup("g") _ = try catalog.addServer( @@ -211,14 +210,12 @@ final class DiagnosticReportTests: XCTestCase { passwordRef: .fresh() ), to: group) - try catalog.setEnabled(group, false) - let counts = DiagnosticReportBuilder.counts(in: catalog) XCTAssertEqual( counts, DiagnosticReportBuilder.CatalogCounts( - servers: 1, groups: 1, effectivelyEnabledServers: 0, + servers: 1, groups: 1, serversWithPlugin: 0, manualServers: 1, subscriptionServers: 0)) } } diff --git a/ShadowsocksX-NG2/Tests/EffectiveEnablementTests.swift b/ShadowsocksX-NG2/Tests/EffectiveEnablementTests.swift deleted file mode 100644 index ef1b8865..00000000 --- a/ShadowsocksX-NG2/Tests/EffectiveEnablementTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -import XCTest - -@testable import ShadowsocksX_NG2 - -/// 有效启用语义:有效启用 = 节点及其全部祖先启用(CONTEXT.md「Enabled」)。 -final class EffectiveEnablementTests: XCTestCase { - func testEnabledChainIsEffectivelyEnabled() throws { - var catalog = ConfigurationCatalog() - let group = try catalog.addGroup("组") - let nested = try catalog.addGroup("嵌套组", to: group) - let leaf = try catalog.addTestServer("leaf", to: nested) - - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), true) - XCTAssertEqual(try catalog.isEffectivelyEnabled(nested), true) - } - - func testDisabledRootAncestorDisablesDeepLeaf() throws { - var catalog = ConfigurationCatalog() - let top = try catalog.addGroup("顶层") - let middle = try catalog.addGroup("中层", to: top) - let inner = try catalog.addGroup("内层", to: middle) - let leaf = try catalog.addTestServer("deep", to: inner) - - try catalog.setEnabled(top, false) - - XCTAssertEqual(try catalog.isEffectivelyEnabled(top), false) - XCTAssertEqual(try catalog.isEffectivelyEnabled(middle), false) - XCTAssertEqual(try catalog.isEffectivelyEnabled(inner), false) - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), false, "四层嵌套,最深层随祖先失效") - - try catalog.setEnabled(top, true) - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), true, "恢复祖先即恢复整条链") - } - - func testDisabledMidGroupOnlyDisablesItsSubtree() throws { - var catalog = ConfigurationCatalog() - let group = try catalog.addGroup("组") - let kept = try catalog.addTestServer("kept", to: group) - let disabledBranch = try catalog.addGroup("停用分支", to: group) - let hidden = try catalog.addTestServer("hidden", to: disabledBranch) - - try catalog.setEnabled(disabledBranch, false) - - XCTAssertEqual(try catalog.isEffectivelyEnabled(hidden), false) - XCTAssertEqual(try catalog.isEffectivelyEnabled(kept), true, "兄弟子树不受影响") - } - - func testDisabledLeafStaysDisabledInsideEnabledGroup() throws { - var catalog = ConfigurationCatalog() - let group = try catalog.addGroup("组") - let leaf = try catalog.addTestServer("leaf", to: group) - - try catalog.setEnabled(leaf, false) - - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), false) - XCTAssertEqual(try catalog.isEffectivelyEnabled(group), true) - } - - func testEffectiveEnablementFollowsMoves() throws { - var catalog = ConfigurationCatalog() - let enabledGroup = try catalog.addGroup("启用组") - let disabledGroup = try catalog.addGroup("停用组") - try catalog.setEnabled(disabledGroup, false) - let leaf = try catalog.addTestServer("leaf", to: enabledGroup) - - try catalog.move(leaf, to: disabledGroup) - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), false, "启用的叶子移入停用组后失效") - - try catalog.move(leaf, to: nil) - XCTAssertEqual(try catalog.isEffectivelyEnabled(leaf), true, "移回目录根后恢复") - } -} diff --git a/ShadowsocksX-NG2/Tests/ServerValidationTests.swift b/ShadowsocksX-NG2/Tests/ServerValidationTests.swift new file mode 100644 index 00000000..ff8b5ba7 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/ServerValidationTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +final class ServerValidationTests: XCTestCase { + func testSupportedServerWithOpaquePluginOptionsIsValid() throws { + let passwordRef = CredentialReference(rawValue: "password") + let optionsRef = CredentialReference(rawValue: "options") + let credentials = try ActivationFixture.makeCredentials([ + passwordRef: "password", + optionsRef: "plugin-option-that-the-app-does-not-parse", + ]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "aes-256-gcm", + passwordRef: passwordRef, + pluginProgram: "v2ray-plugin", + pluginOptionsRef: optionsRef) + + let validation = ServerValidation.evaluate( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertTrue(validation.isValid) + } + + func testUnsupportedEncryptionMethodIsAnInvalidActivationCandidate() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: "password"]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "future-cipher", + passwordRef: passwordRef) + + let validation = ServerValidation.evaluate( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(validation.issues, [.unsupportedEncryptionMethod("future-cipher")]) + } + + func testMissingPluginExecutableIsInvalidWithoutInspectingOptions() throws { + let passwordRef = CredentialReference(rawValue: "password") + let optionsRef = CredentialReference(rawValue: "options") + let credentials = try ActivationFixture.makeCredentials([ + passwordRef: "password", + optionsRef: "opaque-options", + ]) + let fields = ServerFields( + address: "203.0.113.7", + port: 8388, + encryptionMethod: "aes-256-gcm", + passwordRef: passwordRef, + pluginProgram: "unknown-plugin", + pluginOptionsRef: optionsRef) + + let validation = ServerValidation.evaluate( + fields, credentials: credentials, plugins: NoManagedPluginProvider()) + + XCTAssertEqual( + validation.issues, + [.pluginNotProvided(program: "unknown-plugin")]) + } + + func testMissingPasswordIsInvalid() throws { + let passwordRef = CredentialReference(rawValue: "password") + let credentials = try ActivationFixture.makeCredentials([passwordRef: ""]) + let fields = ActivationFixture.plainFields(remark: "server", passwordRef: passwordRef) + + let validation = ServerValidation.evaluate( + fields, credentials: credentials, plugins: ActivationFixture.plugins) + + XCTAssertEqual(validation.issues, [.credentialUnresolved(passwordRef)]) + } +} diff --git a/ShadowsocksX-NG2/Tests/SsUriTests.swift b/ShadowsocksX-NG2/Tests/SsUriTests.swift index 67ed78ec..aee8a52c 100644 --- a/ShadowsocksX-NG2/Tests/SsUriTests.swift +++ b/ShadowsocksX-NG2/Tests/SsUriTests.swift @@ -206,8 +206,6 @@ final class SsUriTests: XCTestCase { host: "203.0.113.7", port: 8388), // IPv6 往返。 SsUri(method: "aes-256-gcm", password: "p", host: "2001:db8::1", port: 9101), - // 空密码合法(如占位)。 - SsUri(method: "aes-256-gcm", password: "", host: "203.0.113.7", port: 8388), ] for original in corpus { let decoded = try SsUri.decode(original.encode()) @@ -215,6 +213,13 @@ final class SsUriTests: XCTestCase { } } + func testDecodeRejectsEmptyPassword() { + let encoded = SsUri( + method: "aes-256-gcm", password: "", host: "203.0.113.7", port: 8388 + ).encode() + XCTAssertThrowsError(try SsUri.decode(encoded)) + } + func testCanonicalInputSurvivesDecodeEncodeVerbatim() throws { let canonical = "ss://cmM0LW1kNTpwYXNzd29yZA@192.168.100.1:8888" diff --git a/ShadowsocksX-NG2/Tests/SubscriptionSnapshotApplicationTests.swift b/ShadowsocksX-NG2/Tests/SubscriptionSnapshotApplicationTests.swift index c4e4124c..353fe5a2 100644 --- a/ShadowsocksX-NG2/Tests/SubscriptionSnapshotApplicationTests.swift +++ b/ShadowsocksX-NG2/Tests/SubscriptionSnapshotApplicationTests.swift @@ -2,9 +2,8 @@ import XCTest @testable import ShadowsocksX_NG2 -/// 订阅快照的目录应用语义(issue #35 验收「overlay 语义与身份连续性」): -/// enabled 仅按身份精确匹配延续;固定分组身份与 enabled 不变;远端删除不留 -/// 墓碑;结构与顺序远端权威。 +/// 订阅快照的目录应用语义(issue #35):固定分组身份保留;远端删除不留 +/// 墓碑;结构、字段与顺序远端权威,不存在本地启用覆盖层。 final class SubscriptionSnapshotApplicationTests: XCTestCase { private let groupID = NodeID(rawValue: "sub1:fixed-group") @@ -39,10 +38,8 @@ final class SubscriptionSnapshotApplicationTests: XCTestCase { // MARK: 快照应用 - func testSnapshotReplacesSubtreeAndCarriesEnabledOverlay() throws { + func testSnapshotReplacesSubtreeWithoutLocalEnableOverlay() throws { var catalog = try makeCatalog() - // 既有 overlay:a 停用、嵌套分组停用(其下 b 有效停用)。 - try catalog.setEnabled(NodeID(rawValue: "sub1:id:a"), false) let document = CatalogSubscriptionSnapshot( name: "远端新名", @@ -56,17 +53,14 @@ final class SubscriptionSnapshotApplicationTests: XCTestCase { let removed = try catalog.applySubscriptionSnapshot(document, into: groupID) XCTAssertEqual(removed.count, 2, "旧树两台服务器全部视为移除(b 从嵌套组移到根)") - // 固定分组身份与 enabled 保持不变,名称跟随远端;远端子序原样落目录。 + // 固定分组身份保持不变,名称跟随远端;远端子序原样落目录。 let fixed = try XCTUnwrap(catalog.entry(for: groupID)) XCTAssertEqual( try catalog.children(of: groupID), [NodeID(rawValue: "sub1:id:b"), NodeID(rawValue: "sub1:g:jp")]) guard case .group(let fields) = fixed.kind else { return XCTFail("固定分组应存在") } XCTAssertEqual(fields.name, "远端新名") - XCTAssertTrue(fixed.enabled) - // 精确匹配延续:a 停用保留;新节点默认启用。 - XCTAssertFalse(try XCTUnwrap(catalog.entry(for: NodeID(rawValue: "sub1:id:a"))).enabled) - XCTAssertTrue(try XCTUnwrap(catalog.entry(for: NodeID(rawValue: "sub1:id:new"))).enabled) + XCTAssertEqual(fixed.source, .subscription) // 旧树已重建,无墓碑:固定分组 + 根序 b + 嵌套组 + a + 新节点。 XCTAssertEqual(catalog.entries.count, 5) } @@ -84,15 +78,14 @@ final class SubscriptionSnapshotApplicationTests: XCTestCase { XCTAssertEqual(catalog.entries.count, 2) } - func testFixedGroupDisabledStateSurvivesRefresh() throws { + func testFixedGroupStateIsFullyRemoteOwnedOnRefresh() throws { var catalog = try makeCatalog() - try catalog.setEnabled(groupID, false) let document = CatalogSubscriptionSnapshot( name: "新名", root: .init(id: groupID, name: "", children: [leaf("sub1:id:a")])) _ = try catalog.applySubscriptionSnapshot(document, into: groupID) - XCTAssertFalse(try XCTUnwrap(catalog.entry(for: groupID)).enabled, "固定分组本地开关保留") + XCTAssertEqual(try XCTUnwrap(catalog.entry(for: groupID)).displayName, "新名") } func testApplyToManualGroupRejected() throws { diff --git a/docs/adr/0001-remove-node-enable-state.md b/docs/adr/0001-remove-node-enable-state.md new file mode 100644 index 00000000..c05282d9 --- /dev/null +++ b/docs/adr/0001-remove-node-enable-state.md @@ -0,0 +1,19 @@ +# Remove node enable state and use activation preflight candidates + +The 2.0 configuration catalog no longer stores or exposes an enable/disable state on servers or groups. Imported and subscribed records with structurally complete but unsupported encryption methods or plugins remain visible as invalid activation candidates, while malformed records are rejected at their source boundary; activation preflight filters invalid group members and requires at least one usable server. This preserves recoverable user data without treating opaque plugin options or remote connectivity as app-verifiable facts, and keeps the active target identity separate from proxy runtime health. + +## Status + +Accepted. + +## Considered options + +- Keep the old state as a hidden compatibility flag: rejected because it would preserve a second eligibility model with no user-facing meaning. +- Reject an entire group when any descendant is invalid: rejected because one broken imported or subscribed record would hide otherwise usable servers. +- Reject every unsupported method or plugin at import time: rejected because users need to see and repair recoverable configuration data. + +## Consequences + +- Catalog persistence migrates old `enabled` values away and writes the next document version without them. +- Group activation can succeed with a subset of descendants, but surfaces the skipped invalid records and fails when none remain. +- App validation reports only known local blockers; opaque plugin options and remote reachability remain runtime concerns.