From 8131f4f873ffcb86216e58a7a508c8ec78f34b24 Mon Sep 17 00:00:00 2001 From: samialabri Date: Fri, 18 Sep 2026 19:11:52 +0400 Subject: [PATCH] Integrate live phone and watch remote prototype with guarded motion control --- .../MotionCapture/INTEGRATION-PROPOSAL.md | 37 ++ Adapters/MotionCapture/Package.swift | 3 + Adapters/MotionCapture/README.md | 21 +- .../MotionCapture/MotionAxisNormalizer.swift | 46 ++ .../MotionCapture/MotionCaptureService.swift | 92 +++ Adapters/PhoneControl/Package.swift | 20 + Adapters/PhoneControl/README.md | 7 + .../PhoneControl/LivePhoneController.swift | 225 +++++++ .../SelectedTelevisionStore.swift | 52 ++ .../PhoneControlTests/PhoneControlTests.swift | 146 +++++ Adapters/SecureStorage/Package.swift | 21 + Adapters/SecureStorage/README.md | 23 +- .../KeychainTVAuthorizationStore.swift | 259 ++++++++ .../KeychainTVAuthorizationStoreTests.swift | 119 ++++ Adapters/TVTransport/INTEGRATION-PROPOSAL.md | 48 ++ Adapters/TVTransport/MANUAL-PAIRING.md | 55 ++ Adapters/TVTransport/Package.swift | 25 + Adapters/TVTransport/README.md | 12 +- .../TVTransport/TVConnectionActor.swift | 561 ++++++++++++++++++ .../TVTransport/TVTransportTypes.swift | 122 ++++ .../TVTransport/URLSessionSamsungSocket.swift | 154 +++++ .../TVConnectionActorTests.swift | 387 ++++++++++++ Adapters/WatchBridge/INTEGRATION-PROPOSAL.md | 2 + .../Sources/Common/RelayContainer.swift | 20 +- .../Sources/Watch/WatchRelayBridge.swift | 58 +- .../Watch/WatchWCSessionDelegate.swift | 19 + .../Sources/iPhone/PhoneRelayBridge.swift | 86 ++- .../Tests/Common/RelayPayloadCodecTests.swift | 10 + .../Tests/Watch/WatchRelayBridgeTests.swift | 57 ++ .../Tests/iPhone/PhoneRelayBridgeTests.swift | 70 ++- Apps/Watch/WatchRemoteDemoView.swift | 348 ++++++++++- Apps/iPhone/LivePhoneView.swift | 140 +++++ Apps/iPhone/PhoneAppComposition.swift | 38 ++ Apps/iPhone/README.md | 67 +++ Apps/iPhone/RemoteDemoView.swift | 411 +++++++++++-- Apps/iPhone/WristivoApp.swift | 3 +- .../CalibratedGestureRecognizer.swift | 135 +++++ .../SamsungProtocol/SamsungEventParser.swift | 82 +++ .../CalibratedGestureRecognizerTests.swift | 70 +++ .../EventParserTests.swift | 38 ++ README.md | 6 +- Scripts/check.sh | 13 +- Scripts/generate-project.rb | 13 + Wristivo.xcodeproj/project.pbxproj | 384 +++++++----- .../xcshareddata/xcschemes/Wristivo.xcscheme | 8 +- .../xcschemes/WristivoWatch.xcscheme | 8 +- docs/ARCHITECTURE.md | 20 +- docs/CONTRACTS.md | 13 +- docs/FIRST-DEVICE-PROTOTYPE.md | 29 + docs/SECURITY-PRIVACY.md | 12 +- docs/VALIDATION.md | 16 + docs/agents/README.md | 3 +- .../2026-09-18-integration-privacy-audit.md | 20 + .../2026-09-18-q-hardware-release-plan.md | 50 ++ 54 files changed, 4355 insertions(+), 329 deletions(-) create mode 100644 Adapters/MotionCapture/INTEGRATION-PROPOSAL.md create mode 100644 Adapters/MotionCapture/Package.swift create mode 100644 Adapters/MotionCapture/Sources/MotionCapture/MotionAxisNormalizer.swift create mode 100644 Adapters/MotionCapture/Sources/MotionCapture/MotionCaptureService.swift create mode 100644 Adapters/PhoneControl/Package.swift create mode 100644 Adapters/PhoneControl/README.md create mode 100644 Adapters/PhoneControl/Sources/PhoneControl/LivePhoneController.swift create mode 100644 Adapters/PhoneControl/Sources/PhoneControl/SelectedTelevisionStore.swift create mode 100644 Adapters/PhoneControl/Tests/PhoneControlTests/PhoneControlTests.swift create mode 100644 Adapters/SecureStorage/Package.swift create mode 100644 Adapters/SecureStorage/Sources/SecureStorage/KeychainTVAuthorizationStore.swift create mode 100644 Adapters/SecureStorage/Tests/SecureStorageTests/KeychainTVAuthorizationStoreTests.swift create mode 100644 Adapters/TVTransport/INTEGRATION-PROPOSAL.md create mode 100644 Adapters/TVTransport/MANUAL-PAIRING.md create mode 100644 Adapters/TVTransport/Package.swift create mode 100644 Adapters/TVTransport/Sources/TVTransport/TVConnectionActor.swift create mode 100644 Adapters/TVTransport/Sources/TVTransport/TVTransportTypes.swift create mode 100644 Adapters/TVTransport/Sources/TVTransport/URLSessionSamsungSocket.swift create mode 100644 Adapters/TVTransport/Tests/TVTransportTests/TVConnectionActorTests.swift create mode 100644 Apps/iPhone/LivePhoneView.swift create mode 100644 Apps/iPhone/PhoneAppComposition.swift create mode 100644 Apps/iPhone/README.md create mode 100644 Packages/WristivoKit/Sources/GestureCore/CalibratedGestureRecognizer.swift create mode 100644 Packages/WristivoKit/Sources/SamsungProtocol/SamsungEventParser.swift create mode 100644 Packages/WristivoKit/Tests/GestureCoreTests/CalibratedGestureRecognizerTests.swift create mode 100644 Packages/WristivoKit/Tests/SamsungProtocolTests/EventParserTests.swift create mode 100644 docs/FIRST-DEVICE-PROTOTYPE.md create mode 100644 docs/test-evidence/2026-09-18-integration-privacy-audit.md create mode 100644 docs/test-evidence/2026-09-18-q-hardware-release-plan.md diff --git a/Adapters/MotionCapture/INTEGRATION-PROPOSAL.md b/Adapters/MotionCapture/INTEGRATION-PROPOSAL.md new file mode 100644 index 0000000..89398da --- /dev/null +++ b/Adapters/MotionCapture/INTEGRATION-PROPOSAL.md @@ -0,0 +1,37 @@ +> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded. + +# MotionCapture integration proposal + +Owner: workstream C (`feat/wrist-motion`). + +This change introduces a watch-only adapter boundary without changing the +RemoteCore envelope. `MotionCaptureService` emits `GestureCore.GestureRecognition` +values (semantic `RemoteCommand`, confidence, and a local monotonic timestamp) +to the watch shell. It never creates envelopes, carries TV identity, or retries +commands. + +## Proposed target membership + +The integrator should add `Adapters/MotionCapture/Sources/MotionCapture/*.swift` +to a watchOS-only target that depends on the `GestureCore` package product. The +pure classifier and its tests remain in `Packages/WristivoKit` and therefore +build on macOS/iOS/watchOS. Core Motion must not be imported by the package. + +## Lifecycle and permission contract + +The watch shell calls `start(sessionID:)` only after calibration and explicit +foreground arming. `stop(reason:)` is idempotent and must be called for scene +inactivity, relay loss, target/session changes, expiry, cancellation, or sensor +failure. A subsequent start creates a fresh neutral baseline. Hardware +availability and start-handler failures are surfaced as typed errors; no +permission denial is treated as an empty sample stream. The watch target needs +the platform's truthful motion purpose string in its host configuration. + +## Migration and tests + +No wire/schema migration is required. Existing watch code can initially ignore +the new service and continue using touch controls. The integrator must add the +watch target membership and motion purpose string, then run the package tests +and a physical permission/foreground suspension check. Simulator and synthetic +traces validate logic only; no hardware recognition claim is made by this +workstream. diff --git a/Adapters/MotionCapture/Package.swift b/Adapters/MotionCapture/Package.swift new file mode 100644 index 0000000..c6cb6d2 --- /dev/null +++ b/Adapters/MotionCapture/Package.swift @@ -0,0 +1,3 @@ +// swift-tools-version: 6.0 +import PackageDescription +let package = Package(name: "MotionCapture", platforms: [.watchOS(.v10)], products: [.library(name: "MotionCapture", targets: ["MotionCapture"])], dependencies: [.package(path: "../../Packages/WristivoKit")], targets: [.target(name: "MotionCapture", dependencies: [.product(name: "GestureCore", package: "WristivoKit")])]) diff --git a/Adapters/MotionCapture/README.md b/Adapters/MotionCapture/README.md index da1dd25..b720235 100644 --- a/Adapters/MotionCapture/README.md +++ b/Adapters/MotionCapture/README.md @@ -1,3 +1,22 @@ # MotionCapture -Planned adapter: watch-only Core Motion acquisition and calibrated classification. No live implementation is present. Owner: workstream C in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules. +This workstream provides a watch-only `CMMotionManager` adapter and a pure +calibrated classifier. The adapter is foreground/lifecycle-bound: it stops on +inactive scene, relay loss, expiry, cancellation, or sensor failure. Samples +are bounded and in memory only; no recording mode or raw traces are included. + +The orientation transform (wrist side and crown orientation) and thresholds are +versioned hypotheses pending physical validation. The package tests prove +state-machine behavior only and are not evidence of reliable watch recognition. + +## Evaluation report (pending hardware) + +No Apple Watch or TV has been tested for this workstream. Precision, recall, +incidental-movement false triggers, and the direction confusion matrix remain +`pending physical evaluation`; do not infer them from synthetic fixtures. +The required report should include per-direction TP/FP/FN, precision/recall, +duplicate count, false triggers per armed hour, and a 4x4 actual-vs-predicted +matrix split by wrist and crown orientation. + +See `INTEGRATION-PROPOSAL.md` for target membership, permission-string, and +lifecycle integration requirements. diff --git a/Adapters/MotionCapture/Sources/MotionCapture/MotionAxisNormalizer.swift b/Adapters/MotionCapture/Sources/MotionCapture/MotionAxisNormalizer.swift new file mode 100644 index 0000000..1285b11 --- /dev/null +++ b/Adapters/MotionCapture/Sources/MotionCapture/MotionAxisNormalizer.swift @@ -0,0 +1,46 @@ +import Foundation +import CoreMotion +import GestureCore + +public struct MotionCalibration: Codable, Sendable, Equatable { + public static let algorithmVersion = 1 + public let algorithmVersion: Int + public let wrist: WristSide + public let crown: CrownOrientation + public let neutralRotationRate: SIMD3 + public let scale: Double + + public init(wrist: WristSide, crown: CrownOrientation, + neutralRotationRate: SIMD3, scale: Double = 2.0) { + self.algorithmVersion = Self.algorithmVersion; self.wrist = wrist; self.crown = crown + self.neutralRotationRate = neutralRotationRate; self.scale = max(0.1, scale) + } +} + +/// Converts Core Motion rotation-rate samples into user-relative axes. This +/// does not integrate acceleration and therefore makes no absolute-position +/// claim. Orientation signs are a versioned hypothesis and require hardware +/// validation for each supported watch/crown configuration. +public struct MotionAxisNormalizer: Sendable { + public let calibration: MotionCalibration + private var baseline: SIMD3 + + public init(calibration: MotionCalibration) { + self.calibration = calibration + self.baseline = calibration.neutralRotationRate + } + + public mutating func normalize(_ motion: CMDeviceMotion) -> NormalizedMotionSample { + let rate = motion.rotationRate + guard rate.x.isFinite, rate.y.isFinite, rate.z.isFinite, motion.timestamp.isFinite else { + // Propagate invalidity to the pure classifier; never clamp NaN into a direction. + return NormalizedMotionSample(timestamp: motion.timestamp, x: .nan, y: .nan, quality: 0) + } + var x = (rate.x - baseline.x) / calibration.scale + var y = (rate.y - baseline.y) / calibration.scale + if calibration.wrist == .left { x = -x } + if calibration.crown == .awayFromHand { y = -y } + return NormalizedMotionSample(timestamp: motion.timestamp, + x: max(-1, min(1, x)), y: max(-1, min(1, y)), quality: 1) + } +} diff --git a/Adapters/MotionCapture/Sources/MotionCapture/MotionCaptureService.swift b/Adapters/MotionCapture/Sources/MotionCapture/MotionCaptureService.swift new file mode 100644 index 0000000..0714563 --- /dev/null +++ b/Adapters/MotionCapture/Sources/MotionCapture/MotionCaptureService.swift @@ -0,0 +1,92 @@ +import Foundation +@preconcurrency import CoreMotion +import GestureCore + +public enum MotionCaptureStopReason: Sendable { case user, inactive, disconnected, expired, cancelled, sensorFailure } +public enum MotionCaptureError: Error, Sendable, Equatable { case unavailable, permissionDenied, failed(String) } + +/// Foreground-only sensor owner. Calibration and armed capture never overlap. +@MainActor +public final class MotionCaptureService { + public typealias RecognitionHandler = @Sendable (GestureRecognition) -> Void + public typealias FailureHandler = @Sendable (MotionCaptureError) -> Void + private let manager = CMMotionManager() + private var recognizer = CalibratedGestureRecognizer() + private var expiryTask: Task? + private var generation = UUID() + public var isAvailable: Bool { manager.isDeviceMotionAvailable } + public init() {} + + public func calibrate(wrist: WristSide, crown: CrownOrientation, + completion: @escaping @MainActor (Result) -> Void) { + stop(reason: .cancelled) + guard isAvailable else { completion(.failure(.unavailable)); return } + let capture = generation + var samples: [SIMD3] = [] + var firstTimestamp: Double? + var previousTimestamp: Double? + manager.deviceMotionUpdateInterval = 1 / 50 + manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in + MainActor.assumeIsolated { + guard let self, self.generation == capture else { return } + guard error == nil, let motion else { + self.stop(reason: .sensorFailure); completion(.failure(.failed("Motion permission or sensor unavailable"))); return + } + let r = motion.rotationRate + let v = SIMD3(r.x, r.y, r.z) + guard r.x.isFinite, r.y.isFinite, r.z.isFinite else { return } + let stable = max(abs(r.x), abs(r.y), abs(r.z)) < 0.15 + if !stable || (previousTimestamp.map { motion.timestamp - $0 > 0.12 || motion.timestamp <= $0 } ?? false) { + samples.removeAll(); firstTimestamp = nil + } + previousTimestamp = motion.timestamp + guard stable else { return } + if firstTimestamp == nil { firstTimestamp = motion.timestamp } + samples.append(v) + if motion.timestamp - (firstTimestamp ?? motion.timestamp) >= 1, samples.count >= 30 { + let baseline = samples.reduce(SIMD3(repeating: 0), +) / Double(samples.count) + self.stop(reason: .user) + completion(.success(MotionCalibration(wrist: wrist, crown: crown, neutralRotationRate: baseline))) + } + } + } + expiryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(8)) + guard !Task.isCancelled, let self, self.generation == capture else { return } + self.stop(reason: .expired); completion(.failure(.failed("Calibration timed out. Hold still and retry."))) + } + } + + public func start(sessionID: UUID, calibration: MotionCalibration, duration: TimeInterval = 10, + onRecognition: @escaping RecognitionHandler, onFailure: @escaping FailureHandler, + onExpiry: @escaping @MainActor () -> Void = {}) throws { + stop(reason: .cancelled) + guard isAvailable else { throw MotionCaptureError.unavailable } + guard duration.isFinite, duration > 0, duration <= 30 else { throw MotionCaptureError.failed("Invalid duration") } + let capture = generation + var normalizer = MotionAxisNormalizer(calibration: calibration) + var armed = false + manager.deviceMotionUpdateInterval = 1 / 50 + manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in + MainActor.assumeIsolated { + guard let self, self.generation == capture else { return } + guard error == nil, let motion else { + self.stop(reason: .sensorFailure); onFailure(.failed("Motion permission or sensor unavailable")); return + } + if !armed { self.recognizer.arm(sessionID: sessionID, at: motion.timestamp); armed = true } + if let result = self.recognizer.process(normalizer.normalize(motion)) { onRecognition(result) } + } + } + expiryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled, let self, self.generation == capture else { return } + self.stop(reason: .expired); onExpiry() + } + } + public func stop(reason: MotionCaptureStopReason) { + generation = UUID(); manager.stopDeviceMotionUpdates(); expiryTask?.cancel(); expiryTask = nil; recognizer.disarm() + } + public func expire() { stop(reason: .expired) } + public func setInactive() { stop(reason: .inactive) } + public func setDisconnected() { stop(reason: .disconnected) } +} diff --git a/Adapters/PhoneControl/Package.swift b/Adapters/PhoneControl/Package.swift new file mode 100644 index 0000000..271cdcd --- /dev/null +++ b/Adapters/PhoneControl/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "PhoneControl", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [.library(name: "PhoneControl", targets: ["PhoneControl"])], + dependencies: [ + .package(path: "../../Packages/WristivoKit"), + .package(path: "../TVTransport"), .package(path: "../SecureStorage") + ], + targets: [ + .target(name: "PhoneControl", dependencies: [ + .product(name: "RemoteCore", package: "WristivoKit"), + .product(name: "SamsungProtocol", package: "WristivoKit"), + "TVTransport", "SecureStorage" + ]), + .testTarget(name: "PhoneControlTests", dependencies: ["PhoneControl"]) + ] +) diff --git a/Adapters/PhoneControl/README.md b/Adapters/PhoneControl/README.md new file mode 100644 index 0000000..597d401 --- /dev/null +++ b/Adapters/PhoneControl/README.md @@ -0,0 +1,7 @@ +# PhoneControl + +Integrator-owned orchestration for the iPhone. `LivePhoneController` is main-actor UI state with injected TV actor, security repository and selected-TV store. It never implements command admission or watch grant issuance. `KeychainSelectedTelevisionStore` keeps a stable opaque TV UUID and address in device-only Keychain storage, separate from credentials. + +Pairing is explicit. Restore/reconnect only uses an existing token. Failed metadata reads/writes do not start pairing; changing the address requires forgetting the old target. Forget disconnects first and retains the identity if deleting credentials fails. Trust failure only offers a certificate candidate; explicit approval stores a pin and requires a separate pairing action. Demo mode disconnects live transport. + +Tests inject actors and stores; they do not access the real Keychain or network. Native composition lives in Apps/iPhone/PhoneAppComposition.swift. Device lock/relaunch and Keychain accessibility remain physical gates. diff --git a/Adapters/PhoneControl/Sources/PhoneControl/LivePhoneController.swift b/Adapters/PhoneControl/Sources/PhoneControl/LivePhoneController.swift new file mode 100644 index 0000000..1d578b8 --- /dev/null +++ b/Adapters/PhoneControl/Sources/PhoneControl/LivePhoneController.swift @@ -0,0 +1,225 @@ +import Combine +import Foundation +import RemoteCore +import SamsungProtocol +import SecureStorage +import TVTransport + +public protocol PhoneTVControlling: Actor { + func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState + func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState + func disconnect() async + func stateUpdates() async -> AsyncStream + func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt +} + +extension TVConnectionActor: PhoneTVControlling { } + +public protocol PhoneAuthorizationManaging: Sendable { + func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws + func forgetTelevision(_ televisionID: UUID) async throws +} + +extension KeychainTVAuthorizationStore: PhoneAuthorizationManaging { } + +public enum PhoneControlError: Error, Equatable, Sendable { + case invalidAddress, forgetFirst, storageUnavailable +} + +public struct CertificateCandidate: Equatable, Sendable, Identifiable { + public let id: UUID + public let host: String + public let sha256: Data + public var fingerprint: String { sha256.map { String(format: "%02X", $0) }.joined(separator: ":") } +} + +/// Composition state only. Authorization, admission and TV writes belong to the +/// injected TV actor; this object never retries a command or creates a watch grant. +@MainActor +public final class LivePhoneController: ObservableObject { + @Published public private(set) var television: SelectedTelevision? + @Published public private(set) var connection: TVConnectionState = .disconnected + @Published public private(set) var certificate: CertificateCandidate? + @Published public private(set) var isBusy = false + @Published public private(set) var notice: String? + @Published public private(set) var error: String? + @Published public private(set) var isDemo = false + + public var onSelectionChanged: ((SelectedTelevision?, Bool) -> Void)? + public var isReady: Bool { + guard !isDemo, let television else { return false } + return connection == .ready(televisionID: television.id) + } + + private let transport: any PhoneTVControlling + private let authorization: any PhoneAuthorizationManaging + private let selection: any SelectedTelevisionStoring + private var observation: Task? + private var restored = false + private var acceptingCertificate = false + + public init(transport: any PhoneTVControlling, authorization: any PhoneAuthorizationManaging, + selection: any SelectedTelevisionStoring) { + self.transport = transport + self.authorization = authorization + self.selection = selection + } + + deinit { observation?.cancel() } + + public func start() async { + guard !isBusy, !isDemo, !restored else { return } + observeConnection() + isBusy = true + defer { isBusy = false } + do { + television = try await selection.load() + restored = true + publish() + if let television { + acceptingCertificate = true + defer { acceptingCertificate = false } + _ = try SamsungRemoteEncoder.endpoint(host: television.host) + connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID()) + publish() + } + } catch { + self.error = "Saved TV information could not be read. Unlock the iPhone and try again." + } + } + + public func pair(address: String) async { + guard !isBusy, !isDemo else { return } + clearFeedback() + let host = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard (try? SamsungRemoteEncoder.endpoint(host: host)) != nil else { + error = "Enter a bare TV address or hostname from its network settings, without a URL or port." + return + } + isBusy = true + defer { isBusy = false; acceptingCertificate = false } + do { + // Load before writing: a failed/locked read must not orphan an existing identity. + if !restored { television = try await selection.load(); restored = true } + if let existing = television, existing.host != host { + error = "Forget the saved TV before selecting a different address." + return + } + let selected = television ?? SelectedTelevision(host: host) + try await selection.save(selected) + television = selected + certificate = nil + acceptingCertificate = true + observeConnection() + connection = .connecting(televisionID: selected.id) + publish() + connection = await transport.pair(target: .init(id: selected.id, host: host), sessionID: UUID()) + publish() + } catch { + self.error = "TV information could not be saved securely. Unlock the iPhone and try again. No pairing was started." + } + } + + public func reconnect() async { + guard !isBusy, !isDemo else { return } + if !restored { await start(); return } + guard let television else { return } + isBusy = true + clearFeedback() + certificate = nil + acceptingCertificate = true + defer { isBusy = false; acceptingCertificate = false } + connection = .connecting(televisionID: television.id) + publish() + connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID()) + publish() + } + + /// A rejected TLS challenge can only produce a review candidate, never trust. + public func receiveUntrustedCertificate(host: String, sha256: Data) { + guard !isDemo, acceptingCertificate, let television, + normalized(host) == normalized(television.host), sha256.count == 32 else { return } + certificate = CertificateCandidate(id: television.id, host: television.host, sha256: sha256) + } + + public func approveCertificate(_ candidate: CertificateCandidate) async { + guard !isBusy, !isDemo, candidate == certificate, candidate.id == television?.id else { return } + isBusy = true + clearFeedback() + defer { isBusy = false } + await transport.disconnect() + connection = .disconnected + publish() + do { + try await authorization.approveCertificate(sha256: candidate.sha256, for: candidate.id, approvedAt: Date()) + certificate = nil + notice = "Certificate saved for this TV. Tap Pair / pair again, then approve Wristivo on the TV." + } catch { self.error = "Certificate approval could not be saved. Unlock the iPhone and try again." } + } + + public func send(_ command: RemoteCommand) async { + guard !isBusy, isReady else { return } + isBusy = true + clearFeedback() + defer { isBusy = false } + let receipt = await transport.sendPhoneCommand(command) + switch receipt.status { + case .sentToTransport: notice = "Sent to TV transport. The TV's response is not confirmed." + case .unavailable: error = "Delivery is uncertain. Check the TV before issuing a new action. Nothing will be replayed." + case .expired: error = "The command expired and was not sent." + case .rejected, .duplicate: error = "The command was rejected. Reconnect before trying a new action." + } + } + + public func forget() async { + guard !isBusy, !isDemo else { return } + isBusy = true + clearFeedback() + defer { isBusy = false } + await transport.disconnect() + connection = .disconnected + certificate = nil + publish() + do { + let saved: SelectedTelevision? + if let television { saved = television } else { saved = try await selection.load() } + if let saved { try await authorization.forgetTelevision(saved.id) } + // Keep metadata if secret deletion fails so the user can retry deletion. + try await selection.delete() + television = nil + restored = true + notice = "Saved TV access and certificate approval were removed from this iPhone." + publish() + } catch { self.error = "The saved TV could not be completely removed. Unlock the iPhone and retry Forget TV." } + } + + public func setDemo(_ enabled: Bool) async { + guard !isBusy, enabled != isDemo else { return } + isBusy = true + await transport.disconnect() + connection = .disconnected + certificate = nil + isDemo = enabled + clearFeedback() + publish() + isBusy = false + if !enabled { await reconnect() } + } + + private func observeConnection() { + guard observation == nil else { return } + let transport = transport + observation = Task { [weak self] in + let stream = await transport.stateUpdates() + for await state in stream { + guard !Task.isCancelled, let self else { return } + self.connection = state + self.publish() + } + } + } + + private func publish() { onSelectionChanged?(isDemo ? nil : television, isReady) } + private func clearFeedback() { notice = nil; error = nil } + private func normalized(_ host: String) -> String { host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) } +} diff --git a/Adapters/PhoneControl/Sources/PhoneControl/SelectedTelevisionStore.swift b/Adapters/PhoneControl/Sources/PhoneControl/SelectedTelevisionStore.swift new file mode 100644 index 0000000..51690f9 --- /dev/null +++ b/Adapters/PhoneControl/Sources/PhoneControl/SelectedTelevisionStore.swift @@ -0,0 +1,52 @@ +import Foundation +import SecureStorage + +public struct SelectedTelevision: Codable, Equatable, Sendable { + public let id: UUID + public let name: String + public let host: String + + public init(id: UUID = UUID(), name: String = "My TV", host: String) { + self.id = id + self.name = name + self.host = host + } +} + +public protocol SelectedTelevisionStoring: Sendable { + func load() async throws -> SelectedTelevision? + func save(_ television: SelectedTelevision) async throws + func delete() async throws +} + +/// Device-only metadata keeps the opaque identity stable across app launches. +/// This separate Keychain item contains no TV token or certificate. +public actor KeychainSelectedTelevisionStore: SelectedTelevisionStoring { + private let client: any KeychainClient + private let service = "com.wristivo.selected-television" + private let account = "selected" + + public init(client: any KeychainClient = SystemKeychainClient()) { self.client = client } + + public func load() async throws -> SelectedTelevision? { + do { + let data = try client.read(service: service, account: account) + guard data.count <= 4096 else { throw PhoneControlError.storageUnavailable } + return try JSONDecoder().decode(SelectedTelevision.self, from: data) + } catch KeychainError.notFound { return nil } + } + + public func save(_ television: SelectedTelevision) async throws { + let data = try JSONEncoder().encode(television) + do { try client.update(service: service, account: account, data: data) } + catch KeychainError.notFound { + try client.add(service: service, account: account, data: data, + accessibility: .afterFirstUnlockThisDeviceOnly) + } + } + + public func delete() async throws { + do { try client.delete(service: service, account: account) } + catch KeychainError.notFound { } + } +} diff --git a/Adapters/PhoneControl/Tests/PhoneControlTests/PhoneControlTests.swift b/Adapters/PhoneControl/Tests/PhoneControlTests/PhoneControlTests.swift new file mode 100644 index 0000000..b401078 --- /dev/null +++ b/Adapters/PhoneControl/Tests/PhoneControlTests/PhoneControlTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +import RemoteCore +import TVTransport +@testable import PhoneControl + +private actor FakeTV: PhoneTVControlling { + var paired: [TelevisionTarget] = [] + var connected: [TelevisionTarget] = [] + var commands: [RemoteCommand] = [] + var disconnects = 0 + var pairHook: (@Sendable () async -> Void)? + func setPairHook(_ hook: @escaping @Sendable () async -> Void) { pairHook = hook } + var status: CommandReceipt.Status = .sentToTransport + func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState { + paired.append(target) + if let pairHook { await pairHook(); return .untrusted(televisionID: target.id) } + return .ready(televisionID: target.id) + } + func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState { + connected.append(target); return .ready(televisionID: target.id) + } + func disconnect() async { disconnects += 1 } + func stateUpdates() async -> AsyncStream { AsyncStream { $0.finish() } } + func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt { + commands.append(command); return .init(id: UUID(), status: status) + } + func failWrites() { status = .unavailable } +} + +private actor MemorySelection: SelectedTelevisionStoring { + var value: SelectedTelevision? + var failSave = false + init(_ value: SelectedTelevision? = nil, failSave: Bool = false) { self.value = value; self.failSave = failSave } + func load() async throws -> SelectedTelevision? { value } + func save(_ television: SelectedTelevision) async throws { + if failSave { throw PhoneControlError.storageUnavailable }; value = television + } + func delete() async throws { value = nil } +} + +private actor MemoryAuthorization: PhoneAuthorizationManaging { + var forgotten: [UUID] = [] + var approvals = 0 + let failDelete: Bool + init(failDelete: Bool = false) { self.failDelete = failDelete } + func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws { approvals += 1 } + func forgetTelevision(_ televisionID: UUID) async throws { + if failDelete { throw PhoneControlError.storageUnavailable }; forgotten.append(televisionID) + } +} + +@MainActor +struct PhoneControlTests { + @Test func restorationUsesSavedIdentityAndNeverPairs() async { + let selected = SelectedTelevision(host: "tv.local") + let tv = FakeTV() + let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(selected)) + await controller.start() + #expect(controller.television == selected) + #expect(controller.isReady) + #expect(await tv.paired.isEmpty) + #expect(await tv.connected.map(\.id) == [selected.id]) + await controller.pair(address: selected.host) + #expect(await tv.paired.map(\.id) == [selected.id]) + } + + @Test func storageFailurePreventsPairingAndDifferentTargetRequiresForget() async { + let tv = FakeTV() + let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(failSave: true)) + await controller.pair(address: "tv.local") + #expect(await tv.paired.isEmpty) + #expect(controller.error != nil) + let existing = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "old.local"))) + await existing.pair(address: "new.local") + #expect(await tv.paired.isEmpty) + #expect(existing.television?.host == "old.local") + } + + @Test func partialForgetKeepsIdentityForDeletionRetryAndDisconnects() async throws { + let selected = SelectedTelevision(host: "tv.local") + let store = MemorySelection(selected) + let tv = FakeTV() + let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(failDelete: true), selection: store) + await controller.start() + await controller.forget() + #expect(!controller.isReady) + #expect(controller.error != nil) + #expect(try await store.load() == selected) + #expect(await tv.disconnects == 1) + } + + @Test func forgetDeletesSecurityAndSelection() async throws { + let selected = SelectedTelevision(host: "tv.local") + let store = MemorySelection(selected) + let auth = MemoryAuthorization() + let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: store) + await controller.start(); await controller.forget() + #expect(controller.television == nil) + #expect(try await store.load() == nil) + #expect(await auth.forgotten == [selected.id]) + } + + @Test func ambiguousWriteIsNotReplayedAndDemoDisconnectsLiveRoute() async { + let tv = FakeTV() + await tv.failWrites() + let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "tv.local"))) + await controller.start(); await controller.send(.right) + #expect(await tv.commands == [.right]) + #expect(controller.error?.contains("uncertain") == true) + await controller.setDemo(true); await controller.send(.left) + #expect(controller.isDemo && !controller.isReady) + #expect(await tv.commands == [.right]) + #expect(await tv.disconnects == 1) + } + + @Test func certificateRequiresExplicitApprovalAndDoesNotRetryPairing() async throws { + let auth = MemoryAuthorization() + let tv = FakeTV() + let controller = LivePhoneController(transport: tv, authorization: auth, selection: MemorySelection()) + await tv.setPairHook { + await controller.receiveUntrustedCertificate(host: "other.local", sha256: Data(repeating: 1, count: 32)) + await controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 2, count: 32)) + } + await controller.pair(address: "tv.local") + let candidate = try #require(controller.certificate) + #expect(candidate.sha256 == Data(repeating: 2, count: 32)) + #expect(await auth.approvals == 0) + await controller.approveCertificate(candidate) + #expect(await auth.approvals == 1) + #expect(await tv.paired.count == 1) + #expect(!controller.isReady) + #expect(controller.certificate == nil) + await controller.approveCertificate(candidate) + #expect(await auth.approvals == 1) + } + + @Test func unsolicitedCertificateIsNotOfferedOrApproved() async { + let auth = MemoryAuthorization() + let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: MemorySelection(.init(host: "tv.local"))) + await controller.start() + controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 1, count: 32)) + #expect(controller.certificate == nil) + #expect(await auth.approvals == 0) + } +} diff --git a/Adapters/SecureStorage/Package.swift b/Adapters/SecureStorage/Package.swift new file mode 100644 index 0000000..fab9dad --- /dev/null +++ b/Adapters/SecureStorage/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SecureStorage", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [.library(name: "SecureStorage", targets: ["SecureStorage"])], + dependencies: [.package(path: "../TVTransport")], + targets: [ + .target( + name: "SecureStorage", + dependencies: [ + .product(name: "TVTransport", package: "TVTransport") + ] + ), + .testTarget( + name: "SecureStorageTests", + dependencies: ["SecureStorage"] + ) + ] +) diff --git a/Adapters/SecureStorage/README.md b/Adapters/SecureStorage/README.md index 7eec638..c80de03 100644 --- a/Adapters/SecureStorage/README.md +++ b/Adapters/SecureStorage/README.md @@ -1,3 +1,24 @@ # SecureStorage -Planned adapter: iPhone Keychain token and per-TV trust storage. No live implementation is present. Owner: workstream E in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules. +This adapter stores the TV token, certificate pin, and pairing state in the +iPhone Keychain under an opaque television UUID. It is injected through +`KeychainClient` for deterministic tests and conforms to the frozen +`TVAuthorizationStore` protocol from `TVTransport`. + +The candidate accessibility is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: +it is the narrowest device-only option that can support a phone-lock relay after +the first unlock. It is unavailable before the first unlock after reboot and has +not yet been validated on a signed iPhone. The stronger +`WhenUnlockedThisDeviceOnly` option remains available for a deployment that +does not require locked-phone relay. + +Certificate trust is fail-closed. A self-signed certificate is accepted only +after an explicit user approval supplied to `approveCertificate`; the exact +SHA-256 leaf fingerprint is then pinned to that opaque TV ID. A changed +fingerprint returns `requiresReapproval` and is never silently accepted. The +stored source records that first-use fingerprinting came from an unauthenticated +connection, so it is not presented as proof of TV identity. + +`forgetTelevision` removes the complete Keychain record. `deleteToken` revokes +pairing while preserving the trust pin; `deleteTrust` removes trust material. +Neither tokens nor trust records are sent to WatchConnectivity or cloud storage. diff --git a/Adapters/SecureStorage/Sources/SecureStorage/KeychainTVAuthorizationStore.swift b/Adapters/SecureStorage/Sources/SecureStorage/KeychainTVAuthorizationStore.swift new file mode 100644 index 0000000..67869a3 --- /dev/null +++ b/Adapters/SecureStorage/Sources/SecureStorage/KeychainTVAuthorizationStore.swift @@ -0,0 +1,259 @@ +import Foundation +import Security +import TVTransport + +/// The smallest Security-framework seam needed by the repository. Tests inject +/// this protocol; the production implementation below is the only type that +/// calls Security directly. +public protocol KeychainClient: Sendable { + func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws + func read(service: String, account: String) throws -> Data + func update(service: String, account: String, data: Data) throws + func delete(service: String, account: String) throws +} + +public enum KeychainAccessibility: Sendable, Equatable { + /// Strongest default, but unavailable while the phone is locked. + case whenUnlockedThisDeviceOnly + /// Candidate for a phone-lock relay after the first device unlock. + /// It is still unavailable before the first unlock after reboot. + case afterFirstUnlockThisDeviceOnly + + fileprivate var secValue: CFString { + switch self { + case .whenUnlockedThisDeviceOnly: return kSecAttrAccessibleWhenUnlockedThisDeviceOnly + case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + } + } +} + +public enum KeychainError: Error, Sendable, Equatable { + case notFound + case locked + case unexpectedStatus(Int32) +} + +public struct SystemKeychainClient: KeychainClient, Sendable { + public init() {} + + public func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecValueData: data, + kSecAttrAccessible: accessibility.secValue + ] + try check(SecItemAdd(query as CFDictionary, nil)) + } + + public func read(service: String, account: String) throws -> Data { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + var result: CFTypeRef? + try check(SecItemCopyMatching(query as CFDictionary, &result)) + guard let data = result as? Data else { throw KeychainError.unexpectedStatus(errSecInternalComponent) } + return data + } + + public func update(service: String, account: String, data: Data) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + try check(SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)) + } + + public func delete(service: String, account: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + try check(SecItemDelete(query as CFDictionary)) + } + + private func check(_ status: OSStatus) throws { + guard status != errSecSuccess else { return } + switch status { + case errSecItemNotFound: throw KeychainError.notFound + case errSecInteractionNotAllowed: throw KeychainError.locked + default: throw KeychainError.unexpectedStatus(status) + } + } +} + +public enum PairingState: String, Codable, Sendable, Equatable { + case paired + case requiresReapproval + case revoked +} + +public enum CertificateTrustValidation: Sendable, Equatable { + case notPinned + case accepted + case requiresReapproval +} + +public struct TVSecurityRecord: Codable, Sendable, Equatable { + public let token: String? + public let certificateSHA256: Data? + public let pairingState: PairingState + public let trustApprovedAt: Date? + /// A fingerprint learned from the same unauthenticated first connection + /// does not authenticate the TV. Keep this limitation explicit in storage. + public let fingerprintSource: String + + public init( + token: String? = nil, + certificateSHA256: Data? = nil, + pairingState: PairingState = .paired, + trustApprovedAt: Date? = nil, + fingerprintSource: String = "unauthenticated-first-use" + ) { + self.token = token + self.certificateSHA256 = certificateSHA256 + self.pairingState = pairingState + self.trustApprovedAt = trustApprovedAt + self.fingerprintSource = fingerprintSource + } +} + +/// iPhone-only Keychain persistence keyed by the opaque television UUID. +/// Tokens and trust records never cross the WatchConnectivity boundary. +public actor KeychainTVAuthorizationStore: TVAuthorizationStore { + public static let service = "com.wristivo.tv-security" + + private let client: any KeychainClient + private let accessibility: KeychainAccessibility + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + client: any KeychainClient = SystemKeychainClient(), + accessibility: KeychainAccessibility = .afterFirstUnlockThisDeviceOnly + ) { + self.client = client + self.accessibility = accessibility + } + + public func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization { + guard let record = try optionalRecord(for: televisionID) else { + return StoredTVAuthorization(token: nil) + } + guard record.pairingState == .paired else { + return StoredTVAuthorization(token: nil, trust: trustPolicy(for: record)) + } + return StoredTVAuthorization(token: record.token, trust: trustPolicy(for: record)) + } + + public func saveToken(_ token: String, for televisionID: UUID) async throws { + guard !token.isEmpty, token.utf8.count <= 4096 else { throw KeychainError.unexpectedStatus(errSecParam) } + var record = try optionalRecord(for: televisionID) ?? TVSecurityRecord() + record = TVSecurityRecord( + token: token, + certificateSHA256: record.certificateSHA256, + pairingState: .paired, + trustApprovedAt: record.trustApprovedAt, + fingerprintSource: record.fingerprintSource + ) + try upsert(record, for: televisionID) + } + + public func deleteToken(for televisionID: UUID) async throws { + guard var record = try optionalRecord(for: televisionID) else { return } + record = TVSecurityRecord( + certificateSHA256: record.certificateSHA256, + pairingState: .revoked, + trustApprovedAt: record.trustApprovedAt, + fingerprintSource: record.fingerprintSource + ) + try upsert(record, for: televisionID) + } + + public func deleteTrust(for televisionID: UUID) async throws { + guard let record = try optionalRecord(for: televisionID) else { return } + let replacement = TVSecurityRecord( + token: record.token, + pairingState: record.token == nil ? .revoked : .requiresReapproval, + fingerprintSource: "no-approved-certificate" + ) + if record.token == nil { + try deleteRecord(for: televisionID) + } else { + try upsert(replacement, for: televisionID) + } + } + + public func approveCertificate( + sha256 fingerprint: Data, + for televisionID: UUID, + approvedAt: Date = Date() + ) async throws { + guard fingerprint.count == 32 else { throw KeychainError.unexpectedStatus(errSecParam) } + let old = try optionalRecord(for: televisionID) ?? TVSecurityRecord() + let replacement = TVSecurityRecord( + token: old.certificateSHA256 == fingerprint ? old.token : nil, + certificateSHA256: fingerprint, + pairingState: .paired, + trustApprovedAt: approvedAt, + fingerprintSource: "unauthenticated-first-use" + ) + try upsert(replacement, for: televisionID) + } + + public func validateCertificate( + sha256 fingerprint: Data, + for televisionID: UUID + ) async throws -> CertificateTrustValidation { + guard let record = try optionalRecord(for: televisionID), let pinned = record.certificateSHA256 else { + return .notPinned + } + return pinned == fingerprint && record.pairingState == .paired ? .accepted : .requiresReapproval + } + + public func securityRecord(for televisionID: UUID) async throws -> TVSecurityRecord? { + do { return try readRecord(for: televisionID) } + catch KeychainError.notFound { return nil } + } + + public func forgetTelevision(_ televisionID: UUID) async throws { + try deleteRecord(for: televisionID) + } + + private func trustPolicy(for record: TVSecurityRecord) -> TVTrustPolicy { + guard let fingerprint = record.certificateSHA256, fingerprint.count == 32 else { return .systemDefault } + return .pinnedLeafCertificateSHA256(fingerprint) + } + + private func readRecord(for televisionID: UUID) throws -> TVSecurityRecord { + let data = try client.read(service: Self.service, account: televisionID.uuidString) + guard data.count <= 16 * 1024 else { throw KeychainError.unexpectedStatus(errSecDecode) } + let record = try decoder.decode(TVSecurityRecord.self, from: data) + if let pin = record.certificateSHA256, pin.count != 32 { throw KeychainError.unexpectedStatus(errSecDecode) } + if let token = record.token, token.isEmpty || token.utf8.count > 4096 { throw KeychainError.unexpectedStatus(errSecDecode) } + return record + } + + private func optionalRecord(for televisionID: UUID) throws -> TVSecurityRecord? { + do { return try readRecord(for: televisionID) } + catch KeychainError.notFound { return nil } + } + + private func upsert(_ record: TVSecurityRecord, for televisionID: UUID) throws { + let data = try encoder.encode(record) + do { try client.update(service: Self.service, account: televisionID.uuidString, data: data) } + catch KeychainError.notFound { try client.add(service: Self.service, account: televisionID.uuidString, data: data, accessibility: accessibility) } + } + + private func deleteRecord(for televisionID: UUID) throws { + do { try client.delete(service: Self.service, account: televisionID.uuidString) } + catch KeychainError.notFound { return } + } +} diff --git a/Adapters/SecureStorage/Tests/SecureStorageTests/KeychainTVAuthorizationStoreTests.swift b/Adapters/SecureStorage/Tests/SecureStorageTests/KeychainTVAuthorizationStoreTests.swift new file mode 100644 index 0000000..51d2ada --- /dev/null +++ b/Adapters/SecureStorage/Tests/SecureStorageTests/KeychainTVAuthorizationStoreTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import SecureStorage + +private final class MockKeychainClient: KeychainClient, @unchecked Sendable { + enum Failure: Error, Equatable { case locked, unexpected } + var values: [String: Data] = [:] + var failure: Failure? + var adds = 0 + var updates = 0 + var deletes = 0 + + func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws { + if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) } + guard values[account] == nil else { throw KeychainError.unexpectedStatus(-25299) } + values[account] = data; adds += 1 + } + + func read(service: String, account: String) throws -> Data { + if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) } + guard let value = values[account] else { throw KeychainError.notFound } + return value + } + + func update(service: String, account: String, data: Data) throws { + if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) } + guard values[account] != nil else { throw KeychainError.notFound } + values[account] = data; updates += 1 + } + + func delete(service: String, account: String) throws { + if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) } + guard values.removeValue(forKey: account) != nil else { throw KeychainError.notFound } + deletes += 1 + } +} + +@Suite("Keychain TV authorization store") +struct KeychainTVAuthorizationStoreTests { + @Test func addReadUpdateDeleteAndNotFound() async throws { + let client = MockKeychainClient() + let store = KeychainTVAuthorizationStore(client: client, accessibility: .whenUnlockedThisDeviceOnly) + let tv = UUID() + + #expect(try await store.securityRecord(for: tv) == nil) + try await store.saveToken("token-1", for: tv) + #expect((try await store.authorization(for: tv)).token == "token-1") + + try await store.saveToken("token-2", for: tv) + #expect((try await store.authorization(for: tv)).token == "token-2") + + try await store.forgetTelevision(tv) + #expect(try await store.securityRecord(for: tv) == nil) + try await store.forgetTelevision(tv) + } + + @Test func certificateApprovalPinsAndChangedCertificateRequiresReapproval() async throws { + let client = MockKeychainClient() + let store = KeychainTVAuthorizationStore(client: client) + let tv = UUID() + let first = Data(repeating: 0x01, count: 32) + let changed = Data(repeating: 0x02, count: 32) + + #expect(try await store.validateCertificate(sha256: first, for: tv) == .notPinned) + try await store.approveCertificate(sha256: first, for: tv) + #expect(try await store.validateCertificate(sha256: first, for: tv) == .accepted) + #expect(try await store.validateCertificate(sha256: changed, for: tv) == .requiresReapproval) + #expect((try await store.authorization(for: tv)).trust == .pinnedLeafCertificateSHA256(first)) + } + + @Test func revokePreservesTrustButForgetRemovesBoth() async throws { + let client = MockKeychainClient() + let store = KeychainTVAuthorizationStore(client: client) + let tv = UUID() + let fingerprint = Data(repeating: 0x03, count: 32) + try await store.saveToken("token", for: tv) + try await store.approveCertificate(sha256: fingerprint, for: tv) + try await store.deleteToken(for: tv) + #expect((try await store.authorization(for: tv)).token == nil) + #expect(try await store.validateCertificate(sha256: fingerprint, for: tv) == .requiresReapproval) + try await store.forgetTelevision(tv) + #expect(try await store.securityRecord(for: tv) == nil) + } + + @Test func lockedAndUnexpectedSecurityErrorsArePropagated() async throws { + let client = MockKeychainClient() + let store = KeychainTVAuthorizationStore(client: client) + let tv = UUID() + client.setFailure(.locked) + await #expect(throws: KeychainError.locked) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) } + client.setFailure(.unexpected) + await #expect(throws: KeychainError.unexpectedStatus(-1)) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) } + } +} + +private extension MockKeychainClient { + func setFailure(_ failure: Failure?) { self.failure = failure } +} + +@Test func newCertificateApprovalCannotReusePreviousToken() async throws { + let store = KeychainTVAuthorizationStore(client: MockKeychainClient()) + let tv = UUID(), first = Data(repeating: 1, count: 32), changed = Data(repeating: 2, count: 32) + try await store.approveCertificate(sha256: first, for: tv) + try await store.saveToken("synthetic-token", for: tv) + try await store.approveCertificate(sha256: changed, for: tv) + let stored = try await store.authorization(for: tv) + #expect(stored.token == nil) + #expect(stored.trust == .pinnedLeafCertificateSHA256(changed)) +} + +@Test func malformedStoredPinDoesNotFallBackToSystemTrust() async throws { + let client = MockKeychainClient() + let id = UUID() + let invalid = TVSecurityRecord(token: "synthetic", certificateSHA256: Data([1])) + try client.add(service: KeychainTVAuthorizationStore.service, account: id.uuidString, + data: JSONEncoder().encode(invalid), accessibility: .whenUnlockedThisDeviceOnly) + let store = KeychainTVAuthorizationStore(client: client) + await #expect(throws: (any Error).self) { try await store.authorization(for: id) } +} diff --git a/Adapters/TVTransport/INTEGRATION-PROPOSAL.md b/Adapters/TVTransport/INTEGRATION-PROPOSAL.md new file mode 100644 index 0000000..1817d95 --- /dev/null +++ b/Adapters/TVTransport/INTEGRATION-PROPOSAL.md @@ -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. diff --git a/Adapters/TVTransport/MANUAL-PAIRING.md b/Adapters/TVTransport/MANUAL-PAIRING.md new file mode 100644 index 0000000..8a48012 --- /dev/null +++ b/Adapters/TVTransport/MANUAL-PAIRING.md @@ -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. diff --git a/Adapters/TVTransport/Package.swift b/Adapters/TVTransport/Package.swift new file mode 100644 index 0000000..b522ccc --- /dev/null +++ b/Adapters/TVTransport/Package.swift @@ -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") + ] + ) + ] +) diff --git a/Adapters/TVTransport/README.md b/Adapters/TVTransport/README.md index 0aef71d..3afefa3 100644 --- a/Adapters/TVTransport/README.md +++ b/Adapters/TVTransport/README.md @@ -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`. diff --git a/Adapters/TVTransport/Sources/TVTransport/TVConnectionActor.swift b/Adapters/TVTransport/Sources/TVTransport/TVConnectionActor.swift new file mode 100644 index 0000000..37d4aaf --- /dev/null +++ b/Adapters/TVTransport/Sources/TVTransport/TVConnectionActor.swift @@ -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 + } + + 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.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? + 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? + + 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 { + let id = UUID() + let (stream, continuation) = AsyncStream.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 { + 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 { + 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 + } + } + } +} diff --git a/Adapters/TVTransport/Sources/TVTransport/TVTransportTypes.swift b/Adapters/TVTransport/Sources/TVTransport/TVTransportTypes.swift new file mode 100644 index 0000000..eadb5dc --- /dev/null +++ b/Adapters/TVTransport/Sources/TVTransport/TVTransportTypes.swift @@ -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) + } +} diff --git a/Adapters/TVTransport/Sources/TVTransport/URLSessionSamsungSocket.swift b/Adapters/TVTransport/Sources/TVTransport/URLSessionSamsungSocket.swift new file mode 100644 index 0000000..e299d65 --- /dev/null +++ b/Adapters/TVTransport/Sources/TVTransport/URLSessionSamsungSocket.swift @@ -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 = [ + .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 diff --git a/Adapters/TVTransport/Tests/TVTransportTests/TVConnectionActorTests.swift b/Adapters/TVTransport/Tests/TVTransportTests/TVConnectionActorTests.swift new file mode 100644 index 0000000..0699393 --- /dev/null +++ b/Adapters/TVTransport/Tests/TVTransportTests/TVConnectionActorTests.swift @@ -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? + private var sendContinuation: CheckedContinuation? + 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"]) +} diff --git a/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md b/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md index 6e272d3..d2b2b68 100644 --- a/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md +++ b/Adapters/WatchBridge/INTEGRATION-PROPOSAL.md @@ -1,3 +1,5 @@ +> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded. + # WatchBridge integration proposal Status: accepted with integration decisions below. This proposal is retained as diff --git a/Adapters/WatchBridge/Sources/Common/RelayContainer.swift b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift index 63cbb6e..5695a09 100644 --- a/Adapters/WatchBridge/Sources/Common/RelayContainer.swift +++ b/Adapters/WatchBridge/Sources/Common/RelayContainer.swift @@ -1,4 +1,5 @@ import Foundation +import CoreFoundation import RemoteCore private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool { @@ -88,22 +89,24 @@ public struct RelayApplicationContext: Sendable, Equatable { public let schemaVersion: Int public let selectedTelevisionID: UUID? public let displayName: String? + public let isConnectionReady: Bool - public init(selectedTelevisionID: UUID?, displayName: String?) { + public init(selectedTelevisionID: UUID?, displayName: String?, isConnectionReady: Bool = false) { self.schemaVersion = RelayWire.schemaVersion self.selectedTelevisionID = selectedTelevisionID self.displayName = displayName + self.isConnectionReady = isConnectionReady && selectedTelevisionID != nil } public func dictionary() throws -> [String: Any] { - var result: [String: Any] = ["schemaVersion": schemaVersion] + var result: [String: Any] = ["schemaVersion": schemaVersion, "isConnectionReady": isConnectionReady] if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString } if let displayName { result["displayName"] = String(displayName.prefix(128)) } return result } public init(dictionary: [String: Any]) throws { - let allowed: Set = ["schemaVersion", "selectedTelevisionID", "displayName"] + let allowed: Set = ["schemaVersion", "selectedTelevisionID", "displayName", "isConnectionReady"] guard Set(dictionary.keys).isSubset(of: allowed) else { throw RelayContainerError.extraKeys } guard let version = dictionary["schemaVersion"] as? NSNumber, relayNSNumberIsInteger(version), @@ -127,6 +130,12 @@ public struct RelayApplicationContext: Sendable, Equatable { } else { self.displayName = nil } + if let raw = dictionary["isConnectionReady"] { + guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { + throw RelayContainerError.invalidPayload + } + self.isConnectionReady = number.boolValue && selectedTelevisionID != nil + } else { self.isConnectionReady = false } self.schemaVersion = version.intValue } } @@ -168,12 +177,13 @@ public final class RelayReplyOnce: @unchecked Sendable { /// a delegate context. This explicit boundary lets the native delegate hop to /// the main actor without pretending the dictionary itself is Sendable. public final class RelayInboundMessage: @unchecked Sendable { - public let message: [String: Any] + private let container: RelayContainer? + public var message: [String: Any] { container?.dictionary() ?? [:] } public let reply: RelayReplyOnce public init(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - self.message = message + self.container = try? RelayContainer(dictionary: message) self.reply = RelayReplyOnce(replyHandler) } } diff --git a/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift b/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift index d831d38..7b4b969 100644 --- a/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift +++ b/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift @@ -19,7 +19,8 @@ public final class WatchRelayBridge: NSObject { public var onStateChange: ((State) -> Void)? private let session: any RelaySessionSending - private var grant: RemoteSessionGrant? + public private(set) var grant: RemoteSessionGrant? + private var generation = UUID() private var nextSequence: UInt64 = 0 private var activationReady = false private var pendingReplies: [UUID: RelayReplyOnce] = [:] @@ -46,7 +47,9 @@ public final class WatchRelayBridge: NSObject { } public func reachabilityDidChange() { - guard activationReady, grant == nil else { return } + guard activationReady else { return } + if !session.isReachable { invalidatePending() } + if grant != nil { return } state = .ready(reachable: session.isReachable) } @@ -65,10 +68,13 @@ public final class WatchRelayBridge: NSObject { } public func beginSession() { + guard grant == nil, state != .sessionNegotiating else { return } guard activationReady, session.isReachable, grant == nil else { state = .unavailable return } + invalidatePending() + let requestGeneration = generation state = .sessionNegotiating let requestID = UUID() let request = BeginRemoteSessionRequest() @@ -77,7 +83,7 @@ public final class WatchRelayBridge: NSObject { return } send(message: message, requestID: requestID) { [weak self] response in - guard let self else { return } + guard let self, self.generation == requestGeneration, self.session.isReachable else { return } guard let container = try? RelayContainer(dictionary: response), container.kind == .session, let received = try? container.decode(RemoteSessionGrant.self, @@ -93,33 +99,34 @@ public final class WatchRelayBridge: NSObject { } public func endSession() { - guard activationReady, let grant else { return } - let request = EndRemoteSessionRequest(sessionID: grant.sessionID) - guard let message = try? RelayContainer(kind: .endSession, value: request).dictionary() else { - self.grant = nil - state = .ready(reachable: session.isReachable) - return - } - let requestID = UUID() - send(message: message, requestID: requestID) { [weak self] response in - guard let self else { return } - guard self.grant?.sessionID == grant.sessionID else { return } - self.grant = nil - self.nextSequence = 0 - self.state = .ready(reachable: self.session.isReachable) + let oldGrant = grant + invalidatePending() + state = activationReady ? .ready(reachable: session.isReachable) : .inactive + guard activationReady, session.isReachable, let oldGrant, + let message = try? RelayContainer(kind: .endSession, value: EndRemoteSessionRequest(sessionID: oldGrant.sessionID)).dictionary() else { return } + // Locally revoke immediately; a delayed end reply cannot revoke a newer grant. + send(message: message, requestID: UUID()) { _ in } + } + + public func sendCommandResult(_ command: RemoteCommand) async -> Result { + await withCheckedContinuation { continuation in + sendCommand(command) { continuation.resume(returning: $0) } } } /// One command is one live sendMessage. A sequence is consumed before the /// send; ambiguous timeout/failure is never replayed. - public func sendCommand(_ command: RemoteCommand, now: Date = Date()) { + public func sendCommand(_ command: RemoteCommand, now: Date = Date(), + completion: @escaping @MainActor (Result) -> Void = { _ in }) { guard activationReady, session.isReachable, let grant else { state = .unavailable + completion(.failure(.init(code: .unavailable))) return } guard nextSequence < UInt64.max else { self.grant = nil state = .unavailable + completion(.failure(.init(code: .sessionInvalidated))) return } let sequence = nextSequence @@ -132,17 +139,20 @@ public final class WatchRelayBridge: NSObject { expiresAt: now.addingTimeInterval(lifetime), command: command) guard let message = try? RelayContainer(kind: .command, value: envelope).dictionary() else { + completion(.failure(.init(code: .malformedRequest))) return } + let requestGeneration = generation send(message: message, requestID: UUID()) { [weak self] response in - guard let self, + guard let self, self.generation == requestGeneration, let container = try? RelayContainer(dictionary: response), container.kind == .receipt, let receipt = try? container.decode(CommandReceipt.self, expectedKind: .receipt), - receipt.id == envelope.id else { return } + receipt.id == envelope.id else { completion(.failure(.init(code: .unavailable))); return } + completion(.success(receipt)) // A receipt is command feedback, not evidence that the TV changed. - if receipt.status == .unavailable { self.state = .unavailable } + if receipt.status != .sentToTransport { self.invalidatePending(); self.state = .unavailable } } } @@ -150,12 +160,12 @@ public final class WatchRelayBridge: NSObject { response: @escaping @MainActor ([String: Any]) -> Void) { guard pendingReplies.count < 32 else { state = .unavailable + response(relayErrorReply(RelayFailure(code: .unavailable))) return } let once = RelayReplyOnce { [weak self] value in Task { @MainActor [weak self] in guard let self, self.pendingReplies.removeValue(forKey: requestID) != nil else { return } - guard self.activationReady else { return } response(value) } } @@ -172,8 +182,8 @@ public final class WatchRelayBridge: NSObject { } private func invalidatePending() { - let replies = pendingReplies.values - pendingReplies.removeAll() + generation = UUID() + let replies = Array(pendingReplies.values) for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) } grant = nil nextSequence = 0 diff --git a/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift b/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift index 504e0a6..db0d259 100644 --- a/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift +++ b/Adapters/WatchBridge/Sources/Watch/WatchWCSessionDelegate.swift @@ -33,8 +33,12 @@ private final class WatchWCSessionSending: RelaySessionSending { @MainActor public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate { public let bridge: WatchRelayBridge + public var onContext: ((RelayApplicationContext) -> Void)? + public private(set) var latestContext: RelayApplicationContext? + private let session: WCSession public init(session: WCSession = .default) { + self.session = session bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session)) super.init() session.delegate = self @@ -48,6 +52,21 @@ public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate { guard let self else { return } if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() } self.bridge.activationDidComplete(error: error) + self.receiveContext(self.session.receivedApplicationContext) + } + } + + private func receiveContext(_ dictionary: [String: Any]) { + guard let context = try? RelayApplicationContext(dictionary: dictionary) else { return } + latestContext = context + onContext?(context) + } + + nonisolated public func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let context = try? RelayApplicationContext(dictionary: applicationContext) else { return } + Task { @MainActor [weak self] in + self?.latestContext = context + self?.onContext?(context) } } diff --git a/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift index fb10a27..bcf4d50 100644 --- a/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift +++ b/Adapters/WatchBridge/Sources/iPhone/PhoneRelayBridge.swift @@ -16,6 +16,21 @@ public final class PhoneRelayBridge: NSObject { private let coordinator: any PhoneCommandCoordinating private var peer: WatchPeerGeneration? private var activationReady = false + private var lifecycleTask: Task? + private var pending: [UUID: RelayReplyOnce] = [:] + private var latestContext: RelayApplicationContext? + + public func publishContext(_ context: RelayApplicationContext) { + latestContext = context + flushContext() + } + + private func flushContext() { + guard session.activationState == .activated, let latestContext else { return } + if let dictionary = try? latestContext.dictionary() { + try? session.updateApplicationContext(dictionary) + } + } public init(session: any RelaySessionSending, dependency: PhoneRelayDependency) { @@ -31,19 +46,24 @@ public final class PhoneRelayBridge: NSObject { } public func activationDidComplete(error: Error?) { - guard error == nil else { + guard error == nil, session.activationState == .activated else { + didDeactivate() state = .unavailable activationReady = false return } + guard peer == nil else { flushContext(); return } let newPeer = WatchPeerGeneration() peer = newPeer activationReady = false state = .ready(reachable: session.isReachable) - Task { [weak self] in + let prior = lifecycleTask + lifecycleTask = Task { [weak self] in + await prior?.value await self?.coordinator.watchBridgeDidActivate(peer: newPeer) guard let self, self.peer == newPeer else { return } self.activationReady = true + self.flushContext() } } @@ -63,7 +83,12 @@ public final class PhoneRelayBridge: NSObject { peer = nil activationReady = false state = .inactive - Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) } + for reply in pending.values { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) } + let prior = lifecycleTask + lifecycleTask = Task { + await prior?.value + await coordinator.watchBridgeDidDeactivate(peer: oldPeer) + } } /// Entry point for the native WCSession delegate. Every path completes the @@ -71,18 +96,36 @@ public final class PhoneRelayBridge: NSObject { /// out requests. public func receive(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - let reply = RelayReplyOnce(replyHandler) + let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler) + guard pending.count < 16 else { + inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) + return + } + let receivedPeer = peer + let id = UUID() + pending[id] = inbound.reply Task { @MainActor [weak self] in guard let self else { - reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) + inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) return } - await self.route(message: message, reply: reply) + guard self.peer == receivedPeer, receivedPeer != nil else { + inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) + self.pending.removeValue(forKey: id) + return + } + await self.route(message: inbound.message, reply: inbound.reply) + self.pending.removeValue(forKey: id) + } + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard let self, let reply = self.pending[id] else { return } + reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) } } private func route(message: [String: Any], reply: RelayReplyOnce) async { - guard activationReady, let currentPeer = peer else { + guard activationReady, session.activationState == .activated, let currentPeer = peer else { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) return } @@ -101,9 +144,10 @@ public final class PhoneRelayBridge: NSObject { reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest))) return } - guard let result = await withTimeout({ + let result = await ({ await self.coordinator.beginWatchSession(request, peer: currentPeer) - }) else { + })() + guard peer == currentPeer, activationReady else { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) return } @@ -120,9 +164,10 @@ public final class PhoneRelayBridge: NSObject { reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest))) return } - guard let result = await withTimeout({ + let result = await ({ await self.coordinator.endWatchSession(request, peer: currentPeer) - }) else { + })() + guard peer == currentPeer, activationReady else { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) return } @@ -139,9 +184,10 @@ public final class PhoneRelayBridge: NSObject { reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest))) return } - guard let receipt = await withTimeout({ + let receipt = await ({ await self.coordinator.submitWatchCommand(envelope, peer: currentPeer) - }) else { + })() + guard peer == currentPeer, activationReady else { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) return } @@ -162,18 +208,4 @@ public final class PhoneRelayBridge: NSObject { relayErrorReply(RelayFailure(code: .malformedRequest)) } - private func withTimeout( - _ operation: @escaping @Sendable () async -> Value - ) async -> Value? { - await withTaskGroup(of: Value?.self) { group in - group.addTask { await operation() } - group.addTask { - try? await Task.sleep(for: .milliseconds(1_000)) - return nil - } - let result = await group.next() ?? nil - group.cancelAll() - return result - } - } } diff --git a/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift index 963669c..1c7c463 100644 --- a/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift +++ b/Adapters/WatchBridge/Tests/Common/RelayPayloadCodecTests.swift @@ -70,3 +70,13 @@ import WatchBridgeCommon _ = try RelayApplicationContext(dictionary: executable) } } + +@Test func readinessContextRequiresBooleanAndSelectedIdentity() throws { + let id = UUID() + let context = RelayApplicationContext(selectedTelevisionID: id, displayName: "Living room", isConnectionReady: true) + #expect(try RelayApplicationContext(dictionary: context.dictionary()) == context) + #expect(!RelayApplicationContext(selectedTelevisionID: nil, displayName: nil, isConnectionReady: true).isConnectionReady) + #expect(throws: RelayContainerError.self) { + try RelayApplicationContext(dictionary: ["schemaVersion": 1, "isConnectionReady": 1]) + } +} diff --git a/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift b/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift index 01e21a2..74f6852 100644 --- a/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift +++ b/Adapters/WatchBridge/Tests/Watch/WatchRelayBridgeTests.swift @@ -91,3 +91,60 @@ private enum RelayTestError: Error { case failed } #expect(bridge.state == .inactive) #expect(fake.sent.count == 1) } + +@MainActor +@Test func watchReachabilityLossRevokesArmedGrantAndNeverReplays() async throws { + let fake = FakeWatchSession() + let bridge = WatchRelayBridge(session: fake) + bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession() + let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date()) + fake.answer(try RelayContainer(kind: .session, value: grant).dictionary()) + await Task.yield() + #expect(bridge.grant != nil) + fake.isReachable = false; bridge.reachabilityDidChange() + #expect(bridge.grant == nil) + #expect(bridge.state == .ready(reachable: false)) + fake.isReachable = true; bridge.reachabilityDidChange() + bridge.sendCommand(.right) + #expect(fake.sent.count == 1) +} + +@MainActor +@Test func lateGrantAfterStopDoesNotRearmAndRepeatedConnectDoesNotDuplicate() async throws { + let fake = FakeWatchSession() + let bridge = WatchRelayBridge(session: fake) + bridge.activate(); bridge.activationDidComplete(error: nil) + bridge.beginSession(); bridge.beginSession() + #expect(fake.sent.count == 1) + let lateReply = fake.reply + bridge.endSession() + let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date()) + lateReply?(try RelayContainer(kind: .session, value: grant).dictionary()) + await Task.yield() + #expect(bridge.grant == nil) + #expect(bridge.state == .ready(reachable: true)) +} + +@MainActor +@Test func invalidationCompletesCommandOnceAndDiscardsLateSuccess() async throws { + let fake = FakeWatchSession() + let bridge = WatchRelayBridge(session: fake) + bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession() + let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date()) + fake.answer(try RelayContainer(kind: .session, value: grant).dictionary()) + await Task.yield() + var completions = 0 + var succeeded = false + bridge.sendCommand(.right) { result in + completions += 1 + if case .success = result { succeeded = true } + } + let command = try RelayContainer(dictionary: fake.sent.last!).decode(CommandEnvelope.self, expectedKind: .command) + let lateReply = fake.reply + bridge.didDeactivate() + lateReply?(try RelayContainer(kind: .receipt, value: CommandReceipt(id: command.id, status: .sentToTransport)).dictionary()) + for _ in 0..<10 { await Task.yield() } + #expect(completions == 1) + #expect(!succeeded) + #expect(bridge.grant == nil) +} diff --git a/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift index 9fc4571..232a4a0 100644 --- a/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift +++ b/Adapters/WatchBridge/Tests/iPhone/PhoneRelayBridgeTests.swift @@ -9,6 +9,7 @@ private final class FakePhoneSession: RelaySessionSending { var activationState: RelayActivationState = .notActivated var isReachable = true var sent: [[String: Any]] = [] + var contexts: [[String: Any]] = [] var reply: (([String: Any]) -> Void)? var error: ((Error) -> Void)? func activate() { activationState = .activated } @@ -19,7 +20,7 @@ private final class FakePhoneSession: RelaySessionSending { reply = replyHandler error = errorHandler } - func updateApplicationContext(_ context: [String: Any]) throws {} + func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) } func answer(_ value: [String: Any]) { reply?(value) } } @@ -86,3 +87,70 @@ private actor RecordingPhoneCoordinator: PhoneCommandCoordinating { await Task.yield() #expect(replies == 1) } + +private actor SuspendedCoordinator: PhoneCommandCoordinating { + var begins = 0 + var active = false + var waiting: [CheckedContinuation, Never>] = [] + func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true } + func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false } + func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result { + begins += 1 + return await withCheckedContinuation { waiting.append($0) } + } + func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result { .failure(RelayFailure(code: .unavailable)) } + func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { CommandReceipt(id: envelope.id, status: .sentToTransport) } + func release() { for continuation in waiting { continuation.resume(returning: .failure(RelayFailure(code: .unavailable))) }; waiting.removeAll() } +} + +@MainActor +@Test func timeoutDoesNotWaitForUncooperativeActorAndBoundsOutstandingCalls() async throws { + let session = FakePhoneSession() + let coordinator = SuspendedCoordinator() + let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator)) + bridge.activate(); bridge.activationDidComplete(error: nil) + try await Task.sleep(for: .milliseconds(30)) + let message = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary() + var replies = 0 + for _ in 0..<20 { bridge.receive(message: message) { _ in replies += 1 } } + try await Task.sleep(for: .milliseconds(1_150)) + #expect(replies == 20) + #expect(await coordinator.begins == 16) + bridge.receive(message: message) { _ in replies += 1 } + #expect(replies == 21) + #expect(await coordinator.begins == 16) + await coordinator.release() + try await Task.sleep(for: .milliseconds(30)) + #expect(replies == 21) +} + +@MainActor +@Test func deactivationCompletesPendingRepliesBeforeActorReturns() async throws { + let session = FakePhoneSession() + let coordinator = SuspendedCoordinator() + let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator)) + bridge.activate(); bridge.activationDidComplete(error: nil) + try await Task.sleep(for: .milliseconds(30)) + var replies = 0 + bridge.receive(message: try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()) { _ in replies += 1 } + try await Task.sleep(for: .milliseconds(30)) + bridge.didDeactivate() + #expect(replies == 1) + await coordinator.release() + try await Task.sleep(for: .milliseconds(30)) + #expect(replies == 1) +} + +@MainActor +@Test func latestContextPublishedBeforeActivationIsFlushed() async throws { + let session = FakePhoneSession() + let coordinator = RecordingPhoneCoordinator() + let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator)) + let context = RelayApplicationContext(selectedTelevisionID: UUID(), displayName: "TV", isConnectionReady: true) + bridge.publishContext(context) + #expect(session.contexts.isEmpty) + bridge.activate(); bridge.activationDidComplete(error: nil) + try await Task.sleep(for: .milliseconds(30)) + #expect(session.contexts.count == 1) + #expect(try RelayApplicationContext(dictionary: session.contexts[0]) == context) +} diff --git a/Apps/Watch/WatchRemoteDemoView.swift b/Apps/Watch/WatchRemoteDemoView.swift index a26f6a4..72b8678 100644 --- a/Apps/Watch/WatchRemoteDemoView.swift +++ b/Apps/Watch/WatchRemoteDemoView.swift @@ -1,37 +1,341 @@ import SwiftUI import RemoteCore +import GestureCore +import WatchKit +import MotionCapture +import WatchBridgeWatch +import WatchBridgeCommon + +enum WatchCommandSendResult: Sendable, Equatable { + case transmitted + case failed(String) + case uncertain(String) + case demo +} + +@MainActor +final class WatchRemoteViewModel: ObservableObject { + enum Status: Equatable { case idle, calibrating, ready, armed, paused } + + @Published private(set) var status: Status = .idle + @Published private(set) var message = "Touch controls are ready" + @Published private(set) var lastCommand: RemoteCommand? + @Published private(set) var selectedTVName: String + @Published private(set) var relayReachable: Bool + @Published private(set) var calibrationComplete = false + @Published private(set) var motionAvailable: Bool + @Published var wrist: WristSide = .right { didSet { orientationChanged() } } + @Published var crown: CrownOrientation = .towardHand { didSet { orientationChanged() } } + + @Published private(set) var isDemo = false + private let motion = MotionCaptureService() + private var calibration: MotionCalibration? + private var relay: WatchWCSessionDelegate? + private var selectedTVID: UUID? + private var sessionID: UUID? + private var active = true + private var uiGeneration = UUID() + + init() { + selectedTVName = "No TV selected" + relayReachable = false + motionAvailable = motion.isAvailable + let relay = WatchWCSessionDelegate() + self.relay = relay + relay.bridge.onStateChange = { [weak self] state in + guard let self, !self.isDemo else { return } + switch state { + case .armed: self.setRelayReachable(true); self.message = "Connected — touch controls ready" + case .ready(let reachable): self.setRelayReachable(reachable) + case .sessionNegotiating: self.message = "Connecting to iPhone…" + default: self.setRelayReachable(false) + } + } + relay.onContext = { [weak self] context in + guard let self, !self.isDemo else { return } + if self.selectedTVID != context.selectedTelevisionID { + self.stopMotionOnly(); self.relay?.bridge.endSession() + self.selectedTVID = context.selectedTelevisionID + self.calibration = nil; self.calibrationComplete = false + self.selectedTVName = context.displayName ?? "No TV selected" + } + if !context.isConnectionReady { self.stopMotionOnly(); self.relay?.bridge.endSession(); self.message = "Connect a TV on iPhone" } + } + } + + static func demo() -> WatchRemoteViewModel { let result = WatchRemoteViewModel(); result.setDemo(true); return result } + func setDemo(_ value: Bool) { + stopMotionOnly(); relay?.bridge.endSession(); isDemo = value + calibration = nil; calibrationComplete = false; status = .idle + message = value ? "Demo only — nothing will be sent" : "Live prototype — connect using iPhone" + } + func connect() { + guard active else { return } + if isDemo { message = "Demo touch controls ready"; return } + relay?.bridge.beginSession() + } + private func orientationChanged() { + stopMotionOnly(); calibration = nil; calibrationComplete = false; status = .idle + message = "Orientation changed — calibrate again" + } + func calibrate() { + stopMotionOnly(); calibrationComplete = false; calibration = nil + guard active, !isDemo else { message = "Motion calibration is available in live prototype mode"; return } + status = .calibrating; message = "Hold still in a neutral position for one second" + let generation = uiGeneration + motion.calibrate(wrist: wrist, crown: crown) { [weak self] result in + guard let self, self.uiGeneration == generation, self.active else { return } + switch result { + case .success(let value): self.calibration = value; self.calibrationComplete = true; self.status = .ready; self.message = "Calibration saved — press Start"; self.haptic(.success) + case .failure: self.status = .idle; self.message = "Calibration failed. Hold still and retry."; self.haptic(.failure) + } + } + } + func start() { + guard active, !isDemo, let calibration, calibrationComplete else { message = "Calibrate before starting"; return } + guard let grant = relay?.bridge.grant, relayReachable else { message = "Press Connect before starting"; return } + stopMotionOnly() + let generation = uiGeneration + do { + try motion.start(sessionID: grant.sessionID, calibration: calibration, onRecognition: { [weak self] result in + Task { @MainActor [weak self] in + guard self?.uiGeneration == generation else { return }; self?.recognized(result) + } + }, onFailure: { [weak self] _ in + Task { @MainActor [weak self] in + guard self?.uiGeneration == generation else { return }; self?.motionFailed("Sensor unavailable") + } + }, onExpiry: { [weak self] in self?.stop(); self?.message = "10-second motion session expired — press Start again" }) + sessionID = grant.sessionID; status = .armed; message = "Armed for 10 seconds — move deliberately"; haptic(.start) + } catch { motionFailed("Sensor unavailable") } + } + private func sendCommand(_ command: RemoteCommand) async -> WatchCommandSendResult { + guard active else { return .failed("Watch app inactive") } + if isDemo { return .demo } + guard let relay else { return .failed("Phone unavailable") } + switch await relay.bridge.sendCommandResult(command) { + case .success(let receipt): + if receipt.status == .unavailable { return .uncertain("Transport could not confirm delivery") } + return receipt.status == .sentToTransport ? .transmitted : .failed(receipt.status.rawValue) + case .failure: return .uncertain("No validated receipt; reconnect before trying another action") + } + } + + func stop() { + stopMotionOnly() + status = calibrationComplete ? .ready : .idle + message = "Stopped — no movement will be sent" + haptic(.stop) + } + + func setSceneActive(_ active: Bool) { + self.active = active + if !active { relay?.bridge.endSession() } + guard !active else { + if status == .paused { message = "Paused — press Start to arm again" } + return + } + stopMotionOnly() + status = .paused + message = "Paused because the watch app is inactive" + } + + func setRelayReachable(_ reachable: Bool) { + relayReachable = reachable + guard reachable else { + stopMotionOnly() + status = .paused + message = "Phone unavailable — motion is disarmed" + return + } + if status == .paused { message = "Phone reachable — press Start to arm again" } + } + + func selectTV(_ name: String) { + guard selectedTVName != name else { return } + stopMotionOnly() + selectedTVName = name + status = .idle + calibrationComplete = false + message = "TV changed — calibrate and start again" + } + + func tap(_ command: RemoteCommand) { + guard active else { + message = "Paused — press Start to use the remote" + return + } + lastCommand = command + haptic(.click) + let generation = uiGeneration + Task { @MainActor [weak self] in + guard let self, self.uiGeneration == generation else { return } + let result = await self.sendCommand(command) + guard self.uiGeneration == generation else { return } + switch result { + case .transmitted: + message = "Transmitted: \(command.title) (TV effect not confirmed)"; haptic(.success) + case .failed(let detail): + message = "Send failed: \(detail)"; haptic(.failure) + case .uncertain(let detail): + message = "Send uncertain: \(detail) — do not repeat automatically"; haptic(.warning) + case .demo: message = "Demo only: \(command.title) — nothing sent" + } + } + } + + private func recognized(_ recognition: GestureRecognition) { + guard status == .armed, recognition.sessionID == sessionID else { return } + lastCommand = recognition.command + message = "Recognized \(recognition.command.title) — sending" + haptic(.recognition) + let generation = uiGeneration + Task { @MainActor [weak self] in + guard let self, self.uiGeneration == generation, self.status == .armed else { return } + let result = await self.sendCommand(recognition.command) + guard self.uiGeneration == generation else { return } + switch result { + case .transmitted: + message = "Transmitted: \(recognition.command.title) (TV effect not confirmed)"; haptic(.success) + case .failed(let detail): + message = "Send failed: \(detail)"; haptic(.failure) + case .uncertain(let detail): + message = "Send uncertain: \(detail) — do not repeat automatically"; haptic(.warning) + case .demo: message = "Demo only: recognized \(recognition.command.title); nothing sent" + } + } + } + + private func motionFailed(_ error: String) { + stopMotionOnly(); status = .paused; message = "Motion stopped: \(error)"; haptic(.failure) + } + + private func stopMotionOnly() { + uiGeneration = UUID(); motion.stop(reason: .user); sessionID = nil + status = calibrationComplete ? .ready : .idle + } + + private enum Haptic { case click, recognition, success, warning, failure, start, stop } + private func haptic(_ kind: Haptic) { + #if os(watchOS) + let type: WKHapticType = switch kind { + case .click: .click + case .recognition: .directionUp + case .success, .start: .success + case .warning: .retry + case .failure: .failure + case .stop: .stop + } + WKInterfaceDevice.current().play(type) + #endif + } +} struct WatchRemoteDemoView: View { - @State private var lastCommand = "No TV connected" + @Environment(\.scenePhase) private var scenePhase + @StateObject private var model: WatchRemoteViewModel + + init(model: WatchRemoteViewModel = WatchRemoteViewModel()) { _model = StateObject(wrappedValue: model) } var body: some View { ScrollView { - VStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { Text("Wristivo").font(.headline) - Text("DEMO").font(.caption2).foregroundStyle(.secondary) - commandButton(.up) - HStack(spacing: 4) { - commandButton(.left) - commandButton(.select) - commandButton(.right) - } - commandButton(.down) - Text(lastCommand).font(.caption2) - HStack(spacing: 4) { - commandButton(.back) - commandButton(.home) - } - Text("Wrist gestures are coming in the hardware prototype.") - .font(.footnote).foregroundStyle(.secondary) + Text(model.isDemo ? "DEMO — nothing sent" : "LIVE PROTOTYPE — hardware validation pending") + .font(.caption2).foregroundStyle(.secondary) + Toggle("Demo mode", isOn: Binding(get: { model.isDemo }, set: { model.setDemo($0) })) + Button("Connect to iPhone") { model.connect() } + statusPanel + setupPanel + remotePanel + Text(model.message).font(.caption2).foregroundStyle(.secondary) + .accessibilityLabel("Status: \(model.message)") }.padding(.horizontal, 4) - }.tint(.teal) + } + .tint(.teal) + .onAppear { model.setSceneActive(scenePhase == .active) } + .onChange(of: scenePhase) { _, phase in model.setSceneActive(phase == .active) } + } + + private var statusPanel: some View { + VStack(alignment: .leading, spacing: 3) { + Label(model.selectedTVName, systemImage: "tv") + Label(model.relayReachable ? "Phone reachable" : "Phone unavailable", + systemImage: model.relayReachable ? "iphone" : "iphone.slash") + Label(statusTitle, systemImage: statusSymbol) + }.font(.caption) + .accessibilityElement(children: .combine) + .accessibilityLabel("Setup status. \(model.selectedTVName). \(statusTitle). \(model.relayReachable ? "Phone reachable" : "Phone unavailable")") + } + + private var setupPanel: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Setup").font(.subheadline) + Text("1. Select a TV on iPhone. 2. Choose wrist and crown orientation. 3. Hold neutral and calibrate. 4. Press Start when ready.") + .font(.caption2) + Picker("Wrist", selection: $model.wrist) { + Text("Right wrist").tag(WristSide.right) + Text("Left wrist").tag(WristSide.left) + } + Picker("Crown", selection: $model.crown) { + Text("Crown toward hand").tag(CrownOrientation.towardHand) + Text("Crown away from hand").tag(CrownOrientation.awayFromHand) + } + Button("Calibrate") { model.calibrate() }.buttonStyle(.bordered) + .disabled(model.status == .armed) + .accessibilityHint("Hold your wrist neutral before calibrating") + HStack { + Button("Start") { model.start() }.buttonStyle(.borderedProminent) + .disabled(model.status == .armed || !model.calibrationComplete) + Button("Stop") { model.stop() }.buttonStyle(.bordered) + .disabled(model.status != .armed && model.status != .calibrating) + } + } + } + + private var remotePanel: some View { + VStack(spacing: 5) { + Text("Remote").font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) + commandButton(.up) + HStack(spacing: 4) { commandButton(.left); selectButton; commandButton(.right) } + commandButton(.down) + HStack(spacing: 4) { commandButton(.back); commandButton(.home) } + } + } + + private var selectButton: some View { + let button = Button { model.tap(.select) } label: { + Image(systemName: RemoteCommand.select.symbol).frame(minWidth: 34, minHeight: 34) + }.buttonStyle(.borderedProminent) + .accessibilityLabel("Select") + .accessibilityHint("Select the focused TV item") + if #available(watchOS 11.0, *) { return AnyView(button.handGestureShortcut(.primaryAction)) } + return AnyView(button) } private func commandButton(_ command: RemoteCommand) -> some View { - Button { lastCommand = "Demo: \(command.title)" } label: { - Image(systemName: command.symbol).frame(minWidth: 24, minHeight: 30) + Button { model.tap(command) } label: { + Image(systemName: command.symbol).frame(minWidth: 34, minHeight: 34) + }.buttonStyle(.bordered) + .accessibilityLabel(command.title) + .accessibilityHint("Send \(command.title) to the selected TV") + } + + private var statusTitle: String { + switch model.status { + case .idle: "Not calibrated" + case .calibrating: "Calibrating" + case .ready: "Ready, stopped" + case .armed: "Armed" + case .paused: "Paused and disarmed" + } + } + + private var statusSymbol: String { + switch model.status { + case .armed: "dot.radiowaves.left.and.right" + case .paused: "pause.circle" + default: "circle" } - .buttonStyle(.bordered) - .accessibilityLabel(command.title) } } diff --git a/Apps/iPhone/LivePhoneView.swift b/Apps/iPhone/LivePhoneView.swift new file mode 100644 index 0000000..9de50f3 --- /dev/null +++ b/Apps/iPhone/LivePhoneView.swift @@ -0,0 +1,140 @@ +import SwiftUI +import PhoneControl +import RemoteCore +import TVTransport + +struct LivePhoneView: View { + @ObservedObject var controller: LivePhoneController + @Environment(\.scenePhase) private var scenePhase + @State private var address = "" + @State private var confirmForget = false + @State private var reviewingCertificate: CertificateCandidate? + + var body: some View { + Group { + if controller.isDemo { + VStack(spacing: 0) { + Button("Leave demo and return to saved TV") { Task { await controller.setDemo(false) } } + .padding().disabled(controller.isBusy) + RemoteDemoView() + } + } else { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Label("Live prototype", systemImage: "tv").font(.headline) + Text("TV compatibility and wrist recognition are awaiting physical testing. Keep your existing remote available.") + .font(.footnote).foregroundStyle(.secondary) + Text(status).font(.title2.bold()).accessibilityAddTraits(.updatesFrequently) + if let error = controller.error { Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.orange) } + if let notice = controller.notice { Text(notice).foregroundStyle(.secondary) } + if let television = controller.television { + Text("\(television.name) · \(television.host)").textSelection(.enabled) + if !controller.isReady { + Button("Reconnect saved TV") { Task { await controller.reconnect() } } + .buttonStyle(.bordered).disabled(controller.isBusy) + } + } + if !controller.isReady { setup } + if let certificate = controller.certificate { + Button("Review TV certificate") { reviewingCertificate = certificate } + .buttonStyle(.bordered).disabled(controller.isBusy) + } + remote + Text("Watch control requires a reachable iPhone relay. Keep the iPhone app open for the first test; locked-phone behavior is not yet verified.") + .font(.footnote).foregroundStyle(.secondary) + if controller.television != nil { + Button("Forget TV", role: .destructive) { confirmForget = true } + .frame(minHeight: 44).disabled(controller.isBusy) + } + Button("Try demo without controlling a TV") { Task { await controller.setDemo(true) } } + .frame(minHeight: 44).disabled(controller.isBusy) + } + .padding().frame(maxWidth: 680).frame(maxWidth: .infinity) + } + .navigationTitle("Wristivo") + .tint(.teal) + .confirmationDialog("Forget this TV?", isPresented: $confirmForget, titleVisibility: .visible) { + Button("Remove saved TV access", role: .destructive) { Task { await controller.forget() } } + } message: { + Text("This disconnects the TV and removes its saved address, token and certificate approval from this iPhone. You may also remove Wristivo from the TV's device list.") + } + .sheet(item: $reviewingCertificate) { candidate in certificateReview(candidate) } + } + } + } + .task { await controller.start() } + .onChange(of: scenePhase) { _, phase in + if phase == .active, !controller.isReady { Task { await controller.reconnect() } } + } + } + + private var setup: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Pair your TV").font(.headline) + Text("Find the TV's IP address in its network settings. Use the same home network, then approve the controller named Wristivo on the TV.") + if controller.television == nil { + TextField("TV IP address or hostname", text: $address) + .textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL) + .textFieldStyle(.roundedBorder).accessibilityLabel("TV local address") + } + Button(controller.isBusy ? "Please wait…" : "Pair / pair again") { + Task { await controller.pair(address: controller.television?.host ?? address) } + }.buttonStyle(.borderedProminent).disabled(controller.isBusy) + Text("Pairing makes a local network connection and may trigger iOS's access prompt. If blocked, check Wristivo's Local Network setting. A failed connection alone cannot tell us whether permission was denied.") + .font(.footnote).foregroundStyle(.secondary) + } + } + + private var remote: some View { + VStack(spacing: 12) { + Text("Touch remote").font(.headline).frame(maxWidth: .infinity, alignment: .leading) + key(.up) + HStack { key(.left); key(.select); key(.right) } + key(.down) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 10) { + ForEach([RemoteCommand.back, .home, .playPause, .volumeDown, .volumeUp, .mute], id: \.self) { key($0) } + } + }.disabled(!controller.isReady || controller.isBusy) + } + + private func key(_ command: RemoteCommand) -> some View { + Button { Task { await controller.send(command) } } label: { + Label(command.title, systemImage: command.symbol).frame(maxWidth: .infinity, minHeight: 48) + }.buttonStyle(.bordered).accessibilityHint("Sends one \(command.title) command") + } + + private func certificateReview(_ candidate: CertificateCandidate) -> some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Approve this TV certificate?").font(.title2.bold()) + Text("iOS could not validate the certificate for \(candidate.host), or it differs from the saved certificate. The connection was stopped.") + Text("This fingerprint came from the connection itself. It does not independently prove the device is your TV. Confirm the address using the TV's settings and only approve on a network you trust. A later certificate change will require approval again.") + Text("SHA-256").font(.headline) + Text(candidate.fingerprint).font(.system(.footnote, design: .monospaced)).textSelection(.enabled) + Button("Approve this certificate for this TV") { + reviewingCertificate = nil + Task { await controller.approveCertificate(candidate) } + }.buttonStyle(.borderedProminent) + Button("Cancel", role: .cancel) { reviewingCertificate = nil } + }.padding() + }.navigationTitle("TV certificate").navigationBarTitleDisplayMode(.inline) + } + } + + private var status: String { + switch controller.connection { + case .disconnected: "TV disconnected" + case .connecting: "Connecting to TV…" + case .awaitingApproval: "Approve Wristivo on your TV" + case .ready: "TV transport ready" + case .denied: "TV approval was denied" + case .unauthorized: "Pair with the TV again" + case .untrusted: "TV certificate needs review" + case .timedOut: "TV connection timed out" + case .unavailable(_, let failure): + failure == .missingToken ? "Pair with the TV to save access" : "TV unavailable — check the network and permissions" + } + } +} diff --git a/Apps/iPhone/PhoneAppComposition.swift b/Apps/iPhone/PhoneAppComposition.swift new file mode 100644 index 0000000..326b7d3 --- /dev/null +++ b/Apps/iPhone/PhoneAppComposition.swift @@ -0,0 +1,38 @@ +import SwiftUI +import PhoneControl +import SecureStorage +import TVTransport +import WatchBridgeCommon +import WatchBridgePhone +import WatchConnectivity + +@MainActor +final class PhoneAppComposition: ObservableObject { + let controller: LivePhoneController + private let relay: PhoneWCSessionDelegate? + + init() { + let authorization = KeychainTVAuthorizationStore() + let certificateReceiver = CertificateReceiver() + let factory = URLSessionSamsungSocketFactory(onUntrustedCertificate: { host, fingerprint in + Task { @MainActor in certificateReceiver.controller?.receiveUntrustedCertificate(host: host, sha256: fingerprint) } + }) + let transport = TVConnectionActor(socketFactory: factory, authorizationStore: authorization) + controller = LivePhoneController(transport: transport, authorization: authorization, + selection: KeychainSelectedTelevisionStore()) + certificateReceiver.controller = controller + if WCSession.isSupported() { + let relay = PhoneWCSessionDelegate(coordinator: transport) + self.relay = relay + controller.onSelectionChanged = { [weak relay] television, ready in + relay?.bridge.publishContext(.init(selectedTelevisionID: television?.id, + displayName: television?.name, isConnectionReady: ready)) + } + } else { relay = nil } + } +} + +@MainActor +private final class CertificateReceiver { + weak var controller: LivePhoneController? +} diff --git a/Apps/iPhone/README.md b/Apps/iPhone/README.md new file mode 100644 index 0000000..5d75353 --- /dev/null +++ b/Apps/iPhone/README.md @@ -0,0 +1,67 @@ +> 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. + +# iPhone experience handoff + +`RemoteDemoView` owns the iPhone setup and touch-remote shell for workstream D. +It is intentionally wired to `DemoPhoneService` from `WristivoApp` and visibly +labels the entire flow as Demo mode. The fake does not open a socket, request a +real permission, persist a token, or claim that a TV changed state. + +## Production wiring + +The integrator should keep `PhoneExperienceService` as the UI boundary and +inject a `@MainActor` production composition from `WristivoApp`: + +```swift +WindowGroup { + RemoteDemoView(service: productionPhoneExperienceService, + isDemo: false) +} +``` + +The production service should own the transport actor, token/trust repository, +and local-network permission request. Views should not gain protocol strings, +envelopes, tokens, addresses beyond the user-entered display value, or network +state. A successful command must remain a transport receipt, not TV confirmation. + +The current initializer defaults `isDemo` to `true` for safe review. When live +composition is integrated, pass `isDemo: false`; the view model will then use +truthful live-service copy for pairing and command receipts. + +## Fake-state UI checks + +Run these checks in Preview or by constructing `DemoPhoneService` with the +listed scenario. These are UI/state checks, not hardware evidence. + +1. Default `.success`: begin setup, enter any redacted local address, continue, + start pairing, verify the selected TV and touch remote appear. Press a + directional button and verify the notice says the action was recorded in + Demo mode and that no TV was contacted. +2. `.permissionDenied`: enter an address and continue. Verify the recoverable + local-network error and Try again action. No setup transition should occur. +3. `.networkUnavailable`: continue, start pairing, and verify the network + error. On the remote screen the same scenario returns an unavailable error; + verify no command is queued. +4. `.authorizationDenied` and `.timedOut`: verify pairing remains recoverable, + explains the failure, and does not show a selected TV. +5. On the remote screen, choose Forget this TV, cancel, then confirm. Verify + cancel preserves the selected TV and confirmation returns to setup. +6. With Larger Text and VoiceOver enabled, verify each remote action has a + distinct spoken label, the Demo banner is announced once, and all controls + remain at least 44 points tall. Check iPhone 13 Pro Max, the smallest + supported phone, and an iPad width; the content uses a bounded readable + column and adaptive action grid. + +## Validation status + +- `git diff --check`: passed. +- `swift test` in `Packages/WristivoKit`: blocked before test execution by the + sandbox's codesign error (`resource fork ... not allowed`) in generated test + bundles. +- iPhone `xcodebuild`: blocked before app compilation by unavailable + CoreSimulator and restricted host Swift/Clang cache paths. The integrator + should run the normal app build on the combined commit. +- Hardware/TV: untested. No physical TV or motion-recognition claim is made. +- Permissions/privacy: this UI adds no manifest entries and requests no camera, + location, tracking, or account access. The production service must add the + local-network usage description only when it is introduced. diff --git a/Apps/iPhone/RemoteDemoView.swift b/Apps/iPhone/RemoteDemoView.swift index 51d46b1..e9c0f9c 100644 --- a/Apps/iPhone/RemoteDemoView.swift +++ b/Apps/iPhone/RemoteDemoView.swift @@ -1,67 +1,376 @@ import SwiftUI import RemoteCore -/// Foundation UI: intentionally never opens a TV connection. +@MainActor +protocol PhoneExperienceService { + func requestLocalNetworkAccess() async -> Result + func pair(address: String, controllerName: String) async -> Result + func send(_ command: RemoteCommand) async -> Result + func forget() async -> Result +} + +struct PairedTelevision: Equatable, Sendable { + let name: String + let address: String +} + +enum PhoneCommandOutcome: Equatable, Sendable { + case recordedInDemo + case sentToTransport +} + +enum PhoneExperienceError: Error, Equatable, Sendable { + case invalidAddress, localNetworkPermissionDenied, networkUnavailable, authorizationDenied, pairingTimedOut, unavailable + + var title: String { + switch self { + case .invalidAddress: "TV address is required" + case .localNetworkPermissionDenied: "Local network access is off" + case .networkUnavailable: "Network unavailable" + case .authorizationDenied: "TV authorization was not completed" + case .pairingTimedOut: "Pairing took too long" + case .unavailable: "The TV is unavailable" + } + } + + var message: String { + switch self { + case .invalidAddress: "Enter the TV address shown in its network settings, then try again." + case .localNetworkPermissionDenied: "Allow Wristivo to access your local network in Settings, then try again." + case .networkUnavailable: "Check that the iPhone and TV are on the same reachable home network." + case .authorizationDenied: "Approve the Wristivo controller on the TV, or start pairing again." + case .pairingTimedOut: "The TV did not answer in time. It is safe to try again." + case .unavailable: "The saved TV cannot be reached right now. No command was queued." + } + } +} + +/// Explicitly fake: no socket, real permission, token, or TV confirmation. +@MainActor +final class DemoPhoneService: PhoneExperienceService { + enum Scenario: String, CaseIterable, Identifiable { + case success, permissionDenied, networkUnavailable, authorizationDenied, timedOut + var id: String { rawValue } + } + + var scenario: Scenario + init(scenario: Scenario = .success) { self.scenario = scenario } + + func requestLocalNetworkAccess() async -> Result { + scenario == .permissionDenied ? .failure(.localNetworkPermissionDenied) : .success(()) + } + + func pair(address: String, controllerName: String) async -> Result { + switch scenario { + case .networkUnavailable: .failure(.networkUnavailable) + case .authorizationDenied: .failure(.authorizationDenied) + case .timedOut: .failure(.pairingTimedOut) + default: .success(PairedTelevision(name: "Demo TV", address: address)) + } + } + + func send(_ command: RemoteCommand) async -> Result { + scenario == .networkUnavailable ? .failure(.unavailable) : .success(.recordedInDemo) + } + + func forget() async -> Result { .success(()) } +} + +@MainActor +final class PhoneExperienceViewModel: ObservableObject { + enum Screen { case welcome, address, pairing, remote } + + @Published var screen: Screen = .welcome + @Published var address = "" + @Published var controllerName = "Wristivo" + @Published private(set) var pairedTV: PairedTelevision? + @Published private(set) var error: PhoneExperienceError? + @Published private(set) var notice: String? + @Published private(set) var lastCommand: RemoteCommand? + @Published private(set) var isBusy = false + @Published var showForgetConfirmation = false + + let isDemo: Bool + private let service: any PhoneExperienceService + + init(service: any PhoneExperienceService, isDemo: Bool = true) { + self.service = service + self.isDemo = isDemo + } + + func beginSetup() { clearFeedback(); screen = .address } + + func requestPairing() { + guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = .invalidAddress + return + } + clearFeedback(); isBusy = true + Task { @MainActor [weak self] in + guard let self else { return } + let result = await service.requestLocalNetworkAccess() + isBusy = false + switch result { + case .success: screen = .pairing + case let .failure(value): error = value + } + } + } + + func pair() { + clearFeedback(); isBusy = true + let enteredAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + let enteredName = controllerName.trimmingCharacters(in: .whitespacesAndNewlines) + Task { @MainActor [weak self] in + guard let self else { return } + let result = await service.pair(address: enteredAddress, controllerName: enteredName.isEmpty ? "Wristivo" : enteredName) + isBusy = false + switch result { + case let .success(tv): + pairedTV = tv + notice = isDemo ? "Demo pairing complete. No TV was contacted." : "Pairing complete." + screen = .remote + case let .failure(value): error = value + } + } + } + + func send(_ command: RemoteCommand) { + clearFeedback(); isBusy = true + Task { @MainActor [weak self] in + guard let self else { return } + let result = await service.send(command) + isBusy = false + switch result { + case .success: + lastCommand = command + notice = isDemo ? "Demo action recorded. The TV was not contacted." : "Command sent to transport. TV effect is not confirmed." + case let .failure(value): error = value + } + } + } + + func forgetTV() { + clearFeedback(); isBusy = true + Task { @MainActor [weak self] in + guard let self else { return } + let result = await service.forget() + isBusy = false + switch result { + case .success: + pairedTV = nil; address = ""; lastCommand = nil + notice = "Demo TV forgotten. No real TV access was stored." + screen = .welcome + case let .failure(value): error = value + } + } + } + + func clearFeedback() { error = nil; notice = nil } + func retry() { + if screen == .address { requestPairing() } + else if screen == .pairing { pair() } + else if screen == .remote { notice = "Try the command again when the TV is reachable." } + } +} + +/// iPhone setup and remote shell. It is intentionally labelled Demo until the +/// integrator injects the production service. struct RemoteDemoView: View { - @State private var lastCommand = "Try a button below" + @StateObject private var model: PhoneExperienceViewModel + + init(service: any PhoneExperienceService = DemoPhoneService()) { + _model = StateObject(wrappedValue: PhoneExperienceViewModel(service: service)) + } var body: some View { NavigationStack { ScrollView { - VStack(spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - Label("Demo mode", systemImage: "play.rectangle") - .font(.headline) - Text("Explore your remote. No television is connected.") - Text("Pairing and wrist movement recognition are still in development.") - .font(.footnote).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 20) { + DemoBanner() + if let error = model.error { FeedbackCard(error: error, retry: model.retry) } + if let notice = model.notice { NoticeCard(text: notice) } + switch model.screen { + case .welcome: WelcomeContent(begin: model.beginSetup) + case .address: AddressContent(model: model) + case .pairing: PairingContent(model: model) + case .remote: RemoteContent(model: model) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20)) - - VStack(spacing: 12) { - commandButton(.up) - HStack(spacing: 16) { - commandButton(.left) - commandButton(.select) - commandButton(.right) - } - commandButton(.down) - } - Text(lastCommand).font(.callout).accessibilityIdentifier("demo.lastCommand") - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - ForEach([RemoteCommand.back, .home, .volumeDown, .volumeUp, .mute, .playPause], id: \.rawValue) { command in - Button { record(command) } label: { - Label(command.title, systemImage: command.symbol) - .frame(maxWidth: .infinity, minHeight: 44) - }.buttonStyle(.bordered) - } - } - VStack(alignment: .leading, spacing: 8) { - Text("Made for a lighter touch").font(.headline) - Text("The planned watch remote translates deliberate wrist movements into directional presses. Your iPhone will relay commands to a paired TV on your home network.") - Text("Gesture control will require an active watch session and a reachable iPhone.") - .foregroundStyle(.secondary) - }.frame(maxWidth: .infinity, alignment: .leading) } - .padding().frame(maxWidth: 560) - .frame(maxWidth: .infinity) + .padding(.horizontal, 20).padding(.vertical, 16) + .frame(maxWidth: 720).frame(maxWidth: .infinity) } .navigationTitle("Wristivo") + .navigationBarTitleDisplayMode(.large) .tint(.teal) + .confirmationDialog("Forget this TV?", isPresented: $model.showForgetConfirmation, titleVisibility: .visible) { + Button("Forget TV", role: .destructive, action: model.forgetTV) + Button("Cancel", role: .cancel) { } + } message: { + Text("This removes the simulated TV and returns to demo setup.") + } } } - - private func commandButton(_ command: RemoteCommand) -> some View { - Button { record(command) } label: { - Image(systemName: command.symbol) - .font(.title2).frame(width: 64, height: 56) - } - .buttonStyle(.borderedProminent) - .accessibilityLabel(command.title) - } - - private func record(_ command: RemoteCommand) { - lastCommand = "Demo: \(command.title)" - } } + +private struct DemoBanner: View { + var body: some View { + Label { + VStack(alignment: .leading, spacing: 3) { + Text("Demo mode").font(.headline) + Text("These demo controls do not contact a TV or use motion data.") + .font(.subheadline).foregroundStyle(.secondary) + } + } icon: { Image(systemName: "play.rectangle.fill").foregroundStyle(.teal) } + .accessibilityElement(children: .combine) + .padding(16).frame(maxWidth: .infinity, alignment: .leading) + .background(.teal.opacity(0.12), in: RoundedRectangle(cornerRadius: 16)) + } +} + +private struct WelcomeContent: View { + let begin: () -> Void + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Set up your TV").font(.largeTitle.weight(.bold)).fixedSize(horizontal: false, vertical: true) + Text("Enter the TV's local address, then approve Wristivo on the TV. You can always use the accessible touch remote on iPhone.") + Button("Begin setup", action: begin).buttonStyle(.borderedProminent).controlSize(.large).frame(minHeight: 52) + .accessibilityHint("Opens manual TV address setup") + Text("Wristivo does not use accounts, cloud services, camera access, or location access.") + .font(.footnote).foregroundStyle(.secondary) + }.accessibilitySortPriority(1) + } +} + +private struct AddressContent: View { + @ObservedObject var model: PhoneExperienceViewModel + var body: some View { + VStack(alignment: .leading, spacing: 18) { + StepHeading(step: "Step 1 of 2", title: "Enter TV address") + Text("Find the address in the TV's network settings. Use a local address or hostname; Wristivo will not discover devices automatically in this setup flow.") + TextField("Example: 192.168.1.25", text: $model.address) + .textContentType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL) + .padding(14).background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityLabel("TV local address") + TextField("Controller name", text: $model.controllerName) + .textInputAutocapitalization(.words).padding(14) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityLabel("Name shown to the TV") + Button(model.isBusy ? "Checking access…" : "Continue") { model.requestPairing() } + .buttonStyle(.borderedProminent).controlSize(.large).frame(maxWidth: .infinity, minHeight: 52).disabled(model.isBusy) + Text("Local network access is requested only when you continue with setup. If it is denied, you can change it later in Settings.") + .font(.footnote).foregroundStyle(.secondary) + } + } +} + +private struct PairingContent: View { + @ObservedObject var model: PhoneExperienceViewModel + var body: some View { + VStack(alignment: .leading, spacing: 18) { + StepHeading(step: "Step 2 of 2", title: "Approve Wristivo") + Label("Use the existing TV remote or TV controls to approve the controller named \"\(model.controllerName.isEmpty ? "Wristivo" : model.controllerName)\".", systemImage: "checkmark.shield") + Button(model.isBusy ? "Pairing…" : "Start pairing") { model.pair() } + .buttonStyle(.borderedProminent).controlSize(.large).frame(maxWidth: .infinity, minHeight: 52).disabled(model.isBusy) + Text("A successful socket connection alone is not treated as authorization. If approval expires, try again; Wristivo will not replay a command.") + .font(.footnote).foregroundStyle(.secondary) + } + } +} + +private struct RemoteContent: View { + @ObservedObject var model: PhoneExperienceViewModel + var body: some View { + VStack(alignment: .leading, spacing: 18) { + if let tv = model.pairedTV { + VStack(alignment: .leading, spacing: 4) { + Label("Selected TV", systemImage: "tv").font(.headline) + Text(tv.name).font(.title2.weight(.semibold)) + Text(tv.address).font(.footnote).foregroundStyle(.secondary) + }.accessibilityElement(children: .combine) + } + Text("Touch remote").font(.title2.weight(.bold)).accessibilityAddTraits(.isHeader) + DPad(model: model) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 132), spacing: 12)], spacing: 12) { + RemoteAction(command: .back, model: model) + RemoteAction(command: .home, model: model) + RemoteAction(command: .playPause, model: model) + RemoteAction(command: .volumeDown, model: model) + RemoteAction(command: .volumeUp, model: model) + RemoteAction(command: .mute, model: model) + } + Button("Forget this TV", role: .destructive) { model.showForgetConfirmation = true } + .frame(maxWidth: .infinity, minHeight: 52) + .accessibilityHint("Removes this selected TV and returns to setup") + } + } +} + +private struct DPad: View { + @ObservedObject var model: PhoneExperienceViewModel + var body: some View { + VStack(spacing: 12) { + RemoteAction(command: .up, model: model, compact: true) + HStack(spacing: 12) { + RemoteAction(command: .left, model: model, compact: true) + RemoteAction(command: .select, model: model, compact: true) + RemoteAction(command: .right, model: model, compact: true) + } + RemoteAction(command: .down, model: model, compact: true) + }.frame(maxWidth: .infinity).padding(16) + .background(Color.secondary.opacity(0.10), in: RoundedRectangle(cornerRadius: 22)) + .accessibilityElement(children: .contain) + } +} + +private struct RemoteAction: View { + let command: RemoteCommand + @ObservedObject var model: PhoneExperienceViewModel + var compact = false + var body: some View { + Button { model.send(command) } label: { + Label(command.title, systemImage: command.symbol).labelStyle(.titleAndIcon) + .frame(maxWidth: compact ? 82 : .infinity, minHeight: 52).contentShape(Rectangle()) + } + .buttonStyle(.bordered).disabled(model.isBusy) + .accessibilityLabel(command.title).accessibilityHint("Sends one \(command.title) command") + } +} + +private struct StepHeading: View { + let step: String + let title: String + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(step.uppercased()).font(.caption.weight(.semibold)).foregroundStyle(.teal) + Text(title).font(.largeTitle.weight(.bold)).fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct FeedbackCard: View { + let error: PhoneExperienceError + let retry: () -> Void + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(error.title, systemImage: "exclamationmark.triangle.fill").font(.headline).foregroundStyle(.orange) + Text(error.message) + Button("Try again", action: retry).buttonStyle(.bordered).frame(minHeight: 44) + }.padding(16).frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 16)) + .accessibilityElement(children: .contain) + } +} + +private struct NoticeCard: View { + let text: String + var body: some View { + Label(text, systemImage: "info.circle.fill").foregroundStyle(.primary) + .padding(14).frame(maxWidth: .infinity, alignment: .leading) + .background(Color.teal.opacity(0.10), in: RoundedRectangle(cornerRadius: 14)) + .accessibilityElement(children: .combine) + } +} + +#Preview("Demo setup") { RemoteDemoView() } +#Preview("Permission error") { RemoteDemoView(service: DemoPhoneService(scenario: .permissionDenied)) } diff --git a/Apps/iPhone/WristivoApp.swift b/Apps/iPhone/WristivoApp.swift index 5f3db32..ff7d3a3 100644 --- a/Apps/iPhone/WristivoApp.swift +++ b/Apps/iPhone/WristivoApp.swift @@ -2,7 +2,8 @@ import SwiftUI @main struct WristivoApp: App { + @StateObject private var composition = PhoneAppComposition() var body: some Scene { - WindowGroup { RemoteDemoView() } + WindowGroup { LivePhoneView(controller: composition.controller) } } } diff --git a/Packages/WristivoKit/Sources/GestureCore/CalibratedGestureRecognizer.swift b/Packages/WristivoKit/Sources/GestureCore/CalibratedGestureRecognizer.swift new file mode 100644 index 0000000..eadee68 --- /dev/null +++ b/Packages/WristivoKit/Sources/GestureCore/CalibratedGestureRecognizer.swift @@ -0,0 +1,135 @@ +import Foundation +import RemoteCore + +public enum WristSide: String, Codable, Sendable { case left, right } +public enum CrownOrientation: String, Codable, Sendable { case towardHand, awayFromHand } + +/// A normalized, user-relative sample. Values are dimensionless and bounded by +/// the motion adapter; this type intentionally contains no sensor or TV data. +public struct NormalizedMotionSample: Sendable, Equatable { + public let timestamp: TimeInterval + public let x: Double + public let y: Double + public let quality: Double + + public init(timestamp: TimeInterval, x: Double, y: Double, quality: Double = 1) { + self.timestamp = timestamp; self.x = x; self.y = y; self.quality = quality + } +} + +public struct GestureRecognition: Sendable, Equatable { + public let sessionID: UUID + public let command: RemoteCommand + public let confidence: Double + public let timestamp: TimeInterval +} + +public struct GestureRecognitionConfiguration: Sendable, Equatable { + public var activationMagnitude: Double = 0.55 + public var neutralMagnitude: Double = 0.18 + public var minimumNeutralDwell: TimeInterval = 0.20 + public var candidateWindow: ClosedRange = 0.12...0.65 + public var cooldown: TimeInterval = 0.40 + public var stableNeutralDwell: TimeInterval = 0.20 + public var maximumSampleGap: TimeInterval = 0.12 + public var dominantAxisRatio: Double = 1.5 + public var minimumQuality: Double = 0.65 + + public init() {} +} + +/// A deterministic classifier over calibrated, user-relative samples. It is +/// deliberately conservative: mixed axes, poor quality, gaps and slow drift +/// produce no intent. One accepted excursion consumes readiness until a stable +/// neutral pose is observed after cooldown. +public struct CalibratedGestureRecognizer: Sendable { + public let configuration: GestureRecognitionConfiguration + public private(set) var sessionID: UUID? + + private var armedAt: TimeInterval? + private var lastTimestamp: TimeInterval? + private var neutralSince: TimeInterval? + private var candidateStart: TimeInterval? + private var candidatePeakX = 0.0 + private var candidatePeakY = 0.0 + private var candidateQuality = 1.0 + private var cooldownUntil: TimeInterval? + private var waitingForNeutral = true + + public init(configuration: GestureRecognitionConfiguration = .init()) { + self.configuration = configuration + } + + public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) { + disarm() + guard timestamp.isFinite else { return } + self.sessionID = sessionID; armedAt = timestamp; lastTimestamp = nil + waitingForNeutral = true + } + + public mutating func disarm() { + sessionID = nil; armedAt = nil; lastTimestamp = nil + neutralSince = nil; candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0 + candidateQuality = 1; cooldownUntil = nil; waitingForNeutral = true + } + + public mutating func process(_ sample: NormalizedMotionSample) -> GestureRecognition? { + guard let sessionID, let armedAt, sample.timestamp.isFinite, + sample.timestamp >= armedAt, + sample.x.isFinite, sample.y.isFinite, + sample.quality.isFinite, sample.quality >= 0, + sample.quality <= 1 else { + resetCandidate(); waitingForNeutral = true; neutralSince = nil + return nil + } + if let lastTimestamp, sample.timestamp <= lastTimestamp { return nil } + if let lastTimestamp, sample.timestamp - lastTimestamp > configuration.maximumSampleGap { + resetCandidate(); waitingForNeutral = true; neutralSince = nil + } + lastTimestamp = sample.timestamp + let magnitude = hypot(sample.x, sample.y) + guard magnitude <= 1.0 else { resetCandidate(); waitingForNeutral = true; return nil } + + if magnitude <= configuration.neutralMagnitude { + if neutralSince == nil { neutralSince = sample.timestamp } + if let cooldownUntil, sample.timestamp < cooldownUntil { return nil } + if sample.timestamp - (neutralSince ?? sample.timestamp) >= configuration.stableNeutralDwell { + waitingForNeutral = false; resetCandidate() + } + return nil + } + neutralSince = nil + guard sample.quality >= configuration.minimumQuality else { resetCandidate(); waitingForNeutral = true; return nil } + guard !waitingForNeutral else { return nil } + + if candidateStart == nil { + guard magnitude >= configuration.neutralMagnitude else { return nil } + candidateStart = sample.timestamp; candidatePeakX = sample.x; candidatePeakY = sample.y + candidateQuality = sample.quality + return nil + } + guard let start = candidateStart else { return nil } + let duration = sample.timestamp - start + guard duration <= configuration.candidateWindow.upperBound else { resetCandidate(); waitingForNeutral = true; return nil } + candidatePeakX = abs(sample.x) > abs(candidatePeakX) ? sample.x : candidatePeakX + candidatePeakY = abs(sample.y) > abs(candidatePeakY) ? sample.y : candidatePeakY + candidateQuality = min(candidateQuality, sample.quality) + let peakX = abs(candidatePeakX), peakY = abs(candidatePeakY) + let peak = max(peakX, peakY) + guard duration >= configuration.candidateWindow.lowerBound, + peak >= configuration.activationMagnitude else { return nil } + let dominant = max(peakX, peakY) / max(0.0001, min(peakX, peakY)) + guard dominant >= configuration.dominantAxisRatio else { resetCandidate(); waitingForNeutral = true; return nil } + let command: RemoteCommand + if peakX > peakY { command = candidatePeakX > 0 ? .right : .left } + else { command = candidatePeakY > 0 ? .up : .down } + let confidence = min(1, 0.5 * (peak / max(0.0001, configuration.activationMagnitude)) + + 0.3 * min(1, dominant / 3) + 0.2 * candidateQuality) + resetCandidate(); waitingForNeutral = true; cooldownUntil = sample.timestamp + configuration.cooldown + return GestureRecognition(sessionID: sessionID, command: command, confidence: confidence, timestamp: sample.timestamp) + } + + private mutating func resetCandidate() { + candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0; candidateQuality = 1 + } +} diff --git a/Packages/WristivoKit/Sources/SamsungProtocol/SamsungEventParser.swift b/Packages/WristivoKit/Sources/SamsungProtocol/SamsungEventParser.swift new file mode 100644 index 0000000..3ed61d8 --- /dev/null +++ b/Packages/WristivoKit/Sources/SamsungProtocol/SamsungEventParser.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Events accepted from the candidate Samsung remote-control channel. +/// +/// This parser intentionally exposes no arbitrary method or key supplied by the TV. +/// Unknown events are inert and can be counted without logging their payload. +public enum SamsungRemoteEvent: Sendable, Equatable { + case connected(token: String?) + case unauthorized + case timedOut + case error + case unknown +} + +public enum SamsungEventParserError: Error, Sendable, Equatable { + case empty + case oversized + case malformed + case invalidEventName + case invalidToken +} + +/// A size-bounded, allowlist-based parser for WebSocket event messages. +public struct SamsungEventParser: Sendable { + public static let defaultMaximumMessageBytes = 16 * 1024 + public static let maximumEventNameBytes = 128 + public static let maximumTokenBytes = 4 * 1024 + + public let maximumMessageBytes: Int + + public init(maximumMessageBytes: Int = Self.defaultMaximumMessageBytes) { + self.maximumMessageBytes = max(1, maximumMessageBytes) + } + + public func parse(_ data: Data) throws -> SamsungRemoteEvent { + guard !data.isEmpty else { throw SamsungEventParserError.empty } + guard data.count <= maximumMessageBytes else { + throw SamsungEventParserError.oversized + } + + let message: Message + do { + message = try JSONDecoder().decode(Message.self, from: data) + } catch { + throw SamsungEventParserError.malformed + } + + guard let eventData = message.event.data(using: .utf8), + !eventData.isEmpty, + eventData.count <= Self.maximumEventNameBytes else { + throw SamsungEventParserError.invalidEventName + } + + switch message.event { + case "ms.channel.connect": + guard let token = message.data?.token else { return .connected(token: nil) } + guard let tokenData = token.data(using: .utf8), + !tokenData.isEmpty, + tokenData.count <= Self.maximumTokenBytes else { + throw SamsungEventParserError.invalidToken + } + return .connected(token: token) + case "ms.channel.unauthorized": + return .unauthorized + case "ms.channel.timeOut": + return .timedOut + case "ms.error": + return .error + default: + return .unknown + } + } + + private struct Message: Decodable { + let event: String + let data: EventData? + } + + private struct EventData: Decodable { + let token: String? + } +} diff --git a/Packages/WristivoKit/Tests/GestureCoreTests/CalibratedGestureRecognizerTests.swift b/Packages/WristivoKit/Tests/GestureCoreTests/CalibratedGestureRecognizerTests.swift new file mode 100644 index 0000000..3e783b6 --- /dev/null +++ b/Packages/WristivoKit/Tests/GestureCoreTests/CalibratedGestureRecognizerTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +import RemoteCore +@testable import GestureCore + +private func sample(_ t: Double, _ x: Double = 0, _ y: Double = 0, _ q: Double = 1) -> NormalizedMotionSample { + NormalizedMotionSample(timestamp: t, x: x, y: y, quality: q) +} + +@Test func requiresStableNeutralAndEmitsOneDirectionalEvent() { + var configuration = GestureRecognitionConfiguration(); configuration.maximumSampleGap = 0.3 + var recognizer = CalibratedGestureRecognizer(configuration: configuration) + let id = UUID(); recognizer.arm(sessionID: id, at: 0) + #expect(recognizer.process(sample(0)) == nil) + #expect(recognizer.process(sample(0.21)) == nil) + #expect(recognizer.process(sample(0.30, 0.65, 0)) == nil) + #expect(recognizer.process(sample(0.41, 0.8, 0)) == nil) + let result = recognizer.process(sample(0.50, 0.9, 0)) + #expect(result?.command == .right) + #expect(result?.sessionID == id) + #expect(recognizer.process(sample(0.55, 0.9, 0)) == nil) +} + +@Test func diagonalLowConfidenceGapAndOutOfOrderAreRejected() { + var recognizer = CalibratedGestureRecognizer() + recognizer.arm(sessionID: UUID(), at: 0) + _ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21)) + _ = recognizer.process(sample(0.35, 0.8, 0.75)) + #expect(recognizer.process(sample(0.50, 0.8, 0.75)) == nil) // ambiguous + #expect(recognizer.process(sample(0.49, 0.9, 0)) == nil) // stale + _ = recognizer.process(sample(0.70)) + #expect(recognizer.process(sample(0.90, 0.9, 0, 0.2)) == nil) +} + +@Test func cooldownAndReturnToNeutralSuppressReverseMotion() { + var configuration = GestureRecognitionConfiguration(); configuration.maximumSampleGap = 0.3 + var recognizer = CalibratedGestureRecognizer(configuration: configuration) + recognizer.arm(sessionID: UUID(), at: 0) + _ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21)) + #expect(recognizer.process(sample(0.30, 0.9, 0)) == nil) + #expect(recognizer.process(sample(0.41, 0.9, 0)) == nil) + #expect(recognizer.process(sample(0.50, 0.9, 0))?.command == .right) + _ = recognizer.process(sample(0.60)); _ = recognizer.process(sample(0.70)); _ = recognizer.process(sample(0.80)) + _ = recognizer.process(sample(0.90)); _ = recognizer.process(sample(0.95)) + #expect(recognizer.process(sample(1.05, -0.9, 0)) == nil) + #expect(recognizer.process(sample(1.15, -0.9, 0)) == nil) + #expect(recognizer.process(sample(1.25, -0.9, 0))?.command == .left) +} + +@Test func disarmClearsSessionAndNoReplayAfterRearm() { + var recognizer = CalibratedGestureRecognizer() + recognizer.arm(sessionID: UUID(), at: 0); _ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21)) + recognizer.disarm() + #expect(recognizer.process(sample(1, 0.9, 0)) == nil) + recognizer.arm(sessionID: UUID(), at: 2) + #expect(recognizer.process(sample(2.1, 0.9, 0)) == nil) +} + +@Test func invalidSampleDiscardsCandidateUntilFreshNeutral() { + var recognizer = CalibratedGestureRecognizer() + recognizer.arm(sessionID: UUID(), at: 0) + for index in 0...20 { + _ = recognizer.process(.init(timestamp: Double(index) / 50, x: 0, y: 0)) + } + _ = recognizer.process(.init(timestamp: 0.42, x: 0.8, y: 0)) + _ = recognizer.process(.init(timestamp: 0.44, x: .nan, y: 0)) + for index in 23...40 { + #expect(recognizer.process(.init(timestamp: Double(index) / 50, x: 0.8, y: 0)) == nil) + } +} diff --git a/Packages/WristivoKit/Tests/SamsungProtocolTests/EventParserTests.swift b/Packages/WristivoKit/Tests/SamsungProtocolTests/EventParserTests.swift new file mode 100644 index 0000000..f1f342c --- /dev/null +++ b/Packages/WristivoKit/Tests/SamsungProtocolTests/EventParserTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import SamsungProtocol + +private let parser = SamsungEventParser(maximumMessageBytes: 256) + +@Test func parsesAllowlistedPairingEvents() throws { + #expect(try parser.parse(Data(#"{"event":"ms.channel.connect","data":{"token":"synthetic-token"}}"#.utf8)) == .connected(token: "synthetic-token")) + #expect(try parser.parse(Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)) == .connected(token: nil)) + #expect(try parser.parse(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)) == .unauthorized) + #expect(try parser.parse(Data(#"{"event":"ms.channel.timeOut"}"#.utf8)) == .timedOut) + #expect(try parser.parse(Data(#"{"event":"ms.error","data":{"message":"redacted"}}"#.utf8)) == .error) +} + +@Test func unknownEventsAreInert() throws { + let fixture = Data(#"{"event":"synthetic.unknown","data":{"token":"not-consumed"}}"#.utf8) + #expect(try parser.parse(fixture) == .unknown) +} + +@Test func rejectsMalformedEmptyAndOversizedMessages() { + #expect(throws: SamsungEventParserError.empty) { try parser.parse(Data()) } + #expect(throws: SamsungEventParserError.malformed) { try parser.parse(Data(#"{"event":42}"#.utf8)) } + #expect(throws: SamsungEventParserError.oversized) { try parser.parse(Data(repeating: 0x41, count: 257)) } +} + +@Test func boundsEventNamesAndTokens() { + let longEvent = String(repeating: "e", count: SamsungEventParser.maximumEventNameBytes + 1) + let eventFixture = Data(#"{"event":"\#(longEvent)"}"#.utf8) + #expect(throws: SamsungEventParserError.invalidEventName) { try parser.parse(eventFixture) } + + let tokenParser = SamsungEventParser(maximumMessageBytes: 8 * 1024) + let longToken = String(repeating: "t", count: SamsungEventParser.maximumTokenBytes + 1) + let tokenFixture = Data(#"{"event":"ms.channel.connect","data":{"token":"\#(longToken)"}}"#.utf8) + #expect(throws: SamsungEventParserError.invalidToken) { try tokenParser.parse(tokenFixture) } + #expect(throws: SamsungEventParserError.invalidToken) { + try tokenParser.parse(Data(#"{"event":"ms.channel.connect","data":{"token":""}}"#.utf8)) + } +} diff --git a/README.md b/README.md index adfcf2c..b2152c7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ A native iPhone and Apple Watch remote foundation for compatible Samsung TVs run ## Current state -This is an engineering starter repository, not a finished TV remote. It includes two native SwiftUI demo apps, tested domain and adapter Swift packages, an Xcode project, and implementation briefs for parallel agents. WatchBridge now has shared control-plane contracts, an injected phone-actor seam, and platform source/test membership; see [B's handoff](docs/agents/WATCH-RELAY-HANDOFF.md). The demo buttons update a local label only. Live TV pairing, WatchConnectivity relay, Keychain storage, Core Motion capture/classification, and App Store assets remain planned work. No physical-device compatibility is claimed. +This is an integrated **live prototype**, not a hardware-validated remote or release. The iPhone composes the TV transport, device-only Keychain storage and WatchConnectivity relay. The watch composes live commands, Core Motion calibration and opt-in ten-second gesture sessions. Both apps retain explicit demo controls. No physical-device compatibility or reliable wrist recognition is claimed. + +Start with the [first-device runbook](docs/FIRST-DEVICE-PROTOTYPE.md), then use [Q’s hardware plan](docs/test-evidence/2026-09-18-q-hardware-release-plan.md). A socket-write receipt is not confirmation that a TV performed an action. The intended path is **Apple Watch → WatchConnectivity → iPhone → local TV WebSocket**. The watch is the control surface; the iPhone is required as a relay. Gestures operate in an explicit active watch session. The app must stop sensing and disarm when inactive. No promise of all-day, wrist-down, or phone-free control. @@ -15,7 +17,7 @@ The intended path is **Apple Watch → WatchConnectivity → iPhone → local TV Open `Wristivo.xcodeproj` in Xcode. Choose `Wristivo` for iPhone or `WristivoWatch` for watchOS. The generated project is committed, so no project generator installation is needed to open it. ```sh -bash Scripts/check.sh # shared package tests and privacy plist syntax +bash Scripts/check.sh # all portable package tests and privacy plist syntax bash Scripts/check.sh --apps # also compile unsigned iOS + watchOS device builds ``` diff --git a/Scripts/check.sh b/Scripts/check.sh index 762e772..2256e21 100755 --- a/Scripts/check.sh +++ b/Scripts/check.sh @@ -6,13 +6,12 @@ trap 'rm -rf "$scratch"' EXIT export CLANG_MODULE_CACHE_PATH="$scratch/clang-cache" export SWIFTPM_MODULECACHE_OVERRIDE="$scratch/module-cache" # These local packages have no third-party dependencies or plugins. Disable nested SwiftPM sandboxing -# for agent environments; Swift Testing does not need the XCTest bundle runner. -swift test --package-path Packages/WristivoKit --scratch-path "$scratch/swiftpm" \ - --cache-path "$scratch/cache" --config-path "$scratch/config" \ - --security-path "$scratch/security" --disable-sandbox --disable-xctest -swift test --package-path Adapters/WatchBridge --scratch-path "$scratch/watchbridge" \ - --cache-path "$scratch/cache" --config-path "$scratch/config" \ - --security-path "$scratch/security" --disable-sandbox --disable-xctest +# for agent environments. All adapter tests must execute, not merely compile. +for package in Packages/WristivoKit Adapters/WatchBridge Adapters/TVTransport Adapters/SecureStorage Adapters/PhoneControl; do + swift test --package-path "$package" --scratch-path "$scratch/$(basename "$package")" \ + --cache-path "$scratch/cache" --config-path "$scratch/config" \ + --security-path "$scratch/security" --disable-sandbox +done plutil -lint Resources/PrivacyInfo.xcprivacy if [ "${1:-}" = "--apps" ]; then extra_args=() diff --git a/Scripts/generate-project.rb b/Scripts/generate-project.rb index 8bbcfa3..5335ffa 100755 --- a/Scripts/generate-project.rb +++ b/Scripts/generate-project.rb @@ -29,6 +29,14 @@ bridge_package = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageRefe bridge_package.relative_path = 'Adapters/WatchBridge' project.root_object.package_references << bridge_package +adapter_packages = {} +%w[TVTransport SecureStorage PhoneControl MotionCapture].each do |name| + ref = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference) + ref.relative_path = "Adapters/#{name}" + project.root_object.package_references << ref + adapter_packages[name] = ref +end + def add_sources(project, target, pattern) Dir.glob(pattern).sort.each do |path| ref = project.main_group.find_file_by_path(path) || project.main_group.new_file(path) @@ -69,12 +77,16 @@ link_product(project, phone, package, 'SamsungProtocol') link_product(project, watch, package, 'GestureCore') link_product(project, phone, bridge_package, 'WatchBridgePhone') link_product(project, watch, bridge_package, 'WatchBridgeWatch') +%w[TVTransport SecureStorage PhoneControl].each { |name| link_product(project, phone, adapter_packages[name], name) } +link_product(project, watch, adapter_packages['MotionCapture'], 'MotionCapture') phone.build_configurations.each do |c| c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = '$(WRISTIVO_BUNDLE_PREFIX)' c.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2' c.build_settings['INFOPLIST_KEY_UILaunchScreen_Generation'] = 'YES' c.build_settings['INFOPLIST_KEY_UIApplicationSceneManifest_Generation'] = 'YES' c.build_settings['SUPPORTS_MACCATALYST'] = 'NO' + c.build_settings['INFOPLIST_KEY_UISupportedInterfaceOrientations'] = 'UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight' + c.build_settings['INFOPLIST_KEY_NSLocalNetworkUsageDescription'] = 'Wristivo connects to the TV you select on your home network to pair and send remote commands.' end watch.build_configurations.each do |c| c.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = '$(WRISTIVO_BUNDLE_PREFIX).watchkitapp' @@ -83,6 +95,7 @@ watch.build_configurations.each do |c| c.build_settings['INFOPLIST_KEY_WKCompanionAppBundleIdentifier'] = '$(WRISTIVO_BUNDLE_PREFIX)' c.build_settings['INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp'] = 'NO' c.build_settings['SKIP_INSTALL'] = 'YES' + c.build_settings['INFOPLIST_KEY_NSMotionUsageDescription'] = 'Wristivo uses wrist movement during calibration and active gesture sessions to recognize TV remote directions.' end phone.add_dependency(watch) embed = phone.new_copy_files_build_phase('Embed Watch Content') diff --git a/Wristivo.xcodeproj/project.pbxproj b/Wristivo.xcodeproj/project.pbxproj index 1cf2024..a8bc25b 100644 --- a/Wristivo.xcodeproj/project.pbxproj +++ b/Wristivo.xcodeproj/project.pbxproj @@ -7,43 +7,49 @@ objects = { /* Begin PBXBuildFile section */ - 0E2482967C119992EFC9731BD93990F7 /* RemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */; }; - 1E53F8BEEE6D61CE71E682F5F7CF2147 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = E8753247C07C7B446324EE3A71951236 /* RemoteCore */; }; - 25F9B24B070225E1A1636D9985324EA6 /* WristivoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */; }; - 48FCE21F445E23DBA4A5DAD86D481954 /* GestureCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */; }; - 641657FFCFCFB81A75762E575E913AE5 /* WristivoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 665A97BD7636C338F128AF1499BAFECB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */; }; - 6BB86F38E5C018B41C6D4E71893A23CE /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */; }; - 75D76893CE6B6841CEA19A8434778F3B /* WristivoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */; }; - 85FBE8F84E0AD27608500C0D032A71F4 /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; - 94FE13C392F93E0209CA935E99858801 /* WatchBridgeWatch in Frameworks */ = {isa = PBXBuildFile; productRef = D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */; }; - A9EFDE4F5319C6E31CBC00B6066B1816 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; - BEADBF605154E2E19723E0E815F8606B /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; - C93002E459E3E8DFA2E2D6947143B1F2 /* WatchRemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */; }; - DB0584C988D0B59B83BFDC5328504D78 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */; }; - E6D8811706FA6D163D5383674346C819 /* WatchBridgePhone in Frameworks */ = {isa = PBXBuildFile; productRef = 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */; }; - EC287AAF374D4034FFC06B54CFCE298C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; - EC830E832AD41359BA3A5A23EA4C59A7 /* SamsungProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */; }; + 08AA4B4F51B8578AA23BECB6BC9F6397 /* GestureCore in Frameworks */ = {isa = PBXBuildFile; productRef = B2609C5AB45B5068ADCC82CD7DA1B244 /* GestureCore */; }; + 158215224EA47D11B5D12386B165D5F2 /* PhoneControl in Frameworks */ = {isa = PBXBuildFile; productRef = BD9590230442342C4BD0D6079093ECDF /* PhoneControl */; }; + 2589DC213E60F7600BF0C436CDA5A201 /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; + 2FC5651130B9696D74CFC93426A73099 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = BB680AC5AD94E2E1204C6737D7FBA374 /* RemoteCore */; }; + 3D36C48360A9A18C24F7290A5457885A /* RemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */; }; + 5C651CD2004E850FE569853F50C04511 /* WatchBridgeWatch in Frameworks */ = {isa = PBXBuildFile; productRef = 8B59743D623BC3D8F186FD3B820C6297 /* WatchBridgeWatch */; }; + 5E2DC3FB24B5F9350BFF6CC336B776D5 /* WristivoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */; }; + 763341B20334FB6F3E1A5A160BE4EACC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; + 7DF8D444491684E427CDDC4FDF1F2804 /* LivePhoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB00680D6186AAB2100EFEB7ECF9783 /* LivePhoneView.swift */; }; + 877FCF9404EBC38A60762247CF2859E4 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = D4C49CD50DE70AAEF4600DC056B49E4A /* RemoteCore */; }; + 8896DDC459CD9BD3CE4545109F120F2F /* PhoneAppComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF531D616DF67256FF6B45F261F406A /* PhoneAppComposition.swift */; }; + 8B60054882487EFA4DAD717BF0D621F8 /* MotionCapture in Frameworks */ = {isa = PBXBuildFile; productRef = 3C3315C9117F9BBC8BD3132FE6AD8BAB /* MotionCapture */; }; + 8EEE5A0A19C98EEFEC8DBC74AE82BB1F /* WatchRemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */; }; + 9BD72C6A3AEFD7D9C0B2FF7CEE24C0A9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; }; + B48A9F43BCE0ADE7E1F9191E47227AD7 /* SamsungProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = F584742F26C2A5D56A645DB85792BAF9 /* SamsungProtocol */; }; + BBE2A0BC1499B149246C5AB4DC0D26B2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */; }; + CE6C5EE656027D9716585E9B9FB3CAD3 /* TVTransport in Frameworks */ = {isa = PBXBuildFile; productRef = 0E02A167398F7B9444A964240446A2F1 /* TVTransport */; }; + D0B1C2E67B656B0F6763A79C075FEB9F /* WristivoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */; }; + D41E8416AD24AAE27C54B67A3D727EC0 /* SecureStorage in Frameworks */ = {isa = PBXBuildFile; productRef = E595FA69C204748E5F56A58A4075CFEC /* SecureStorage */; }; + D5B4C5EF77D63A7E0B24F361200BBCA1 /* WatchBridgePhone in Frameworks */ = {isa = PBXBuildFile; productRef = 9F6B7B14281EC69AA5E0EB5EA91BA14D /* WatchBridgePhone */; }; + DA792A51BBB9BFFB0B920F6AE8945286 /* WristivoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DC21C4C763518C2B4CE86A3B016CA7D7 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */; }; + E574D39D92E400166EF03143685FC69B /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - AD16755ABE4DE4DC55D93AE00CC51988 /* PBXContainerItemProxy */ = { + C24B943398CBBE0C80BE10FEB2EEB2EE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A6B9AD07CCD6814B2B51DF0509865B11 /* Project object */; proxyType = 1; - remoteGlobalIDString = E847B268313C04CBDC6F406F02397F95; + remoteGlobalIDString = 021B5C7BA1AB0214281EFF292D2534DB; remoteInfo = WristivoWatch; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 204A5DA2221ABB055B8F5E4427992562 /* Embed Watch Content */ = { + F502729A53397FDE6D51E77DB05FEF56 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( - 641657FFCFCFB81A75762E575E913AE5 /* WristivoWatch.app in Embed Watch Content */, + DA792A51BBB9BFFB0B920F6AE8945286 /* WristivoWatch.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; @@ -52,6 +58,7 @@ /* Begin PBXFileReference section */ 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CommandPresentation.swift; path = Apps/Shared/CommandPresentation.swift; sourceTree = ""; }; + 1CF531D616DF67256FF6B45F261F406A /* PhoneAppComposition.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PhoneAppComposition.swift; path = Apps/iPhone/PhoneAppComposition.swift; sourceTree = ""; }; 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteDemoView.swift; path = Apps/iPhone/RemoteDemoView.swift; sourceTree = ""; }; 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WatchRemoteDemoView.swift; path = Apps/Watch/WatchRemoteDemoView.swift; sourceTree = ""; }; 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Resources/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -59,31 +66,36 @@ 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = ""; }; 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WristivoApp.swift; path = Apps/iPhone/WristivoApp.swift; sourceTree = ""; }; 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WristivoWatchApp.swift; path = Apps/Watch/WristivoWatchApp.swift; sourceTree = ""; }; + 9EB00680D6186AAB2100EFEB7ECF9783 /* LivePhoneView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LivePhoneView.swift; path = Apps/iPhone/LivePhoneView.swift; sourceTree = ""; }; A2F3C9F86650749EDA3E56A620D8ED43 /* Wristivo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Wristivo.app; sourceTree = BUILT_PRODUCTS_DIR; }; D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 7E27479456E7603104693DA6C9A3E8DC /* Frameworks */ = { + 9A0328506C66955C68928883584FC484 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB0584C988D0B59B83BFDC5328504D78 /* Foundation.framework in Frameworks */, - 6BB86F38E5C018B41C6D4E71893A23CE /* RemoteCore in Frameworks */, - 48FCE21F445E23DBA4A5DAD86D481954 /* GestureCore in Frameworks */, - 94FE13C392F93E0209CA935E99858801 /* WatchBridgeWatch in Frameworks */, + DC21C4C763518C2B4CE86A3B016CA7D7 /* Foundation.framework in Frameworks */, + 2FC5651130B9696D74CFC93426A73099 /* RemoteCore in Frameworks */, + 08AA4B4F51B8578AA23BECB6BC9F6397 /* GestureCore in Frameworks */, + 5C651CD2004E850FE569853F50C04511 /* WatchBridgeWatch in Frameworks */, + 8B60054882487EFA4DAD717BF0D621F8 /* MotionCapture in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - EF34AAE38B8E8F5C777ABCE512830344 /* Frameworks */ = { + B6BC727B014317D62607D9893E94C16A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 665A97BD7636C338F128AF1499BAFECB /* Foundation.framework in Frameworks */, - 1E53F8BEEE6D61CE71E682F5F7CF2147 /* RemoteCore in Frameworks */, - EC830E832AD41359BA3A5A23EA4C59A7 /* SamsungProtocol in Frameworks */, - E6D8811706FA6D163D5383674346C819 /* WatchBridgePhone in Frameworks */, + BBE2A0BC1499B149246C5AB4DC0D26B2 /* Foundation.framework in Frameworks */, + 877FCF9404EBC38A60762247CF2859E4 /* RemoteCore in Frameworks */, + B48A9F43BCE0ADE7E1F9191E47227AD7 /* SamsungProtocol in Frameworks */, + D5B4C5EF77D63A7E0B24F361200BBCA1 /* WatchBridgePhone in Frameworks */, + CE6C5EE656027D9716585E9B9FB3CAD3 /* TVTransport in Frameworks */, + D41E8416AD24AAE27C54B67A3D727EC0 /* SecureStorage in Frameworks */, + 158215224EA47D11B5D12386B165D5F2 /* PhoneControl in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -123,6 +135,8 @@ 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */, 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */, 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */, + 9EB00680D6186AAB2100EFEB7ECF9783 /* LivePhoneView.swift */, + 1CF531D616DF67256FF6B45F261F406A /* PhoneAppComposition.swift */, 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */, 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */, 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */, @@ -142,37 +156,13 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 51E84BBEC4583A7F7C052F58F2EAAF78 /* Wristivo */ = { + 021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */ = { isa = PBXNativeTarget; - buildConfigurationList = 93F0F7B38CDEC6A75FDEC3AFA9C730CE /* Build configuration list for PBXNativeTarget "Wristivo" */; + buildConfigurationList = E08F800FF2710E80C01363715E0E7460 /* Build configuration list for PBXNativeTarget "WristivoWatch" */; buildPhases = ( - 2866B4A68DF0BA6A92BB55E3913CCFCE /* Sources */, - EF34AAE38B8E8F5C777ABCE512830344 /* Frameworks */, - 376501AED0FE93CCB5CD8877460F8CA6 /* Resources */, - 204A5DA2221ABB055B8F5E4427992562 /* Embed Watch Content */, - ); - buildRules = ( - ); - dependencies = ( - 75F3F1FC01E91504E0D509169C05FF60 /* PBXTargetDependency */, - ); - name = Wristivo; - packageProductDependencies = ( - E8753247C07C7B446324EE3A71951236 /* RemoteCore */, - ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */, - 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */, - ); - productName = Wristivo; - productReference = A2F3C9F86650749EDA3E56A620D8ED43 /* Wristivo.app */; - productType = "com.apple.product-type.application"; - }; - E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */ = { - isa = PBXNativeTarget; - buildConfigurationList = FA77B001FC3F57F319FC00E23843C2E4 /* Build configuration list for PBXNativeTarget "WristivoWatch" */; - buildPhases = ( - 9C18AE3B1CD5BEED88FD5DDB178CA25A /* Sources */, - 7E27479456E7603104693DA6C9A3E8DC /* Frameworks */, - BFD51CDC523FB0EDE831D838E1110BB2 /* Resources */, + 297FEE65C9762D2C35AADE096BD7DC0D /* Sources */, + 9A0328506C66955C68928883584FC484 /* Frameworks */, + A8500D0A0C0A200B2283E36BA000FA31 /* Resources */, ); buildRules = ( ); @@ -180,14 +170,42 @@ ); name = WristivoWatch; packageProductDependencies = ( - 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */, - 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */, - D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */, + BB680AC5AD94E2E1204C6737D7FBA374 /* RemoteCore */, + B2609C5AB45B5068ADCC82CD7DA1B244 /* GestureCore */, + 8B59743D623BC3D8F186FD3B820C6297 /* WatchBridgeWatch */, + 3C3315C9117F9BBC8BD3132FE6AD8BAB /* MotionCapture */, ); productName = WristivoWatch; productReference = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; productType = "com.apple.product-type.application"; }; + 2359978616F0049FAAE3392CD7B62BAC /* Wristivo */ = { + isa = PBXNativeTarget; + buildConfigurationList = DAB983D402EEB07761711034EDFB752E /* Build configuration list for PBXNativeTarget "Wristivo" */; + buildPhases = ( + 8B4CDB0E36F0113815A7A847F175C7E3 /* Sources */, + B6BC727B014317D62607D9893E94C16A /* Frameworks */, + D912C772FCF20DFEEACCCB5892CF2A70 /* Resources */, + F502729A53397FDE6D51E77DB05FEF56 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + D40F97C4AB59EDDFB840293D32ACBDC6 /* PBXTargetDependency */, + ); + name = Wristivo; + packageProductDependencies = ( + D4C49CD50DE70AAEF4600DC056B49E4A /* RemoteCore */, + F584742F26C2A5D56A645DB85792BAF9 /* SamsungProtocol */, + 9F6B7B14281EC69AA5E0EB5EA91BA14D /* WatchBridgePhone */, + 0E02A167398F7B9444A964240446A2F1 /* TVTransport */, + E595FA69C204748E5F56A58A4075CFEC /* SecureStorage */, + BD9590230442342C4BD0D6079093ECDF /* PhoneControl */, + ); + productName = Wristivo; + productReference = A2F3C9F86650749EDA3E56A620D8ED43 /* Wristivo.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -208,95 +226,78 @@ mainGroup = AA6CD4C83BB8EE679A0550AAAF6CA77D; minimizedProjectReferenceProxies = 0; packageReferences = ( - 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */, - C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */, + 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */, + 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */, + BFE41D2C320E50C462F22F5FEDD3115A /* XCLocalSwiftPackageReference "TVTransport" */, + 66C0235140BBC6C9725E5AE8CBD4707D /* XCLocalSwiftPackageReference "SecureStorage" */, + 56C6046BB877C84039B52D0053D808AB /* XCLocalSwiftPackageReference "PhoneControl" */, + C0546D2DA5F55B16D1C355CA3EB03B56 /* XCLocalSwiftPackageReference "MotionCapture" */, ); preferredProjectObjectVersion = 77; productRefGroup = E852DA970C83730E757E48147D2344A5 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 51E84BBEC4583A7F7C052F58F2EAAF78 /* Wristivo */, - E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */, + 2359978616F0049FAAE3392CD7B62BAC /* Wristivo */, + 021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 376501AED0FE93CCB5CD8877460F8CA6 /* Resources */ = { + A8500D0A0C0A200B2283E36BA000FA31 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - EC287AAF374D4034FFC06B54CFCE298C /* PrivacyInfo.xcprivacy in Resources */, + 763341B20334FB6F3E1A5A160BE4EACC /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - BFD51CDC523FB0EDE831D838E1110BB2 /* Resources */ = { + D912C772FCF20DFEEACCCB5892CF2A70 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - A9EFDE4F5319C6E31CBC00B6066B1816 /* PrivacyInfo.xcprivacy in Resources */, + 9BD72C6A3AEFD7D9C0B2FF7CEE24C0A9 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 2866B4A68DF0BA6A92BB55E3913CCFCE /* Sources */ = { + 297FEE65C9762D2C35AADE096BD7DC0D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 85FBE8F84E0AD27608500C0D032A71F4 /* CommandPresentation.swift in Sources */, - 0E2482967C119992EFC9731BD93990F7 /* RemoteDemoView.swift in Sources */, - 25F9B24B070225E1A1636D9985324EA6 /* WristivoApp.swift in Sources */, + 2589DC213E60F7600BF0C436CDA5A201 /* CommandPresentation.swift in Sources */, + 8EEE5A0A19C98EEFEC8DBC74AE82BB1F /* WatchRemoteDemoView.swift in Sources */, + 5E2DC3FB24B5F9350BFF6CC336B776D5 /* WristivoWatchApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 9C18AE3B1CD5BEED88FD5DDB178CA25A /* Sources */ = { + 8B4CDB0E36F0113815A7A847F175C7E3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - BEADBF605154E2E19723E0E815F8606B /* CommandPresentation.swift in Sources */, - C93002E459E3E8DFA2E2D6947143B1F2 /* WatchRemoteDemoView.swift in Sources */, - 75D76893CE6B6841CEA19A8434778F3B /* WristivoWatchApp.swift in Sources */, + E574D39D92E400166EF03143685FC69B /* CommandPresentation.swift in Sources */, + 7DF8D444491684E427CDDC4FDF1F2804 /* LivePhoneView.swift in Sources */, + 8896DDC459CD9BD3CE4545109F120F2F /* PhoneAppComposition.swift in Sources */, + 3D36C48360A9A18C24F7290A5457885A /* RemoteDemoView.swift in Sources */, + D0B1C2E67B656B0F6763A79C075FEB9F /* WristivoApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 75F3F1FC01E91504E0D509169C05FF60 /* PBXTargetDependency */ = { + D40F97C4AB59EDDFB840293D32ACBDC6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = WristivoWatch; - target = E847B268313C04CBDC6F406F02397F95 /* WristivoWatch */; - targetProxy = AD16755ABE4DE4DC55D93AE00CC51988 /* PBXContainerItemProxy */; + target = 021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */; + targetProxy = C24B943398CBBE0C80BE10FEB2EEB2EE /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 0AE74C51A5980FF7C345A772BFB29C55 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wristivo; - INFOPLIST_KEY_WKApplication = YES; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Debug; - }; 0B8C0DFEB77BFF8CE1C1F368E2F7E051 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */; @@ -352,7 +353,7 @@ }; name = Release; }; - 142429C6DF0CAB80FB8127E0D4DAAE55 /* Debug */ = { + 598823B10402AE1811A7F9BD64C3E9E4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -362,8 +363,10 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Wristivo connects to the TV you select on your home network to pair and send remote commands."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX)"; @@ -375,30 +378,6 @@ }; name = Debug; }; - 5D249F97F4CFC9569534BE2CB17D687A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGN_STYLE = Automatic; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Wristivo; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX)"; - SDKROOT = iphoneos; - SUPPORTS_MACCATALYST = NO; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; 7CF594D2A1A8B67A92FF51BEF2E85928 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */; @@ -461,7 +440,33 @@ }; name = Debug; }; - FEA9ACEBD5497DD7394F3F4E0965A32D /* Release */ = { + 80EDBFC744D81FC4D03D36A8A3830EE0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Wristivo connects to the TV you select on your home network to pair and send remote commands."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX)"; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + ACF4DD4FCB7A6DE481CB290113BAC91C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -470,6 +475,31 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_NSMotionUsageDescription = "Wristivo uses wrist movement during calibration and active gesture sessions to recognize TV remote directions."; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "$(WRISTIVO_BUNDLE_PREFIX).watchkitapp"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + C76FED1BA41E91991F7AFDB4933D2309 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Wristivo; + INFOPLIST_KEY_NSMotionUsageDescription = "Wristivo uses wrist movement during calibration and active gesture sessions to recognize TV remote directions."; INFOPLIST_KEY_WKApplication = YES; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "$(WRISTIVO_BUNDLE_PREFIX)"; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; @@ -497,20 +527,20 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 93F0F7B38CDEC6A75FDEC3AFA9C730CE /* Build configuration list for PBXNativeTarget "Wristivo" */ = { + DAB983D402EEB07761711034EDFB752E /* Build configuration list for PBXNativeTarget "Wristivo" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5D249F97F4CFC9569534BE2CB17D687A /* Release */, - 142429C6DF0CAB80FB8127E0D4DAAE55 /* Debug */, + 80EDBFC744D81FC4D03D36A8A3830EE0 /* Release */, + 598823B10402AE1811A7F9BD64C3E9E4 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FA77B001FC3F57F319FC00E23843C2E4 /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = { + E08F800FF2710E80C01363715E0E7460 /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = { isa = XCConfigurationList; buildConfigurations = ( - FEA9ACEBD5497DD7394F3F4E0965A32D /* Release */, - 0AE74C51A5980FF7C345A772BFB29C55 /* Debug */, + C76FED1BA41E91991F7AFDB4933D2309 /* Release */, + ACF4DD4FCB7A6DE481CB290113BAC91C /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -518,47 +548,83 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */ = { + 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/WristivoKit; }; - C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */ = { + 56C6046BB877C84039B52D0053D808AB /* XCLocalSwiftPackageReference "PhoneControl" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Adapters/PhoneControl; + }; + 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */ = { isa = XCLocalSwiftPackageReference; relativePath = Adapters/WatchBridge; }; + 66C0235140BBC6C9725E5AE8CBD4707D /* XCLocalSwiftPackageReference "SecureStorage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Adapters/SecureStorage; + }; + BFE41D2C320E50C462F22F5FEDD3115A /* XCLocalSwiftPackageReference "TVTransport" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Adapters/TVTransport; + }; + C0546D2DA5F55B16D1C355CA3EB03B56 /* XCLocalSwiftPackageReference "MotionCapture" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Adapters/MotionCapture; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 4BDCC823CAE7AF85DEB80F49EA2FA408 /* GestureCore */ = { + 0E02A167398F7B9444A964240446A2F1 /* TVTransport */ = { isa = XCSwiftPackageProductDependency; - package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; - productName = GestureCore; + package = BFE41D2C320E50C462F22F5FEDD3115A /* XCLocalSwiftPackageReference "TVTransport" */; + productName = TVTransport; }; - 680557163CFB560AC3E3060AB13041E5 /* WatchBridgePhone */ = { + 3C3315C9117F9BBC8BD3132FE6AD8BAB /* MotionCapture */ = { isa = XCSwiftPackageProductDependency; - package = C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */; - productName = WatchBridgePhone; + package = C0546D2DA5F55B16D1C355CA3EB03B56 /* XCLocalSwiftPackageReference "MotionCapture" */; + productName = MotionCapture; }; - 9E6C269953D3C4013B98995A8D81F8F4 /* RemoteCore */ = { + 8B59743D623BC3D8F186FD3B820C6297 /* WatchBridgeWatch */ = { isa = XCSwiftPackageProductDependency; - package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; - productName = RemoteCore; - }; - ABFF37EAE43C10491026D0A9DA7A4D16 /* SamsungProtocol */ = { - isa = XCSwiftPackageProductDependency; - package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; - productName = SamsungProtocol; - }; - D39AAD66EB3940123D81B61C66173DE9 /* WatchBridgeWatch */ = { - isa = XCSwiftPackageProductDependency; - package = C392D09E694FC793E9D75786813ADCCC /* XCLocalSwiftPackageReference "WatchBridge" */; + package = 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */; productName = WatchBridgeWatch; }; - E8753247C07C7B446324EE3A71951236 /* RemoteCore */ = { + 9F6B7B14281EC69AA5E0EB5EA91BA14D /* WatchBridgePhone */ = { isa = XCSwiftPackageProductDependency; - package = 612F0E86D70AC2A16A96EB622CA81827 /* XCLocalSwiftPackageReference "WristivoKit" */; + package = 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */; + productName = WatchBridgePhone; + }; + B2609C5AB45B5068ADCC82CD7DA1B244 /* GestureCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */; + productName = GestureCore; + }; + BB680AC5AD94E2E1204C6737D7FBA374 /* RemoteCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */; productName = RemoteCore; }; + BD9590230442342C4BD0D6079093ECDF /* PhoneControl */ = { + isa = XCSwiftPackageProductDependency; + package = 56C6046BB877C84039B52D0053D808AB /* XCLocalSwiftPackageReference "PhoneControl" */; + productName = PhoneControl; + }; + D4C49CD50DE70AAEF4600DC056B49E4A /* RemoteCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */; + productName = RemoteCore; + }; + E595FA69C204748E5F56A58A4075CFEC /* SecureStorage */ = { + isa = XCSwiftPackageProductDependency; + package = 66C0235140BBC6C9725E5AE8CBD4707D /* XCLocalSwiftPackageReference "SecureStorage" */; + productName = SecureStorage; + }; + F584742F26C2A5D56A645DB85792BAF9 /* SamsungProtocol */ = { + isa = XCSwiftPackageProductDependency; + package = 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */; + productName = SamsungProtocol; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A6B9AD07CCD6814B2B51DF0509865B11 /* Project object */; diff --git a/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme b/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme index 3625de8..2b52224 100644 --- a/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme +++ b/Wristivo.xcodeproj/xcshareddata/xcschemes/Wristivo.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> @@ -30,7 +30,7 @@ @@ -53,7 +53,7 @@ runnableDebuggingMode = "0"> @@ -70,7 +70,7 @@ runnableDebuggingMode = "0"> diff --git a/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme b/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme index 00d7a79..be2bb66 100644 --- a/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme +++ b/Wristivo.xcodeproj/xcshareddata/xcschemes/WristivoWatch.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> @@ -30,7 +30,7 @@ @@ -53,7 +53,7 @@ runnableDebuggingMode = "0"> @@ -70,7 +70,7 @@ runnableDebuggingMode = "0"> diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 53cde68..fdf5e29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,16 +24,18 @@ Apple TN3135 restricts ordinary watch apps' low-level networking, including URLS | Location | Responsibility | Current state | | --- | --- | --- | -| Apps/iPhone | Setup, manual remote, status and troubleshooting | Local demo UI | -| Apps/Watch | Session controls, touch remote, calibration UI | Local demo UI | +| Apps/iPhone | Setup, manual remote, status and troubleshooting | Live composition with explicit demo mode | +| Apps/Watch | Session controls, touch remote, calibration UI | Live composition with explicit demo mode | | Apps/Shared | Command presentation | Implemented | -| Packages/WristivoKit/RemoteCore | Commands, envelopes, admission and receipts | Foundation implementation | -| Packages/WristivoKit/SamsungProtocol | Key mapping and frame/URL encoding | Pure encoding, no socket | -| Packages/WristivoKit/GestureCore | Normalized directional excursion state machine | Algorithm seam, no raw sensor classifier | -| Adapters/TVTransport | iPhone URLSession WebSocket state machine | Planned | -| Adapters/WatchBridge | WCSession lifecycle and live delivery | Source/test package and injection seams ready; live WCSession implementation pending | -| Adapters/MotionCapture | Core Motion, coordinate calibration, normalization | Planned | -| Adapters/SecureStorage | Keychain token and trust persistence | Planned | +| Packages/WristivoKit/RemoteCore | Commands, envelopes, admission and receipts | Implemented and tested | +| Packages/WristivoKit/SamsungProtocol | Key mapping and frame/URL encoding | Encoding and bounded event parser | +| Packages/WristivoKit/GestureCore | Normalized directional excursion state machine | Conservative calibrated classifier; hardware thresholds unvalidated | +| Adapters/TVTransport | iPhone URLSession WebSocket state machine | Integrated prototype; hardware pending | +| Adapters/WatchBridge | WCSession lifecycle and live delivery | Live delegates, bounded messages and session negotiation | +| Adapters/MotionCapture | Core Motion, coordinate calibration, normalization | Integrated prototype; hardware pending | +| Adapters/SecureStorage | Keychain token and trust persistence | Integrated prototype; hardware pending | + +Adapters/PhoneControl owns testable iPhone setup orchestration and selected-TV metadata. Apps/iPhone/PhoneAppComposition constructs one TVConnectionActor, one authorization store and one phone WCSession delegate. It publishes only the selected opaque ID, display name and readiness to the watch. Phone and watch commands have separate session admission state in the same TV actor and share its serialized socket drain. Package target source directories are under Sources. Imported modules use the module names, not the package name. OS adapters stay platform-specific. The integrator adds targets/dependencies or file memberships when implementations arrive. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 9d82d1d..182012e 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -1,12 +1,12 @@ # Command contract v1 -Source of truth: `Packages/WristivoKit/Sources/RemoteCore`, including `WatchSession.swift`. The integration owner maintains these types. The v1 control-plane DTOs, codec and actor protocol are now defined. WCSession delivery and the production phone actor are still to be implemented; protocol definitions are not a functioning handshake. +Source of truth: `Packages/WristivoKit/Sources/RemoteCore`, including `WatchSession.swift`. The integration owner maintains these types. The v1 control-plane DTOs, codec and actor protocol are now defined. WCSession delivery and the production phone actor are integrated. Handshake behavior is covered by fake-session tests; real paired-device delivery is pending. ## Commands and envelope `RemoteCommand`: up, down, left, right, select, back, home, playPause, volumeUp, volumeDown, mute. Wire values are the enum's raw strings. Unknown values fail decoding. No raw arbitrary Samsung keys from UI or watch. -`CommandEnvelope`: `schemaVersion` (1), `id` (UUID), `sessionID` (UUID), `televisionID` (UUID), `sequence` (UInt64), `issuedAt` (Date), `expiresAt` (Date), `command`. UUIDs identify local opaque records, not TV serial numbers or network addresses. Date encoding on the future bridge is explicitly JSON milliseconds since Unix epoch; both encoders/decoders must set the same strategy. Package round-trip tests alone are not sufficient to validate that adapter. +`CommandEnvelope`: `schemaVersion` (1), `id` (UUID), `sessionID` (UUID), `televisionID` (UUID), `sequence` (UInt64), `issuedAt` (Date), `expiresAt` (Date), `command`. UUIDs identify local opaque records, not TV serial numbers or network addresses. Date encoding on the bridge is explicitly JSON milliseconds since Unix epoch; both encoders/decoders must set the same strategy. Package round-trip tests alone are not sufficient to validate that adapter. A session is issued by the phone for one selected paired TV, bound to the currently activated paired WCSession. The watch cannot select an arbitrary target by manufacturing an envelope. `BeginRemoteSessionRequest` returns `RemoteSessionGrant` with v1, sessionID, televisionID, issuedAt and maximumCommandLifetimeMilliseconds (1...1000); `EndRemoteSessionRequest` ends that exact session and returns `RemoteSessionEnded`. Errors use `RelayFailure`, which conforms to Error for Result return values. A new phone process, TV switch, re-pair, bridge deactivation or app stop invalidates old authorization. Starting a session must never issue a TV key. @@ -30,8 +30,15 @@ Existing `RemoteTransport.send(_ envelope: CommandEnvelope) async -> CommandRece The local `WatchPeerGeneration` is a fresh UUID per phone-side bridge activation. It is not Codable and must not cross the wire, persist, identify watch hardware, or come from the remote message. B serializes lifecycle delivery, awaits activation before forwarding requests, and passes the captured generation on every call. The phone actor rejects requests from non-current peers and ignores delayed deactivation for old peers. Repeated activation of the same peer is idempotent; activating a new peer invalidates previous grants. Repeated begin requests create fresh sessions and invalidate the previous session; B must serialize starts and ignore delayed replies after a lifecycle or local request-generation change. Stale end requests return sessionInvalidated and never end a newer session. After every suspension, the actor rechecks generation/session/target/deadline before sending. A protocol cannot enforce these semantics; A's tests must prove them. -GestureCore receives normalized inputs from the future MotionCapture adapter. Its thresholds are algorithm fixtures, not physically calibrated degrees or validated human-motion confidence. Sensor coordinate transforms and physical traces are required before claiming wrist-swipe recognition. Watch and phone UI depend on domain contracts, never Samsung frame strings. +GestureCore receives normalized rotation-rate inputs from the MotionCapture adapter. Its thresholds are algorithm fixtures, not physically calibrated degrees or validated human-motion confidence. Sensor coordinate transforms and physical traces are required before claiming wrist-swipe recognition. Watch and phone UI depend on domain contracts, never Samsung frame strings. ## Compatibility change policy An additive optional field may remain v1 only if existing behavior stays safe. New command semantics, changed time units or altered authorization require a new schema version and explicit negotiation. Unsupported versions fail closed. Mixed-version phone/watch installations must show update guidance and preserve accessible phone control. + + +## Integration decision — 2026-09-18 + +PhoneControl composes one TVConnectionActor with KeychainTVAuthorizationStore. `sendPhoneCommand` creates a local envelope; watch envelopes enter through `submitWatchCommand`. Separate local/watch session counters feed a single actor-owned drain, with exactly one admission consumption per command. Phone actions do not replace the watch grant. Peer replacement or grant rotation during an active write closes the ambiguous socket; reconnect is explicit and never replays. + +Configuration context now includes `isConnectionReady: Bool` (default false when absent) alongside the optional opaque ID/name. It never contains hostnames, certificates, tokens or commands. Old receivers that reject this additive key fail closed; deploy matching prototype phone/watch builds. A context snapshot is advisory, not authority: the phone actor still issues and validates every grant. diff --git a/docs/FIRST-DEVICE-PROTOTYPE.md b/docs/FIRST-DEVICE-PROTOTYPE.md new file mode 100644 index 0000000..ea8b355 --- /dev/null +++ b/docs/FIRST-DEVICE-PROTOTYPE.md @@ -0,0 +1,29 @@ +# First-device prototype + +Integration branch: `integration/first-device-prototype`. Use this branch's Xcode project, not the original agents' worktrees. The integration commit accompanies this document; record `git rev-parse HEAD` in every device test. No remote push or signing configuration has been changed by the integrator. + +## What is connected + +- iPhone live setup → stable opaque TV identity → iPhone Keychain → one TVConnectionActor → TLS WebSocket. +- Watch Connect → phone-issued session → watch touch commands → same TV actor. +- Watch physical neutral calibration → explicit Start → up to ten seconds of sampled wrist rotation → conservative classifier → same live relay. +- Foreground interruption, reachability loss, expiry and Stop halt motion. No command backlog or automatic replay. Wrist/crown changes clear calibration. +- Demo is an explicit mode. Entering phone Demo disconnects live TV control. Watch Demo stops motion and ends its local relay session. Demo touch actions do not contact the TV. + +## First run + +1. Open `Wristivo.xcodeproj`, select your signing team in Xcode and install the iPhone/watch apps on the paired test devices. Device installation/provisioning remains a user step; unsigned builds do not prove it works. +2. Keep both apps foreground initially. On iPhone enter the TV address from the TV's network settings. Tap **Pair / pair again**, grant local-network access if prompted, and approve **Wristivo** using the existing TV controls. +3. If TLS fails and **Review TV certificate** appears, inspect the selected address and fingerprint. Approval is a separate explicit action, followed by a fresh Pair action. The fingerprint from the same connection does not independently authenticate the TV. A changed fingerprint discards the old token and requires pairing again. There is no plaintext fallback. +4. Test one iPhone direction and observe the TV separately. Stop if the TV does not pair, the certificate cannot be reviewed, or a command does not behave as expected. Do not mark compatibility from a transport receipt. +5. On watch tap **Connect to iPhone**, then test one watch touch direction. Touch controls do not need motion permission or calibration. +6. Choose the wrist/crown orientation, hold still for one second and tap **Calibrate**. Calibration uses actual sensor samples and times out after eight seconds if stability is not achieved. Tap **Start** for a ten-second motion session. Test one deliberate direction, returning to neutral. Axis mappings and thresholds are hypotheses until evaluated on hardware. +7. Verify Stop, expiry, app inactivity, relay loss and re-arm behavior before broader gesture trials. Record exact metadata and results using Q's plan and the hardware template. + +Saved TV recovery uses only the stored token; it never silently falls back to pairing. If unavailable, unlock the phone, check network/permissions, then reconnect or pair explicitly. Forget disconnects and removes both the saved selection and security record. A deletion failure retains the identity so deletion can be retried. + +## Pending gates + +Physical TV pairing/TLS/key behavior; phone lock, wake and relaunch; watch sleep/inactivity; calibration and direction precision/recall; false triggers; battery; VoiceOver and layouts; signed install and archive privacy report. No app icons, App Store listing, distribution, trademark clearance or compatibility certification is completed by this integration. + +Keep Q's original dated plan as a planning snapshot. Its demo-only observations describe the earlier worktree; the integration validation and privacy audit describe this build. Only later measured device evidence can close hardware gates. diff --git a/docs/SECURITY-PRIVACY.md b/docs/SECURITY-PRIVACY.md index ef2a5d5..73ea363 100644 --- a/docs/SECURITY-PRIVACY.md +++ b/docs/SECURITY-PRIVACY.md @@ -1,6 +1,6 @@ # Security and privacy design -Status: policy for production adapters; foundation demo performs no network or sensor access. +Status: integrated prototype policy and implementation. See the dated [static privacy audit](test-evidence/2026-09-18-integration-privacy-audit.md); signed archive and physical behavior remain unverified. ## Threats and controls @@ -20,11 +20,11 @@ Self-signed certificates may be present on compatible TVs. Default trust must fa | Data | Location | Retention / deletion | | --- | --- | --- | -| TV nickname, local ID, address, capabilities | Phone local store (planned) | Until forget/reset; prevent unintended cloud sync | -| TV token and trust pin | iPhone Keychain (planned) | Until forget/revoke/re-pair; device-bound policy | -| Selected TV label and opaque ID | Watch context (planned) | Replace on selection; clear on forget | +| TV nickname, local ID, address, capabilities | iPhone device-only Keychain | Until forget/reset; prevent unintended cloud sync | +| TV token and trust pin | iPhone device-only Keychain | Until forget/revoke/re-pair; device-bound policy | +| Selected TV label and opaque ID | Watch application context | Replace on selection; clear on forget | | Raw motion | Watch memory during active session | Discard immediately after bounded processing; no default recording | -| Calibration preferences | Local watch/phone (planned) | User-resettable, no cloud sync by default | +| Calibration preferences | Watch memory for this launch | User-resettable, no cloud sync by default | | Diagnostic metrics | Local bounded buffer (planned) | No identifiers/tokens/raw motion; explicit export only | No backend, account, ads, tracking or analytics SDK is included. Do not declare future data practices as implemented. If motion recordings are required for QA, ask explicit informed consent, store locally outside git, define deletion time and never upload by default. Export only redacted developer-selected evidence. @@ -33,7 +33,7 @@ Keychain accessibility must be chosen against actual locked-phone relay needs. P ## Permissions and manifest -Production phone setup will need NSLocalNetworkUsageDescription. Motion capture needs an accurate motion purpose string where required by the adopted API/platform. Add permission strings when adapters are introduced; the demo requests neither. Manual unicast address entry is the baseline. SSDP/multicast discovery and Wake-on-LAN may need separate Apple networking entitlement review; do not enable blanket capabilities speculatively. NSBonjourServices entries apply only to the actual Bonjour service types used, not generic SSDP. +Phone setup declares NSLocalNetworkUsageDescription. Motion capture needs an accurate motion purpose string where required by the adopted API/platform. The watch declares NSMotionUsageDescription. Explicit demo controls request neither. Manual unicast address entry is the baseline. SSDP/multicast discovery and Wake-on-LAN may need separate Apple networking entitlement review; do not enable blanket capabilities speculatively. NSBonjourServices entries apply only to the actual Bonjour service types used, not generic SSDP. The current PrivacyInfo.xcprivacy describes the current demo's lack of tracking, collection and required-reason API declarations. It is not a release approval or a blanket policy for future code. Re-audit FileTimestamp/SystemBootTime/DiskSpace/UserDefaults and other required-reason APIs, dependency manifests and data flows after each adapter. Declare truthful approved reasons actually applicable to the implementation. The public privacy policy and App Store answers must match the final binary and include support/contact details before submission. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 2504f95..c7d22f8 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -40,3 +40,19 @@ Xcode required local Swift package manifest-cache write permission. Temporary sc - No Gitea remote creation, authentication, push or CI execution. Owner will create the private repository. Current app behavior is explicitly a local demo. The raw-motion pipeline, live adapters, final signing, icons, privacy policy and submission materials remain future milestones. Use Tests/Hardware/session-template.md for dated evidence when those pieces are implemented. Never convert a pending test to passed because a unit test or compilation succeeded. + +## Integrated live prototype — 2026-09-18 + +Branch: `integration/first-device-prototype`. This record supersedes the demo-only state above for the accompanying integration commit. Q's dated plan remains a historical read-only review, not physical acceptance. + +- **72 automated tests passed** across portable packages: WristivoKit 29 (RemoteCore 12, SamsungProtocol 7, GestureCore 10); WatchBridge 18 (Common 5, Phone 6, Watch 7); TVTransport 12; SecureStorage 6; PhoneControl 7. The combined check ran all packages; final focused runs covered the additional invalid-sample, corrupted-pin and explicit-certificate-consent regressions. +- `WRISTIVO_AGENT_BUILD=1 bash Scripts/check.sh --apps`: both generic unsigned iOS and watchOS app builds passed. Final source changes also passed an incremental iPhone build including its embedded watch target. +- `git diff --check`: passed. Privacy plist syntax: passed. +- Inspected generated app Info.plists: local-network purpose on phone, motion purpose on watch; both bundle PrivacyInfo.xcprivacy. No UIBackgroundModes or blanket NSAppTransportSecurity exception. Phone declares all four interface orientations. +- Fake tests exercise relay timeout with a suspended coordinator, bounded outstanding requests, stale peer/end/grant handling, reachability disarm, duplicate responses, finite TV writes, ready-socket revocation, stable saved-TV identity, partial deletion recovery, explicit pin consent, changed pin token removal and no replay after ambiguous sends. + +Host restrictions: nested manifest sandbox disabled only for these local dependency-free packages; caches redirected to temporary paths, with scoped Xcode manifest-cache permission. CoreSimulator service was unavailable, but generic device compilation succeeded. No simulator launch or UI runtime check is claimed. Compiler emitted the tooling notice that AppIntents metadata extraction was skipped; no AppIntents integration is included. + +Still **not run**: signed installation/Keychain tests, physical pairing or TLS, actual Samsung key behavior, real WatchConnectivity reachability/wake, locked or terminated phone, motion recognition/false triggers, accessibility runtime, battery/latency, signed archive/privacy report and App Store review. Prototype builds do not close those gates. + +Use [FIRST-DEVICE-PROTOTYPE.md](FIRST-DEVICE-PROTOTYPE.md) and [Q's plan](test-evidence/2026-09-18-q-hardware-release-plan.md) for the next steps. No code was pushed or distributed. diff --git a/docs/agents/README.md b/docs/agents/README.md index e25121a..326aefa 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -59,7 +59,8 @@ Before dispatch, I creates a branch/worktree from the same baseline commit and r | Workstream | Owner/task | Base SHA | State | Evidence / blockers | | --- | --- | --- | --- | --- | -| A–Q implementation | Unassigned | Set at dispatch | Planned | Requires hardware and agreed contracts | +| A–F implementation | Parallel implementation agents + integrator | a92698e / 3e78754 | Combined on integration/first-device-prototype | See VALIDATION.md; physical gates pending | +| Q hardware/release | Owner-run Q task | a92698e planning snapshot | Planning complete; device execution pending | docs/test-evidence/2026-09-18-q-hardware-release-plan.md | | I foundation | Coordinating engineer | Initial repository | Prepared | See VALIDATION.md | ## Merge acceptance diff --git a/docs/test-evidence/2026-09-18-integration-privacy-audit.md b/docs/test-evidence/2026-09-18-integration-privacy-audit.md new file mode 100644 index 0000000..ec774f8 --- /dev/null +++ b/docs/test-evidence/2026-09-18-integration-privacy-audit.md @@ -0,0 +1,20 @@ +# Integrated prototype static privacy/API audit — 2026-09-18 + +Scope: tracked source and local package graph on `integration/first-device-prototype`. This is a static review and unsigned build check, not a signed archive privacy report or App Store acceptance. + +| Surface | Implemented use / retention | Declaration / remaining evidence | +| --- | --- | --- | +| iPhone URLSession | User-selected TV, TLS WebSocket, ephemeral session; credentials never logged or sent to watch | NSLocalNetworkUsageDescription; real prompt/denial and ATS/TLS behavior pending | +| WatchConnectivity | Live bounded commands/replies; latest opaque TV ID, label and readiness in context | No tokens, hostnames, motion samples, certificates or command backlog in context | +| Core Motion | Watch device-motion rotation rates at requested 50 Hz, neutral calibration, bounded active sessions | NSMotionUsageDescription; samples in memory; physical permission/lifecycle behavior pending | +| Security / CryptoKit | Per-TV token/pin and selected-TV metadata on iPhone, SHA-256 certificate comparison | Device-only Keychain, candidate AfterFirstUnlock accessibility to evaluate locked-phone relay; no shared access group or cloud sync | +| Required-reason APIs | No direct UserDefaults/AppStorage, systemUptime/mach_absolute_time, file timestamps, disk-space queries or active-keyboard enumeration found in app/adapter/core sources | Empty NSPrivacyAccessedAPITypes retained for reviewed source; ContinuousClock and CM sample timestamps replace boot-time API calls | +| Collection / tracking | No developer backend, analytics, ads or third-party runtime SDK | Empty collected-data categories and tracking=false retained; archive and store answers require release review | + +App-generated data stays between the user's devices and selected TV. This does not excuse a release privacy policy. No raw motion persistence/export was added. Calibration and orientation are held in watch memory. Forget removes selected-TV metadata, token and trust record; device Keychain deletion needs signed hardware evidence. No background workout/audio/location entitlement or blanket ATS exception was introduced. + +The certificate approval screen explains unauthenticated first-use trust. Approval stores only the exact per-TV SHA-256 pin. Challenge failure stops the connection; a UI review candidate does not itself approve trust. Changed-certificate approval drops prior credentials. + +Source audit used searches for the required-reason API families plus manual review of every adapter and the two composition roots. Both app Info.plists and bundled PrivacyInfo.xcprivacy must be checked in the generated build; a signed release archive privacy report remains mandatory. + +References: [Apple required-reason API categories](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype), [Apple local-network privacy TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy). Local-network permission has no general status-query API; the UI does not claim that a failed socket proves denial. diff --git a/docs/test-evidence/2026-09-18-q-hardware-release-plan.md b/docs/test-evidence/2026-09-18-q-hardware-release-plan.md new file mode 100644 index 0000000..0a91c51 --- /dev/null +++ b/docs/test-evidence/2026-09-18-q-hardware-release-plan.md @@ -0,0 +1,50 @@ +# Q hardware and release review — 2026-09-18 + +Status: planning and read-only review. No physical-device, TV, accessibility-runtime, signed-install, or release-archive test was run. This record is for the integrated commit; it must not be treated as acceptance evidence for the current dirty worktree. + +## Review basis + +- Baseline reviewed: `a92698e1e6a6ab33c2e4cef47a4c54fb1f59ac20` (worktree also contains uncommitted changes from other workstreams). +- Documents: `PRODUCT`, `ARCHITECTURE`, `CONTRACTS`, `PLATFORM-FEASIBILITY`, `GESTURE-SPEC`, `SECURITY-PRIVACY`, `RELEASE-CHECKLIST`, `VALIDATION`, `BRAND-AND-IP`, `DEPENDENCIES-AND-ASSETS`, and `Tests/Hardware/session-template.md`. +- Static checks run: `git diff --check` passed; `plutil -lint Resources/PrivacyInfo.xcprivacy` passed. +- Existing validation says 14 package tests and unsigned generic iOS/watchOS builds passed, but no simulator launch, signed install, physical relay, motion, TV, accessibility runtime audit, or archive privacy report was performed. + +## Findings and release impact + +1. The hardware matrix has no dated session record yet. The candidate devices are only user-reported: Apple Watch Ultra 2, iPhone 13 Pro Max, and Samsung `QA85Q8FAAUXZN`. Record exact Settings/label values, OS/firmware builds, region, wrist/crown orientation, topology, commit, and debugger state before any result is called a pass. +2. The following cases are mandatory and currently pending: pairing denial/timeout/revocation, certificate change, locked or suspended phone, phone/watch reboot, inactive watch, network loss/packet loss, clock skew, duplicate/stale/wrong-session messages, TV-off behavior, and ordinary-motion false triggers. +3. `Resources/PrivacyInfo.xcprivacy` is syntactically valid and empty for the demo. It is not release evidence. After integration, audit both executable targets and every linked SDK/adapter for local-network, motion, Keychain, WatchConnectivity, required-reason APIs, data collection, and purpose strings; generate and inspect the archive privacy report. +4. The current iPhone UI has an accessibility hint written as `"Sends one (command.title) command"`; this will be spoken literally unless corrected by the owning UI agent. Verify the integrated UI with VoiceOver, Dynamic Type, contrast/non-color status cues, touch targets, haptic alternatives, and manual fallback. This is a review finding, not an implementation change by Q. +5. The current watch shell marks calibration complete in the demo path without physical calibration evidence. Keep the demo label and do not use it as recognition evidence. Integrated hardware testing must prove calibration, explicit arm, neutral return, disarm on inactivity, and re-arm after resume. +6. No compatibility or TV-execution claim is supportable yet. A socket write/transport receipt is not a TV acknowledgement. Release copy must name only tested model/firmware combinations and retain the independent Samsung/Apple disclaimer and provisional-brand review. +7. No beta distribution is authorized until developer membership, signing/provisioning, privacy/support materials, and archive review are available. Personal-team device testing is separate from TestFlight/App Store distribution. + +## Physical setup required before testing + +Use the owner's paired devices first, without assuming “latest” versions: + +- iPhone: exact model, iOS version and build from Settings; signed integrated app build number and commit. +- Watch: exact model, watchOS version/build, left or right wrist, crown toward/away from hand, paired iPhone, and whether the watch app was launched from the Home Screen with Xcode detached. +- TV: model code from Settings and physical label, region, firmware/Tizen version, TV power state, and whether the TV was factory-reset or already has a Wristivo authorization. +- Network: same LAN or guest/VLAN, Wi-Fi bands/AP isolation, IPv4/IPv6 if relevant, and whether the phone/watch/TV changed networks during a case. Redact addresses, tokens, certificate material, and household identifiers. +- Timing/evidence: synchronized video or a justified same-clock method; separate monotonic clocks must not be compared directly. Record warm/cold state, battery levels, display state, sensor rate, and debugger attached/detached. +- People/data: consenting testers, no personal accounts or copyrighted TV content in captures, raw motion outside git with an explicit deletion date, and redacted screenshots/video only. + +## Execution procedure for the integrated commit + +1. Record the session header from `Tests/Hardware/session-template.md`, integrated commit/build, exact hardware, network topology, permissions, and debugger state. Install cleanly, then repeat key lifecycle cases with Xcode detached. +2. Reset the test fixture. On the phone, grant local-network access only when requested. On the TV, start pairing and verify the displayed controller name is `Wristivo`; approve with the existing remote. Capture the user-visible state, never the token or certificate secret. +3. Repeat setup with local-network denial, TV denial, timeout, revoked authorization, forgotten TV, changed certificate, TV off, wrong network, and network interruption. Confirm recoverable UI, no queued command, no silent trust acceptance, and explicit re-pair where required. +4. Establish the phone touch baseline: send each supported touch command one at a time and verify the observed TV effect separately from the app receipt. Include back/home/playback/volume/mute and record unsupported/uncertain outcomes without retrying automatically. +5. Establish the watch touch relay baseline: reachable phone, locked phone, suspended phone, phone app terminated, watch display sleep, watch app inactive, phone reboot, watch reboot, and phone out of range. Record delivery, latency, failures, and whether any stale action executes after recovery. +6. For gestures, keep tuning and final evaluation samples separate. For each wrist/crown configuration, run 20 prompted trials per direction across at least five consenting testers where practical. Include return-to-neutral, held pose, reverse motion, diagonals, wrist lowering, conversation gestures, reaching, posture changes, and resting while armed. Count TP/FP/FN, wrong direction, duplicate, missed, and no-command trials; do not discard failures. +7. Interrupt every armed run with scene inactivity, notification/app switch, relay loss, target switch, sensor error, stop, expiry, and resume. Confirm sensing stops, pending candidates clear, no command is replayed, and explicit re-arm plus fresh neutral baseline is required. +8. Exercise protocol admission with controlled redacted fixtures or an approved test harness: future timestamp, stale/expired, lifetime over one second, time skew, duplicate ID, non-increasing sequence, wrong session/TV/schema, malformed/oversize payload, and reconnect after ambiguous transport failure. Confirm fail-closed rejection and no automatic replay. +9. Measure latency from motion completion to observed TV effect for warm and cold/wake paths. Report sample size, p50/p95, failures/timeouts, and timing method. Measure battery/thermal impact for 30 minutes of repeated use against an idle/disarmed baseline with starting/ending levels and display/sensor conditions. +10. Run accessibility review on phone and watch: VoiceOver rotor/order and labels, Larger Text/Dynamic Type, contrast and non-color state cues, 44-point phone controls, smallest declared watch layout, touch-only operation with motion disabled, haptic alternatives, and error recovery. Record each failure, not just a screenshot of the happy path. +11. Audit the final signed archive: generated privacy manifests and required-reason declarations, Info.plist usage strings, entitlements, SDK/dependency/asset provenance, demo labeling, unsupported claims, support URL/privacy policy, review notes, and signing/membership readiness. Do not distribute a beta until the release owner signs these gates. + +## Required result format + +Copy `Tests/Hardware/session-template.md` for each integrated commit. Every row must say pass, fail, or not run and include measured evidence. Add the gesture confusion matrix, latency and battery tables, privacy/asset/accessibility findings, exact build/commit, and unresolved risks. Keep all currently unrun cases marked **pending/not run**; unit tests, generic builds, simulated TV behavior, and a successful socket write cannot substitute for physical evidence. +