Establish watch relay contracts and platform package wiring
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user