Create Wristivo iPhone and Apple Watch foundation
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user