feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -11,13 +11,39 @@ public enum RepositoryError: Error, Equatable, Sendable {
|
||||
public struct Instance: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public var name: String
|
||||
public var origin: URL
|
||||
/// Where this node is reached from. Direct on the local network, or through a relay.
|
||||
///
|
||||
/// This replaced a non-optional `origin: URL`. The migration in `init(from:)` reads the old
|
||||
/// shape so an existing node list upgrades without being reported as duplicate, and every write
|
||||
/// emits only the new shape.
|
||||
public var endpoint: InstanceEndpoint
|
||||
public var tags: [String]
|
||||
public var credentialConfigured: Bool
|
||||
public var configRevision: Int
|
||||
public let createdAt: Date
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
name: String,
|
||||
endpoint: InstanceEndpoint,
|
||||
tags: [String] = [],
|
||||
credentialConfigured: Bool = false,
|
||||
configRevision: Int = 1,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
self.tags = tags
|
||||
self.credentialConfigured = credentialConfigured
|
||||
self.configRevision = configRevision
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convenience for the direct case, which is what most call sites mean.
|
||||
public init(
|
||||
id: UUID,
|
||||
name: String,
|
||||
@@ -28,50 +54,113 @@ public struct Instance: Codable, Equatable, Identifiable, Sendable {
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.credentialConfigured = credentialConfigured
|
||||
self.configRevision = configRevision
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
endpoint: .direct(origin),
|
||||
tags: tags,
|
||||
credentialConfigured: credentialConfigured,
|
||||
configRevision: configRevision,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// The direct origin, when this instance has one.
|
||||
public var origin: URL? { endpoint.directOrigin }
|
||||
|
||||
public var isRelay: Bool { endpoint.isRelay }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, endpoint, origin, tags, credentialConfigured, configRevision, createdAt, updatedAt
|
||||
}
|
||||
|
||||
/// Decodes the new shape, falling back to the pre-relay `origin` field.
|
||||
///
|
||||
/// The fallback is what makes the upgrade invisible: an app that dropped support for the old key
|
||||
/// would fail to decode the whole store and lose every node. Note that `origin` is decoded
|
||||
/// leniently as a string and parsed here rather than decoded as a `URL`, because the encoder
|
||||
/// that wrote it may have produced a spelling `URL` will not round-trip from a bare string in
|
||||
/// every case.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
if let endpoint = try container.decodeIfPresent(InstanceEndpoint.self, forKey: .endpoint) {
|
||||
self.endpoint = endpoint
|
||||
} else if let origin = try container.decodeIfPresent(URL.self, forKey: .origin) {
|
||||
self.endpoint = .direct(origin)
|
||||
} else {
|
||||
throw DecodingError.keyNotFound(
|
||||
CodingKeys.endpoint,
|
||||
DecodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "an instance must carry either endpoint or the legacy origin"
|
||||
)
|
||||
)
|
||||
}
|
||||
tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
|
||||
credentialConfigured = try container.decodeIfPresent(Bool.self, forKey: .credentialConfigured) ?? false
|
||||
configRevision = try container.decodeIfPresent(Int.self, forKey: .configRevision) ?? 1
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(endpoint, forKey: .endpoint)
|
||||
try container.encode(tags, forKey: .tags)
|
||||
try container.encode(credentialConfigured, forKey: .credentialConfigured)
|
||||
try container.encode(configRevision, forKey: .configRevision)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
try container.encode(updatedAt, forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstanceInput: Sendable {
|
||||
public var name: String
|
||||
public var origin: URL
|
||||
public var endpoint: InstanceEndpoint
|
||||
public var tags: [String]
|
||||
public var password: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
endpoint: InstanceEndpoint,
|
||||
tags: [String] = [],
|
||||
password: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
self.tags = tags
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
origin: URL,
|
||||
tags: [String] = [],
|
||||
password: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.password = password
|
||||
self.init(name: name, endpoint: .direct(origin), tags: tags, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstancePatch: Sendable {
|
||||
public var name: String?
|
||||
public var origin: URL?
|
||||
public var endpoint: InstanceEndpoint?
|
||||
public var tags: [String]?
|
||||
public var password: String?
|
||||
|
||||
public init(
|
||||
name: String? = nil,
|
||||
origin: URL? = nil,
|
||||
endpoint: InstanceEndpoint? = nil,
|
||||
tags: [String]? = nil,
|
||||
password: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.endpoint = endpoint
|
||||
self.tags = tags
|
||||
self.password = password
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user