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

167 lines
6.9 KiB
Swift

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: Encodable>(_ 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<Value: Decodable>(_ 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<RemoteSessionGrant, RelayFailure>
/// 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<RemoteSessionEnded, RelayFailure>
/// 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
}