import Foundation /// How a node is reached, as a value the list can badge. public enum InstanceChannel: String, CaseIterable, Sendable { case direct case relay public var label: String { switch self { case .direct: return "直通" case .relay: return "中继" } } } public extension InstanceEndpoint { var channel: InstanceChannel { switch self { case .direct: return .direct case .relay: return .relay } } } /// How the app reaches a node. /// /// This exists rather than an optional `relayNodeID` field on `Instance` because `origin` currently /// carries three separate responsibilities — the request target, the duplicate-detection key, and /// the cookie binding domain — and in relay mode all three have to be re-homed. An optional field /// would leave two of them quietly pointing at an address that does not exist on a relay node. public enum InstanceEndpoint: Equatable, Sendable { /// The node is reachable directly on the local network. This is the original behaviour and its /// code path is unchanged. case direct(URL) /// The node is reached through a relay, with the agent on the node holding the session against /// the local SimAdmin. No node password is stored on the phone in this mode. case relay(RelayEndpoint) } public struct RelayEndpoint: Equatable, Sendable { /// The relay's base URL, https. The only outbound destination in relay mode. public var relayBaseURL: URL /// The node this endpoint reaches, as the relay and the agent both name it. public var nodeID: String /// `K_agent_sign` public half, Ed25519, 32 bytes. Every response is verified against it. public var agentSigningKey: Data /// `K_agent_kx` public half, X25519, 32 bytes. Requests are encrypted to it. public var agentKxKey: Data /// The bearer the relay expects from this phone. It authorises nothing on the node: the node's /// authorisation comes from the signature the phone produces with its own private key. /// /// A `String` rather than `Data`, which the design sketch drew: the relay compares the token as /// an opaque bearer value, so what has to survive the round trip is the exact text the node /// minted. Holding it as bytes invites a hex-encode on the way out, which sends a different /// string than the one the agent registered and fails as "unknown device" with nothing to see. public var deviceToken: String /// The device id the node knows this phone by, and which the phone signs into every request. public var deviceID: String /// The node's display name at pairing time, used only when the user has not renamed it. public var nodeName: String? public init( relayBaseURL: URL, nodeID: String, agentSigningKey: Data, agentKxKey: Data, deviceToken: String, deviceID: String, nodeName: String? = nil ) { self.relayBaseURL = relayBaseURL self.nodeID = nodeID self.agentSigningKey = agentSigningKey self.agentKxKey = agentKxKey self.deviceToken = deviceToken self.deviceID = deviceID self.nodeName = nodeName } } public extension InstanceEndpoint { /// The direct origin, or nil in relay mode. /// /// Callers that need "the address to dial directly" get nil rather than a substituted value, so /// a relay instance cannot accidentally be handed to a code path that would dial a LAN address /// that is not there. var directOrigin: URL? { if case .direct(let url) = self { return url } return nil } var relay: RelayEndpoint? { if case .relay(let endpoint) = self { return endpoint } return nil } var isRelay: Bool { relay != nil } /// The identity used for duplicate detection. /// /// Direct endpoints keep using the normalised origin so an existing node list upgrades without /// suddenly reporting duplicates; relay endpoints use the node id, because two phones paired to /// the same node are two different instances and one phone paired to two nodes is not. var dedupKey: String { switch self { case .direct(let url): return url.absoluteString case .relay(let endpoint): return "relay:\(endpoint.nodeID)" } } /// What the user sees as "where this node is". var displayAddress: String { switch self { case .direct(let url): return url.absoluteString case .relay(let endpoint): return "\(endpoint.relayBaseURL.host ?? "relay") · \(endpoint.nodeID)" } } /// Whether the phone holds a node password for this instance. /// /// The two modes mean different things and must not share a string: directly it means "this /// phone has the node's password", through a relay it means "the node's agent has its own /// credential". Showing the first wording in relay mode would tell the user their password is /// on the phone when it deliberately is not. var credentialDescription: String { switch self { case .direct: return "已保存本机密码" case .relay: return "由节点 agent 保存凭据" } } } // MARK: - Codable extension InstanceEndpoint: Codable { private enum CodingKeys: String, CodingKey { case kind, url, relay } private enum Kind: String, Codable { case direct, relay } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) switch try container.decode(Kind.self, forKey: .kind) { case .direct: self = .direct(try container.decode(URL.self, forKey: .url)) case .relay: self = .relay(try container.decode(RelayEndpoint.self, forKey: .relay)) } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .direct(let url): try container.encode(Kind.direct, forKey: .kind) try container.encode(url, forKey: .url) case .relay(let endpoint): try container.encode(Kind.relay, forKey: .kind) try container.encode(endpoint, forKey: .relay) } } } extension RelayEndpoint: Codable { private enum CodingKeys: String, CodingKey { case relayBaseURL, nodeID, agentSigningKey, agentKxKey, deviceToken, deviceID, nodeName } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) relayBaseURL = try container.decode(URL.self, forKey: .relayBaseURL) nodeID = try container.decode(String.self, forKey: .nodeID) agentSigningKey = try container.decode(Data.self, forKey: .agentSigningKey) agentKxKey = try container.decode(Data.self, forKey: .agentKxKey) deviceToken = try container.decode(String.self, forKey: .deviceToken) deviceID = try container.decode(String.self, forKey: .deviceID) nodeName = try container.decodeIfPresent(String.self, forKey: .nodeName) guard agentSigningKey.count == RelayWire.keyBytes, agentKxKey.count == RelayWire.keyBytes else { throw DecodingError.dataCorruptedError( forKey: .agentSigningKey, in: container, debugDescription: "agent key material must be 32 raw bytes" ) } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(relayBaseURL, forKey: .relayBaseURL) try container.encode(nodeID, forKey: .nodeID) try container.encode(agentSigningKey, forKey: .agentSigningKey) try container.encode(agentKxKey, forKey: .agentKxKey) try container.encode(deviceToken, forKey: .deviceToken) try container.encode(deviceID, forKey: .deviceID) try container.encodeIfPresent(nodeName, forKey: .nodeName) } } // MARK: - Relay error vocabulary /// Every code the relay can return, mirroring `relay/src/errors.js`. /// /// The console's `ProblemDetails.code` is an unconstrained string in the OpenAPI document, so this /// is a new vocabulary rather than a reuse of an existing one. It has to be enumerated in one place /// in the first version, because three implementations writing their own strings is exactly how /// cross-tier debugging stops working: the phone shows a code, the relay logs a code, and nobody can /// match them. public enum RelayErrorCode: String, CaseIterable, Sendable { case unauthorized = "relay_unauthorized" case nodeUnknown = "relay_node_unknown" case agentOffline = "relay_agent_offline" case mailboxFull = "relay_mailbox_full" case agentTimeout = "relay_agent_timeout" case badEnvelope = "relay_bad_envelope" case payloadTooLarge = "relay_payload_too_large" case rateLimited = "relay_rate_limited" case internalError = "relay_internal" case sessionExpired = "relay_session_expired" case badChallenge = "relay_bad_challenge" } /// The agent's own classification, carried inside the ciphertext as `InnerResponse.outcome`. /// /// Separate from `RelayErrorCode` because the two arrive through different channels and mean /// different things: a relay code is "the relay could not deliver this", an agent outcome is "the /// node received it and refused it". Collapsing them into one enum would lose the distinction the /// user needs, which is whether retrying can help. public enum AgentOutcome: String, CaseIterable, Sendable { case ok case badEnvelope = "bad_envelope" case badSignature = "bad_signature" case unknownDevice = "unknown_device" case expired case replay case policyDenied = "policy_denied" case confirmationMissing = "confirmation_missing" case confirmationMismatch = "confirmation_mismatch" case rateLimited = "rate_limited" case payloadTooLarge = "payload_too_large" case upstreamError = "upstream_error" case nodeAuthFailed = "node_auth_failed" } /// Freshness of a snapshot, mirroring `FRESHNESS_STATUSES` in `packages/contracts/src/instances.ts`. /// /// The four values are the existing vocabulary rather than new names. Without it a relay instance /// cannot express that its "online" reading is forty seconds old, which in a two-hop architecture is /// a substantive misstatement rather than a cosmetic one. public enum SnapshotFreshness: String, CaseIterable, Sendable { case fresh case stale case expired case unknown /// Thresholds, chosen to line up with what the UI already implies: a direct probe is fresh for /// as long as a user would call the screen current, and anything past the relay's own request /// deadline is expired rather than merely old. public static func classify(age: TimeInterval?) -> SnapshotFreshness { guard let age, age >= 0 else { return .unknown } switch age { case ..<15: return .fresh case ..<90: return .stale default: return .expired } } public var label: String { switch self { case .fresh: return "刚刚" case .stale: return "稍旧" case .expired: return "已过期" case .unknown: return "未知" } } }