129 lines
5.0 KiB
Swift
129 lines
5.0 KiB
Swift
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<UUID> = []
|
|
|
|
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
|
|
}
|
|
}
|