Establish watch relay contracts and platform package wiring

This commit is contained in:
2026-09-18 09:34:26 +04:00
parent a92698e1e6
commit 9b87299bf5
22 changed files with 778 additions and 159 deletions
@@ -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
}
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import RemoteCore
@Test func wireDatesUseUnixMillisecondsForGrantAndCommand() throws {
let session = UUID(), tv = UUID()
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let grant = RemoteSessionGrant(sessionID: session, televisionID: tv, issuedAt: issued)
let data = try RelayJSON.encode(grant)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(object["issuedAt"] as? Double == 1_700_000_000_125)
#expect(try RelayJSON.decode(RemoteSessionGrant.self, from: data) == grant)
let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .right)
let commandData = try RelayJSON.encode(command)
let commandObject = try #require(JSONSerialization.jsonObject(with: commandData) as? [String: Any])
#expect(commandObject["expiresAt"] as? Double == 1_700_000_000_625)
#expect(try RelayJSON.decode(CommandEnvelope.self, from: commandData) == command)
}
@Test func controlPayloadsRoundTripAndFailureIsAnError() throws {
let session = UUID()
let begin = BeginRemoteSessionRequest()
let end = EndRemoteSessionRequest(sessionID: session)
let ended = RemoteSessionEnded(sessionID: session)
let failure = RelayFailure(code: .sessionInvalidated)
#expect(try RelayJSON.decode(BeginRemoteSessionRequest.self, from: RelayJSON.encode(begin)) == begin)
#expect(try RelayJSON.decode(EndRemoteSessionRequest.self, from: RelayJSON.encode(end)) == end)
#expect(try RelayJSON.decode(RemoteSessionEnded.self, from: RelayJSON.encode(ended)) == ended)
#expect(try RelayJSON.decode(RelayFailure.self, from: RelayJSON.encode(failure)) == failure)
let result: Result<RemoteSessionGrant, RelayFailure> = .failure(failure)
#expect(throws: failure) { try result.get() }
}
@Test func malformedOrUnsupportedControlPayloadsFailClosed() throws {
#expect(throws: RelayContractError.unsupportedSchema(2)) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":2}"#.utf8))
}
#expect(throws: (any Error).self) {
try RelayJSON.decode(EndRemoteSessionRequest.self, from: Data(#"{"schemaVersion":1,"sessionID":"bad"}"#.utf8))
}
#expect(throws: (any Error).self) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":true}"#.utf8))
}
#expect(throws: RelayContractError.unsupportedSchema(9)) {
try RelayJSON.encode(RemoteSessionEnded(sessionID: UUID(), schemaVersion: 9))
}
}
@Test func invalidGrantLifetimeRejectedOnEncodeAndDecode() throws {
for lifetime: UInt64 in [0, 1_001, .max] {
let invalid = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(),
issuedAt: Date(timeIntervalSince1970: 1_000), maximumCommandLifetimeMilliseconds: lifetime)
#expect(throws: RelayContractError.invalidLifetime) { try RelayJSON.encode(invalid) }
// Bypass the validating encoder to simulate an untrusted peer.
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
let raw = try encoder.encode(invalid)
#expect(throws: RelayContractError.invalidLifetime) {
try RelayJSON.decode(RemoteSessionGrant.self, from: raw)
}
}
let invalidDate = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(),
issuedAt: Date(timeIntervalSince1970: .infinity))
#expect(throws: RelayContractError.invalidTimestamp) { try RelayJSON.encode(invalidDate) }
}
@Test func byteLimitIsCheckedBeforeDecodingAndAtEncoding() throws {
// JSON string quotes use 2 bytes. Exactly 4096 bytes is permitted.
let boundary = String(repeating: "a", count: RelayWire.maximumPayloadBytes - 2)
let encoded = try RelayJSON.encode(boundary)
#expect(encoded.count == RelayWire.maximumPayloadBytes)
#expect(try RelayJSON.decode(String.self, from: encoded) == boundary)
#expect(throws: RelayContractError.payloadTooLarge) { try RelayJSON.encode(boundary + "a") }
#expect(throws: RelayContractError.payloadTooLarge) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(repeating: 0, count: 4_097))
}
}
@Test func commandDecodeDoesNotConsumeOrReplaceAdmission() throws {
let session = UUID(), tv = UUID(), now = Date(timeIntervalSince1970: 1_000)
let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1,
issuedAt: now, expiresAt: now.addingTimeInterval(0.5), command: .left)
let data = try RelayJSON.encode(command)
var admission = CommandAdmission(sessionID: session, televisionID: tv)
#expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .accepted)
#expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .rejected(.duplicate))
}