- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
139 lines
5.3 KiB
Swift
139 lines
5.3 KiB
Swift
import Foundation
|
|
|
|
public protocol SimAdminClientProtocol: Sendable {
|
|
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
|
|
}
|
|
|
|
extension SimAdminClient: SimAdminClientProtocol {}
|
|
|
|
public enum ConnectionStatus: String, Equatable, Sendable {
|
|
case online
|
|
case offline
|
|
case authRequired
|
|
case degraded
|
|
case unknown
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .online: return "在线"
|
|
case .offline: return "离线"
|
|
case .authRequired: return "需登录"
|
|
case .degraded: return "降级"
|
|
case .unknown: return "未知"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ConnectionResult: Equatable, Sendable {
|
|
public let status: ConnectionStatus
|
|
public let requestLatencyMilliseconds: Int?
|
|
public let checkedAt: Date
|
|
/// Why the probe ended where it did.
|
|
///
|
|
/// Kept alongside the status rather than folded into it because the status vocabulary is five
|
|
/// values and the failure vocabulary is much larger: in relay mode "the relay is unreachable",
|
|
/// "the agent is offline", "the envelope was refused" and "the node returned an error" are four
|
|
/// different user actions, and a single `.offline` cannot say which one happened.
|
|
public let error: UpstreamError?
|
|
|
|
public init(
|
|
status: ConnectionStatus,
|
|
requestLatencyMilliseconds: Int? = nil,
|
|
checkedAt: Date,
|
|
error: UpstreamError? = nil
|
|
) {
|
|
self.status = status
|
|
self.requestLatencyMilliseconds = requestLatencyMilliseconds
|
|
self.checkedAt = checkedAt
|
|
self.error = error
|
|
}
|
|
|
|
/// How old this reading is, for the freshness vocabulary. A relay reading is inherently older
|
|
/// than a direct one, and saying so is the difference between "online" meaning now and meaning
|
|
/// forty seconds ago.
|
|
public var freshness: SnapshotFreshness {
|
|
SnapshotFreshness.classify(age: Date().timeIntervalSince(checkedAt))
|
|
}
|
|
}
|
|
|
|
public struct ConnectionProbe: Sendable {
|
|
private let transport: any NodeTransport
|
|
|
|
public init(transport: any NodeTransport) {
|
|
self.transport = transport
|
|
}
|
|
|
|
/// Direct-mode construction, kept for the existing call sites and tests.
|
|
public init(client: any SimAdminClientProtocol) {
|
|
self.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore()))
|
|
}
|
|
|
|
public func test(_ instance: Instance) async throws -> ConnectionResult {
|
|
let startedAt = DispatchTime.now()
|
|
do {
|
|
let response = try await transport.send(
|
|
.health,
|
|
instanceID: instance.id,
|
|
endpoint: instance.endpoint
|
|
)
|
|
let latency = Self.elapsedMilliseconds(since: startedAt)
|
|
if response.status == 401 || response.status == 403 {
|
|
return ConnectionResult(
|
|
status: .authRequired,
|
|
requestLatencyMilliseconds: latency,
|
|
checkedAt: Date()
|
|
)
|
|
}
|
|
return ConnectionResult(
|
|
status: .online,
|
|
requestLatencyMilliseconds: latency,
|
|
checkedAt: Date()
|
|
)
|
|
} catch {
|
|
let latency = Self.elapsedMilliseconds(since: startedAt)
|
|
let upstream = error as? UpstreamError
|
|
return ConnectionResult(
|
|
status: Self.status(for: upstream, error: error),
|
|
requestLatencyMilliseconds: latency,
|
|
checkedAt: Date(),
|
|
error: upstream
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Maps a failure onto the five-value status vocabulary the list already uses, while the typed
|
|
/// error travels alongside it for the detail view.
|
|
///
|
|
/// The relay-mode cases are mapped rather than collapsed: an offline agent is a node problem the
|
|
/// user waits out, a rejected envelope is a pairing problem that will never fix itself, and an
|
|
/// unreachable relay is neither.
|
|
static func status(for upstream: UpstreamError?, error: Error) -> ConnectionStatus {
|
|
switch upstream {
|
|
case .unauthorized:
|
|
return .authRequired
|
|
case .timeout, .relayTimeout, .relayUnreachable, .agentOffline, .agentTimeout, .nodeBusy:
|
|
return .offline
|
|
case .relayRejectedDevice, .relayNodeUnknown:
|
|
// The pairing is gone: retrying cannot help, and the user has to pair again.
|
|
return .offline
|
|
case .relayPolicyDenied, .confirmationRejected, .envelopeRejected,
|
|
.envelopeExpired, .relayResponseUnverifiable, .relayResponseMismatch,
|
|
.relayBadEnvelope, .nodeAuthFailed, .payloadTooLarge:
|
|
return .degraded
|
|
case .relayRateLimited, .nodeRateLimited:
|
|
return .degraded
|
|
case .invalidRequest, .notSupported, .transportFailed, .invalidResponse, .forbidden:
|
|
return .degraded
|
|
case .none:
|
|
if error is URLError { return .offline }
|
|
return .degraded
|
|
}
|
|
}
|
|
|
|
private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? {
|
|
let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds
|
|
let milliseconds = Double(nanoseconds) / 1_000_000
|
|
return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil
|
|
}
|
|
}
|