Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -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.
|
||||
@@ -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")])])
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"])
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
+119
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user