Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
@@ -0,0 +1,48 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# TVTransport integration proposal
Status: proposed by workstream A; integration owner review required before app wiring.
## Required seam with SecureStorage (E)
`TVTransport` consumes an injected `TVAuthorizationStore`. The store is keyed only by
the shared opaque television UUID and returns a token plus a scoped TLS policy. The
transport never knows about Keychain APIs, never caches the token outside the active
connection attempt, and never sends either value to Watch.
The proposed values are:
- `StoredTVAuthorization(token: String?, trust: TVTrustPolicy)`
- `TVTrustPolicy.systemDefault`
- `TVTrustPolicy.pinnedLeafCertificateSHA256(Data)` for a fingerprint that a user
explicitly approved for this television
- async load, save-token, delete-token and delete-trust operations
Workstream E should conform its Keychain repository to this protocol. It should use
device-only accessibility, report locked/unavailable Keychain as an error, and keep
the consent record needed before returning a pin. A first-contact certificate is
never accepted merely because the transport observed it. Certificate capture and
the consent UI remain an integration task after physical TLS evidence exists.
Migration impact: none; no persisted format exists. If E chooses a differently
named public contract, the integrator can adapt it at composition or rename this
consumer-owned seam without changing actor behavior.
## Build wiring request
Add the `Adapters/TVTransport` library product to the iPhone target only. It depends
on the existing `RemoteCore` and `SamsungProtocol` products plus Apple Foundation,
Security and CryptoKit for the iOS URLSession adapter. Do not add it to watchOS.
The local package manifest exists only so workstream A can compile and run focused
tests before project-generator integration.
The pure Samsung event parser belongs to the already owned `SamsungProtocol` target;
no new shared domain type or command value is required.
## Test impact
Before merge, run the adapter package tests plus the root `WristivoKit` tests. The
integrator should then regenerate source membership and run both application builds.
Physical pairing, certificate behavior and token persistence remain explicitly
untested until the owner completes the manual hardware script.
+55
View File
@@ -0,0 +1,55 @@
# Wristivo physical pairing and TLS feasibility spike
Status: owner run pending. This procedure is evidence collection, not a compatibility claim.
Record the exact iPhone model/iOS build, TV model from Settings and firmware/Tizen
version, Wi-Fi topology, build commit, and whether a debugger is attached. The current
`QA85Q8FAAUXZN` model report is unverified until the TV label and Settings are checked.
Redact the TV address, pairing token, certificate details that identify the household,
and client lists before saving evidence. Keep raw captures outside git.
## Pair, deny and timeout
1. Put the iPhone and TV on the same LAN. Keep the existing TV remote available and
turn the TV on. Enter only the host name or address in the Wristivo setup screen.
2. Grant Local Network access when the setup flow explains why it is needed. Start
pairing explicitly. The candidate endpoint is TLS `wss` on port `8002`; do not try
port `8001` or a plaintext fallback.
3. Confirm that the TV prompt/device list identifies the controller as **Wristivo**.
Record the exact prompt text and firmware. Approve once, then issue one supported
direction from the iPhone and record that a socket submission is only a transport
receipt until the TV visibly responds.
4. Repeat with Deny. Confirm the app enters a recoverable denied state and sends no
command. Repeat with no TV approval until the 30-second pairing timeout; confirm
it does not become ready.
## TLS policy
First run with normal platform certificate and hostname validation. Record whether the
TV presents a publicly trusted chain, a self-signed certificate, or another failure;
capture only a redacted fingerprint and trust result. A failed trust check must remain
failed. Do not install a global ATS exception or accept-all challenge handler.
If the device presents a self-signed certificate and a product experiment is still
warranted, stop and obtain explicit user approval for that specific TV fingerprint.
Only a stored, per-TV leaf SHA-256 pin may be tested with the scoped policy; a pin
learned from the same unauthenticated first connection does not prove first-contact
identity. A changed certificate must require reapproval/re-pairing.
## Reconnect, revoke and lifecycle cases
After approval, terminate/relaunch the app and reboot the phone once. Verify a stored
token reconnects without pairing, including the locked/first-unlock behavior that the
chosen Keychain accessibility permits. Revoke the controller in the TV's device list,
then reconnect: the app must become unauthorized, delete the rejected token, and ask
for explicit pairing again. The app's Forget TV action must delete both token and trust
record; removing the controller on the TV is a separate TV-menu action.
Run a TV-off case, a LAN interruption, a selected-TV switch, and a phone suspend/reopen.
No command may be replayed or drained after reconnect. A command whose deadline expires
while disconnected must be reported unavailable/expired and discarded.
Record every offered directional, Select, Back, Home, playback and volume key separately.
Hide or disable any key without evidence. Do not test service-menu, factory, power or
purchase actions. Do not mark this TV or any Samsung model compatible from a simulator,
fake socket, unit test or successful WebSocket write alone.
+25
View File
@@ -0,0 +1,25 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "TVTransport",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "TVTransport", targets: ["TVTransport"])],
dependencies: [.package(path: "../../Packages/WristivoKit")],
targets: [
.target(
name: "TVTransport",
dependencies: [
.product(name: "RemoteCore", package: "WristivoKit"),
.product(name: "SamsungProtocol", package: "WristivoKit")
]
),
.testTarget(
name: "TVTransportTests",
dependencies: [
"TVTransport",
.product(name: "RemoteCore", package: "WristivoKit")
]
)
]
)
+11 -1
View File
@@ -1,3 +1,13 @@
# TVTransport
Planned adapter: iPhone-only paired local TV networking. No live implementation is present. Owner: workstream A in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
Workstream A implementation for iPhone-only paired local TV networking. The actor
owns pairing authorization, bounded event parsing, one-at-a-time command submission,
admission/deadline checks and generation cancellation. It never claims that a socket
write changed the TV. `SamsungEventParser` and `SamsungRemoteEncoder` remain pure
package seams; URLSession and TLS handling stay in this adapter.
The local `Package.swift` is a focused test harness. The integrator must add the
adapter to the iPhone target through the project generator, conform workstream E's
Keychain/trust repository to the injected seam in `INTEGRATION-PROPOSAL.md`, and keep
the adapter out of watchOS. Physical pairing and certificate behavior are untested
until the owner completes `MANUAL-PAIRING.md`.
@@ -0,0 +1,561 @@
import Foundation
import RemoteCore
import SamsungProtocol
/// The single phone-side owner of TV connection state and command admission.
/// A completed send reports transport submission only; it is not TV confirmation.
public actor TVConnectionActor: RemoteTransport, PhoneCommandCoordinating {
private enum ConnectionMode { case pair, reconnect }
private struct PendingSend {
let envelope: CommandEnvelope
let isWatch: Bool
let controlEpoch: UUID
let generation: UUID
let monotonicDeadline: TimeInterval
let continuation: CheckedContinuation<CommandReceipt, Never>
}
private let socketFactory: any TVWebSocketFactory
private let authorizationStore: any TVAuthorizationStore
private let clock: TVTransportClock
private let configuration: TVTransportConfiguration
private let parser: SamsungEventParser
private var state: TVConnectionState = .disconnected {
didSet { for observer in observers.values { observer.yield(state) } }
}
private var observers: [UUID: AsyncStream<TVConnectionState>.Continuation] = [:]
private var watchPeer: WatchPeerGeneration?
private var watchSession: UUID?
private var watchAdmission: CommandAdmission?
private var controlEpoch = UUID()
private var phoneSession = UUID()
private var phoneSequence: UInt64 = 0
private var activeWrite: UUID?
private var watchRequestRevision = UUID()
private var credentialMaintenance = false
private var receiveTask: Task<Void, Never>?
private var target: TelevisionTarget?
private var socket: (any TVWebSocket)?
private var admission: CommandAdmission?
private var generation = UUID()
private var queue: [PendingSend] = []
private var drainTask: Task<Void, Never>?
public init(
socketFactory: any TVWebSocketFactory,
authorizationStore: any TVAuthorizationStore,
clock: TVTransportClock = .system(),
configuration: TVTransportConfiguration = .init()
) {
self.socketFactory = socketFactory
self.authorizationStore = authorizationStore
self.clock = clock
self.configuration = configuration
self.parser = SamsungEventParser(maximumMessageBytes: configuration.maximumEventBytes)
}
public func connectionState() -> TVConnectionState { state }
public func stateUpdates() -> AsyncStream<TVConnectionState> {
let id = UUID()
let (stream, continuation) = AsyncStream<TVConnectionState>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[id] = continuation
continuation.yield(state)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(id) }
}
return stream
}
private func removeObserver(_ id: UUID) { observers[id] = nil }
public func watchBridgeDidActivate(peer: WatchPeerGeneration) async {
guard watchPeer != peer else { return }
watchPeer = peer
await invalidateWatchControl()
}
public func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async {
guard watchPeer == peer else { return }
watchPeer = nil
await invalidateWatchControl()
}
public func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer else { return .failure(.init(code: .sessionInvalidated)) }
let revision = UUID()
watchRequestRevision = revision
await invalidateWatchControl()
guard watchPeer == peer, watchRequestRevision == revision else { return .failure(.init(code: .sessionInvalidated)) }
guard case .ready(let id) = state, socket != nil else { return .failure(.init(code: .unavailable)) }
let session = UUID()
watchSession = session
watchAdmission = CommandAdmission(sessionID: session, televisionID: id)
return .success(.init(sessionID: session, televisionID: id, issuedAt: clock.wallNow()))
}
public func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionEnded, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer, watchSession == request.sessionID else { return .failure(.init(code: .sessionInvalidated)) }
await invalidateWatchControl()
return .success(.init(sessionID: request.sessionID))
}
public func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) async -> CommandReceipt {
guard watchPeer == peer, watchSession == envelope.sessionID else {
return .init(id: envelope.id, status: .rejected)
}
return await enqueue(envelope, isWatch: true)
}
public func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
guard let target else { return .init(id: UUID(), status: .unavailable) }
guard phoneSequence < UInt64.max else { return .init(id: UUID(), status: .rejected) }
phoneSequence += 1
let now = clock.wallNow()
return await send(.init(sessionID: phoneSession, televisionID: target.id,
sequence: phoneSequence, issuedAt: now, expiresAt: now.addingTimeInterval(0.8), command: command))
}
private func invalidateWatchControl() async {
controlEpoch = UUID()
watchSession = nil
watchAdmission = nil
// A write already submitted cannot be recalled. Close before another grant.
if activeWrite != nil { await disconnect() }
}
/// Starts an explicit first-pair attempt without sending a stored token.
@discardableResult
public func pair(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .pair)
}
/// Reconnects only when a stored token is available. It never falls back to pairing.
@discardableResult
public func connect(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .reconnect)
}
/// Invalidates the current control session and closes its socket so an in-flight
/// old-session write cannot continue on a connection authorized for a new session.
/// Call `connect(target:sessionID:)` explicitly to establish the replacement.
public func invalidateSession() async {
await disconnect()
}
public func disconnect() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
target = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
/// Deletes the rejected/revoked pairing token while retaining an approved trust pin.
public func revokeAuthorization() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
guard let televisionID else { return }
do {
try await authorizationStore.deleteToken(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
/// Deletes both token and trust material for a complete forget-TV operation.
public func forgetTelevision() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
if let televisionID {
do {
try await authorizationStore.deleteToken(for: televisionID)
try await authorizationStore.deleteTrust(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
// Forget removes the selected target as well as its authorization material.
target = nil
}
public func send(_ envelope: CommandEnvelope) async -> CommandReceipt {
await enqueue(envelope, isWatch: false)
}
private func enqueue(_ envelope: CommandEnvelope, isWatch: Bool) async -> CommandReceipt {
guard case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
socket != nil,
queue.count < configuration.maximumQueuedCommands else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let remaining = envelope.expiresAt.timeIntervalSince(clock.wallNow())
guard remaining > 0 else {
return CommandReceipt(id: envelope.id, status: .expired)
}
let commandGeneration = generation
let deadline = clock.monotonicNow() + remaining
return await withCheckedContinuation { continuation in
queue.append(PendingSend(
envelope: envelope,
isWatch: isWatch,
controlEpoch: controlEpoch,
generation: commandGeneration,
monotonicDeadline: deadline,
continuation: continuation
))
startDrainingIfNeeded(generation: commandGeneration)
}
}
private func establish(
target newTarget: TelevisionTarget,
sessionID: UUID,
mode: ConnectionMode
) async -> TVConnectionState {
guard !credentialMaintenance else { return .unavailable(televisionID: newTarget.id, failure: .credentialStore) }
let attempt = UUID()
generation = attempt
cancelCommandGeneration()
admission = nil
target = newTarget
let oldSocket = socket
socket = nil
state = .connecting(televisionID: newTarget.id)
await oldSocket?.close()
guard generation == attempt else { return state }
let stored: StoredTVAuthorization
do {
stored = try await authorizationStore.authorization(for: newTarget.id)
} catch {
guard generation == attempt else { return state }
state = .unavailable(televisionID: newTarget.id, failure: .credentialStore)
return state
}
guard generation == attempt else { return state }
let token: String?
switch mode {
case .pair:
token = nil
case .reconnect:
guard let existingToken = stored.token, !existingToken.isEmpty else {
state = .unauthorized(televisionID: newTarget.id)
return state
}
token = existingToken
}
let endpoint: URL
do {
endpoint = try SamsungRemoteEncoder.endpoint(host: newTarget.host, token: token)
} catch {
state = .unavailable(televisionID: newTarget.id, failure: .invalidTarget)
return state
}
let openedSocket: any TVWebSocket
do {
openedSocket = try await socketFactory.open(
endpoint: endpoint,
trust: stored.trust,
maximumMessageBytes: configuration.maximumEventBytes
)
} catch {
guard generation == attempt else { return state }
state = connectionFailureState(error, televisionID: newTarget.id)
return state
}
guard generation == attempt else {
await openedSocket.close()
return state
}
socket = openedSocket
state = .awaitingApproval(televisionID: newTarget.id)
let timeoutDeadline = clock.monotonicNow() + configuration.pairingTimeout
while generation == attempt {
let remaining = timeoutDeadline - clock.monotonicNow()
guard remaining > 0 else {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
let eventData: Data
do {
eventData = try await receive(from: openedSocket, timeout: remaining)
} catch is PairingTimeoutError {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
} catch {
guard generation == attempt else {
await openedSocket.close()
return state
}
return await failConnection(connectionFailureState(error, televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
guard generation == attempt else {
await openedSocket.close()
return state
}
let event: SamsungRemoteEvent
do {
event = try parser.parse(eventData)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .malformedResponse), socket: openedSocket, attempt: attempt)
}
switch event {
case .unknown:
continue
case .connected(let returnedToken):
let usableToken = returnedToken ?? token
guard let usableToken, !usableToken.isEmpty else {
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
if let returnedToken {
do {
try await authorizationStore.saveToken(returnedToken, for: newTarget.id)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .credentialStore), socket: openedSocket, attempt: attempt)
}
}
guard generation == attempt else {
await openedSocket.close()
return state
}
phoneSession = sessionID
phoneSequence = 0
admission = CommandAdmission(sessionID: sessionID, televisionID: newTarget.id)
state = .ready(televisionID: newTarget.id)
receiveTask = Task { await self.monitor(openedSocket, attempt: attempt, televisionID: newTarget.id) }
return state
case .unauthorized:
switch mode {
case .reconnect:
try? await authorizationStore.deleteToken(for: newTarget.id)
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .pair:
return await failConnection(.denied(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
case .timedOut:
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .error:
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .socket), socket: openedSocket, attempt: attempt)
}
}
await openedSocket.close()
return state
}
private func startDrainingIfNeeded(generation commandGeneration: UUID) {
guard drainTask == nil else { return }
drainTask = Task { await self.drain(generation: commandGeneration) }
}
private func drain(generation commandGeneration: UUID) async {
while !Task.isCancelled,
generation == commandGeneration,
!queue.isEmpty {
let pending = queue.removeFirst()
guard pending.generation == commandGeneration else {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
continue
}
let receipt = await process(pending)
pending.continuation.resume(returning: receipt)
}
if generation == commandGeneration {
drainTask = nil
if !queue.isEmpty { startDrainingIfNeeded(generation: commandGeneration) }
}
}
private func process(_ pending: PendingSend) async -> CommandReceipt {
let envelope = pending.envelope
guard generation == pending.generation,
case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
let liveSocket = socket,
var currentAdmission = (pending.isWatch ? watchAdmission : admission),
!pending.isWatch || pending.controlEpoch == controlEpoch else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let decision = currentAdmission.admit(envelope, now: clock.wallNow())
if pending.isWatch { watchAdmission = currentAdmission } else { admission = currentAdmission }
switch decision {
case .rejected(.duplicate), .rejected(.outOfOrder):
return CommandReceipt(id: envelope.id, status: .duplicate)
case .rejected(.expired), .rejected(.stale):
return CommandReceipt(id: envelope.id, status: .expired)
case .rejected:
return CommandReceipt(id: envelope.id, status: .rejected)
case .accepted:
break
}
let frame: Data
do {
frame = try SamsungRemoteEncoder.clickFrame(for: envelope.command)
} catch {
return CommandReceipt(id: envelope.id, status: .rejected)
}
// Admission has been consumed. Recheck both wall and receiver-local monotonic
// deadlines immediately before the one allowed socket submission.
guard generation == pending.generation,
state == .ready(televisionID: televisionID),
clock.wallNow() < envelope.expiresAt,
clock.monotonicNow() < pending.monotonicDeadline,
!Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .expired)
}
do {
let writeID = UUID()
activeWrite = writeID
defer { if activeWrite == writeID { activeWrite = nil } }
try await sendBounded(frame, socket: liveSocket, timeout: max(0.001, pending.monotonicDeadline - clock.monotonicNow()))
guard generation == pending.generation, !Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
return CommandReceipt(id: envelope.id, status: .sentToTransport)
} catch {
if generation == pending.generation {
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: pending.generation)
}
return CommandReceipt(id: envelope.id, status: .unavailable)
}
}
private func cancelCommandGeneration() {
activeWrite = nil
receiveTask?.cancel()
receiveTask = nil
watchSession = nil
watchAdmission = nil
controlEpoch = UUID()
drainTask?.cancel()
drainTask = nil
let abandoned = queue
queue.removeAll(keepingCapacity: true)
for pending in abandoned {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
}
}
private func disconnectKeepingTargetForDeletion() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
private func failConnection(
_ failureState: TVConnectionState,
socket failedSocket: any TVWebSocket,
attempt: UUID
) async -> TVConnectionState {
if generation == attempt {
generation = UUID()
cancelCommandGeneration()
socket = nil
admission = nil
state = failureState
}
await failedSocket.close()
return state
}
private func monitor(_ liveSocket: any TVWebSocket, attempt: UUID, televisionID: UUID) async {
do {
while generation == attempt, !Task.isCancelled {
let data = try await liveSocket.receive()
guard generation == attempt, !Task.isCancelled else { return }
switch try parser.parse(data) {
case .connected, .unknown: continue
case .unauthorized:
credentialMaintenance = true
defer { credentialMaintenance = false }
_ = await failConnection(.unauthorized(televisionID: televisionID), socket: liveSocket, attempt: attempt)
try? await authorizationStore.deleteToken(for: televisionID)
return
case .timedOut, .error:
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
return
}
}
} catch {
guard generation == attempt else { return }
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
}
}
private func sendBounded(_ data: Data, socket: any TVWebSocket, timeout: TimeInterval) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await socket.send(data) }
group.addTask { try await Task.sleep(for: .seconds(timeout)); throw PairingTimeoutError() }
do {
_ = try await group.next()
group.cancelAll()
} catch {
group.cancelAll()
await socket.close()
throw error
}
}
}
private func connectionFailureState(_ error: Error, televisionID: UUID) -> TVConnectionState {
if let socketError = error as? TVSocketError, socketError == .untrustedCertificate {
return .untrusted(televisionID: televisionID)
}
return .unavailable(televisionID: televisionID, failure: .socket)
}
private struct PairingTimeoutError: Error {}
private func receive(from socket: any TVWebSocket, timeout: TimeInterval) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await socket.receive() }
group.addTask {
try await Task.sleep(for: .seconds(timeout))
throw PairingTimeoutError()
}
do {
guard let result = try await group.next() else { throw TVSocketError.closed }
group.cancelAll()
return result
} catch {
// Cancelling a Swift child task does not necessarily cancel a
// URLSessionWebSocketTask (or an injected fake) that is suspended
// in receive(). Close the socket before leaving the task group so
// the losing receive child cannot hold a pairing attempt open.
group.cancelAll()
await socket.close()
throw error
}
}
}
}
@@ -0,0 +1,122 @@
import Foundation
public struct TelevisionTarget: Sendable, Equatable {
public let id: UUID
public let host: String
public init(id: UUID, host: String) {
self.id = id
self.host = host
}
}
public enum TVTrustPolicy: Sendable, Equatable {
/// Require the platform's normal certificate and hostname validation.
case systemDefault
/// Accept only an exact leaf-certificate SHA-256 fingerprint that the user
/// previously approved for this television. The 32-byte value is not learned
/// or saved by the transport during the connection being authorized.
case pinnedLeafCertificateSHA256(Data)
}
public struct StoredTVAuthorization: Sendable, Equatable {
public let token: String?
public let trust: TVTrustPolicy
public init(token: String?, trust: TVTrustPolicy = .systemDefault) {
self.token = token
self.trust = trust
}
}
/// Consumer-owned persistence seam. The iPhone Keychain adapter from workstream E
/// supplies the implementation; the transport never caches credentials in plaintext.
public protocol TVAuthorizationStore: Sendable {
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization
func saveToken(_ token: String, for televisionID: UUID) async throws
func deleteToken(for televisionID: UUID) async throws
func deleteTrust(for televisionID: UUID) async throws
}
public enum TVSocketError: Error, Sendable, Equatable {
case untrustedCertificate
case unavailable
case closed
}
public protocol TVWebSocket: Sendable {
func receive() async throws -> Data
func send(_ data: Data) async throws
func close() async
}
public protocol TVWebSocketFactory: Sendable {
func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket
}
public enum TVConnectionFailure: Sendable, Equatable {
case invalidTarget
case missingToken
case malformedResponse
case credentialStore
case socket
}
public enum TVConnectionState: Sendable, Equatable {
case disconnected
case connecting(televisionID: UUID)
case awaitingApproval(televisionID: UUID)
case ready(televisionID: UUID)
case denied(televisionID: UUID)
case unauthorized(televisionID: UUID)
case untrusted(televisionID: UUID)
case timedOut(televisionID: UUID)
case unavailable(televisionID: UUID?, failure: TVConnectionFailure)
}
public struct TVTransportClock: Sendable {
public var wallNow: @Sendable () -> Date
public var monotonicNow: @Sendable () -> TimeInterval
public init(
wallNow: @escaping @Sendable () -> Date,
monotonicNow: @escaping @Sendable () -> TimeInterval
) {
self.wallNow = wallNow
self.monotonicNow = monotonicNow
}
public static func system() -> Self {
let clock = ContinuousClock()
let origin = clock.now
return Self(
wallNow: Date.init,
monotonicNow: {
let components = origin.duration(to: clock.now).components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
}
)
}
}
public struct TVTransportConfiguration: Sendable, Equatable {
public var pairingTimeout: TimeInterval
public var maximumQueuedCommands: Int
public var maximumEventBytes: Int
public init(
pairingTimeout: TimeInterval = 30,
maximumQueuedCommands: Int = 8,
maximumEventBytes: Int = 16 * 1024
) {
self.pairingTimeout = min(max(pairingTimeout, 0.001), 30)
self.maximumQueuedCommands = min(max(maximumQueuedCommands, 1), 32)
self.maximumEventBytes = min(max(maximumEventBytes, 256), 64 * 1024)
}
}
@@ -0,0 +1,154 @@
#if os(iOS)
import CryptoKit
import Foundation
import Security
public struct URLSessionSamsungSocketFactory: TVWebSocketFactory {
private let onUntrustedCertificate: (@Sendable (String, Data) -> Void)?
public init(onUntrustedCertificate: (@Sendable (String, Data) -> Void)? = nil) {
self.onUntrustedCertificate = onUntrustedCertificate
}
public func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket {
guard endpoint.scheme == "wss", let host = endpoint.host else { throw TVSocketError.unavailable }
let delegate = ScopedTrustDelegate(policy: trust, host: host, observer: onUntrustedCertificate)
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = true
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
let task = session.webSocketTask(with: endpoint)
task.maximumMessageSize = maximumMessageBytes
task.resume()
return URLSessionSamsungSocket(session: session, task: task, delegate: delegate)
}
}
private final class URLSessionSamsungSocket: TVWebSocket, @unchecked Sendable {
private let session: URLSession
private let task: URLSessionWebSocketTask
private let delegate: ScopedTrustDelegate
init(session: URLSession, task: URLSessionWebSocketTask, delegate: ScopedTrustDelegate) {
self.session = session
self.task = task
self.delegate = delegate
}
func receive() async throws -> Data {
do {
switch try await task.receive() {
case .data(let data):
return data
case .string(let text):
return Data(text.utf8)
@unknown default:
throw TVSocketError.unavailable
}
} catch {
throw normalized(error)
}
}
func send(_ data: Data) async throws {
do {
guard let text = String(data: data, encoding: .utf8) else { throw TVSocketError.unavailable }
try await task.send(.string(text))
} catch {
throw normalized(error)
}
}
func close() async {
task.cancel(with: .goingAway, reason: nil)
session.invalidateAndCancel()
_ = delegate
}
private func normalized(_ error: Error) -> Error {
if delegate.rejectedTrust { return TVSocketError.untrustedCertificate }
let certificateCodes: Set<URLError.Code> = [
.serverCertificateHasBadDate,
.serverCertificateUntrusted,
.serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid,
.clientCertificateRejected,
.clientCertificateRequired
]
if let urlError = error as? URLError, certificateCodes.contains(urlError.code) {
return TVSocketError.untrustedCertificate
}
if (error as? URLError)?.code == .cancelled { return TVSocketError.closed }
return TVSocketError.unavailable
}
}
private final class ScopedTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let policy: TVTrustPolicy
private let lock = NSLock()
private var trustWasRejected = false
var rejectedTrust: Bool { lock.withLock { trustWasRejected } }
private func rejectTrust() { lock.withLock { trustWasRejected = true } }
private let host: String
private let observer: (@Sendable (String, Data) -> Void)?
init(policy: TVTrustPolicy, host: String, observer: (@Sendable (String, Data) -> Void)?) {
self.policy = policy
self.host = host
self.observer = observer
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
guard challenge.protectionSpace.host.caseInsensitiveCompare(host) == .orderedSame,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(certificate) as Data))
switch policy {
case .systemDefault:
guard SecTrustEvaluateWithError(serverTrust, nil) else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
case .pinnedLeafCertificateSHA256(let expectedFingerprint):
guard expectedFingerprint.count == SHA256.byteCount,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let certificateData = SecCertificateCopyData(certificate) as Data
let presentedFingerprint = Data(SHA256.hash(data: certificateData))
guard presentedFingerprint == expectedFingerprint else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
}
}
}
#endif
@@ -0,0 +1,387 @@
import Foundation
import RemoteCore
import Testing
@testable import TVTransport
private let baseDate = Date(timeIntervalSince1970: 10_000)
private func envelope(
sessionID: UUID,
televisionID: UUID,
sequence: UInt64,
command: RemoteCommand,
id: UUID = UUID(),
issuedAt: Date = baseDate,
expiresAt: Date = baseDate.addingTimeInterval(0.8)
) -> CommandEnvelope {
CommandEnvelope(
sessionID: sessionID,
televisionID: televisionID,
sequence: sequence,
issuedAt: issuedAt,
expiresAt: expiresAt,
command: command,
id: id
)
}
@Test func explicitPairThenTokenReconnectAuthorizesBeforeReady() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let pairSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let reconnectSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [pairSocket, reconnectSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store)
let firstSession = UUID()
#expect(await transport.pair(target: tv, sessionID: firstSession) == .ready(televisionID: tv.id))
#expect(await store.savedToken(for: tv.id) == "redacted-token")
let firstEndpoint = try #require(await factory.endpoint(at: 0))
#expect(URLComponents(url: firstEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.contains(where: { $0.name == "token" }) == false)
await transport.disconnect()
let secondSession = UUID()
#expect(await transport.connect(target: tv, sessionID: secondSession) == .ready(televisionID: tv.id))
let secondEndpoint = try #require(await factory.endpoint(at: 1))
#expect(URLComponents(url: secondEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "token" })?.value == "redacted-token")
}
@Test func pairingDenialTimeoutAndMissingInitialTokenFailClosed() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let deniedSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)])
let missingTokenSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let timeoutSocket = FakeSocket(events: [])
let factory = FakeSocketFactory(sockets: [deniedSocket, missingTokenSocket, timeoutSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store, pairingTimeout: 0.01)
#expect(await transport.pair(target: tv, sessionID: UUID()) == .denied(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .timedOut(televisionID: tv.id))
}
@Test func sendsInOrderConsumesAdmissionAndExpiresQueuedWork() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let clock = TestClock(wall: baseDate, monotonic: 50)
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
blockedSendCount: 1
)
let factory = FakeSocketFactory(sockets: [socket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore(), clock: clock.value)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let first = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .right)
let second = envelope(sessionID: session, televisionID: tv.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await socket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
try? await Task.sleep(for: .milliseconds(10))
clock.advanceMonotonic(seconds: 1)
await socket.releaseBlockedSend()
#expect(await firstTask.value.status == .sentToTransport)
#expect(await secondTask.value.status == .expired)
#expect(await socket.sentKeyNames() == ["KEY_RIGHT"])
#expect(await transport.send(first).status == .duplicate)
}
@Test func failedWriteIsConsumedAndNeverRetried() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
failingSendCount: 1
)
let transport = makeTransport(
factory: FakeSocketFactory(sockets: [socket]),
store: FakeAuthorizationStore()
)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up)
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.send(command).status == .unavailable)
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}
@Test func targetChangeCancelsInflightAndQueuedOldGenerationCommands() async {
let firstTV = TelevisionTarget(id: UUID(), host: "first.synthetic.test")
let secondTV = TelevisionTarget(id: UUID(), host: "second.synthetic.test")
let firstSession = UUID()
let firstSocket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-one"}}"#.utf8)],
blockedSendCount: 1
)
let secondSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-two"}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [firstSocket, secondSocket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore())
#expect(await transport.pair(target: firstTV, sessionID: firstSession) == .ready(televisionID: firstTV.id))
let first = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 1, command: .right)
let second = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await firstSocket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
await Task.yield()
#expect(await transport.pair(target: secondTV, sessionID: UUID()) == .ready(televisionID: secondTV.id))
#expect(await firstTask.value.status == .unavailable)
#expect(await secondTask.value.status == .unavailable)
#expect(await firstSocket.sentKeyNames() == ["KEY_RIGHT"])
}
@Test func reconnectRequiresTokenAndRevocationDeletesIt() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: []), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
await transport.revokeAuthorization()
#expect(await store.savedToken(for: tv.id) == nil)
}
@Test func forgetDeletesAuthorizationAndSelectedTarget() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .ready(televisionID: tv.id))
await transport.forgetTelevision()
#expect(await transport.connectionState() == .disconnected)
#expect(await store.savedToken(for: tv.id) == nil)
}
private func makeTransport(
factory: FakeSocketFactory,
store: FakeAuthorizationStore,
clock: TVTransportClock = TestClock(wall: baseDate, monotonic: 50).value,
pairingTimeout: TimeInterval = 1
) -> TVConnectionActor {
TVConnectionActor(
socketFactory: factory,
authorizationStore: store,
clock: clock,
configuration: .init(pairingTimeout: pairingTimeout)
)
}
private final class TestClock: @unchecked Sendable {
private let lock = NSLock()
private var wall: Date
private var monotonic: TimeInterval
init(wall: Date, monotonic: TimeInterval) {
self.wall = wall
self.monotonic = monotonic
}
var value: TVTransportClock {
TVTransportClock(
wallNow: { [self] in lock.withLock { wall } },
monotonicNow: { [self] in lock.withLock { monotonic } }
)
}
func advance(seconds: TimeInterval) {
lock.withLock {
wall = wall.addingTimeInterval(seconds)
monotonic += seconds
}
}
func advanceMonotonic(seconds: TimeInterval) {
lock.withLock { monotonic += seconds }
}
}
private actor FakeAuthorizationStore: TVAuthorizationStore {
private var values: [UUID: StoredTVAuthorization] = [:]
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
values[televisionID] ?? StoredTVAuthorization(token: nil)
}
func saveToken(_ token: String, for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: token, trust: trust)
}
func deleteToken(for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: nil, trust: trust)
}
func deleteTrust(for televisionID: UUID) async throws {
let token = values[televisionID]?.token
values[televisionID] = StoredTVAuthorization(token: token, trust: .systemDefault)
}
func savedToken(for televisionID: UUID) -> String? { values[televisionID]?.token }
func setAuthorization(_ authorization: StoredTVAuthorization, for televisionID: UUID) {
values[televisionID] = authorization
}
}
private actor FakeSocketFactory: TVWebSocketFactory {
private var sockets: [FakeSocket]
private var endpoints: [URL] = []
init(sockets: [FakeSocket]) { self.sockets = sockets }
func open(endpoint: URL, trust: TVTrustPolicy, maximumMessageBytes: Int) async throws -> any TVWebSocket {
endpoints.append(endpoint)
guard !sockets.isEmpty else { throw TVSocketError.unavailable }
return sockets.removeFirst()
}
func endpoint(at index: Int) -> URL? {
endpoints.indices.contains(index) ? endpoints[index] : nil
}
}
private actor FakeSocket: TVWebSocket {
private var events: [Data]
private var receiveContinuation: CheckedContinuation<Data, Error>?
private var sendContinuation: CheckedContinuation<Void, Error>?
private var sent: [Data] = []
private var blockedSendCount: Int
private var failingSendCount: Int
private var closed = false
init(events: [Data], blockedSendCount: Int = 0, failingSendCount: Int = 0) {
self.events = events
self.blockedSendCount = blockedSendCount
self.failingSendCount = failingSendCount
}
func receive() async throws -> Data {
if closed { throw TVSocketError.closed }
if !events.isEmpty { return events.removeFirst() }
return try await withCheckedThrowingContinuation { receiveContinuation = $0 }
}
func send(_ data: Data) async throws {
if closed { throw TVSocketError.closed }
sent.append(data)
if failingSendCount > 0 {
failingSendCount -= 1
throw TVSocketError.unavailable
}
if blockedSendCount > 0 {
blockedSendCount -= 1
try await withCheckedThrowingContinuation { sendContinuation = $0 }
}
}
func close() async {
closed = true
receiveContinuation?.resume(throwing: TVSocketError.closed)
receiveContinuation = nil
sendContinuation?.resume(throwing: TVSocketError.closed)
sendContinuation = nil
}
func deliver(_ data: Data) {
if let pending = receiveContinuation {
receiveContinuation = nil
pending.resume(returning: data)
} else { events.append(data) }
}
func releaseBlockedSend() {
sendContinuation?.resume()
sendContinuation = nil
}
func waitForSendCount(_ count: Int) async {
while sent.count < count { await Task.yield() }
}
func sentKeyNames() -> [String] {
sent.compactMap { data in
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let parameters = object["params"] as? [String: String] else { return nil }
return parameters["DataOfCmd"]
}
}
}
@Test func watchNegotiationKeepsIdleSocketAndPhoneCommandsDoNotStealGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
#expect(await transport.sendPhoneCommand(.left).status == .sentToTransport)
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .sentToTransport)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .duplicate)
#expect(await socket.sentKeyNames() == ["KEY_LEFT", "KEY_RIGHT"])
await transport.disconnect()
}
@Test func stalePeerAndStaleEndCannotInvalidateCurrentGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let oldPeer = WatchPeerGeneration(), peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: oldPeer)
let old = try await transport.beginWatchSession(.init(), peer: oldPeer).get()
await transport.watchBridgeDidActivate(peer: peer)
let current = try await transport.beginWatchSession(.init(), peer: peer).get()
await transport.watchBridgeDidDeactivate(peer: oldPeer)
#expect(await transport.endWatchSession(.init(sessionID: old.sessionID), peer: peer) == .failure(.init(code: .sessionInvalidated)))
#expect(await transport.submitWatchCommand(envelope(sessionID: old.sessionID, televisionID: tv.id, sequence: 1, command: .left), peer: oldPeer).status == .rejected)
#expect(await transport.submitWatchCommand(envelope(sessionID: current.sessionID, televisionID: tv.id, sequence: 1, command: .right), peer: peer).status == .sentToTransport)
await transport.disconnect()
}
@Test func rotatingGrantDuringWriteClosesSocketBeforeNewGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
let pending = Task { await transport.submitWatchCommand(command, peer: peer) }
await socket.waitForSendCount(1)
#expect(await transport.beginWatchSession(.init(), peer: peer) == .failure(.init(code: .unavailable)))
#expect(await pending.value.status == .unavailable)
#expect(await transport.connectionState() == .disconnected)
}
@Test func readyConnectionConsumesRevocationAndStopsCommands() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
_ = await transport.pair(target: tv, sessionID: UUID())
await socket.deliver(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8))
for _ in 0..<1000 {
if await transport.connectionState() == .unauthorized(televisionID: tv.id) { break }
await Task.yield()
}
#expect(await transport.connectionState() == .unauthorized(televisionID: tv.id))
#expect(await transport.sendPhoneCommand(.left).status == .unavailable)
}
@Test func sendDeadlineClosesNonCompletingSocket() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
let session = UUID()
_ = await transport.pair(target: tv, sessionID: session)
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up, expiresAt: baseDate.addingTimeInterval(0.02))
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.connectionState() == .unavailable(televisionID: tv.id, failure: .socket))
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}