Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.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

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
}