- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
133 lines
5.0 KiB
Swift
133 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
public enum UpstreamRoute: Hashable, Sendable {
|
|
case health
|
|
case device
|
|
case sim
|
|
case network
|
|
case signalStrength
|
|
case stats
|
|
case statsCpu
|
|
case connectivity
|
|
case smsList(limit: Int, offset: Int, direction: String?)
|
|
case smsSend(phoneNumber: String, content: String)
|
|
case smsBatchDelete(ids: [Int])
|
|
case authLogin(password: String)
|
|
case authLogout
|
|
|
|
public var method: String {
|
|
switch self {
|
|
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
|
|
.connectivity, .smsList:
|
|
return "GET"
|
|
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
|
|
return "POST"
|
|
}
|
|
}
|
|
|
|
public var path: String {
|
|
switch self {
|
|
case .health: return "/api/health"
|
|
case .device: return "/api/device"
|
|
case .sim: return "/api/sim"
|
|
case .network: return "/api/network"
|
|
case .signalStrength: return "/api/network/signal-strength"
|
|
case .stats: return "/api/stats"
|
|
case .statsCpu: return "/api/stats/cpu"
|
|
case .connectivity: return "/api/connectivity"
|
|
case .smsList(let limit, let offset, let direction):
|
|
var items = ["limit=\(limit)", "offset=\(offset)"]
|
|
if let direction {
|
|
items.append("direction=\(direction)")
|
|
}
|
|
return "/api/sms/list?\(items.joined(separator: "&"))"
|
|
case .smsSend: return "/api/sms/send"
|
|
case .smsBatchDelete: return "/api/sms/batch-delete"
|
|
case .authLogin: return "/api/auth/login"
|
|
case .authLogout: return "/api/auth/logout"
|
|
}
|
|
}
|
|
|
|
public var body: Data? {
|
|
switch self {
|
|
case .smsSend(let phoneNumber, let content):
|
|
return try? JSONSerialization.data(
|
|
withJSONObject: ["phone_number": phoneNumber, "content": content]
|
|
)
|
|
case .smsBatchDelete(let ids):
|
|
return try? JSONSerialization.data(withJSONObject: ["ids": ids])
|
|
case .authLogin(let password):
|
|
return try? JSONSerialization.data(withJSONObject: ["password": password])
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Whether the node's agent will demand a confirmation intent for this route.
|
|
///
|
|
/// This is the app's copy of `policy.dangerous` in `contracts/agent-policy.json`, and the two
|
|
/// are asserted equal by the relay repository's policy test. It is a copy rather than a shared
|
|
/// file because the app cannot read the relay's repository at build time; the test is what
|
|
/// keeps it from drifting.
|
|
public var requiresConfirmation: Bool {
|
|
switch self {
|
|
case .smsSend, .smsBatchDelete:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum UpstreamError: Error, Equatable {
|
|
case invalidRequest
|
|
case unauthorized
|
|
case forbidden
|
|
case notSupported
|
|
case timeout
|
|
case transportFailed
|
|
case invalidResponse
|
|
|
|
// Relay-mode failures. These are deliberately separate cases rather than a reuse of
|
|
// transportFailed: in relay mode the user has four genuinely different situations to tell
|
|
// apart, and a single "请求失败" for all of them is what makes a two-hop architecture
|
|
// impossible to operate.
|
|
/// The relay could not be reached at all.
|
|
case relayUnreachable
|
|
/// The relay took too long to answer.
|
|
case relayTimeout
|
|
/// The relay rejected this phone's bearer token: the pairing is no longer valid there.
|
|
case relayRejectedDevice
|
|
/// The relay does not know this node, or this phone is not paired to it.
|
|
case relayNodeUnknown
|
|
/// The node's agent is not connected to the relay.
|
|
case agentOffline
|
|
/// The node is connected but already has the maximum number of requests in flight.
|
|
case nodeBusy
|
|
/// The node accepted the request but did not answer within the relay's deadline. It may or may
|
|
/// not have executed it, which is why the message must not invite a blind retry.
|
|
case agentTimeout
|
|
/// The relay refused the envelope before delivering it.
|
|
case relayBadEnvelope
|
|
/// The relay is rate limiting this phone.
|
|
case relayRateLimited
|
|
/// The node's agent rejected the request: not on its allowlist, or the device is not paired.
|
|
case relayPolicyDenied
|
|
/// The node received the request and the confirmation intent did not match.
|
|
case confirmationRejected
|
|
/// The envelope was outside the node's freshness window, or its id had already been used.
|
|
case envelopeExpired
|
|
/// The node's own rate limit refused a dangerous write.
|
|
case nodeRateLimited
|
|
/// The node's agent could not authenticate to its local SimAdmin.
|
|
case nodeAuthFailed
|
|
/// The request body exceeded the node's limit.
|
|
case payloadTooLarge
|
|
/// The node signed nothing, or the answer was not for this request.
|
|
case relayResponseUnverifiable
|
|
/// The relay answered about a different request or node.
|
|
case relayResponseMismatch
|
|
/// The node refused the envelope's signature or shape.
|
|
case envelopeRejected
|
|
}
|