import Foundation public enum RemoteCommand: String, Codable, Sendable, CaseIterable { case up, down, left, right, select, back, home, playPause case volumeUp, volumeDown, mute } /// Decoding is structural only. Run every decoded envelope through CommandAdmission /// immediately before transport dispatch. Never queue an accepted command for replay. public struct CommandEnvelope: Codable, Sendable, Equatable { public let schemaVersion: Int public let id: UUID public let sessionID: UUID public let televisionID: UUID public let sequence: UInt64 public let issuedAt: Date public let expiresAt: Date public let command: RemoteCommand public init( sessionID: UUID, televisionID: UUID, sequence: UInt64, issuedAt: Date, expiresAt: Date, command: RemoteCommand, id: UUID = UUID(), schemaVersion: Int = 1 ) { self.schemaVersion = schemaVersion self.id = id self.sessionID = sessionID self.televisionID = televisionID self.sequence = sequence self.issuedAt = issuedAt self.expiresAt = expiresAt self.command = command } } public struct CommandReceipt: Codable, Sendable, Equatable { public enum Status: String, Codable, Sendable { /// Bytes were submitted to the transport; this does not verify a TV effect. case sentToTransport case rejected, expired, duplicate, unavailable } public let id: UUID public let status: Status public init(id: UUID, status: Status) { self.id = id self.status = status } } /// Implementations own admission, readiness, credentials and bounded lifetime. /// They must not retry/replay expired commands or describe a send as TV confirmation. public protocol RemoteTransport: Sendable { func send(_ envelope: CommandEnvelope) async -> CommandReceipt } public enum AdmissionRejection: String, Codable, Sendable, Equatable { case unsupportedSchema, wrongSession, wrongTelevision, invalidTimestamp case invalidLifetime, futureTimestamp, expired, stale, duplicate, outOfOrder } public enum AdmissionDecision: Sendable, Equatable { case accepted case rejected(AdmissionRejection) } /// Value-type admission gate: isolate mutation in the transport actor. State advances /// at admission, so a failed send consumes its sequence rather than replaying it. public struct CommandAdmission: Sendable { public private(set) var sessionID: UUID public private(set) var televisionID: UUID public private(set) var lastSequence: UInt64? public static let maximumAge: TimeInterval = 1 public static let maximumFutureSkew: TimeInterval = 0.25 public static let maximumLifetime: TimeInterval = 1 public static let rememberedIDLimit = 128 private var rememberedIDs: [UUID] = [] private var rememberedIDSet: Set = [] public init(sessionID: UUID, televisionID: UUID) { self.sessionID = sessionID self.televisionID = televisionID } /// A reconnect must create a fresh session ID; never reset into an old session. /// The returned ID must be communicated to the watch before admitting input. @discardableResult public mutating func startNewSession(televisionID: UUID) -> UUID { sessionID = UUID() self.televisionID = televisionID lastSequence = nil rememberedIDs.removeAll(keepingCapacity: true) rememberedIDSet.removeAll(keepingCapacity: true) return sessionID } public mutating func admit(_ envelope: CommandEnvelope, now: Date) -> AdmissionDecision { guard envelope.schemaVersion == 1 else { return .rejected(.unsupportedSchema) } guard envelope.sessionID == sessionID else { return .rejected(.wrongSession) } guard envelope.televisionID == televisionID else { return .rejected(.wrongTelevision) } let issued = envelope.issuedAt.timeIntervalSinceReferenceDate let expiry = envelope.expiresAt.timeIntervalSinceReferenceDate let current = now.timeIntervalSinceReferenceDate guard issued.isFinite, expiry.isFinite, current.isFinite else { return .rejected(.invalidTimestamp) } let lifetime = expiry - issued guard lifetime > 0, lifetime <= Self.maximumLifetime else { return .rejected(.invalidLifetime) } guard issued - current <= Self.maximumFutureSkew else { return .rejected(.futureTimestamp) } guard current - issued <= Self.maximumAge else { return .rejected(.stale) } guard current < expiry else { return .rejected(.expired) } guard !rememberedIDSet.contains(envelope.id) else { return .rejected(.duplicate) } if let lastSequence, envelope.sequence <= lastSequence { return .rejected(.outOfOrder) } lastSequence = envelope.sequence rememberedIDs.append(envelope.id) rememberedIDSet.insert(envelope.id) if rememberedIDs.count > Self.rememberedIDLimit { rememberedIDSet.remove(rememberedIDs.removeFirst()) } return .accepted } }