Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/Subscription/SubscriptionRefreshFailure.swift
T
Qiu Yuzhou 856d6b4272 refactor(domain): 按主题重组 Domain/ 源码目录
Domain/ 根目录原有 62 个平铺源文件,无法从目录读出主题划分。

按「目录即主题」重组为 11 个目录(Rules/Custom 为嵌套子目录):Catalog /
Subscription / Rules / Runtime / Activation / Settings / SystemProxy /
Plugin / Credentials / LegacyImport / Diagnostics。纯移动:类型名、模块名
与 import 均未改动,Domain/ 根下不再有散落文件。目录口径取自
GLOSSARY.md 的章节划分(配置与订阅、代理规则、代理控制与网络、设置、
凭据与诊断),而非新造分类。

配套改动:

- project.yml:wrapper 与 helper 两个 target 按文件引用的 15 条显式路径
  随目录改名,并补注释固定这一约定——这 15 条指向不存在的文件时,
  xcodegen generate 以 "missing source directory" 直接失败(实测,不静默)。
- SkeletonTests 新增 DomainLayoutTests:断言 Domain/ 根下无平铺 .swift、
  树下只有 .swift。后者防的是与 App/ 同源的坑——Domain/ 整目录是 app 与
  Core 的 source path,任何非源码文件都会被 XcodeGen 收进 Resources 阶段
  并封进签名后的 bundle。
- README 工程结构说明同步;docs/design 与 docs/research 属历史文档不改。

验证:xcodegen generate 后 app 164 / Core 163 源文件数与整理前一致;Agent
恰好 9 个、Helper 恰好 6 个 Domain 文件(无缺失无多出);app 与 Core 各覆盖
全部 62 个。clean build 0 error;task test 1024 用例全通过(整理前 1022,
+2 为新增守卫);task format 未改写任何被移动文件(证明纯移动);task lint
与整理前同为 21 条内容型 warning;packaging-gate.sh ALL CHECKS PASSED。

布局守卫已做变异验证:临时投放松散 .swift 与非 .swift 文件时两个用例均
失败并点名违规文件,清理后复验为绿。
2026-10-05 20:13:35 +08:00

288 lines
9.2 KiB
Swift

import Foundation
/// Safe, durable facts for the most recent subscription refresh failure.
///
/// This type is deliberately a closed whitelist. It carries enough information
/// for a presentation edge to explain the failure, but never stores a URL,
/// token, credential reference, arbitrary platform description, or raw parser
/// detail in the catalog.
enum SubscriptionRefreshFailure: Codable, Equatable, Sendable {
enum TransportCategory: String, Codable, Equatable, Sendable {
case timedOut
case tls
case connection
case unknown
}
enum ContentTypeCategory: String, Codable, Equatable, Sendable {
case missing
case unsupported
}
enum RecordField: String, Codable, Equatable, Sendable {
case address
case port
case method
case password
case identity
}
enum CredentialCategory: String, Codable, Equatable, Sendable {
case missing
case read
case write
}
enum CommitCategory: String, Codable, Equatable, Sendable {
case persistence
case credentials
}
case invalidURL
case unsupportedScheme
case insecureRedirect
case transport(TransportCategory)
case httpStatus(code: Int)
case contentType(ContentTypeCategory)
case decodingFailure
case unsupportedSchemaVersion
case missingServers
case recordValidation(index: Int, field: RecordField)
case duplicateIdentity
case credential(category: CredentialCategory)
case commit(category: CommitCategory, rollback: CredentialRollbackStatus)
/// Failure text loaded from a pre-v4 document. The old detail is intentionally
/// discarded and cannot be written back.
case legacy
case unknown
private enum CodingKeys: String, CodingKey {
case kind
case category
case code
case index
case field
case rollback
}
private enum Kind: String, Codable {
case invalidURL
case unsupportedScheme
case insecureRedirect
case transport
case httpStatus
case contentType
case decodingFailure
case unsupportedSchemaVersion
case missingServers
case recordValidation
case duplicateIdentity
case credential
case commit
case legacy
case unknown
}
/// 无 payload 的失败值 ↔ kind 对照表:编解码共用,保持与 Kind 枚举一一对应。
private static let payloadlessKinds: [(kind: Kind, failure: SubscriptionRefreshFailure)] = [
(.invalidURL, .invalidURL),
(.unsupportedScheme, .unsupportedScheme),
(.insecureRedirect, .insecureRedirect),
(.decodingFailure, .decodingFailure),
(.unsupportedSchemaVersion, .unsupportedSchemaVersion),
(.missingServers, .missingServers),
(.duplicateIdentity, .duplicateIdentity),
(.legacy, .legacy),
(.unknown, .unknown),
]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(Kind.self, forKey: .kind)
if let payloadless = Self.payloadlessKinds.first(where: { $0.kind == kind })?.failure {
self = payloadless
} else {
self = try Self.decodePayload(kind: kind, from: container)
}
}
/// 带 payload 的 kind → 关联值解码;无 payload 的 kind 由对照表处理,
/// 这里显式列出以保持对 Kind 枚举的穷举检查。
private static func decodePayload(
kind: Kind, from container: KeyedDecodingContainer<CodingKeys>
) throws -> SubscriptionRefreshFailure {
switch kind {
case .transport:
return .transport(try container.decode(TransportCategory.self, forKey: .category))
case .httpStatus:
return .httpStatus(code: try container.decode(Int.self, forKey: .code))
case .contentType:
return .contentType(try container.decode(ContentTypeCategory.self, forKey: .category))
case .recordValidation:
return .recordValidation(
index: try container.decode(Int.self, forKey: .index),
field: try container.decode(RecordField.self, forKey: .field))
case .credential:
return .credential(category: try container.decode(CredentialCategory.self, forKey: .category))
case .commit:
return .commit(
category: try container.decode(CommitCategory.self, forKey: .category),
rollback: try container.decode(CredentialRollbackStatus.self, forKey: .rollback))
case .invalidURL, .unsupportedScheme, .insecureRedirect, .decodingFailure,
.unsupportedSchemaVersion, .missingServers, .duplicateIdentity, .legacy, .unknown:
preconditionFailure("无 payload 的失败种类应由对照表处理")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(kind, forKey: .kind)
try encodePayload(to: &container)
}
private var kind: Kind {
if let kind = Self.payloadlessKinds.first(where: { $0.failure == self })?.kind {
return kind
}
switch self {
case .transport:
return .transport
case .httpStatus:
return .httpStatus
case .contentType:
return .contentType
case .recordValidation:
return .recordValidation
case .credential:
return .credential
case .commit:
return .commit
case .invalidURL, .unsupportedScheme, .insecureRedirect, .decodingFailure,
.unsupportedSchemaVersion, .missingServers, .duplicateIdentity, .legacy, .unknown:
preconditionFailure("无 payload 的失败种类应由对照表处理")
}
}
/// 仅 payload 字段;kind 已由 encode(to:) 写入。穷举保持编译期检查。
private func encodePayload(to container: inout KeyedEncodingContainer<CodingKeys>) throws {
switch self {
case .transport(let category):
try container.encode(category, forKey: .category)
case .httpStatus(let code):
try container.encode(code, forKey: .code)
case .contentType(let category):
try container.encode(category, forKey: .category)
case .recordValidation(let index, let field):
try container.encode(index, forKey: .index)
try container.encode(field, forKey: .field)
case .credential(let category):
try container.encode(category, forKey: .category)
case .commit(let category, let rollback):
try container.encode(category, forKey: .category)
try container.encode(rollback, forKey: .rollback)
case .invalidURL, .unsupportedScheme, .insecureRedirect, .decodingFailure,
.unsupportedSchemaVersion, .missingServers, .duplicateIdentity, .legacy, .unknown:
break
}
}
}
/// Coarse credential recovery status allowed in persisted subscription state.
/// The full `CredentialRollbackOutcome` remains transient and stays in memory.
enum CredentialRollbackStatus: String, Codable, Equatable, Sendable {
case notNeeded
case restored
case incomplete
}
extension SubscriptionRefreshFailure {
/// The only conversion point for transport failures entering durable state.
static func from(fetchError: SubscriptionFetchError) -> Self {
switch fetchError {
case .invalidURL:
.invalidURL
case .unsupportedScheme:
.unsupportedScheme
case .insecureRedirect:
.insecureRedirect
case .transport(let detail):
.transport(transportCategory(for: detail))
case .httpStatus(let code):
.httpStatus(code: code)
case .contentType(let received):
.contentType(received == nil ? .missing : .unsupported)
}
}
/// The only conversion point for parser failures entering durable state.
static func from(parseError: SubscriptionParseError) -> Self {
switch parseError {
case .decodingFailure:
.decodingFailure
case .unsupportedSchemaVersion:
.unsupportedSchemaVersion
case .missingServers:
.missingServers
case .recordValidation(let index, let reason):
.recordValidation(index: index, field: recordField(for: reason))
case .duplicateServerID:
.duplicateIdentity
}
}
static func from(credentialError: CredentialStoreError, category: CredentialCategory) -> Self {
switch category {
case .missing:
.credential(category: .missing)
case .read:
.credential(category: .read)
case .write:
.credential(category: .write)
}
}
static func rollbackStatus(for outcome: CredentialRollbackOutcome) -> CredentialRollbackStatus {
switch outcome {
case .nothingToRestore:
.notNeeded
case .restored:
.restored
case .partial:
.incomplete
}
}
private static func transportCategory(for detail: String) -> TransportCategory {
let normalized = detail.lowercased()
if normalized.contains("timedout") || normalized.contains("timeout") {
return .timedOut
}
if normalized.contains("tls") || normalized.contains("secure") {
return .tls
}
if normalized.contains("connection") || normalized.contains("network")
|| normalized.contains("cannotconnect")
{
return .connection
}
return .unknown
}
private static func recordField(for reason: String) -> RecordField {
switch reason {
case let value where value.contains("server_port"):
.port
case let value where value.contains("server"):
.address
case let value where value.contains("method"):
.method
case let value where value.contains("password"):
.password
case let value where value.contains("id"):
.identity
default:
.identity
}
}
}