167 lines
6.9 KiB
Swift
167 lines
6.9 KiB
Swift
import Foundation
|
|
|
|
/// Frozen v1 routing vocabulary. Dictionary parsing and request/reply routing
|
|
/// belong to WatchBridge; these constants are not an authorization mechanism.
|
|
public enum RelayMessageKind: String, Codable, Sendable, CaseIterable {
|
|
case beginSession, session, endSession, ended, command, receipt, error
|
|
}
|
|
|
|
public enum RelayWire {
|
|
public static let schemaVersion = 1
|
|
public static let maximumPayloadBytes = 4_096
|
|
public static let maximumCommandLifetimeMilliseconds: UInt64 = 1_000
|
|
}
|
|
|
|
public enum RelayContractError: Error, Sendable, Equatable {
|
|
case unsupportedSchema(Int)
|
|
case invalidLifetime
|
|
case invalidTimestamp
|
|
case payloadTooLarge
|
|
}
|
|
|
|
/// Control-plane structural invariants only. Command admission remains in the
|
|
/// phone actor and must never be consumed by a bridge decoder.
|
|
public protocol RelayControlPayload: Codable, Sendable {
|
|
func validate() throws
|
|
}
|
|
|
|
private func validateSchema(_ version: Int) throws {
|
|
guard version == RelayWire.schemaVersion else {
|
|
throw RelayContractError.unsupportedSchema(version)
|
|
}
|
|
}
|
|
|
|
public struct BeginRemoteSessionRequest: RelayControlPayload, Equatable {
|
|
public let schemaVersion: Int
|
|
public init(schemaVersion: Int = RelayWire.schemaVersion) {
|
|
self.schemaVersion = schemaVersion
|
|
}
|
|
public func validate() throws { try validateSchema(schemaVersion) }
|
|
}
|
|
|
|
public struct RemoteSessionGrant: RelayControlPayload, Equatable {
|
|
public let schemaVersion: Int
|
|
public let sessionID: UUID
|
|
public let televisionID: UUID
|
|
public let issuedAt: Date
|
|
public let maximumCommandLifetimeMilliseconds: UInt64
|
|
|
|
public init(sessionID: UUID, televisionID: UUID, issuedAt: Date,
|
|
maximumCommandLifetimeMilliseconds: UInt64 = RelayWire.maximumCommandLifetimeMilliseconds,
|
|
schemaVersion: Int = RelayWire.schemaVersion) {
|
|
self.schemaVersion = schemaVersion
|
|
self.sessionID = sessionID
|
|
self.televisionID = televisionID
|
|
self.issuedAt = issuedAt
|
|
self.maximumCommandLifetimeMilliseconds = maximumCommandLifetimeMilliseconds
|
|
}
|
|
|
|
public func validate() throws {
|
|
try validateSchema(schemaVersion)
|
|
guard issuedAt.timeIntervalSince1970.isFinite else {
|
|
throw RelayContractError.invalidTimestamp
|
|
}
|
|
guard maximumCommandLifetimeMilliseconds > 0,
|
|
maximumCommandLifetimeMilliseconds <= RelayWire.maximumCommandLifetimeMilliseconds else {
|
|
throw RelayContractError.invalidLifetime
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct EndRemoteSessionRequest: RelayControlPayload, Equatable {
|
|
public let schemaVersion: Int
|
|
public let sessionID: UUID
|
|
public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) {
|
|
self.schemaVersion = schemaVersion
|
|
self.sessionID = sessionID
|
|
}
|
|
public func validate() throws { try validateSchema(schemaVersion) }
|
|
}
|
|
|
|
public struct RemoteSessionEnded: RelayControlPayload, Equatable {
|
|
public let schemaVersion: Int
|
|
public let sessionID: UUID
|
|
public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) {
|
|
self.schemaVersion = schemaVersion
|
|
self.sessionID = sessionID
|
|
}
|
|
public func validate() throws { try validateSchema(schemaVersion) }
|
|
}
|
|
|
|
public struct RelayFailure: Error, RelayControlPayload, Equatable {
|
|
public enum Code: String, Codable, Sendable {
|
|
case unsupportedSchema, malformedRequest, noSelectedTelevision
|
|
case unavailable, sessionInvalidated
|
|
}
|
|
public let schemaVersion: Int
|
|
public let code: Code
|
|
public init(code: Code, schemaVersion: Int = RelayWire.schemaVersion) {
|
|
self.schemaVersion = schemaVersion
|
|
self.code = code
|
|
}
|
|
public func validate() throws { try validateSchema(schemaVersion) }
|
|
}
|
|
|
|
/// Every call creates a local coder, avoiding shared mutable coder concurrency.
|
|
/// Dates are numeric milliseconds since Unix epoch, never Foundation's default
|
|
/// reference-date seconds. A decoded command still requires actor admission.
|
|
public enum RelayJSON {
|
|
public static func encode<Value: Encodable>(_ value: Value) throws -> Data {
|
|
if let control = value as? any RelayControlPayload { try control.validate() }
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .millisecondsSince1970
|
|
let data = try encoder.encode(value)
|
|
guard data.count <= RelayWire.maximumPayloadBytes else {
|
|
throw RelayContractError.payloadTooLarge
|
|
}
|
|
return data
|
|
}
|
|
|
|
public static func decode<Value: Decodable>(_ type: Value.Type, from data: Data) throws -> Value {
|
|
guard data.count <= RelayWire.maximumPayloadBytes else {
|
|
throw RelayContractError.payloadTooLarge
|
|
}
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .millisecondsSince1970
|
|
let value = try decoder.decode(type, from: data)
|
|
if let control = value as? any RelayControlPayload { try control.validate() }
|
|
return value
|
|
}
|
|
}
|
|
|
|
/// Local activation generation, not a hardware identifier or wire value.
|
|
/// Create a fresh value each time the phone bridge activates a counterpart.
|
|
public struct WatchPeerGeneration: Sendable, Hashable {
|
|
public let id: UUID
|
|
public init(id: UUID = UUID()) { self.id = id }
|
|
}
|
|
|
|
/// A/integration implements this on the SAME actor that owns CommandAdmission
|
|
/// and TV dispatch. B injects it and can use a fake while A is implemented.
|
|
/// Never add a second consuming gate behind a forwarding implementation.
|
|
public protocol PhoneCommandCoordinating: Actor {
|
|
/// B serializes lifecycle events and awaits this before accepting requests.
|
|
/// Re-activating the same peer is idempotent; a new peer invalidates old
|
|
/// grants and pending work. This method must not pair or send a TV key.
|
|
func watchBridgeDidActivate(peer: WatchPeerGeneration) async
|
|
|
|
/// Validate the request and current peer, require a ready selected TV, and
|
|
/// issue a fresh session (invalidating any previous one for this peer).
|
|
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
|
|
async -> Result<RemoteSessionGrant, RelayFailure>
|
|
|
|
/// End only the named current session for this peer. Stale ends must not
|
|
/// invalidate a newer session; report sessionInvalidated instead.
|
|
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
|
|
async -> Result<RemoteSessionEnded, RelayFailure>
|
|
|
|
/// Sole semantic admission and TV dispatch boundary. Reject old peers and
|
|
/// recheck peer/session/TV/deadline after suspension before writing bytes.
|
|
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration)
|
|
async -> CommandReceipt
|
|
|
|
/// Ignore a stale peer's delayed deactivation. For the current peer,
|
|
/// invalidate sessions and pending commands; do not replay on activation.
|
|
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async
|
|
}
|