Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
@@ -0,0 +1,37 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# MotionCapture integration proposal
Owner: workstream C (`feat/wrist-motion`).
This change introduces a watch-only adapter boundary without changing the
RemoteCore envelope. `MotionCaptureService` emits `GestureCore.GestureRecognition`
values (semantic `RemoteCommand`, confidence, and a local monotonic timestamp)
to the watch shell. It never creates envelopes, carries TV identity, or retries
commands.
## Proposed target membership
The integrator should add `Adapters/MotionCapture/Sources/MotionCapture/*.swift`
to a watchOS-only target that depends on the `GestureCore` package product. The
pure classifier and its tests remain in `Packages/WristivoKit` and therefore
build on macOS/iOS/watchOS. Core Motion must not be imported by the package.
## Lifecycle and permission contract
The watch shell calls `start(sessionID:)` only after calibration and explicit
foreground arming. `stop(reason:)` is idempotent and must be called for scene
inactivity, relay loss, target/session changes, expiry, cancellation, or sensor
failure. A subsequent start creates a fresh neutral baseline. Hardware
availability and start-handler failures are surfaced as typed errors; no
permission denial is treated as an empty sample stream. The watch target needs
the platform's truthful motion purpose string in its host configuration.
## Migration and tests
No wire/schema migration is required. Existing watch code can initially ignore
the new service and continue using touch controls. The integrator must add the
watch target membership and motion purpose string, then run the package tests
and a physical permission/foreground suspension check. Simulator and synthetic
traces validate logic only; no hardware recognition claim is made by this
workstream.
+3
View File
@@ -0,0 +1,3 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "MotionCapture", platforms: [.watchOS(.v10)], products: [.library(name: "MotionCapture", targets: ["MotionCapture"])], dependencies: [.package(path: "../../Packages/WristivoKit")], targets: [.target(name: "MotionCapture", dependencies: [.product(name: "GestureCore", package: "WristivoKit")])])
+20 -1
View File
@@ -1,3 +1,22 @@
# MotionCapture
Planned adapter: watch-only Core Motion acquisition and calibrated classification. No live implementation is present. Owner: workstream C in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
This workstream provides a watch-only `CMMotionManager` adapter and a pure
calibrated classifier. The adapter is foreground/lifecycle-bound: it stops on
inactive scene, relay loss, expiry, cancellation, or sensor failure. Samples
are bounded and in memory only; no recording mode or raw traces are included.
The orientation transform (wrist side and crown orientation) and thresholds are
versioned hypotheses pending physical validation. The package tests prove
state-machine behavior only and are not evidence of reliable watch recognition.
## Evaluation report (pending hardware)
No Apple Watch or TV has been tested for this workstream. Precision, recall,
incidental-movement false triggers, and the direction confusion matrix remain
`pending physical evaluation`; do not infer them from synthetic fixtures.
The required report should include per-direction TP/FP/FN, precision/recall,
duplicate count, false triggers per armed hour, and a 4x4 actual-vs-predicted
matrix split by wrist and crown orientation.
See `INTEGRATION-PROPOSAL.md` for target membership, permission-string, and
lifecycle integration requirements.
@@ -0,0 +1,46 @@
import Foundation
import CoreMotion
import GestureCore
public struct MotionCalibration: Codable, Sendable, Equatable {
public static let algorithmVersion = 1
public let algorithmVersion: Int
public let wrist: WristSide
public let crown: CrownOrientation
public let neutralRotationRate: SIMD3<Double>
public let scale: Double
public init(wrist: WristSide, crown: CrownOrientation,
neutralRotationRate: SIMD3<Double>, scale: Double = 2.0) {
self.algorithmVersion = Self.algorithmVersion; self.wrist = wrist; self.crown = crown
self.neutralRotationRate = neutralRotationRate; self.scale = max(0.1, scale)
}
}
/// Converts Core Motion rotation-rate samples into user-relative axes. This
/// does not integrate acceleration and therefore makes no absolute-position
/// claim. Orientation signs are a versioned hypothesis and require hardware
/// validation for each supported watch/crown configuration.
public struct MotionAxisNormalizer: Sendable {
public let calibration: MotionCalibration
private var baseline: SIMD3<Double>
public init(calibration: MotionCalibration) {
self.calibration = calibration
self.baseline = calibration.neutralRotationRate
}
public mutating func normalize(_ motion: CMDeviceMotion) -> NormalizedMotionSample {
let rate = motion.rotationRate
guard rate.x.isFinite, rate.y.isFinite, rate.z.isFinite, motion.timestamp.isFinite else {
// Propagate invalidity to the pure classifier; never clamp NaN into a direction.
return NormalizedMotionSample(timestamp: motion.timestamp, x: .nan, y: .nan, quality: 0)
}
var x = (rate.x - baseline.x) / calibration.scale
var y = (rate.y - baseline.y) / calibration.scale
if calibration.wrist == .left { x = -x }
if calibration.crown == .awayFromHand { y = -y }
return NormalizedMotionSample(timestamp: motion.timestamp,
x: max(-1, min(1, x)), y: max(-1, min(1, y)), quality: 1)
}
}
@@ -0,0 +1,92 @@
import Foundation
@preconcurrency import CoreMotion
import GestureCore
public enum MotionCaptureStopReason: Sendable { case user, inactive, disconnected, expired, cancelled, sensorFailure }
public enum MotionCaptureError: Error, Sendable, Equatable { case unavailable, permissionDenied, failed(String) }
/// Foreground-only sensor owner. Calibration and armed capture never overlap.
@MainActor
public final class MotionCaptureService {
public typealias RecognitionHandler = @Sendable (GestureRecognition) -> Void
public typealias FailureHandler = @Sendable (MotionCaptureError) -> Void
private let manager = CMMotionManager()
private var recognizer = CalibratedGestureRecognizer()
private var expiryTask: Task<Void, Never>?
private var generation = UUID()
public var isAvailable: Bool { manager.isDeviceMotionAvailable }
public init() {}
public func calibrate(wrist: WristSide, crown: CrownOrientation,
completion: @escaping @MainActor (Result<MotionCalibration, MotionCaptureError>) -> Void) {
stop(reason: .cancelled)
guard isAvailable else { completion(.failure(.unavailable)); return }
let capture = generation
var samples: [SIMD3<Double>] = []
var firstTimestamp: Double?
var previousTimestamp: Double?
manager.deviceMotionUpdateInterval = 1 / 50
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
MainActor.assumeIsolated {
guard let self, self.generation == capture else { return }
guard error == nil, let motion else {
self.stop(reason: .sensorFailure); completion(.failure(.failed("Motion permission or sensor unavailable"))); return
}
let r = motion.rotationRate
let v = SIMD3(r.x, r.y, r.z)
guard r.x.isFinite, r.y.isFinite, r.z.isFinite else { return }
let stable = max(abs(r.x), abs(r.y), abs(r.z)) < 0.15
if !stable || (previousTimestamp.map { motion.timestamp - $0 > 0.12 || motion.timestamp <= $0 } ?? false) {
samples.removeAll(); firstTimestamp = nil
}
previousTimestamp = motion.timestamp
guard stable else { return }
if firstTimestamp == nil { firstTimestamp = motion.timestamp }
samples.append(v)
if motion.timestamp - (firstTimestamp ?? motion.timestamp) >= 1, samples.count >= 30 {
let baseline = samples.reduce(SIMD3<Double>(repeating: 0), +) / Double(samples.count)
self.stop(reason: .user)
completion(.success(MotionCalibration(wrist: wrist, crown: crown, neutralRotationRate: baseline)))
}
}
}
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled, let self, self.generation == capture else { return }
self.stop(reason: .expired); completion(.failure(.failed("Calibration timed out. Hold still and retry.")))
}
}
public func start(sessionID: UUID, calibration: MotionCalibration, duration: TimeInterval = 10,
onRecognition: @escaping RecognitionHandler, onFailure: @escaping FailureHandler,
onExpiry: @escaping @MainActor () -> Void = {}) throws {
stop(reason: .cancelled)
guard isAvailable else { throw MotionCaptureError.unavailable }
guard duration.isFinite, duration > 0, duration <= 30 else { throw MotionCaptureError.failed("Invalid duration") }
let capture = generation
var normalizer = MotionAxisNormalizer(calibration: calibration)
var armed = false
manager.deviceMotionUpdateInterval = 1 / 50
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
MainActor.assumeIsolated {
guard let self, self.generation == capture else { return }
guard error == nil, let motion else {
self.stop(reason: .sensorFailure); onFailure(.failed("Motion permission or sensor unavailable")); return
}
if !armed { self.recognizer.arm(sessionID: sessionID, at: motion.timestamp); armed = true }
if let result = self.recognizer.process(normalizer.normalize(motion)) { onRecognition(result) }
}
}
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration))
guard !Task.isCancelled, let self, self.generation == capture else { return }
self.stop(reason: .expired); onExpiry()
}
}
public func stop(reason: MotionCaptureStopReason) {
generation = UUID(); manager.stopDeviceMotionUpdates(); expiryTask?.cancel(); expiryTask = nil; recognizer.disarm()
}
public func expire() { stop(reason: .expired) }
public func setInactive() { stop(reason: .inactive) }
public func setDisconnected() { stop(reason: .disconnected) }
}
+20
View File
@@ -0,0 +1,20 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "PhoneControl",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "PhoneControl", targets: ["PhoneControl"])],
dependencies: [
.package(path: "../../Packages/WristivoKit"),
.package(path: "../TVTransport"), .package(path: "../SecureStorage")
],
targets: [
.target(name: "PhoneControl", dependencies: [
.product(name: "RemoteCore", package: "WristivoKit"),
.product(name: "SamsungProtocol", package: "WristivoKit"),
"TVTransport", "SecureStorage"
]),
.testTarget(name: "PhoneControlTests", dependencies: ["PhoneControl"])
]
)
+7
View File
@@ -0,0 +1,7 @@
# PhoneControl
Integrator-owned orchestration for the iPhone. `LivePhoneController` is main-actor UI state with injected TV actor, security repository and selected-TV store. It never implements command admission or watch grant issuance. `KeychainSelectedTelevisionStore` keeps a stable opaque TV UUID and address in device-only Keychain storage, separate from credentials.
Pairing is explicit. Restore/reconnect only uses an existing token. Failed metadata reads/writes do not start pairing; changing the address requires forgetting the old target. Forget disconnects first and retains the identity if deleting credentials fails. Trust failure only offers a certificate candidate; explicit approval stores a pin and requires a separate pairing action. Demo mode disconnects live transport.
Tests inject actors and stores; they do not access the real Keychain or network. Native composition lives in Apps/iPhone/PhoneAppComposition.swift. Device lock/relaunch and Keychain accessibility remain physical gates.
@@ -0,0 +1,225 @@
import Combine
import Foundation
import RemoteCore
import SamsungProtocol
import SecureStorage
import TVTransport
public protocol PhoneTVControlling: Actor {
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
func disconnect() async
func stateUpdates() async -> AsyncStream<TVConnectionState>
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt
}
extension TVConnectionActor: PhoneTVControlling { }
public protocol PhoneAuthorizationManaging: Sendable {
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws
func forgetTelevision(_ televisionID: UUID) async throws
}
extension KeychainTVAuthorizationStore: PhoneAuthorizationManaging { }
public enum PhoneControlError: Error, Equatable, Sendable {
case invalidAddress, forgetFirst, storageUnavailable
}
public struct CertificateCandidate: Equatable, Sendable, Identifiable {
public let id: UUID
public let host: String
public let sha256: Data
public var fingerprint: String { sha256.map { String(format: "%02X", $0) }.joined(separator: ":") }
}
/// Composition state only. Authorization, admission and TV writes belong to the
/// injected TV actor; this object never retries a command or creates a watch grant.
@MainActor
public final class LivePhoneController: ObservableObject {
@Published public private(set) var television: SelectedTelevision?
@Published public private(set) var connection: TVConnectionState = .disconnected
@Published public private(set) var certificate: CertificateCandidate?
@Published public private(set) var isBusy = false
@Published public private(set) var notice: String?
@Published public private(set) var error: String?
@Published public private(set) var isDemo = false
public var onSelectionChanged: ((SelectedTelevision?, Bool) -> Void)?
public var isReady: Bool {
guard !isDemo, let television else { return false }
return connection == .ready(televisionID: television.id)
}
private let transport: any PhoneTVControlling
private let authorization: any PhoneAuthorizationManaging
private let selection: any SelectedTelevisionStoring
private var observation: Task<Void, Never>?
private var restored = false
private var acceptingCertificate = false
public init(transport: any PhoneTVControlling, authorization: any PhoneAuthorizationManaging,
selection: any SelectedTelevisionStoring) {
self.transport = transport
self.authorization = authorization
self.selection = selection
}
deinit { observation?.cancel() }
public func start() async {
guard !isBusy, !isDemo, !restored else { return }
observeConnection()
isBusy = true
defer { isBusy = false }
do {
television = try await selection.load()
restored = true
publish()
if let television {
acceptingCertificate = true
defer { acceptingCertificate = false }
_ = try SamsungRemoteEncoder.endpoint(host: television.host)
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
publish()
}
} catch {
self.error = "Saved TV information could not be read. Unlock the iPhone and try again."
}
}
public func pair(address: String) async {
guard !isBusy, !isDemo else { return }
clearFeedback()
let host = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard (try? SamsungRemoteEncoder.endpoint(host: host)) != nil else {
error = "Enter a bare TV address or hostname from its network settings, without a URL or port."
return
}
isBusy = true
defer { isBusy = false; acceptingCertificate = false }
do {
// Load before writing: a failed/locked read must not orphan an existing identity.
if !restored { television = try await selection.load(); restored = true }
if let existing = television, existing.host != host {
error = "Forget the saved TV before selecting a different address."
return
}
let selected = television ?? SelectedTelevision(host: host)
try await selection.save(selected)
television = selected
certificate = nil
acceptingCertificate = true
observeConnection()
connection = .connecting(televisionID: selected.id)
publish()
connection = await transport.pair(target: .init(id: selected.id, host: host), sessionID: UUID())
publish()
} catch {
self.error = "TV information could not be saved securely. Unlock the iPhone and try again. No pairing was started."
}
}
public func reconnect() async {
guard !isBusy, !isDemo else { return }
if !restored { await start(); return }
guard let television else { return }
isBusy = true
clearFeedback()
certificate = nil
acceptingCertificate = true
defer { isBusy = false; acceptingCertificate = false }
connection = .connecting(televisionID: television.id)
publish()
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
publish()
}
/// A rejected TLS challenge can only produce a review candidate, never trust.
public func receiveUntrustedCertificate(host: String, sha256: Data) {
guard !isDemo, acceptingCertificate, let television,
normalized(host) == normalized(television.host), sha256.count == 32 else { return }
certificate = CertificateCandidate(id: television.id, host: television.host, sha256: sha256)
}
public func approveCertificate(_ candidate: CertificateCandidate) async {
guard !isBusy, !isDemo, candidate == certificate, candidate.id == television?.id else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
await transport.disconnect()
connection = .disconnected
publish()
do {
try await authorization.approveCertificate(sha256: candidate.sha256, for: candidate.id, approvedAt: Date())
certificate = nil
notice = "Certificate saved for this TV. Tap Pair / pair again, then approve Wristivo on the TV."
} catch { self.error = "Certificate approval could not be saved. Unlock the iPhone and try again." }
}
public func send(_ command: RemoteCommand) async {
guard !isBusy, isReady else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
let receipt = await transport.sendPhoneCommand(command)
switch receipt.status {
case .sentToTransport: notice = "Sent to TV transport. The TV's response is not confirmed."
case .unavailable: error = "Delivery is uncertain. Check the TV before issuing a new action. Nothing will be replayed."
case .expired: error = "The command expired and was not sent."
case .rejected, .duplicate: error = "The command was rejected. Reconnect before trying a new action."
}
}
public func forget() async {
guard !isBusy, !isDemo else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
await transport.disconnect()
connection = .disconnected
certificate = nil
publish()
do {
let saved: SelectedTelevision?
if let television { saved = television } else { saved = try await selection.load() }
if let saved { try await authorization.forgetTelevision(saved.id) }
// Keep metadata if secret deletion fails so the user can retry deletion.
try await selection.delete()
television = nil
restored = true
notice = "Saved TV access and certificate approval were removed from this iPhone."
publish()
} catch { self.error = "The saved TV could not be completely removed. Unlock the iPhone and retry Forget TV." }
}
public func setDemo(_ enabled: Bool) async {
guard !isBusy, enabled != isDemo else { return }
isBusy = true
await transport.disconnect()
connection = .disconnected
certificate = nil
isDemo = enabled
clearFeedback()
publish()
isBusy = false
if !enabled { await reconnect() }
}
private func observeConnection() {
guard observation == nil else { return }
let transport = transport
observation = Task { [weak self] in
let stream = await transport.stateUpdates()
for await state in stream {
guard !Task.isCancelled, let self else { return }
self.connection = state
self.publish()
}
}
}
private func publish() { onSelectionChanged?(isDemo ? nil : television, isReady) }
private func clearFeedback() { notice = nil; error = nil }
private func normalized(_ host: String) -> String { host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) }
}
@@ -0,0 +1,52 @@
import Foundation
import SecureStorage
public struct SelectedTelevision: Codable, Equatable, Sendable {
public let id: UUID
public let name: String
public let host: String
public init(id: UUID = UUID(), name: String = "My TV", host: String) {
self.id = id
self.name = name
self.host = host
}
}
public protocol SelectedTelevisionStoring: Sendable {
func load() async throws -> SelectedTelevision?
func save(_ television: SelectedTelevision) async throws
func delete() async throws
}
/// Device-only metadata keeps the opaque identity stable across app launches.
/// This separate Keychain item contains no TV token or certificate.
public actor KeychainSelectedTelevisionStore: SelectedTelevisionStoring {
private let client: any KeychainClient
private let service = "com.wristivo.selected-television"
private let account = "selected"
public init(client: any KeychainClient = SystemKeychainClient()) { self.client = client }
public func load() async throws -> SelectedTelevision? {
do {
let data = try client.read(service: service, account: account)
guard data.count <= 4096 else { throw PhoneControlError.storageUnavailable }
return try JSONDecoder().decode(SelectedTelevision.self, from: data)
} catch KeychainError.notFound { return nil }
}
public func save(_ television: SelectedTelevision) async throws {
let data = try JSONEncoder().encode(television)
do { try client.update(service: service, account: account, data: data) }
catch KeychainError.notFound {
try client.add(service: service, account: account, data: data,
accessibility: .afterFirstUnlockThisDeviceOnly)
}
}
public func delete() async throws {
do { try client.delete(service: service, account: account) }
catch KeychainError.notFound { }
}
}
@@ -0,0 +1,146 @@
import Foundation
import Testing
import RemoteCore
import TVTransport
@testable import PhoneControl
private actor FakeTV: PhoneTVControlling {
var paired: [TelevisionTarget] = []
var connected: [TelevisionTarget] = []
var commands: [RemoteCommand] = []
var disconnects = 0
var pairHook: (@Sendable () async -> Void)?
func setPairHook(_ hook: @escaping @Sendable () async -> Void) { pairHook = hook }
var status: CommandReceipt.Status = .sentToTransport
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
paired.append(target)
if let pairHook { await pairHook(); return .untrusted(televisionID: target.id) }
return .ready(televisionID: target.id)
}
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
connected.append(target); return .ready(televisionID: target.id)
}
func disconnect() async { disconnects += 1 }
func stateUpdates() async -> AsyncStream<TVConnectionState> { AsyncStream { $0.finish() } }
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
commands.append(command); return .init(id: UUID(), status: status)
}
func failWrites() { status = .unavailable }
}
private actor MemorySelection: SelectedTelevisionStoring {
var value: SelectedTelevision?
var failSave = false
init(_ value: SelectedTelevision? = nil, failSave: Bool = false) { self.value = value; self.failSave = failSave }
func load() async throws -> SelectedTelevision? { value }
func save(_ television: SelectedTelevision) async throws {
if failSave { throw PhoneControlError.storageUnavailable }; value = television
}
func delete() async throws { value = nil }
}
private actor MemoryAuthorization: PhoneAuthorizationManaging {
var forgotten: [UUID] = []
var approvals = 0
let failDelete: Bool
init(failDelete: Bool = false) { self.failDelete = failDelete }
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws { approvals += 1 }
func forgetTelevision(_ televisionID: UUID) async throws {
if failDelete { throw PhoneControlError.storageUnavailable }; forgotten.append(televisionID)
}
}
@MainActor
struct PhoneControlTests {
@Test func restorationUsesSavedIdentityAndNeverPairs() async {
let selected = SelectedTelevision(host: "tv.local")
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(selected))
await controller.start()
#expect(controller.television == selected)
#expect(controller.isReady)
#expect(await tv.paired.isEmpty)
#expect(await tv.connected.map(\.id) == [selected.id])
await controller.pair(address: selected.host)
#expect(await tv.paired.map(\.id) == [selected.id])
}
@Test func storageFailurePreventsPairingAndDifferentTargetRequiresForget() async {
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(failSave: true))
await controller.pair(address: "tv.local")
#expect(await tv.paired.isEmpty)
#expect(controller.error != nil)
let existing = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "old.local")))
await existing.pair(address: "new.local")
#expect(await tv.paired.isEmpty)
#expect(existing.television?.host == "old.local")
}
@Test func partialForgetKeepsIdentityForDeletionRetryAndDisconnects() async throws {
let selected = SelectedTelevision(host: "tv.local")
let store = MemorySelection(selected)
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(failDelete: true), selection: store)
await controller.start()
await controller.forget()
#expect(!controller.isReady)
#expect(controller.error != nil)
#expect(try await store.load() == selected)
#expect(await tv.disconnects == 1)
}
@Test func forgetDeletesSecurityAndSelection() async throws {
let selected = SelectedTelevision(host: "tv.local")
let store = MemorySelection(selected)
let auth = MemoryAuthorization()
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: store)
await controller.start(); await controller.forget()
#expect(controller.television == nil)
#expect(try await store.load() == nil)
#expect(await auth.forgotten == [selected.id])
}
@Test func ambiguousWriteIsNotReplayedAndDemoDisconnectsLiveRoute() async {
let tv = FakeTV()
await tv.failWrites()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "tv.local")))
await controller.start(); await controller.send(.right)
#expect(await tv.commands == [.right])
#expect(controller.error?.contains("uncertain") == true)
await controller.setDemo(true); await controller.send(.left)
#expect(controller.isDemo && !controller.isReady)
#expect(await tv.commands == [.right])
#expect(await tv.disconnects == 1)
}
@Test func certificateRequiresExplicitApprovalAndDoesNotRetryPairing() async throws {
let auth = MemoryAuthorization()
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: auth, selection: MemorySelection())
await tv.setPairHook {
await controller.receiveUntrustedCertificate(host: "other.local", sha256: Data(repeating: 1, count: 32))
await controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 2, count: 32))
}
await controller.pair(address: "tv.local")
let candidate = try #require(controller.certificate)
#expect(candidate.sha256 == Data(repeating: 2, count: 32))
#expect(await auth.approvals == 0)
await controller.approveCertificate(candidate)
#expect(await auth.approvals == 1)
#expect(await tv.paired.count == 1)
#expect(!controller.isReady)
#expect(controller.certificate == nil)
await controller.approveCertificate(candidate)
#expect(await auth.approvals == 1)
}
@Test func unsolicitedCertificateIsNotOfferedOrApproved() async {
let auth = MemoryAuthorization()
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: MemorySelection(.init(host: "tv.local")))
await controller.start()
controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 1, count: 32))
#expect(controller.certificate == nil)
#expect(await auth.approvals == 0)
}
}
+21
View File
@@ -0,0 +1,21 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SecureStorage",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "SecureStorage", targets: ["SecureStorage"])],
dependencies: [.package(path: "../TVTransport")],
targets: [
.target(
name: "SecureStorage",
dependencies: [
.product(name: "TVTransport", package: "TVTransport")
]
),
.testTarget(
name: "SecureStorageTests",
dependencies: ["SecureStorage"]
)
]
)
+22 -1
View File
@@ -1,3 +1,24 @@
# SecureStorage
Planned adapter: iPhone Keychain token and per-TV trust storage. No live implementation is present. Owner: workstream E in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
This adapter stores the TV token, certificate pin, and pairing state in the
iPhone Keychain under an opaque television UUID. It is injected through
`KeychainClient` for deterministic tests and conforms to the frozen
`TVAuthorizationStore` protocol from `TVTransport`.
The candidate accessibility is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
it is the narrowest device-only option that can support a phone-lock relay after
the first unlock. It is unavailable before the first unlock after reboot and has
not yet been validated on a signed iPhone. The stronger
`WhenUnlockedThisDeviceOnly` option remains available for a deployment that
does not require locked-phone relay.
Certificate trust is fail-closed. A self-signed certificate is accepted only
after an explicit user approval supplied to `approveCertificate`; the exact
SHA-256 leaf fingerprint is then pinned to that opaque TV ID. A changed
fingerprint returns `requiresReapproval` and is never silently accepted. The
stored source records that first-use fingerprinting came from an unauthenticated
connection, so it is not presented as proof of TV identity.
`forgetTelevision` removes the complete Keychain record. `deleteToken` revokes
pairing while preserving the trust pin; `deleteTrust` removes trust material.
Neither tokens nor trust records are sent to WatchConnectivity or cloud storage.
@@ -0,0 +1,259 @@
import Foundation
import Security
import TVTransport
/// The smallest Security-framework seam needed by the repository. Tests inject
/// this protocol; the production implementation below is the only type that
/// calls Security directly.
public protocol KeychainClient: Sendable {
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws
func read(service: String, account: String) throws -> Data
func update(service: String, account: String, data: Data) throws
func delete(service: String, account: String) throws
}
public enum KeychainAccessibility: Sendable, Equatable {
/// Strongest default, but unavailable while the phone is locked.
case whenUnlockedThisDeviceOnly
/// Candidate for a phone-lock relay after the first device unlock.
/// It is still unavailable before the first unlock after reboot.
case afterFirstUnlockThisDeviceOnly
fileprivate var secValue: CFString {
switch self {
case .whenUnlockedThisDeviceOnly: return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
}
}
}
public enum KeychainError: Error, Sendable, Equatable {
case notFound
case locked
case unexpectedStatus(Int32)
}
public struct SystemKeychainClient: KeychainClient, Sendable {
public init() {}
public func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data,
kSecAttrAccessible: accessibility.secValue
]
try check(SecItemAdd(query as CFDictionary, nil))
}
public func read(service: String, account: String) throws -> Data {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: CFTypeRef?
try check(SecItemCopyMatching(query as CFDictionary, &result))
guard let data = result as? Data else { throw KeychainError.unexpectedStatus(errSecInternalComponent) }
return data
}
public func update(service: String, account: String, data: Data) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary))
}
public func delete(service: String, account: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemDelete(query as CFDictionary))
}
private func check(_ status: OSStatus) throws {
guard status != errSecSuccess else { return }
switch status {
case errSecItemNotFound: throw KeychainError.notFound
case errSecInteractionNotAllowed: throw KeychainError.locked
default: throw KeychainError.unexpectedStatus(status)
}
}
}
public enum PairingState: String, Codable, Sendable, Equatable {
case paired
case requiresReapproval
case revoked
}
public enum CertificateTrustValidation: Sendable, Equatable {
case notPinned
case accepted
case requiresReapproval
}
public struct TVSecurityRecord: Codable, Sendable, Equatable {
public let token: String?
public let certificateSHA256: Data?
public let pairingState: PairingState
public let trustApprovedAt: Date?
/// A fingerprint learned from the same unauthenticated first connection
/// does not authenticate the TV. Keep this limitation explicit in storage.
public let fingerprintSource: String
public init(
token: String? = nil,
certificateSHA256: Data? = nil,
pairingState: PairingState = .paired,
trustApprovedAt: Date? = nil,
fingerprintSource: String = "unauthenticated-first-use"
) {
self.token = token
self.certificateSHA256 = certificateSHA256
self.pairingState = pairingState
self.trustApprovedAt = trustApprovedAt
self.fingerprintSource = fingerprintSource
}
}
/// iPhone-only Keychain persistence keyed by the opaque television UUID.
/// Tokens and trust records never cross the WatchConnectivity boundary.
public actor KeychainTVAuthorizationStore: TVAuthorizationStore {
public static let service = "com.wristivo.tv-security"
private let client: any KeychainClient
private let accessibility: KeychainAccessibility
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
client: any KeychainClient = SystemKeychainClient(),
accessibility: KeychainAccessibility = .afterFirstUnlockThisDeviceOnly
) {
self.client = client
self.accessibility = accessibility
}
public func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
guard let record = try optionalRecord(for: televisionID) else {
return StoredTVAuthorization(token: nil)
}
guard record.pairingState == .paired else {
return StoredTVAuthorization(token: nil, trust: trustPolicy(for: record))
}
return StoredTVAuthorization(token: record.token, trust: trustPolicy(for: record))
}
public func saveToken(_ token: String, for televisionID: UUID) async throws {
guard !token.isEmpty, token.utf8.count <= 4096 else { throw KeychainError.unexpectedStatus(errSecParam) }
var record = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
record = TVSecurityRecord(
token: token,
certificateSHA256: record.certificateSHA256,
pairingState: .paired,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteToken(for televisionID: UUID) async throws {
guard var record = try optionalRecord(for: televisionID) else { return }
record = TVSecurityRecord(
certificateSHA256: record.certificateSHA256,
pairingState: .revoked,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteTrust(for televisionID: UUID) async throws {
guard let record = try optionalRecord(for: televisionID) else { return }
let replacement = TVSecurityRecord(
token: record.token,
pairingState: record.token == nil ? .revoked : .requiresReapproval,
fingerprintSource: "no-approved-certificate"
)
if record.token == nil {
try deleteRecord(for: televisionID)
} else {
try upsert(replacement, for: televisionID)
}
}
public func approveCertificate(
sha256 fingerprint: Data,
for televisionID: UUID,
approvedAt: Date = Date()
) async throws {
guard fingerprint.count == 32 else { throw KeychainError.unexpectedStatus(errSecParam) }
let old = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
let replacement = TVSecurityRecord(
token: old.certificateSHA256 == fingerprint ? old.token : nil,
certificateSHA256: fingerprint,
pairingState: .paired,
trustApprovedAt: approvedAt,
fingerprintSource: "unauthenticated-first-use"
)
try upsert(replacement, for: televisionID)
}
public func validateCertificate(
sha256 fingerprint: Data,
for televisionID: UUID
) async throws -> CertificateTrustValidation {
guard let record = try optionalRecord(for: televisionID), let pinned = record.certificateSHA256 else {
return .notPinned
}
return pinned == fingerprint && record.pairingState == .paired ? .accepted : .requiresReapproval
}
public func securityRecord(for televisionID: UUID) async throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
public func forgetTelevision(_ televisionID: UUID) async throws {
try deleteRecord(for: televisionID)
}
private func trustPolicy(for record: TVSecurityRecord) -> TVTrustPolicy {
guard let fingerprint = record.certificateSHA256, fingerprint.count == 32 else { return .systemDefault }
return .pinnedLeafCertificateSHA256(fingerprint)
}
private func readRecord(for televisionID: UUID) throws -> TVSecurityRecord {
let data = try client.read(service: Self.service, account: televisionID.uuidString)
guard data.count <= 16 * 1024 else { throw KeychainError.unexpectedStatus(errSecDecode) }
let record = try decoder.decode(TVSecurityRecord.self, from: data)
if let pin = record.certificateSHA256, pin.count != 32 { throw KeychainError.unexpectedStatus(errSecDecode) }
if let token = record.token, token.isEmpty || token.utf8.count > 4096 { throw KeychainError.unexpectedStatus(errSecDecode) }
return record
}
private func optionalRecord(for televisionID: UUID) throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
private func upsert(_ record: TVSecurityRecord, for televisionID: UUID) throws {
let data = try encoder.encode(record)
do { try client.update(service: Self.service, account: televisionID.uuidString, data: data) }
catch KeychainError.notFound { try client.add(service: Self.service, account: televisionID.uuidString, data: data, accessibility: accessibility) }
}
private func deleteRecord(for televisionID: UUID) throws {
do { try client.delete(service: Self.service, account: televisionID.uuidString) }
catch KeychainError.notFound { return }
}
}
@@ -0,0 +1,119 @@
import Foundation
import Testing
@testable import SecureStorage
private final class MockKeychainClient: KeychainClient, @unchecked Sendable {
enum Failure: Error, Equatable { case locked, unexpected }
var values: [String: Data] = [:]
var failure: Failure?
var adds = 0
var updates = 0
var deletes = 0
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] == nil else { throw KeychainError.unexpectedStatus(-25299) }
values[account] = data; adds += 1
}
func read(service: String, account: String) throws -> Data {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard let value = values[account] else { throw KeychainError.notFound }
return value
}
func update(service: String, account: String, data: Data) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] != nil else { throw KeychainError.notFound }
values[account] = data; updates += 1
}
func delete(service: String, account: String) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values.removeValue(forKey: account) != nil else { throw KeychainError.notFound }
deletes += 1
}
}
@Suite("Keychain TV authorization store")
struct KeychainTVAuthorizationStoreTests {
@Test func addReadUpdateDeleteAndNotFound() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client, accessibility: .whenUnlockedThisDeviceOnly)
let tv = UUID()
#expect(try await store.securityRecord(for: tv) == nil)
try await store.saveToken("token-1", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-1")
try await store.saveToken("token-2", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-2")
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
try await store.forgetTelevision(tv)
}
@Test func certificateApprovalPinsAndChangedCertificateRequiresReapproval() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let first = Data(repeating: 0x01, count: 32)
let changed = Data(repeating: 0x02, count: 32)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .notPinned)
try await store.approveCertificate(sha256: first, for: tv)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .accepted)
#expect(try await store.validateCertificate(sha256: changed, for: tv) == .requiresReapproval)
#expect((try await store.authorization(for: tv)).trust == .pinnedLeafCertificateSHA256(first))
}
@Test func revokePreservesTrustButForgetRemovesBoth() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let fingerprint = Data(repeating: 0x03, count: 32)
try await store.saveToken("token", for: tv)
try await store.approveCertificate(sha256: fingerprint, for: tv)
try await store.deleteToken(for: tv)
#expect((try await store.authorization(for: tv)).token == nil)
#expect(try await store.validateCertificate(sha256: fingerprint, for: tv) == .requiresReapproval)
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
}
@Test func lockedAndUnexpectedSecurityErrorsArePropagated() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
client.setFailure(.locked)
await #expect(throws: KeychainError.locked) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
client.setFailure(.unexpected)
await #expect(throws: KeychainError.unexpectedStatus(-1)) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
}
}
private extension MockKeychainClient {
func setFailure(_ failure: Failure?) { self.failure = failure }
}
@Test func newCertificateApprovalCannotReusePreviousToken() async throws {
let store = KeychainTVAuthorizationStore(client: MockKeychainClient())
let tv = UUID(), first = Data(repeating: 1, count: 32), changed = Data(repeating: 2, count: 32)
try await store.approveCertificate(sha256: first, for: tv)
try await store.saveToken("synthetic-token", for: tv)
try await store.approveCertificate(sha256: changed, for: tv)
let stored = try await store.authorization(for: tv)
#expect(stored.token == nil)
#expect(stored.trust == .pinnedLeafCertificateSHA256(changed))
}
@Test func malformedStoredPinDoesNotFallBackToSystemTrust() async throws {
let client = MockKeychainClient()
let id = UUID()
let invalid = TVSecurityRecord(token: "synthetic", certificateSHA256: Data([1]))
try client.add(service: KeychainTVAuthorizationStore.service, account: id.uuidString,
data: JSONEncoder().encode(invalid), accessibility: .whenUnlockedThisDeviceOnly)
let store = KeychainTVAuthorizationStore(client: client)
await #expect(throws: (any Error).self) { try await store.authorization(for: id) }
}
@@ -0,0 +1,48 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# TVTransport integration proposal
Status: proposed by workstream A; integration owner review required before app wiring.
## Required seam with SecureStorage (E)
`TVTransport` consumes an injected `TVAuthorizationStore`. The store is keyed only by
the shared opaque television UUID and returns a token plus a scoped TLS policy. The
transport never knows about Keychain APIs, never caches the token outside the active
connection attempt, and never sends either value to Watch.
The proposed values are:
- `StoredTVAuthorization(token: String?, trust: TVTrustPolicy)`
- `TVTrustPolicy.systemDefault`
- `TVTrustPolicy.pinnedLeafCertificateSHA256(Data)` for a fingerprint that a user
explicitly approved for this television
- async load, save-token, delete-token and delete-trust operations
Workstream E should conform its Keychain repository to this protocol. It should use
device-only accessibility, report locked/unavailable Keychain as an error, and keep
the consent record needed before returning a pin. A first-contact certificate is
never accepted merely because the transport observed it. Certificate capture and
the consent UI remain an integration task after physical TLS evidence exists.
Migration impact: none; no persisted format exists. If E chooses a differently
named public contract, the integrator can adapt it at composition or rename this
consumer-owned seam without changing actor behavior.
## Build wiring request
Add the `Adapters/TVTransport` library product to the iPhone target only. It depends
on the existing `RemoteCore` and `SamsungProtocol` products plus Apple Foundation,
Security and CryptoKit for the iOS URLSession adapter. Do not add it to watchOS.
The local package manifest exists only so workstream A can compile and run focused
tests before project-generator integration.
The pure Samsung event parser belongs to the already owned `SamsungProtocol` target;
no new shared domain type or command value is required.
## Test impact
Before merge, run the adapter package tests plus the root `WristivoKit` tests. The
integrator should then regenerate source membership and run both application builds.
Physical pairing, certificate behavior and token persistence remain explicitly
untested until the owner completes the manual hardware script.
+55
View File
@@ -0,0 +1,55 @@
# Wristivo physical pairing and TLS feasibility spike
Status: owner run pending. This procedure is evidence collection, not a compatibility claim.
Record the exact iPhone model/iOS build, TV model from Settings and firmware/Tizen
version, Wi-Fi topology, build commit, and whether a debugger is attached. The current
`QA85Q8FAAUXZN` model report is unverified until the TV label and Settings are checked.
Redact the TV address, pairing token, certificate details that identify the household,
and client lists before saving evidence. Keep raw captures outside git.
## Pair, deny and timeout
1. Put the iPhone and TV on the same LAN. Keep the existing TV remote available and
turn the TV on. Enter only the host name or address in the Wristivo setup screen.
2. Grant Local Network access when the setup flow explains why it is needed. Start
pairing explicitly. The candidate endpoint is TLS `wss` on port `8002`; do not try
port `8001` or a plaintext fallback.
3. Confirm that the TV prompt/device list identifies the controller as **Wristivo**.
Record the exact prompt text and firmware. Approve once, then issue one supported
direction from the iPhone and record that a socket submission is only a transport
receipt until the TV visibly responds.
4. Repeat with Deny. Confirm the app enters a recoverable denied state and sends no
command. Repeat with no TV approval until the 30-second pairing timeout; confirm
it does not become ready.
## TLS policy
First run with normal platform certificate and hostname validation. Record whether the
TV presents a publicly trusted chain, a self-signed certificate, or another failure;
capture only a redacted fingerprint and trust result. A failed trust check must remain
failed. Do not install a global ATS exception or accept-all challenge handler.
If the device presents a self-signed certificate and a product experiment is still
warranted, stop and obtain explicit user approval for that specific TV fingerprint.
Only a stored, per-TV leaf SHA-256 pin may be tested with the scoped policy; a pin
learned from the same unauthenticated first connection does not prove first-contact
identity. A changed certificate must require reapproval/re-pairing.
## Reconnect, revoke and lifecycle cases
After approval, terminate/relaunch the app and reboot the phone once. Verify a stored
token reconnects without pairing, including the locked/first-unlock behavior that the
chosen Keychain accessibility permits. Revoke the controller in the TV's device list,
then reconnect: the app must become unauthorized, delete the rejected token, and ask
for explicit pairing again. The app's Forget TV action must delete both token and trust
record; removing the controller on the TV is a separate TV-menu action.
Run a TV-off case, a LAN interruption, a selected-TV switch, and a phone suspend/reopen.
No command may be replayed or drained after reconnect. A command whose deadline expires
while disconnected must be reported unavailable/expired and discarded.
Record every offered directional, Select, Back, Home, playback and volume key separately.
Hide or disable any key without evidence. Do not test service-menu, factory, power or
purchase actions. Do not mark this TV or any Samsung model compatible from a simulator,
fake socket, unit test or successful WebSocket write alone.
+25
View File
@@ -0,0 +1,25 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "TVTransport",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "TVTransport", targets: ["TVTransport"])],
dependencies: [.package(path: "../../Packages/WristivoKit")],
targets: [
.target(
name: "TVTransport",
dependencies: [
.product(name: "RemoteCore", package: "WristivoKit"),
.product(name: "SamsungProtocol", package: "WristivoKit")
]
),
.testTarget(
name: "TVTransportTests",
dependencies: [
"TVTransport",
.product(name: "RemoteCore", package: "WristivoKit")
]
)
]
)
+11 -1
View File
@@ -1,3 +1,13 @@
# TVTransport
Planned adapter: iPhone-only paired local TV networking. No live implementation is present. Owner: workstream A in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
Workstream A implementation for iPhone-only paired local TV networking. The actor
owns pairing authorization, bounded event parsing, one-at-a-time command submission,
admission/deadline checks and generation cancellation. It never claims that a socket
write changed the TV. `SamsungEventParser` and `SamsungRemoteEncoder` remain pure
package seams; URLSession and TLS handling stay in this adapter.
The local `Package.swift` is a focused test harness. The integrator must add the
adapter to the iPhone target through the project generator, conform workstream E's
Keychain/trust repository to the injected seam in `INTEGRATION-PROPOSAL.md`, and keep
the adapter out of watchOS. Physical pairing and certificate behavior are untested
until the owner completes `MANUAL-PAIRING.md`.
@@ -0,0 +1,561 @@
import Foundation
import RemoteCore
import SamsungProtocol
/// The single phone-side owner of TV connection state and command admission.
/// A completed send reports transport submission only; it is not TV confirmation.
public actor TVConnectionActor: RemoteTransport, PhoneCommandCoordinating {
private enum ConnectionMode { case pair, reconnect }
private struct PendingSend {
let envelope: CommandEnvelope
let isWatch: Bool
let controlEpoch: UUID
let generation: UUID
let monotonicDeadline: TimeInterval
let continuation: CheckedContinuation<CommandReceipt, Never>
}
private let socketFactory: any TVWebSocketFactory
private let authorizationStore: any TVAuthorizationStore
private let clock: TVTransportClock
private let configuration: TVTransportConfiguration
private let parser: SamsungEventParser
private var state: TVConnectionState = .disconnected {
didSet { for observer in observers.values { observer.yield(state) } }
}
private var observers: [UUID: AsyncStream<TVConnectionState>.Continuation] = [:]
private var watchPeer: WatchPeerGeneration?
private var watchSession: UUID?
private var watchAdmission: CommandAdmission?
private var controlEpoch = UUID()
private var phoneSession = UUID()
private var phoneSequence: UInt64 = 0
private var activeWrite: UUID?
private var watchRequestRevision = UUID()
private var credentialMaintenance = false
private var receiveTask: Task<Void, Never>?
private var target: TelevisionTarget?
private var socket: (any TVWebSocket)?
private var admission: CommandAdmission?
private var generation = UUID()
private var queue: [PendingSend] = []
private var drainTask: Task<Void, Never>?
public init(
socketFactory: any TVWebSocketFactory,
authorizationStore: any TVAuthorizationStore,
clock: TVTransportClock = .system(),
configuration: TVTransportConfiguration = .init()
) {
self.socketFactory = socketFactory
self.authorizationStore = authorizationStore
self.clock = clock
self.configuration = configuration
self.parser = SamsungEventParser(maximumMessageBytes: configuration.maximumEventBytes)
}
public func connectionState() -> TVConnectionState { state }
public func stateUpdates() -> AsyncStream<TVConnectionState> {
let id = UUID()
let (stream, continuation) = AsyncStream<TVConnectionState>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[id] = continuation
continuation.yield(state)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(id) }
}
return stream
}
private func removeObserver(_ id: UUID) { observers[id] = nil }
public func watchBridgeDidActivate(peer: WatchPeerGeneration) async {
guard watchPeer != peer else { return }
watchPeer = peer
await invalidateWatchControl()
}
public func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async {
guard watchPeer == peer else { return }
watchPeer = nil
await invalidateWatchControl()
}
public func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer else { return .failure(.init(code: .sessionInvalidated)) }
let revision = UUID()
watchRequestRevision = revision
await invalidateWatchControl()
guard watchPeer == peer, watchRequestRevision == revision else { return .failure(.init(code: .sessionInvalidated)) }
guard case .ready(let id) = state, socket != nil else { return .failure(.init(code: .unavailable)) }
let session = UUID()
watchSession = session
watchAdmission = CommandAdmission(sessionID: session, televisionID: id)
return .success(.init(sessionID: session, televisionID: id, issuedAt: clock.wallNow()))
}
public func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionEnded, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer, watchSession == request.sessionID else { return .failure(.init(code: .sessionInvalidated)) }
await invalidateWatchControl()
return .success(.init(sessionID: request.sessionID))
}
public func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) async -> CommandReceipt {
guard watchPeer == peer, watchSession == envelope.sessionID else {
return .init(id: envelope.id, status: .rejected)
}
return await enqueue(envelope, isWatch: true)
}
public func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
guard let target else { return .init(id: UUID(), status: .unavailable) }
guard phoneSequence < UInt64.max else { return .init(id: UUID(), status: .rejected) }
phoneSequence += 1
let now = clock.wallNow()
return await send(.init(sessionID: phoneSession, televisionID: target.id,
sequence: phoneSequence, issuedAt: now, expiresAt: now.addingTimeInterval(0.8), command: command))
}
private func invalidateWatchControl() async {
controlEpoch = UUID()
watchSession = nil
watchAdmission = nil
// A write already submitted cannot be recalled. Close before another grant.
if activeWrite != nil { await disconnect() }
}
/// Starts an explicit first-pair attempt without sending a stored token.
@discardableResult
public func pair(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .pair)
}
/// Reconnects only when a stored token is available. It never falls back to pairing.
@discardableResult
public func connect(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .reconnect)
}
/// Invalidates the current control session and closes its socket so an in-flight
/// old-session write cannot continue on a connection authorized for a new session.
/// Call `connect(target:sessionID:)` explicitly to establish the replacement.
public func invalidateSession() async {
await disconnect()
}
public func disconnect() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
target = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
/// Deletes the rejected/revoked pairing token while retaining an approved trust pin.
public func revokeAuthorization() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
guard let televisionID else { return }
do {
try await authorizationStore.deleteToken(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
/// Deletes both token and trust material for a complete forget-TV operation.
public func forgetTelevision() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
if let televisionID {
do {
try await authorizationStore.deleteToken(for: televisionID)
try await authorizationStore.deleteTrust(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
// Forget removes the selected target as well as its authorization material.
target = nil
}
public func send(_ envelope: CommandEnvelope) async -> CommandReceipt {
await enqueue(envelope, isWatch: false)
}
private func enqueue(_ envelope: CommandEnvelope, isWatch: Bool) async -> CommandReceipt {
guard case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
socket != nil,
queue.count < configuration.maximumQueuedCommands else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let remaining = envelope.expiresAt.timeIntervalSince(clock.wallNow())
guard remaining > 0 else {
return CommandReceipt(id: envelope.id, status: .expired)
}
let commandGeneration = generation
let deadline = clock.monotonicNow() + remaining
return await withCheckedContinuation { continuation in
queue.append(PendingSend(
envelope: envelope,
isWatch: isWatch,
controlEpoch: controlEpoch,
generation: commandGeneration,
monotonicDeadline: deadline,
continuation: continuation
))
startDrainingIfNeeded(generation: commandGeneration)
}
}
private func establish(
target newTarget: TelevisionTarget,
sessionID: UUID,
mode: ConnectionMode
) async -> TVConnectionState {
guard !credentialMaintenance else { return .unavailable(televisionID: newTarget.id, failure: .credentialStore) }
let attempt = UUID()
generation = attempt
cancelCommandGeneration()
admission = nil
target = newTarget
let oldSocket = socket
socket = nil
state = .connecting(televisionID: newTarget.id)
await oldSocket?.close()
guard generation == attempt else { return state }
let stored: StoredTVAuthorization
do {
stored = try await authorizationStore.authorization(for: newTarget.id)
} catch {
guard generation == attempt else { return state }
state = .unavailable(televisionID: newTarget.id, failure: .credentialStore)
return state
}
guard generation == attempt else { return state }
let token: String?
switch mode {
case .pair:
token = nil
case .reconnect:
guard let existingToken = stored.token, !existingToken.isEmpty else {
state = .unauthorized(televisionID: newTarget.id)
return state
}
token = existingToken
}
let endpoint: URL
do {
endpoint = try SamsungRemoteEncoder.endpoint(host: newTarget.host, token: token)
} catch {
state = .unavailable(televisionID: newTarget.id, failure: .invalidTarget)
return state
}
let openedSocket: any TVWebSocket
do {
openedSocket = try await socketFactory.open(
endpoint: endpoint,
trust: stored.trust,
maximumMessageBytes: configuration.maximumEventBytes
)
} catch {
guard generation == attempt else { return state }
state = connectionFailureState(error, televisionID: newTarget.id)
return state
}
guard generation == attempt else {
await openedSocket.close()
return state
}
socket = openedSocket
state = .awaitingApproval(televisionID: newTarget.id)
let timeoutDeadline = clock.monotonicNow() + configuration.pairingTimeout
while generation == attempt {
let remaining = timeoutDeadline - clock.monotonicNow()
guard remaining > 0 else {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
let eventData: Data
do {
eventData = try await receive(from: openedSocket, timeout: remaining)
} catch is PairingTimeoutError {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
} catch {
guard generation == attempt else {
await openedSocket.close()
return state
}
return await failConnection(connectionFailureState(error, televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
guard generation == attempt else {
await openedSocket.close()
return state
}
let event: SamsungRemoteEvent
do {
event = try parser.parse(eventData)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .malformedResponse), socket: openedSocket, attempt: attempt)
}
switch event {
case .unknown:
continue
case .connected(let returnedToken):
let usableToken = returnedToken ?? token
guard let usableToken, !usableToken.isEmpty else {
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
if let returnedToken {
do {
try await authorizationStore.saveToken(returnedToken, for: newTarget.id)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .credentialStore), socket: openedSocket, attempt: attempt)
}
}
guard generation == attempt else {
await openedSocket.close()
return state
}
phoneSession = sessionID
phoneSequence = 0
admission = CommandAdmission(sessionID: sessionID, televisionID: newTarget.id)
state = .ready(televisionID: newTarget.id)
receiveTask = Task { await self.monitor(openedSocket, attempt: attempt, televisionID: newTarget.id) }
return state
case .unauthorized:
switch mode {
case .reconnect:
try? await authorizationStore.deleteToken(for: newTarget.id)
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .pair:
return await failConnection(.denied(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
case .timedOut:
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .error:
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .socket), socket: openedSocket, attempt: attempt)
}
}
await openedSocket.close()
return state
}
private func startDrainingIfNeeded(generation commandGeneration: UUID) {
guard drainTask == nil else { return }
drainTask = Task { await self.drain(generation: commandGeneration) }
}
private func drain(generation commandGeneration: UUID) async {
while !Task.isCancelled,
generation == commandGeneration,
!queue.isEmpty {
let pending = queue.removeFirst()
guard pending.generation == commandGeneration else {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
continue
}
let receipt = await process(pending)
pending.continuation.resume(returning: receipt)
}
if generation == commandGeneration {
drainTask = nil
if !queue.isEmpty { startDrainingIfNeeded(generation: commandGeneration) }
}
}
private func process(_ pending: PendingSend) async -> CommandReceipt {
let envelope = pending.envelope
guard generation == pending.generation,
case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
let liveSocket = socket,
var currentAdmission = (pending.isWatch ? watchAdmission : admission),
!pending.isWatch || pending.controlEpoch == controlEpoch else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let decision = currentAdmission.admit(envelope, now: clock.wallNow())
if pending.isWatch { watchAdmission = currentAdmission } else { admission = currentAdmission }
switch decision {
case .rejected(.duplicate), .rejected(.outOfOrder):
return CommandReceipt(id: envelope.id, status: .duplicate)
case .rejected(.expired), .rejected(.stale):
return CommandReceipt(id: envelope.id, status: .expired)
case .rejected:
return CommandReceipt(id: envelope.id, status: .rejected)
case .accepted:
break
}
let frame: Data
do {
frame = try SamsungRemoteEncoder.clickFrame(for: envelope.command)
} catch {
return CommandReceipt(id: envelope.id, status: .rejected)
}
// Admission has been consumed. Recheck both wall and receiver-local monotonic
// deadlines immediately before the one allowed socket submission.
guard generation == pending.generation,
state == .ready(televisionID: televisionID),
clock.wallNow() < envelope.expiresAt,
clock.monotonicNow() < pending.monotonicDeadline,
!Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .expired)
}
do {
let writeID = UUID()
activeWrite = writeID
defer { if activeWrite == writeID { activeWrite = nil } }
try await sendBounded(frame, socket: liveSocket, timeout: max(0.001, pending.monotonicDeadline - clock.monotonicNow()))
guard generation == pending.generation, !Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
return CommandReceipt(id: envelope.id, status: .sentToTransport)
} catch {
if generation == pending.generation {
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: pending.generation)
}
return CommandReceipt(id: envelope.id, status: .unavailable)
}
}
private func cancelCommandGeneration() {
activeWrite = nil
receiveTask?.cancel()
receiveTask = nil
watchSession = nil
watchAdmission = nil
controlEpoch = UUID()
drainTask?.cancel()
drainTask = nil
let abandoned = queue
queue.removeAll(keepingCapacity: true)
for pending in abandoned {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
}
}
private func disconnectKeepingTargetForDeletion() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
private func failConnection(
_ failureState: TVConnectionState,
socket failedSocket: any TVWebSocket,
attempt: UUID
) async -> TVConnectionState {
if generation == attempt {
generation = UUID()
cancelCommandGeneration()
socket = nil
admission = nil
state = failureState
}
await failedSocket.close()
return state
}
private func monitor(_ liveSocket: any TVWebSocket, attempt: UUID, televisionID: UUID) async {
do {
while generation == attempt, !Task.isCancelled {
let data = try await liveSocket.receive()
guard generation == attempt, !Task.isCancelled else { return }
switch try parser.parse(data) {
case .connected, .unknown: continue
case .unauthorized:
credentialMaintenance = true
defer { credentialMaintenance = false }
_ = await failConnection(.unauthorized(televisionID: televisionID), socket: liveSocket, attempt: attempt)
try? await authorizationStore.deleteToken(for: televisionID)
return
case .timedOut, .error:
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
return
}
}
} catch {
guard generation == attempt else { return }
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
}
}
private func sendBounded(_ data: Data, socket: any TVWebSocket, timeout: TimeInterval) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await socket.send(data) }
group.addTask { try await Task.sleep(for: .seconds(timeout)); throw PairingTimeoutError() }
do {
_ = try await group.next()
group.cancelAll()
} catch {
group.cancelAll()
await socket.close()
throw error
}
}
}
private func connectionFailureState(_ error: Error, televisionID: UUID) -> TVConnectionState {
if let socketError = error as? TVSocketError, socketError == .untrustedCertificate {
return .untrusted(televisionID: televisionID)
}
return .unavailable(televisionID: televisionID, failure: .socket)
}
private struct PairingTimeoutError: Error {}
private func receive(from socket: any TVWebSocket, timeout: TimeInterval) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await socket.receive() }
group.addTask {
try await Task.sleep(for: .seconds(timeout))
throw PairingTimeoutError()
}
do {
guard let result = try await group.next() else { throw TVSocketError.closed }
group.cancelAll()
return result
} catch {
// Cancelling a Swift child task does not necessarily cancel a
// URLSessionWebSocketTask (or an injected fake) that is suspended
// in receive(). Close the socket before leaving the task group so
// the losing receive child cannot hold a pairing attempt open.
group.cancelAll()
await socket.close()
throw error
}
}
}
}
@@ -0,0 +1,122 @@
import Foundation
public struct TelevisionTarget: Sendable, Equatable {
public let id: UUID
public let host: String
public init(id: UUID, host: String) {
self.id = id
self.host = host
}
}
public enum TVTrustPolicy: Sendable, Equatable {
/// Require the platform's normal certificate and hostname validation.
case systemDefault
/// Accept only an exact leaf-certificate SHA-256 fingerprint that the user
/// previously approved for this television. The 32-byte value is not learned
/// or saved by the transport during the connection being authorized.
case pinnedLeafCertificateSHA256(Data)
}
public struct StoredTVAuthorization: Sendable, Equatable {
public let token: String?
public let trust: TVTrustPolicy
public init(token: String?, trust: TVTrustPolicy = .systemDefault) {
self.token = token
self.trust = trust
}
}
/// Consumer-owned persistence seam. The iPhone Keychain adapter from workstream E
/// supplies the implementation; the transport never caches credentials in plaintext.
public protocol TVAuthorizationStore: Sendable {
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization
func saveToken(_ token: String, for televisionID: UUID) async throws
func deleteToken(for televisionID: UUID) async throws
func deleteTrust(for televisionID: UUID) async throws
}
public enum TVSocketError: Error, Sendable, Equatable {
case untrustedCertificate
case unavailable
case closed
}
public protocol TVWebSocket: Sendable {
func receive() async throws -> Data
func send(_ data: Data) async throws
func close() async
}
public protocol TVWebSocketFactory: Sendable {
func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket
}
public enum TVConnectionFailure: Sendable, Equatable {
case invalidTarget
case missingToken
case malformedResponse
case credentialStore
case socket
}
public enum TVConnectionState: Sendable, Equatable {
case disconnected
case connecting(televisionID: UUID)
case awaitingApproval(televisionID: UUID)
case ready(televisionID: UUID)
case denied(televisionID: UUID)
case unauthorized(televisionID: UUID)
case untrusted(televisionID: UUID)
case timedOut(televisionID: UUID)
case unavailable(televisionID: UUID?, failure: TVConnectionFailure)
}
public struct TVTransportClock: Sendable {
public var wallNow: @Sendable () -> Date
public var monotonicNow: @Sendable () -> TimeInterval
public init(
wallNow: @escaping @Sendable () -> Date,
monotonicNow: @escaping @Sendable () -> TimeInterval
) {
self.wallNow = wallNow
self.monotonicNow = monotonicNow
}
public static func system() -> Self {
let clock = ContinuousClock()
let origin = clock.now
return Self(
wallNow: Date.init,
monotonicNow: {
let components = origin.duration(to: clock.now).components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
}
)
}
}
public struct TVTransportConfiguration: Sendable, Equatable {
public var pairingTimeout: TimeInterval
public var maximumQueuedCommands: Int
public var maximumEventBytes: Int
public init(
pairingTimeout: TimeInterval = 30,
maximumQueuedCommands: Int = 8,
maximumEventBytes: Int = 16 * 1024
) {
self.pairingTimeout = min(max(pairingTimeout, 0.001), 30)
self.maximumQueuedCommands = min(max(maximumQueuedCommands, 1), 32)
self.maximumEventBytes = min(max(maximumEventBytes, 256), 64 * 1024)
}
}
@@ -0,0 +1,154 @@
#if os(iOS)
import CryptoKit
import Foundation
import Security
public struct URLSessionSamsungSocketFactory: TVWebSocketFactory {
private let onUntrustedCertificate: (@Sendable (String, Data) -> Void)?
public init(onUntrustedCertificate: (@Sendable (String, Data) -> Void)? = nil) {
self.onUntrustedCertificate = onUntrustedCertificate
}
public func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket {
guard endpoint.scheme == "wss", let host = endpoint.host else { throw TVSocketError.unavailable }
let delegate = ScopedTrustDelegate(policy: trust, host: host, observer: onUntrustedCertificate)
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = true
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
let task = session.webSocketTask(with: endpoint)
task.maximumMessageSize = maximumMessageBytes
task.resume()
return URLSessionSamsungSocket(session: session, task: task, delegate: delegate)
}
}
private final class URLSessionSamsungSocket: TVWebSocket, @unchecked Sendable {
private let session: URLSession
private let task: URLSessionWebSocketTask
private let delegate: ScopedTrustDelegate
init(session: URLSession, task: URLSessionWebSocketTask, delegate: ScopedTrustDelegate) {
self.session = session
self.task = task
self.delegate = delegate
}
func receive() async throws -> Data {
do {
switch try await task.receive() {
case .data(let data):
return data
case .string(let text):
return Data(text.utf8)
@unknown default:
throw TVSocketError.unavailable
}
} catch {
throw normalized(error)
}
}
func send(_ data: Data) async throws {
do {
guard let text = String(data: data, encoding: .utf8) else { throw TVSocketError.unavailable }
try await task.send(.string(text))
} catch {
throw normalized(error)
}
}
func close() async {
task.cancel(with: .goingAway, reason: nil)
session.invalidateAndCancel()
_ = delegate
}
private func normalized(_ error: Error) -> Error {
if delegate.rejectedTrust { return TVSocketError.untrustedCertificate }
let certificateCodes: Set<URLError.Code> = [
.serverCertificateHasBadDate,
.serverCertificateUntrusted,
.serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid,
.clientCertificateRejected,
.clientCertificateRequired
]
if let urlError = error as? URLError, certificateCodes.contains(urlError.code) {
return TVSocketError.untrustedCertificate
}
if (error as? URLError)?.code == .cancelled { return TVSocketError.closed }
return TVSocketError.unavailable
}
}
private final class ScopedTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let policy: TVTrustPolicy
private let lock = NSLock()
private var trustWasRejected = false
var rejectedTrust: Bool { lock.withLock { trustWasRejected } }
private func rejectTrust() { lock.withLock { trustWasRejected = true } }
private let host: String
private let observer: (@Sendable (String, Data) -> Void)?
init(policy: TVTrustPolicy, host: String, observer: (@Sendable (String, Data) -> Void)?) {
self.policy = policy
self.host = host
self.observer = observer
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
guard challenge.protectionSpace.host.caseInsensitiveCompare(host) == .orderedSame,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(certificate) as Data))
switch policy {
case .systemDefault:
guard SecTrustEvaluateWithError(serverTrust, nil) else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
case .pinnedLeafCertificateSHA256(let expectedFingerprint):
guard expectedFingerprint.count == SHA256.byteCount,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let certificateData = SecCertificateCopyData(certificate) as Data
let presentedFingerprint = Data(SHA256.hash(data: certificateData))
guard presentedFingerprint == expectedFingerprint else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
}
}
}
#endif
@@ -0,0 +1,387 @@
import Foundation
import RemoteCore
import Testing
@testable import TVTransport
private let baseDate = Date(timeIntervalSince1970: 10_000)
private func envelope(
sessionID: UUID,
televisionID: UUID,
sequence: UInt64,
command: RemoteCommand,
id: UUID = UUID(),
issuedAt: Date = baseDate,
expiresAt: Date = baseDate.addingTimeInterval(0.8)
) -> CommandEnvelope {
CommandEnvelope(
sessionID: sessionID,
televisionID: televisionID,
sequence: sequence,
issuedAt: issuedAt,
expiresAt: expiresAt,
command: command,
id: id
)
}
@Test func explicitPairThenTokenReconnectAuthorizesBeforeReady() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let pairSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let reconnectSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [pairSocket, reconnectSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store)
let firstSession = UUID()
#expect(await transport.pair(target: tv, sessionID: firstSession) == .ready(televisionID: tv.id))
#expect(await store.savedToken(for: tv.id) == "redacted-token")
let firstEndpoint = try #require(await factory.endpoint(at: 0))
#expect(URLComponents(url: firstEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.contains(where: { $0.name == "token" }) == false)
await transport.disconnect()
let secondSession = UUID()
#expect(await transport.connect(target: tv, sessionID: secondSession) == .ready(televisionID: tv.id))
let secondEndpoint = try #require(await factory.endpoint(at: 1))
#expect(URLComponents(url: secondEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "token" })?.value == "redacted-token")
}
@Test func pairingDenialTimeoutAndMissingInitialTokenFailClosed() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let deniedSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)])
let missingTokenSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let timeoutSocket = FakeSocket(events: [])
let factory = FakeSocketFactory(sockets: [deniedSocket, missingTokenSocket, timeoutSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store, pairingTimeout: 0.01)
#expect(await transport.pair(target: tv, sessionID: UUID()) == .denied(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .timedOut(televisionID: tv.id))
}
@Test func sendsInOrderConsumesAdmissionAndExpiresQueuedWork() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let clock = TestClock(wall: baseDate, monotonic: 50)
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
blockedSendCount: 1
)
let factory = FakeSocketFactory(sockets: [socket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore(), clock: clock.value)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let first = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .right)
let second = envelope(sessionID: session, televisionID: tv.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await socket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
try? await Task.sleep(for: .milliseconds(10))
clock.advanceMonotonic(seconds: 1)
await socket.releaseBlockedSend()
#expect(await firstTask.value.status == .sentToTransport)
#expect(await secondTask.value.status == .expired)
#expect(await socket.sentKeyNames() == ["KEY_RIGHT"])
#expect(await transport.send(first).status == .duplicate)
}
@Test func failedWriteIsConsumedAndNeverRetried() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
failingSendCount: 1
)
let transport = makeTransport(
factory: FakeSocketFactory(sockets: [socket]),
store: FakeAuthorizationStore()
)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up)
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.send(command).status == .unavailable)
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}
@Test func targetChangeCancelsInflightAndQueuedOldGenerationCommands() async {
let firstTV = TelevisionTarget(id: UUID(), host: "first.synthetic.test")
let secondTV = TelevisionTarget(id: UUID(), host: "second.synthetic.test")
let firstSession = UUID()
let firstSocket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-one"}}"#.utf8)],
blockedSendCount: 1
)
let secondSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-two"}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [firstSocket, secondSocket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore())
#expect(await transport.pair(target: firstTV, sessionID: firstSession) == .ready(televisionID: firstTV.id))
let first = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 1, command: .right)
let second = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await firstSocket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
await Task.yield()
#expect(await transport.pair(target: secondTV, sessionID: UUID()) == .ready(televisionID: secondTV.id))
#expect(await firstTask.value.status == .unavailable)
#expect(await secondTask.value.status == .unavailable)
#expect(await firstSocket.sentKeyNames() == ["KEY_RIGHT"])
}
@Test func reconnectRequiresTokenAndRevocationDeletesIt() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: []), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
await transport.revokeAuthorization()
#expect(await store.savedToken(for: tv.id) == nil)
}
@Test func forgetDeletesAuthorizationAndSelectedTarget() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .ready(televisionID: tv.id))
await transport.forgetTelevision()
#expect(await transport.connectionState() == .disconnected)
#expect(await store.savedToken(for: tv.id) == nil)
}
private func makeTransport(
factory: FakeSocketFactory,
store: FakeAuthorizationStore,
clock: TVTransportClock = TestClock(wall: baseDate, monotonic: 50).value,
pairingTimeout: TimeInterval = 1
) -> TVConnectionActor {
TVConnectionActor(
socketFactory: factory,
authorizationStore: store,
clock: clock,
configuration: .init(pairingTimeout: pairingTimeout)
)
}
private final class TestClock: @unchecked Sendable {
private let lock = NSLock()
private var wall: Date
private var monotonic: TimeInterval
init(wall: Date, monotonic: TimeInterval) {
self.wall = wall
self.monotonic = monotonic
}
var value: TVTransportClock {
TVTransportClock(
wallNow: { [self] in lock.withLock { wall } },
monotonicNow: { [self] in lock.withLock { monotonic } }
)
}
func advance(seconds: TimeInterval) {
lock.withLock {
wall = wall.addingTimeInterval(seconds)
monotonic += seconds
}
}
func advanceMonotonic(seconds: TimeInterval) {
lock.withLock { monotonic += seconds }
}
}
private actor FakeAuthorizationStore: TVAuthorizationStore {
private var values: [UUID: StoredTVAuthorization] = [:]
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
values[televisionID] ?? StoredTVAuthorization(token: nil)
}
func saveToken(_ token: String, for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: token, trust: trust)
}
func deleteToken(for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: nil, trust: trust)
}
func deleteTrust(for televisionID: UUID) async throws {
let token = values[televisionID]?.token
values[televisionID] = StoredTVAuthorization(token: token, trust: .systemDefault)
}
func savedToken(for televisionID: UUID) -> String? { values[televisionID]?.token }
func setAuthorization(_ authorization: StoredTVAuthorization, for televisionID: UUID) {
values[televisionID] = authorization
}
}
private actor FakeSocketFactory: TVWebSocketFactory {
private var sockets: [FakeSocket]
private var endpoints: [URL] = []
init(sockets: [FakeSocket]) { self.sockets = sockets }
func open(endpoint: URL, trust: TVTrustPolicy, maximumMessageBytes: Int) async throws -> any TVWebSocket {
endpoints.append(endpoint)
guard !sockets.isEmpty else { throw TVSocketError.unavailable }
return sockets.removeFirst()
}
func endpoint(at index: Int) -> URL? {
endpoints.indices.contains(index) ? endpoints[index] : nil
}
}
private actor FakeSocket: TVWebSocket {
private var events: [Data]
private var receiveContinuation: CheckedContinuation<Data, Error>?
private var sendContinuation: CheckedContinuation<Void, Error>?
private var sent: [Data] = []
private var blockedSendCount: Int
private var failingSendCount: Int
private var closed = false
init(events: [Data], blockedSendCount: Int = 0, failingSendCount: Int = 0) {
self.events = events
self.blockedSendCount = blockedSendCount
self.failingSendCount = failingSendCount
}
func receive() async throws -> Data {
if closed { throw TVSocketError.closed }
if !events.isEmpty { return events.removeFirst() }
return try await withCheckedThrowingContinuation { receiveContinuation = $0 }
}
func send(_ data: Data) async throws {
if closed { throw TVSocketError.closed }
sent.append(data)
if failingSendCount > 0 {
failingSendCount -= 1
throw TVSocketError.unavailable
}
if blockedSendCount > 0 {
blockedSendCount -= 1
try await withCheckedThrowingContinuation { sendContinuation = $0 }
}
}
func close() async {
closed = true
receiveContinuation?.resume(throwing: TVSocketError.closed)
receiveContinuation = nil
sendContinuation?.resume(throwing: TVSocketError.closed)
sendContinuation = nil
}
func deliver(_ data: Data) {
if let pending = receiveContinuation {
receiveContinuation = nil
pending.resume(returning: data)
} else { events.append(data) }
}
func releaseBlockedSend() {
sendContinuation?.resume()
sendContinuation = nil
}
func waitForSendCount(_ count: Int) async {
while sent.count < count { await Task.yield() }
}
func sentKeyNames() -> [String] {
sent.compactMap { data in
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let parameters = object["params"] as? [String: String] else { return nil }
return parameters["DataOfCmd"]
}
}
}
@Test func watchNegotiationKeepsIdleSocketAndPhoneCommandsDoNotStealGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
#expect(await transport.sendPhoneCommand(.left).status == .sentToTransport)
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .sentToTransport)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .duplicate)
#expect(await socket.sentKeyNames() == ["KEY_LEFT", "KEY_RIGHT"])
await transport.disconnect()
}
@Test func stalePeerAndStaleEndCannotInvalidateCurrentGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let oldPeer = WatchPeerGeneration(), peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: oldPeer)
let old = try await transport.beginWatchSession(.init(), peer: oldPeer).get()
await transport.watchBridgeDidActivate(peer: peer)
let current = try await transport.beginWatchSession(.init(), peer: peer).get()
await transport.watchBridgeDidDeactivate(peer: oldPeer)
#expect(await transport.endWatchSession(.init(sessionID: old.sessionID), peer: peer) == .failure(.init(code: .sessionInvalidated)))
#expect(await transport.submitWatchCommand(envelope(sessionID: old.sessionID, televisionID: tv.id, sequence: 1, command: .left), peer: oldPeer).status == .rejected)
#expect(await transport.submitWatchCommand(envelope(sessionID: current.sessionID, televisionID: tv.id, sequence: 1, command: .right), peer: peer).status == .sentToTransport)
await transport.disconnect()
}
@Test func rotatingGrantDuringWriteClosesSocketBeforeNewGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
let pending = Task { await transport.submitWatchCommand(command, peer: peer) }
await socket.waitForSendCount(1)
#expect(await transport.beginWatchSession(.init(), peer: peer) == .failure(.init(code: .unavailable)))
#expect(await pending.value.status == .unavailable)
#expect(await transport.connectionState() == .disconnected)
}
@Test func readyConnectionConsumesRevocationAndStopsCommands() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
_ = await transport.pair(target: tv, sessionID: UUID())
await socket.deliver(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8))
for _ in 0..<1000 {
if await transport.connectionState() == .unauthorized(televisionID: tv.id) { break }
await Task.yield()
}
#expect(await transport.connectionState() == .unauthorized(televisionID: tv.id))
#expect(await transport.sendPhoneCommand(.left).status == .unavailable)
}
@Test func sendDeadlineClosesNonCompletingSocket() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
let session = UUID()
_ = await transport.pair(target: tv, sessionID: session)
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up, expiresAt: baseDate.addingTimeInterval(0.02))
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.connectionState() == .unavailable(televisionID: tv.id, failure: .socket))
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}
@@ -1,3 +1,5 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# WatchBridge integration proposal
Status: accepted with integration decisions below. This proposal is retained as
@@ -1,4 +1,5 @@
import Foundation
import CoreFoundation
import RemoteCore
private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool {
@@ -88,22 +89,24 @@ public struct RelayApplicationContext: Sendable, Equatable {
public let schemaVersion: Int
public let selectedTelevisionID: UUID?
public let displayName: String?
public let isConnectionReady: Bool
public init(selectedTelevisionID: UUID?, displayName: String?) {
public init(selectedTelevisionID: UUID?, displayName: String?, isConnectionReady: Bool = false) {
self.schemaVersion = RelayWire.schemaVersion
self.selectedTelevisionID = selectedTelevisionID
self.displayName = displayName
self.isConnectionReady = isConnectionReady && selectedTelevisionID != nil
}
public func dictionary() throws -> [String: Any] {
var result: [String: Any] = ["schemaVersion": schemaVersion]
var result: [String: Any] = ["schemaVersion": schemaVersion, "isConnectionReady": isConnectionReady]
if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString }
if let displayName { result["displayName"] = String(displayName.prefix(128)) }
return result
}
public init(dictionary: [String: Any]) throws {
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName"]
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName", "isConnectionReady"]
guard Set(dictionary.keys).isSubset(of: allowed) else { throw RelayContainerError.extraKeys }
guard let version = dictionary["schemaVersion"] as? NSNumber,
relayNSNumberIsInteger(version),
@@ -127,6 +130,12 @@ public struct RelayApplicationContext: Sendable, Equatable {
} else {
self.displayName = nil
}
if let raw = dictionary["isConnectionReady"] {
guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
throw RelayContainerError.invalidPayload
}
self.isConnectionReady = number.boolValue && selectedTelevisionID != nil
} else { self.isConnectionReady = false }
self.schemaVersion = version.intValue
}
}
@@ -168,12 +177,13 @@ public final class RelayReplyOnce: @unchecked Sendable {
/// a delegate context. This explicit boundary lets the native delegate hop to
/// the main actor without pretending the dictionary itself is Sendable.
public final class RelayInboundMessage: @unchecked Sendable {
public let message: [String: Any]
private let container: RelayContainer?
public var message: [String: Any] { container?.dictionary() ?? [:] }
public let reply: RelayReplyOnce
public init(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
self.message = message
self.container = try? RelayContainer(dictionary: message)
self.reply = RelayReplyOnce(replyHandler)
}
}
@@ -19,7 +19,8 @@ public final class WatchRelayBridge: NSObject {
public var onStateChange: ((State) -> Void)?
private let session: any RelaySessionSending
private var grant: RemoteSessionGrant?
public private(set) var grant: RemoteSessionGrant?
private var generation = UUID()
private var nextSequence: UInt64 = 0
private var activationReady = false
private var pendingReplies: [UUID: RelayReplyOnce] = [:]
@@ -46,7 +47,9 @@ public final class WatchRelayBridge: NSObject {
}
public func reachabilityDidChange() {
guard activationReady, grant == nil else { return }
guard activationReady else { return }
if !session.isReachable { invalidatePending() }
if grant != nil { return }
state = .ready(reachable: session.isReachable)
}
@@ -65,10 +68,13 @@ public final class WatchRelayBridge: NSObject {
}
public func beginSession() {
guard grant == nil, state != .sessionNegotiating else { return }
guard activationReady, session.isReachable, grant == nil else {
state = .unavailable
return
}
invalidatePending()
let requestGeneration = generation
state = .sessionNegotiating
let requestID = UUID()
let request = BeginRemoteSessionRequest()
@@ -77,7 +83,7 @@ public final class WatchRelayBridge: NSObject {
return
}
send(message: message, requestID: requestID) { [weak self] response in
guard let self else { return }
guard let self, self.generation == requestGeneration, self.session.isReachable else { return }
guard let container = try? RelayContainer(dictionary: response),
container.kind == .session,
let received = try? container.decode(RemoteSessionGrant.self,
@@ -93,33 +99,34 @@ public final class WatchRelayBridge: NSObject {
}
public func endSession() {
guard activationReady, let grant else { return }
let request = EndRemoteSessionRequest(sessionID: grant.sessionID)
guard let message = try? RelayContainer(kind: .endSession, value: request).dictionary() else {
self.grant = nil
state = .ready(reachable: session.isReachable)
return
}
let requestID = UUID()
send(message: message, requestID: requestID) { [weak self] response in
guard let self else { return }
guard self.grant?.sessionID == grant.sessionID else { return }
self.grant = nil
self.nextSequence = 0
self.state = .ready(reachable: self.session.isReachable)
let oldGrant = grant
invalidatePending()
state = activationReady ? .ready(reachable: session.isReachable) : .inactive
guard activationReady, session.isReachable, let oldGrant,
let message = try? RelayContainer(kind: .endSession, value: EndRemoteSessionRequest(sessionID: oldGrant.sessionID)).dictionary() else { return }
// Locally revoke immediately; a delayed end reply cannot revoke a newer grant.
send(message: message, requestID: UUID()) { _ in }
}
public func sendCommandResult(_ command: RemoteCommand) async -> Result<CommandReceipt, RelayFailure> {
await withCheckedContinuation { continuation in
sendCommand(command) { continuation.resume(returning: $0) }
}
}
/// One command is one live sendMessage. A sequence is consumed before the
/// send; ambiguous timeout/failure is never replayed.
public func sendCommand(_ command: RemoteCommand, now: Date = Date()) {
public func sendCommand(_ command: RemoteCommand, now: Date = Date(),
completion: @escaping @MainActor (Result<CommandReceipt, RelayFailure>) -> Void = { _ in }) {
guard activationReady, session.isReachable, let grant else {
state = .unavailable
completion(.failure(.init(code: .unavailable)))
return
}
guard nextSequence < UInt64.max else {
self.grant = nil
state = .unavailable
completion(.failure(.init(code: .sessionInvalidated)))
return
}
let sequence = nextSequence
@@ -132,17 +139,20 @@ public final class WatchRelayBridge: NSObject {
expiresAt: now.addingTimeInterval(lifetime),
command: command)
guard let message = try? RelayContainer(kind: .command, value: envelope).dictionary() else {
completion(.failure(.init(code: .malformedRequest)))
return
}
let requestGeneration = generation
send(message: message, requestID: UUID()) { [weak self] response in
guard let self,
guard let self, self.generation == requestGeneration,
let container = try? RelayContainer(dictionary: response),
container.kind == .receipt,
let receipt = try? container.decode(CommandReceipt.self,
expectedKind: .receipt),
receipt.id == envelope.id else { return }
receipt.id == envelope.id else { completion(.failure(.init(code: .unavailable))); return }
completion(.success(receipt))
// A receipt is command feedback, not evidence that the TV changed.
if receipt.status == .unavailable { self.state = .unavailable }
if receipt.status != .sentToTransport { self.invalidatePending(); self.state = .unavailable }
}
}
@@ -150,12 +160,12 @@ public final class WatchRelayBridge: NSObject {
response: @escaping @MainActor ([String: Any]) -> Void) {
guard pendingReplies.count < 32 else {
state = .unavailable
response(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let once = RelayReplyOnce { [weak self] value in
Task { @MainActor [weak self] in
guard let self, self.pendingReplies.removeValue(forKey: requestID) != nil else { return }
guard self.activationReady else { return }
response(value)
}
}
@@ -172,8 +182,8 @@ public final class WatchRelayBridge: NSObject {
}
private func invalidatePending() {
let replies = pendingReplies.values
pendingReplies.removeAll()
generation = UUID()
let replies = Array(pendingReplies.values)
for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) }
grant = nil
nextSequence = 0
@@ -33,8 +33,12 @@ private final class WatchWCSessionSending: RelaySessionSending {
@MainActor
public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
public let bridge: WatchRelayBridge
public var onContext: ((RelayApplicationContext) -> Void)?
public private(set) var latestContext: RelayApplicationContext?
private let session: WCSession
public init(session: WCSession = .default) {
self.session = session
bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session))
super.init()
session.delegate = self
@@ -48,6 +52,21 @@ public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
guard let self else { return }
if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() }
self.bridge.activationDidComplete(error: error)
self.receiveContext(self.session.receivedApplicationContext)
}
}
private func receiveContext(_ dictionary: [String: Any]) {
guard let context = try? RelayApplicationContext(dictionary: dictionary) else { return }
latestContext = context
onContext?(context)
}
nonisolated public func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
guard let context = try? RelayApplicationContext(dictionary: applicationContext) else { return }
Task { @MainActor [weak self] in
self?.latestContext = context
self?.onContext?(context)
}
}
@@ -16,6 +16,21 @@ public final class PhoneRelayBridge: NSObject {
private let coordinator: any PhoneCommandCoordinating
private var peer: WatchPeerGeneration?
private var activationReady = false
private var lifecycleTask: Task<Void, Never>?
private var pending: [UUID: RelayReplyOnce] = [:]
private var latestContext: RelayApplicationContext?
public func publishContext(_ context: RelayApplicationContext) {
latestContext = context
flushContext()
}
private func flushContext() {
guard session.activationState == .activated, let latestContext else { return }
if let dictionary = try? latestContext.dictionary() {
try? session.updateApplicationContext(dictionary)
}
}
public init(session: any RelaySessionSending,
dependency: PhoneRelayDependency) {
@@ -31,19 +46,24 @@ public final class PhoneRelayBridge: NSObject {
}
public func activationDidComplete(error: Error?) {
guard error == nil else {
guard error == nil, session.activationState == .activated else {
didDeactivate()
state = .unavailable
activationReady = false
return
}
guard peer == nil else { flushContext(); return }
let newPeer = WatchPeerGeneration()
peer = newPeer
activationReady = false
state = .ready(reachable: session.isReachable)
Task { [weak self] in
let prior = lifecycleTask
lifecycleTask = Task { [weak self] in
await prior?.value
await self?.coordinator.watchBridgeDidActivate(peer: newPeer)
guard let self, self.peer == newPeer else { return }
self.activationReady = true
self.flushContext()
}
}
@@ -63,7 +83,12 @@ public final class PhoneRelayBridge: NSObject {
peer = nil
activationReady = false
state = .inactive
Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) }
for reply in pending.values { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) }
let prior = lifecycleTask
lifecycleTask = Task {
await prior?.value
await coordinator.watchBridgeDidDeactivate(peer: oldPeer)
}
}
/// Entry point for the native WCSession delegate. Every path completes the
@@ -71,18 +96,36 @@ public final class PhoneRelayBridge: NSObject {
/// out requests.
public func receive(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
let reply = RelayReplyOnce(replyHandler)
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
guard pending.count < 16 else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let receivedPeer = peer
let id = UUID()
pending[id] = inbound.reply
Task { @MainActor [weak self] in
guard let self else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
await self.route(message: message, reply: reply)
guard self.peer == receivedPeer, receivedPeer != nil else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
self.pending.removeValue(forKey: id)
return
}
await self.route(message: inbound.message, reply: inbound.reply)
self.pending.removeValue(forKey: id)
}
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, let reply = self.pending[id] else { return }
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
}
}
private func route(message: [String: Any], reply: RelayReplyOnce) async {
guard activationReady, let currentPeer = peer else {
guard activationReady, session.activationState == .activated, let currentPeer = peer else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -101,9 +144,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
let result = await ({
await self.coordinator.beginWatchSession(request, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -120,9 +164,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
let result = await ({
await self.coordinator.endWatchSession(request, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -139,9 +184,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let receipt = await withTimeout({
let receipt = await ({
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -162,18 +208,4 @@ public final class PhoneRelayBridge: NSObject {
relayErrorReply(RelayFailure(code: .malformedRequest))
}
private func withTimeout<Value: Sendable>(
_ operation: @escaping @Sendable () async -> Value
) async -> Value? {
await withTaskGroup(of: Value?.self) { group in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(for: .milliseconds(1_000))
return nil
}
let result = await group.next() ?? nil
group.cancelAll()
return result
}
}
}
@@ -70,3 +70,13 @@ import WatchBridgeCommon
_ = try RelayApplicationContext(dictionary: executable)
}
}
@Test func readinessContextRequiresBooleanAndSelectedIdentity() throws {
let id = UUID()
let context = RelayApplicationContext(selectedTelevisionID: id, displayName: "Living room", isConnectionReady: true)
#expect(try RelayApplicationContext(dictionary: context.dictionary()) == context)
#expect(!RelayApplicationContext(selectedTelevisionID: nil, displayName: nil, isConnectionReady: true).isConnectionReady)
#expect(throws: RelayContainerError.self) {
try RelayApplicationContext(dictionary: ["schemaVersion": 1, "isConnectionReady": 1])
}
}
@@ -91,3 +91,60 @@ private enum RelayTestError: Error { case failed }
#expect(bridge.state == .inactive)
#expect(fake.sent.count == 1)
}
@MainActor
@Test func watchReachabilityLossRevokesArmedGrantAndNeverReplays() async throws {
let fake = FakeWatchSession()
let bridge = WatchRelayBridge(session: fake)
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
await Task.yield()
#expect(bridge.grant != nil)
fake.isReachable = false; bridge.reachabilityDidChange()
#expect(bridge.grant == nil)
#expect(bridge.state == .ready(reachable: false))
fake.isReachable = true; bridge.reachabilityDidChange()
bridge.sendCommand(.right)
#expect(fake.sent.count == 1)
}
@MainActor
@Test func lateGrantAfterStopDoesNotRearmAndRepeatedConnectDoesNotDuplicate() async throws {
let fake = FakeWatchSession()
let bridge = WatchRelayBridge(session: fake)
bridge.activate(); bridge.activationDidComplete(error: nil)
bridge.beginSession(); bridge.beginSession()
#expect(fake.sent.count == 1)
let lateReply = fake.reply
bridge.endSession()
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
lateReply?(try RelayContainer(kind: .session, value: grant).dictionary())
await Task.yield()
#expect(bridge.grant == nil)
#expect(bridge.state == .ready(reachable: true))
}
@MainActor
@Test func invalidationCompletesCommandOnceAndDiscardsLateSuccess() async throws {
let fake = FakeWatchSession()
let bridge = WatchRelayBridge(session: fake)
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
await Task.yield()
var completions = 0
var succeeded = false
bridge.sendCommand(.right) { result in
completions += 1
if case .success = result { succeeded = true }
}
let command = try RelayContainer(dictionary: fake.sent.last!).decode(CommandEnvelope.self, expectedKind: .command)
let lateReply = fake.reply
bridge.didDeactivate()
lateReply?(try RelayContainer(kind: .receipt, value: CommandReceipt(id: command.id, status: .sentToTransport)).dictionary())
for _ in 0..<10 { await Task.yield() }
#expect(completions == 1)
#expect(!succeeded)
#expect(bridge.grant == nil)
}
@@ -9,6 +9,7 @@ private final class FakePhoneSession: RelaySessionSending {
var activationState: RelayActivationState = .notActivated
var isReachable = true
var sent: [[String: Any]] = []
var contexts: [[String: Any]] = []
var reply: (([String: Any]) -> Void)?
var error: ((Error) -> Void)?
func activate() { activationState = .activated }
@@ -19,7 +20,7 @@ private final class FakePhoneSession: RelaySessionSending {
reply = replyHandler
error = errorHandler
}
func updateApplicationContext(_ context: [String: Any]) throws {}
func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) }
func answer(_ value: [String: Any]) { reply?(value) }
}
@@ -86,3 +87,70 @@ private actor RecordingPhoneCoordinator: PhoneCommandCoordinating {
await Task.yield()
#expect(replies == 1)
}
private actor SuspendedCoordinator: PhoneCommandCoordinating {
var begins = 0
var active = false
var waiting: [CheckedContinuation<Result<RemoteSessionGrant, RelayFailure>, Never>] = []
func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true }
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false }
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
begins += 1
return await withCheckedContinuation { waiting.append($0) }
}
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result<RemoteSessionEnded, RelayFailure> { .failure(RelayFailure(code: .unavailable)) }
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { CommandReceipt(id: envelope.id, status: .sentToTransport) }
func release() { for continuation in waiting { continuation.resume(returning: .failure(RelayFailure(code: .unavailable))) }; waiting.removeAll() }
}
@MainActor
@Test func timeoutDoesNotWaitForUncooperativeActorAndBoundsOutstandingCalls() async throws {
let session = FakePhoneSession()
let coordinator = SuspendedCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
let message = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
var replies = 0
for _ in 0..<20 { bridge.receive(message: message) { _ in replies += 1 } }
try await Task.sleep(for: .milliseconds(1_150))
#expect(replies == 20)
#expect(await coordinator.begins == 16)
bridge.receive(message: message) { _ in replies += 1 }
#expect(replies == 21)
#expect(await coordinator.begins == 16)
await coordinator.release()
try await Task.sleep(for: .milliseconds(30))
#expect(replies == 21)
}
@MainActor
@Test func deactivationCompletesPendingRepliesBeforeActorReturns() async throws {
let session = FakePhoneSession()
let coordinator = SuspendedCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
var replies = 0
bridge.receive(message: try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()) { _ in replies += 1 }
try await Task.sleep(for: .milliseconds(30))
bridge.didDeactivate()
#expect(replies == 1)
await coordinator.release()
try await Task.sleep(for: .milliseconds(30))
#expect(replies == 1)
}
@MainActor
@Test func latestContextPublishedBeforeActivationIsFlushed() async throws {
let session = FakePhoneSession()
let coordinator = RecordingPhoneCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
let context = RelayApplicationContext(selectedTelevisionID: UUID(), displayName: "TV", isConnectionReady: true)
bridge.publishContext(context)
#expect(session.contexts.isEmpty)
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
#expect(session.contexts.count == 1)
#expect(try RelayApplicationContext(dictionary: session.contexts[0]) == context)
}