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