Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift
T
chick 3f6090081f 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)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

186 lines
6.3 KiB
Swift

import Foundation
public enum RepositoryError: Error, Equatable, Sendable {
case invalidOrigin
case duplicateOrigin
case revisionConflict
case notFound
case databaseFailed
}
public struct Instance: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var name: String
/// 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,
origin: URL,
tags: [String] = [],
credentialConfigured: Bool = false,
configRevision: Int = 1,
createdAt: Date,
updatedAt: Date
) {
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 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.init(name: name, endpoint: .direct(origin), tags: tags, password: password)
}
}
public struct InstancePatch: Sendable {
public var name: String?
public var endpoint: InstanceEndpoint?
public var tags: [String]?
public var password: String?
public init(
name: String? = nil,
endpoint: InstanceEndpoint? = nil,
tags: [String]? = nil,
password: String? = nil
) {
self.name = name
self.endpoint = endpoint
self.tags = tags
self.password = password
}
}
public struct InstanceQuery: Sendable {
public var search: String?
public var tags: [String]?
public init(search: String? = nil, tags: [String]? = nil) {
self.search = search
self.tags = tags
}
}
public protocol InstanceRepository: Sendable {
func list(_ query: InstanceQuery) async throws -> [Instance]
func get(_ id: UUID) async throws -> Instance?
func create(_ input: InstanceInput) async throws -> Instance
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
func delete(_ id: UUID, expectedRevision: Int) async throws
}