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: 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(_ 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 /// 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 /// 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 }