Files
wristivo/Packages/WristivoKit/Sources/RemoteCore/RemoteCore.swift
T

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
}
}