65 changed files with 5849 additions and 291 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Read README.md, docs/ARCHITECTURE.md, docs/CONTRACTS.md, your brief in docs/agen
## Shared ownership
The integrating engineer owns Package.swift, RemoteCore, Xcode project/schemes, Config, Scripts, AGENTS.md, root docs and integration composition. Propose a contract change with migration and test impact; do not silently edit shared types or fork duplicate types locally. Changes to source file membership must be handed to the integrator through the project generator. Do not regenerate the Xcode project from another agent's branch.
The integrating engineer owns all Package.swift manifests, RemoteCore, Xcode project/schemes, Config, Scripts, AGENTS.md, root docs and integration composition. Propose a contract change with migration and test impact; do not silently edit shared types or fork duplicate types locally. New source/test files inside an existing Swift package target are automatically included and may be added by the owning feature agent. New targets/dependencies or directly compiled app-source membership require the integrator's generator changes. Do not regenerate the Xcode project from another agent's branch. Never switch a checkout used by another running agent; use a separate worktree.
## Engineering rules
@@ -0,0 +1,37 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# MotionCapture integration proposal
Owner: workstream C (`feat/wrist-motion`).
This change introduces a watch-only adapter boundary without changing the
RemoteCore envelope. `MotionCaptureService` emits `GestureCore.GestureRecognition`
values (semantic `RemoteCommand`, confidence, and a local monotonic timestamp)
to the watch shell. It never creates envelopes, carries TV identity, or retries
commands.
## Proposed target membership
The integrator should add `Adapters/MotionCapture/Sources/MotionCapture/*.swift`
to a watchOS-only target that depends on the `GestureCore` package product. The
pure classifier and its tests remain in `Packages/WristivoKit` and therefore
build on macOS/iOS/watchOS. Core Motion must not be imported by the package.
## Lifecycle and permission contract
The watch shell calls `start(sessionID:)` only after calibration and explicit
foreground arming. `stop(reason:)` is idempotent and must be called for scene
inactivity, relay loss, target/session changes, expiry, cancellation, or sensor
failure. A subsequent start creates a fresh neutral baseline. Hardware
availability and start-handler failures are surfaced as typed errors; no
permission denial is treated as an empty sample stream. The watch target needs
the platform's truthful motion purpose string in its host configuration.
## Migration and tests
No wire/schema migration is required. Existing watch code can initially ignore
the new service and continue using touch controls. The integrator must add the
watch target membership and motion purpose string, then run the package tests
and a physical permission/foreground suspension check. Simulator and synthetic
traces validate logic only; no hardware recognition claim is made by this
workstream.
+3
View File
@@ -0,0 +1,3 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "MotionCapture", platforms: [.watchOS(.v10)], products: [.library(name: "MotionCapture", targets: ["MotionCapture"])], dependencies: [.package(path: "../../Packages/WristivoKit")], targets: [.target(name: "MotionCapture", dependencies: [.product(name: "GestureCore", package: "WristivoKit")])])
+20 -1
View File
@@ -1,3 +1,22 @@
# MotionCapture
Planned adapter: watch-only Core Motion acquisition and calibrated classification. No live implementation is present. Owner: workstream C in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
This workstream provides a watch-only `CMMotionManager` adapter and a pure
calibrated classifier. The adapter is foreground/lifecycle-bound: it stops on
inactive scene, relay loss, expiry, cancellation, or sensor failure. Samples
are bounded and in memory only; no recording mode or raw traces are included.
The orientation transform (wrist side and crown orientation) and thresholds are
versioned hypotheses pending physical validation. The package tests prove
state-machine behavior only and are not evidence of reliable watch recognition.
## Evaluation report (pending hardware)
No Apple Watch or TV has been tested for this workstream. Precision, recall,
incidental-movement false triggers, and the direction confusion matrix remain
`pending physical evaluation`; do not infer them from synthetic fixtures.
The required report should include per-direction TP/FP/FN, precision/recall,
duplicate count, false triggers per armed hour, and a 4x4 actual-vs-predicted
matrix split by wrist and crown orientation.
See `INTEGRATION-PROPOSAL.md` for target membership, permission-string, and
lifecycle integration requirements.
@@ -0,0 +1,46 @@
import Foundation
import CoreMotion
import GestureCore
public struct MotionCalibration: Codable, Sendable, Equatable {
public static let algorithmVersion = 1
public let algorithmVersion: Int
public let wrist: WristSide
public let crown: CrownOrientation
public let neutralRotationRate: SIMD3<Double>
public let scale: Double
public init(wrist: WristSide, crown: CrownOrientation,
neutralRotationRate: SIMD3<Double>, scale: Double = 2.0) {
self.algorithmVersion = Self.algorithmVersion; self.wrist = wrist; self.crown = crown
self.neutralRotationRate = neutralRotationRate; self.scale = max(0.1, scale)
}
}
/// Converts Core Motion rotation-rate samples into user-relative axes. This
/// does not integrate acceleration and therefore makes no absolute-position
/// claim. Orientation signs are a versioned hypothesis and require hardware
/// validation for each supported watch/crown configuration.
public struct MotionAxisNormalizer: Sendable {
public let calibration: MotionCalibration
private var baseline: SIMD3<Double>
public init(calibration: MotionCalibration) {
self.calibration = calibration
self.baseline = calibration.neutralRotationRate
}
public mutating func normalize(_ motion: CMDeviceMotion) -> NormalizedMotionSample {
let rate = motion.rotationRate
guard rate.x.isFinite, rate.y.isFinite, rate.z.isFinite, motion.timestamp.isFinite else {
// Propagate invalidity to the pure classifier; never clamp NaN into a direction.
return NormalizedMotionSample(timestamp: motion.timestamp, x: .nan, y: .nan, quality: 0)
}
var x = (rate.x - baseline.x) / calibration.scale
var y = (rate.y - baseline.y) / calibration.scale
if calibration.wrist == .left { x = -x }
if calibration.crown == .awayFromHand { y = -y }
return NormalizedMotionSample(timestamp: motion.timestamp,
x: max(-1, min(1, x)), y: max(-1, min(1, y)), quality: 1)
}
}
@@ -0,0 +1,92 @@
import Foundation
@preconcurrency import CoreMotion
import GestureCore
public enum MotionCaptureStopReason: Sendable { case user, inactive, disconnected, expired, cancelled, sensorFailure }
public enum MotionCaptureError: Error, Sendable, Equatable { case unavailable, permissionDenied, failed(String) }
/// Foreground-only sensor owner. Calibration and armed capture never overlap.
@MainActor
public final class MotionCaptureService {
public typealias RecognitionHandler = @Sendable (GestureRecognition) -> Void
public typealias FailureHandler = @Sendable (MotionCaptureError) -> Void
private let manager = CMMotionManager()
private var recognizer = CalibratedGestureRecognizer()
private var expiryTask: Task<Void, Never>?
private var generation = UUID()
public var isAvailable: Bool { manager.isDeviceMotionAvailable }
public init() {}
public func calibrate(wrist: WristSide, crown: CrownOrientation,
completion: @escaping @MainActor (Result<MotionCalibration, MotionCaptureError>) -> Void) {
stop(reason: .cancelled)
guard isAvailable else { completion(.failure(.unavailable)); return }
let capture = generation
var samples: [SIMD3<Double>] = []
var firstTimestamp: Double?
var previousTimestamp: Double?
manager.deviceMotionUpdateInterval = 1 / 50
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
MainActor.assumeIsolated {
guard let self, self.generation == capture else { return }
guard error == nil, let motion else {
self.stop(reason: .sensorFailure); completion(.failure(.failed("Motion permission or sensor unavailable"))); return
}
let r = motion.rotationRate
let v = SIMD3(r.x, r.y, r.z)
guard r.x.isFinite, r.y.isFinite, r.z.isFinite else { return }
let stable = max(abs(r.x), abs(r.y), abs(r.z)) < 0.15
if !stable || (previousTimestamp.map { motion.timestamp - $0 > 0.12 || motion.timestamp <= $0 } ?? false) {
samples.removeAll(); firstTimestamp = nil
}
previousTimestamp = motion.timestamp
guard stable else { return }
if firstTimestamp == nil { firstTimestamp = motion.timestamp }
samples.append(v)
if motion.timestamp - (firstTimestamp ?? motion.timestamp) >= 1, samples.count >= 30 {
let baseline = samples.reduce(SIMD3<Double>(repeating: 0), +) / Double(samples.count)
self.stop(reason: .user)
completion(.success(MotionCalibration(wrist: wrist, crown: crown, neutralRotationRate: baseline)))
}
}
}
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled, let self, self.generation == capture else { return }
self.stop(reason: .expired); completion(.failure(.failed("Calibration timed out. Hold still and retry.")))
}
}
public func start(sessionID: UUID, calibration: MotionCalibration, duration: TimeInterval = 10,
onRecognition: @escaping RecognitionHandler, onFailure: @escaping FailureHandler,
onExpiry: @escaping @MainActor () -> Void = {}) throws {
stop(reason: .cancelled)
guard isAvailable else { throw MotionCaptureError.unavailable }
guard duration.isFinite, duration > 0, duration <= 30 else { throw MotionCaptureError.failed("Invalid duration") }
let capture = generation
var normalizer = MotionAxisNormalizer(calibration: calibration)
var armed = false
manager.deviceMotionUpdateInterval = 1 / 50
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
MainActor.assumeIsolated {
guard let self, self.generation == capture else { return }
guard error == nil, let motion else {
self.stop(reason: .sensorFailure); onFailure(.failed("Motion permission or sensor unavailable")); return
}
if !armed { self.recognizer.arm(sessionID: sessionID, at: motion.timestamp); armed = true }
if let result = self.recognizer.process(normalizer.normalize(motion)) { onRecognition(result) }
}
}
expiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration))
guard !Task.isCancelled, let self, self.generation == capture else { return }
self.stop(reason: .expired); onExpiry()
}
}
public func stop(reason: MotionCaptureStopReason) {
generation = UUID(); manager.stopDeviceMotionUpdates(); expiryTask?.cancel(); expiryTask = nil; recognizer.disarm()
}
public func expire() { stop(reason: .expired) }
public func setInactive() { stop(reason: .inactive) }
public func setDisconnected() { stop(reason: .disconnected) }
}
+20
View File
@@ -0,0 +1,20 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "PhoneControl",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "PhoneControl", targets: ["PhoneControl"])],
dependencies: [
.package(path: "../../Packages/WristivoKit"),
.package(path: "../TVTransport"), .package(path: "../SecureStorage")
],
targets: [
.target(name: "PhoneControl", dependencies: [
.product(name: "RemoteCore", package: "WristivoKit"),
.product(name: "SamsungProtocol", package: "WristivoKit"),
"TVTransport", "SecureStorage"
]),
.testTarget(name: "PhoneControlTests", dependencies: ["PhoneControl"])
]
)
+7
View File
@@ -0,0 +1,7 @@
# PhoneControl
Integrator-owned orchestration for the iPhone. `LivePhoneController` is main-actor UI state with injected TV actor, security repository and selected-TV store. It never implements command admission or watch grant issuance. `KeychainSelectedTelevisionStore` keeps a stable opaque TV UUID and address in device-only Keychain storage, separate from credentials.
Pairing is explicit. Restore/reconnect only uses an existing token. Failed metadata reads/writes do not start pairing; changing the address requires forgetting the old target. Forget disconnects first and retains the identity if deleting credentials fails. Trust failure only offers a certificate candidate; explicit approval stores a pin and requires a separate pairing action. Demo mode disconnects live transport.
Tests inject actors and stores; they do not access the real Keychain or network. Native composition lives in Apps/iPhone/PhoneAppComposition.swift. Device lock/relaunch and Keychain accessibility remain physical gates.
@@ -0,0 +1,225 @@
import Combine
import Foundation
import RemoteCore
import SamsungProtocol
import SecureStorage
import TVTransport
public protocol PhoneTVControlling: Actor {
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
func disconnect() async
func stateUpdates() async -> AsyncStream<TVConnectionState>
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt
}
extension TVConnectionActor: PhoneTVControlling { }
public protocol PhoneAuthorizationManaging: Sendable {
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws
func forgetTelevision(_ televisionID: UUID) async throws
}
extension KeychainTVAuthorizationStore: PhoneAuthorizationManaging { }
public enum PhoneControlError: Error, Equatable, Sendable {
case invalidAddress, forgetFirst, storageUnavailable
}
public struct CertificateCandidate: Equatable, Sendable, Identifiable {
public let id: UUID
public let host: String
public let sha256: Data
public var fingerprint: String { sha256.map { String(format: "%02X", $0) }.joined(separator: ":") }
}
/// Composition state only. Authorization, admission and TV writes belong to the
/// injected TV actor; this object never retries a command or creates a watch grant.
@MainActor
public final class LivePhoneController: ObservableObject {
@Published public private(set) var television: SelectedTelevision?
@Published public private(set) var connection: TVConnectionState = .disconnected
@Published public private(set) var certificate: CertificateCandidate?
@Published public private(set) var isBusy = false
@Published public private(set) var notice: String?
@Published public private(set) var error: String?
@Published public private(set) var isDemo = false
public var onSelectionChanged: ((SelectedTelevision?, Bool) -> Void)?
public var isReady: Bool {
guard !isDemo, let television else { return false }
return connection == .ready(televisionID: television.id)
}
private let transport: any PhoneTVControlling
private let authorization: any PhoneAuthorizationManaging
private let selection: any SelectedTelevisionStoring
private var observation: Task<Void, Never>?
private var restored = false
private var acceptingCertificate = false
public init(transport: any PhoneTVControlling, authorization: any PhoneAuthorizationManaging,
selection: any SelectedTelevisionStoring) {
self.transport = transport
self.authorization = authorization
self.selection = selection
}
deinit { observation?.cancel() }
public func start() async {
guard !isBusy, !isDemo, !restored else { return }
observeConnection()
isBusy = true
defer { isBusy = false }
do {
television = try await selection.load()
restored = true
publish()
if let television {
acceptingCertificate = true
defer { acceptingCertificate = false }
_ = try SamsungRemoteEncoder.endpoint(host: television.host)
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
publish()
}
} catch {
self.error = "Saved TV information could not be read. Unlock the iPhone and try again."
}
}
public func pair(address: String) async {
guard !isBusy, !isDemo else { return }
clearFeedback()
let host = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard (try? SamsungRemoteEncoder.endpoint(host: host)) != nil else {
error = "Enter a bare TV address or hostname from its network settings, without a URL or port."
return
}
isBusy = true
defer { isBusy = false; acceptingCertificate = false }
do {
// Load before writing: a failed/locked read must not orphan an existing identity.
if !restored { television = try await selection.load(); restored = true }
if let existing = television, existing.host != host {
error = "Forget the saved TV before selecting a different address."
return
}
let selected = television ?? SelectedTelevision(host: host)
try await selection.save(selected)
television = selected
certificate = nil
acceptingCertificate = true
observeConnection()
connection = .connecting(televisionID: selected.id)
publish()
connection = await transport.pair(target: .init(id: selected.id, host: host), sessionID: UUID())
publish()
} catch {
self.error = "TV information could not be saved securely. Unlock the iPhone and try again. No pairing was started."
}
}
public func reconnect() async {
guard !isBusy, !isDemo else { return }
if !restored { await start(); return }
guard let television else { return }
isBusy = true
clearFeedback()
certificate = nil
acceptingCertificate = true
defer { isBusy = false; acceptingCertificate = false }
connection = .connecting(televisionID: television.id)
publish()
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
publish()
}
/// A rejected TLS challenge can only produce a review candidate, never trust.
public func receiveUntrustedCertificate(host: String, sha256: Data) {
guard !isDemo, acceptingCertificate, let television,
normalized(host) == normalized(television.host), sha256.count == 32 else { return }
certificate = CertificateCandidate(id: television.id, host: television.host, sha256: sha256)
}
public func approveCertificate(_ candidate: CertificateCandidate) async {
guard !isBusy, !isDemo, candidate == certificate, candidate.id == television?.id else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
await transport.disconnect()
connection = .disconnected
publish()
do {
try await authorization.approveCertificate(sha256: candidate.sha256, for: candidate.id, approvedAt: Date())
certificate = nil
notice = "Certificate saved for this TV. Tap Pair / pair again, then approve Wristivo on the TV."
} catch { self.error = "Certificate approval could not be saved. Unlock the iPhone and try again." }
}
public func send(_ command: RemoteCommand) async {
guard !isBusy, isReady else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
let receipt = await transport.sendPhoneCommand(command)
switch receipt.status {
case .sentToTransport: notice = "Sent to TV transport. The TV's response is not confirmed."
case .unavailable: error = "Delivery is uncertain. Check the TV before issuing a new action. Nothing will be replayed."
case .expired: error = "The command expired and was not sent."
case .rejected, .duplicate: error = "The command was rejected. Reconnect before trying a new action."
}
}
public func forget() async {
guard !isBusy, !isDemo else { return }
isBusy = true
clearFeedback()
defer { isBusy = false }
await transport.disconnect()
connection = .disconnected
certificate = nil
publish()
do {
let saved: SelectedTelevision?
if let television { saved = television } else { saved = try await selection.load() }
if let saved { try await authorization.forgetTelevision(saved.id) }
// Keep metadata if secret deletion fails so the user can retry deletion.
try await selection.delete()
television = nil
restored = true
notice = "Saved TV access and certificate approval were removed from this iPhone."
publish()
} catch { self.error = "The saved TV could not be completely removed. Unlock the iPhone and retry Forget TV." }
}
public func setDemo(_ enabled: Bool) async {
guard !isBusy, enabled != isDemo else { return }
isBusy = true
await transport.disconnect()
connection = .disconnected
certificate = nil
isDemo = enabled
clearFeedback()
publish()
isBusy = false
if !enabled { await reconnect() }
}
private func observeConnection() {
guard observation == nil else { return }
let transport = transport
observation = Task { [weak self] in
let stream = await transport.stateUpdates()
for await state in stream {
guard !Task.isCancelled, let self else { return }
self.connection = state
self.publish()
}
}
}
private func publish() { onSelectionChanged?(isDemo ? nil : television, isReady) }
private func clearFeedback() { notice = nil; error = nil }
private func normalized(_ host: String) -> String { host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) }
}
@@ -0,0 +1,52 @@
import Foundation
import SecureStorage
public struct SelectedTelevision: Codable, Equatable, Sendable {
public let id: UUID
public let name: String
public let host: String
public init(id: UUID = UUID(), name: String = "My TV", host: String) {
self.id = id
self.name = name
self.host = host
}
}
public protocol SelectedTelevisionStoring: Sendable {
func load() async throws -> SelectedTelevision?
func save(_ television: SelectedTelevision) async throws
func delete() async throws
}
/// Device-only metadata keeps the opaque identity stable across app launches.
/// This separate Keychain item contains no TV token or certificate.
public actor KeychainSelectedTelevisionStore: SelectedTelevisionStoring {
private let client: any KeychainClient
private let service = "com.wristivo.selected-television"
private let account = "selected"
public init(client: any KeychainClient = SystemKeychainClient()) { self.client = client }
public func load() async throws -> SelectedTelevision? {
do {
let data = try client.read(service: service, account: account)
guard data.count <= 4096 else { throw PhoneControlError.storageUnavailable }
return try JSONDecoder().decode(SelectedTelevision.self, from: data)
} catch KeychainError.notFound { return nil }
}
public func save(_ television: SelectedTelevision) async throws {
let data = try JSONEncoder().encode(television)
do { try client.update(service: service, account: account, data: data) }
catch KeychainError.notFound {
try client.add(service: service, account: account, data: data,
accessibility: .afterFirstUnlockThisDeviceOnly)
}
}
public func delete() async throws {
do { try client.delete(service: service, account: account) }
catch KeychainError.notFound { }
}
}
@@ -0,0 +1,146 @@
import Foundation
import Testing
import RemoteCore
import TVTransport
@testable import PhoneControl
private actor FakeTV: PhoneTVControlling {
var paired: [TelevisionTarget] = []
var connected: [TelevisionTarget] = []
var commands: [RemoteCommand] = []
var disconnects = 0
var pairHook: (@Sendable () async -> Void)?
func setPairHook(_ hook: @escaping @Sendable () async -> Void) { pairHook = hook }
var status: CommandReceipt.Status = .sentToTransport
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
paired.append(target)
if let pairHook { await pairHook(); return .untrusted(televisionID: target.id) }
return .ready(televisionID: target.id)
}
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
connected.append(target); return .ready(televisionID: target.id)
}
func disconnect() async { disconnects += 1 }
func stateUpdates() async -> AsyncStream<TVConnectionState> { AsyncStream { $0.finish() } }
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
commands.append(command); return .init(id: UUID(), status: status)
}
func failWrites() { status = .unavailable }
}
private actor MemorySelection: SelectedTelevisionStoring {
var value: SelectedTelevision?
var failSave = false
init(_ value: SelectedTelevision? = nil, failSave: Bool = false) { self.value = value; self.failSave = failSave }
func load() async throws -> SelectedTelevision? { value }
func save(_ television: SelectedTelevision) async throws {
if failSave { throw PhoneControlError.storageUnavailable }; value = television
}
func delete() async throws { value = nil }
}
private actor MemoryAuthorization: PhoneAuthorizationManaging {
var forgotten: [UUID] = []
var approvals = 0
let failDelete: Bool
init(failDelete: Bool = false) { self.failDelete = failDelete }
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws { approvals += 1 }
func forgetTelevision(_ televisionID: UUID) async throws {
if failDelete { throw PhoneControlError.storageUnavailable }; forgotten.append(televisionID)
}
}
@MainActor
struct PhoneControlTests {
@Test func restorationUsesSavedIdentityAndNeverPairs() async {
let selected = SelectedTelevision(host: "tv.local")
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(selected))
await controller.start()
#expect(controller.television == selected)
#expect(controller.isReady)
#expect(await tv.paired.isEmpty)
#expect(await tv.connected.map(\.id) == [selected.id])
await controller.pair(address: selected.host)
#expect(await tv.paired.map(\.id) == [selected.id])
}
@Test func storageFailurePreventsPairingAndDifferentTargetRequiresForget() async {
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(failSave: true))
await controller.pair(address: "tv.local")
#expect(await tv.paired.isEmpty)
#expect(controller.error != nil)
let existing = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "old.local")))
await existing.pair(address: "new.local")
#expect(await tv.paired.isEmpty)
#expect(existing.television?.host == "old.local")
}
@Test func partialForgetKeepsIdentityForDeletionRetryAndDisconnects() async throws {
let selected = SelectedTelevision(host: "tv.local")
let store = MemorySelection(selected)
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(failDelete: true), selection: store)
await controller.start()
await controller.forget()
#expect(!controller.isReady)
#expect(controller.error != nil)
#expect(try await store.load() == selected)
#expect(await tv.disconnects == 1)
}
@Test func forgetDeletesSecurityAndSelection() async throws {
let selected = SelectedTelevision(host: "tv.local")
let store = MemorySelection(selected)
let auth = MemoryAuthorization()
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: store)
await controller.start(); await controller.forget()
#expect(controller.television == nil)
#expect(try await store.load() == nil)
#expect(await auth.forgotten == [selected.id])
}
@Test func ambiguousWriteIsNotReplayedAndDemoDisconnectsLiveRoute() async {
let tv = FakeTV()
await tv.failWrites()
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "tv.local")))
await controller.start(); await controller.send(.right)
#expect(await tv.commands == [.right])
#expect(controller.error?.contains("uncertain") == true)
await controller.setDemo(true); await controller.send(.left)
#expect(controller.isDemo && !controller.isReady)
#expect(await tv.commands == [.right])
#expect(await tv.disconnects == 1)
}
@Test func certificateRequiresExplicitApprovalAndDoesNotRetryPairing() async throws {
let auth = MemoryAuthorization()
let tv = FakeTV()
let controller = LivePhoneController(transport: tv, authorization: auth, selection: MemorySelection())
await tv.setPairHook {
await controller.receiveUntrustedCertificate(host: "other.local", sha256: Data(repeating: 1, count: 32))
await controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 2, count: 32))
}
await controller.pair(address: "tv.local")
let candidate = try #require(controller.certificate)
#expect(candidate.sha256 == Data(repeating: 2, count: 32))
#expect(await auth.approvals == 0)
await controller.approveCertificate(candidate)
#expect(await auth.approvals == 1)
#expect(await tv.paired.count == 1)
#expect(!controller.isReady)
#expect(controller.certificate == nil)
await controller.approveCertificate(candidate)
#expect(await auth.approvals == 1)
}
@Test func unsolicitedCertificateIsNotOfferedOrApproved() async {
let auth = MemoryAuthorization()
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: MemorySelection(.init(host: "tv.local")))
await controller.start()
controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 1, count: 32))
#expect(controller.certificate == nil)
#expect(await auth.approvals == 0)
}
}
+21
View File
@@ -0,0 +1,21 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SecureStorage",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "SecureStorage", targets: ["SecureStorage"])],
dependencies: [.package(path: "../TVTransport")],
targets: [
.target(
name: "SecureStorage",
dependencies: [
.product(name: "TVTransport", package: "TVTransport")
]
),
.testTarget(
name: "SecureStorageTests",
dependencies: ["SecureStorage"]
)
]
)
+22 -1
View File
@@ -1,3 +1,24 @@
# SecureStorage
Planned adapter: iPhone Keychain token and per-TV trust storage. No live implementation is present. Owner: workstream E in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
This adapter stores the TV token, certificate pin, and pairing state in the
iPhone Keychain under an opaque television UUID. It is injected through
`KeychainClient` for deterministic tests and conforms to the frozen
`TVAuthorizationStore` protocol from `TVTransport`.
The candidate accessibility is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
it is the narrowest device-only option that can support a phone-lock relay after
the first unlock. It is unavailable before the first unlock after reboot and has
not yet been validated on a signed iPhone. The stronger
`WhenUnlockedThisDeviceOnly` option remains available for a deployment that
does not require locked-phone relay.
Certificate trust is fail-closed. A self-signed certificate is accepted only
after an explicit user approval supplied to `approveCertificate`; the exact
SHA-256 leaf fingerprint is then pinned to that opaque TV ID. A changed
fingerprint returns `requiresReapproval` and is never silently accepted. The
stored source records that first-use fingerprinting came from an unauthenticated
connection, so it is not presented as proof of TV identity.
`forgetTelevision` removes the complete Keychain record. `deleteToken` revokes
pairing while preserving the trust pin; `deleteTrust` removes trust material.
Neither tokens nor trust records are sent to WatchConnectivity or cloud storage.
@@ -0,0 +1,259 @@
import Foundation
import Security
import TVTransport
/// The smallest Security-framework seam needed by the repository. Tests inject
/// this protocol; the production implementation below is the only type that
/// calls Security directly.
public protocol KeychainClient: Sendable {
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws
func read(service: String, account: String) throws -> Data
func update(service: String, account: String, data: Data) throws
func delete(service: String, account: String) throws
}
public enum KeychainAccessibility: Sendable, Equatable {
/// Strongest default, but unavailable while the phone is locked.
case whenUnlockedThisDeviceOnly
/// Candidate for a phone-lock relay after the first device unlock.
/// It is still unavailable before the first unlock after reboot.
case afterFirstUnlockThisDeviceOnly
fileprivate var secValue: CFString {
switch self {
case .whenUnlockedThisDeviceOnly: return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
}
}
}
public enum KeychainError: Error, Sendable, Equatable {
case notFound
case locked
case unexpectedStatus(Int32)
}
public struct SystemKeychainClient: KeychainClient, Sendable {
public init() {}
public func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data,
kSecAttrAccessible: accessibility.secValue
]
try check(SecItemAdd(query as CFDictionary, nil))
}
public func read(service: String, account: String) throws -> Data {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: CFTypeRef?
try check(SecItemCopyMatching(query as CFDictionary, &result))
guard let data = result as? Data else { throw KeychainError.unexpectedStatus(errSecInternalComponent) }
return data
}
public func update(service: String, account: String, data: Data) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary))
}
public func delete(service: String, account: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemDelete(query as CFDictionary))
}
private func check(_ status: OSStatus) throws {
guard status != errSecSuccess else { return }
switch status {
case errSecItemNotFound: throw KeychainError.notFound
case errSecInteractionNotAllowed: throw KeychainError.locked
default: throw KeychainError.unexpectedStatus(status)
}
}
}
public enum PairingState: String, Codable, Sendable, Equatable {
case paired
case requiresReapproval
case revoked
}
public enum CertificateTrustValidation: Sendable, Equatable {
case notPinned
case accepted
case requiresReapproval
}
public struct TVSecurityRecord: Codable, Sendable, Equatable {
public let token: String?
public let certificateSHA256: Data?
public let pairingState: PairingState
public let trustApprovedAt: Date?
/// A fingerprint learned from the same unauthenticated first connection
/// does not authenticate the TV. Keep this limitation explicit in storage.
public let fingerprintSource: String
public init(
token: String? = nil,
certificateSHA256: Data? = nil,
pairingState: PairingState = .paired,
trustApprovedAt: Date? = nil,
fingerprintSource: String = "unauthenticated-first-use"
) {
self.token = token
self.certificateSHA256 = certificateSHA256
self.pairingState = pairingState
self.trustApprovedAt = trustApprovedAt
self.fingerprintSource = fingerprintSource
}
}
/// iPhone-only Keychain persistence keyed by the opaque television UUID.
/// Tokens and trust records never cross the WatchConnectivity boundary.
public actor KeychainTVAuthorizationStore: TVAuthorizationStore {
public static let service = "com.wristivo.tv-security"
private let client: any KeychainClient
private let accessibility: KeychainAccessibility
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
client: any KeychainClient = SystemKeychainClient(),
accessibility: KeychainAccessibility = .afterFirstUnlockThisDeviceOnly
) {
self.client = client
self.accessibility = accessibility
}
public func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
guard let record = try optionalRecord(for: televisionID) else {
return StoredTVAuthorization(token: nil)
}
guard record.pairingState == .paired else {
return StoredTVAuthorization(token: nil, trust: trustPolicy(for: record))
}
return StoredTVAuthorization(token: record.token, trust: trustPolicy(for: record))
}
public func saveToken(_ token: String, for televisionID: UUID) async throws {
guard !token.isEmpty, token.utf8.count <= 4096 else { throw KeychainError.unexpectedStatus(errSecParam) }
var record = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
record = TVSecurityRecord(
token: token,
certificateSHA256: record.certificateSHA256,
pairingState: .paired,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteToken(for televisionID: UUID) async throws {
guard var record = try optionalRecord(for: televisionID) else { return }
record = TVSecurityRecord(
certificateSHA256: record.certificateSHA256,
pairingState: .revoked,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteTrust(for televisionID: UUID) async throws {
guard let record = try optionalRecord(for: televisionID) else { return }
let replacement = TVSecurityRecord(
token: record.token,
pairingState: record.token == nil ? .revoked : .requiresReapproval,
fingerprintSource: "no-approved-certificate"
)
if record.token == nil {
try deleteRecord(for: televisionID)
} else {
try upsert(replacement, for: televisionID)
}
}
public func approveCertificate(
sha256 fingerprint: Data,
for televisionID: UUID,
approvedAt: Date = Date()
) async throws {
guard fingerprint.count == 32 else { throw KeychainError.unexpectedStatus(errSecParam) }
let old = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
let replacement = TVSecurityRecord(
token: old.certificateSHA256 == fingerprint ? old.token : nil,
certificateSHA256: fingerprint,
pairingState: .paired,
trustApprovedAt: approvedAt,
fingerprintSource: "unauthenticated-first-use"
)
try upsert(replacement, for: televisionID)
}
public func validateCertificate(
sha256 fingerprint: Data,
for televisionID: UUID
) async throws -> CertificateTrustValidation {
guard let record = try optionalRecord(for: televisionID), let pinned = record.certificateSHA256 else {
return .notPinned
}
return pinned == fingerprint && record.pairingState == .paired ? .accepted : .requiresReapproval
}
public func securityRecord(for televisionID: UUID) async throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
public func forgetTelevision(_ televisionID: UUID) async throws {
try deleteRecord(for: televisionID)
}
private func trustPolicy(for record: TVSecurityRecord) -> TVTrustPolicy {
guard let fingerprint = record.certificateSHA256, fingerprint.count == 32 else { return .systemDefault }
return .pinnedLeafCertificateSHA256(fingerprint)
}
private func readRecord(for televisionID: UUID) throws -> TVSecurityRecord {
let data = try client.read(service: Self.service, account: televisionID.uuidString)
guard data.count <= 16 * 1024 else { throw KeychainError.unexpectedStatus(errSecDecode) }
let record = try decoder.decode(TVSecurityRecord.self, from: data)
if let pin = record.certificateSHA256, pin.count != 32 { throw KeychainError.unexpectedStatus(errSecDecode) }
if let token = record.token, token.isEmpty || token.utf8.count > 4096 { throw KeychainError.unexpectedStatus(errSecDecode) }
return record
}
private func optionalRecord(for televisionID: UUID) throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
private func upsert(_ record: TVSecurityRecord, for televisionID: UUID) throws {
let data = try encoder.encode(record)
do { try client.update(service: Self.service, account: televisionID.uuidString, data: data) }
catch KeychainError.notFound { try client.add(service: Self.service, account: televisionID.uuidString, data: data, accessibility: accessibility) }
}
private func deleteRecord(for televisionID: UUID) throws {
do { try client.delete(service: Self.service, account: televisionID.uuidString) }
catch KeychainError.notFound { return }
}
}
@@ -0,0 +1,119 @@
import Foundation
import Testing
@testable import SecureStorage
private final class MockKeychainClient: KeychainClient, @unchecked Sendable {
enum Failure: Error, Equatable { case locked, unexpected }
var values: [String: Data] = [:]
var failure: Failure?
var adds = 0
var updates = 0
var deletes = 0
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] == nil else { throw KeychainError.unexpectedStatus(-25299) }
values[account] = data; adds += 1
}
func read(service: String, account: String) throws -> Data {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard let value = values[account] else { throw KeychainError.notFound }
return value
}
func update(service: String, account: String, data: Data) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] != nil else { throw KeychainError.notFound }
values[account] = data; updates += 1
}
func delete(service: String, account: String) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values.removeValue(forKey: account) != nil else { throw KeychainError.notFound }
deletes += 1
}
}
@Suite("Keychain TV authorization store")
struct KeychainTVAuthorizationStoreTests {
@Test func addReadUpdateDeleteAndNotFound() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client, accessibility: .whenUnlockedThisDeviceOnly)
let tv = UUID()
#expect(try await store.securityRecord(for: tv) == nil)
try await store.saveToken("token-1", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-1")
try await store.saveToken("token-2", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-2")
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
try await store.forgetTelevision(tv)
}
@Test func certificateApprovalPinsAndChangedCertificateRequiresReapproval() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let first = Data(repeating: 0x01, count: 32)
let changed = Data(repeating: 0x02, count: 32)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .notPinned)
try await store.approveCertificate(sha256: first, for: tv)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .accepted)
#expect(try await store.validateCertificate(sha256: changed, for: tv) == .requiresReapproval)
#expect((try await store.authorization(for: tv)).trust == .pinnedLeafCertificateSHA256(first))
}
@Test func revokePreservesTrustButForgetRemovesBoth() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let fingerprint = Data(repeating: 0x03, count: 32)
try await store.saveToken("token", for: tv)
try await store.approveCertificate(sha256: fingerprint, for: tv)
try await store.deleteToken(for: tv)
#expect((try await store.authorization(for: tv)).token == nil)
#expect(try await store.validateCertificate(sha256: fingerprint, for: tv) == .requiresReapproval)
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
}
@Test func lockedAndUnexpectedSecurityErrorsArePropagated() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
client.setFailure(.locked)
await #expect(throws: KeychainError.locked) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
client.setFailure(.unexpected)
await #expect(throws: KeychainError.unexpectedStatus(-1)) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
}
}
private extension MockKeychainClient {
func setFailure(_ failure: Failure?) { self.failure = failure }
}
@Test func newCertificateApprovalCannotReusePreviousToken() async throws {
let store = KeychainTVAuthorizationStore(client: MockKeychainClient())
let tv = UUID(), first = Data(repeating: 1, count: 32), changed = Data(repeating: 2, count: 32)
try await store.approveCertificate(sha256: first, for: tv)
try await store.saveToken("synthetic-token", for: tv)
try await store.approveCertificate(sha256: changed, for: tv)
let stored = try await store.authorization(for: tv)
#expect(stored.token == nil)
#expect(stored.trust == .pinnedLeafCertificateSHA256(changed))
}
@Test func malformedStoredPinDoesNotFallBackToSystemTrust() async throws {
let client = MockKeychainClient()
let id = UUID()
let invalid = TVSecurityRecord(token: "synthetic", certificateSHA256: Data([1]))
try client.add(service: KeychainTVAuthorizationStore.service, account: id.uuidString,
data: JSONEncoder().encode(invalid), accessibility: .whenUnlockedThisDeviceOnly)
let store = KeychainTVAuthorizationStore(client: client)
await #expect(throws: (any Error).self) { try await store.authorization(for: id) }
}
@@ -0,0 +1,48 @@
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
# TVTransport integration proposal
Status: proposed by workstream A; integration owner review required before app wiring.
## Required seam with SecureStorage (E)
`TVTransport` consumes an injected `TVAuthorizationStore`. The store is keyed only by
the shared opaque television UUID and returns a token plus a scoped TLS policy. The
transport never knows about Keychain APIs, never caches the token outside the active
connection attempt, and never sends either value to Watch.
The proposed values are:
- `StoredTVAuthorization(token: String?, trust: TVTrustPolicy)`
- `TVTrustPolicy.systemDefault`
- `TVTrustPolicy.pinnedLeafCertificateSHA256(Data)` for a fingerprint that a user
explicitly approved for this television
- async load, save-token, delete-token and delete-trust operations
Workstream E should conform its Keychain repository to this protocol. It should use
device-only accessibility, report locked/unavailable Keychain as an error, and keep
the consent record needed before returning a pin. A first-contact certificate is
never accepted merely because the transport observed it. Certificate capture and
the consent UI remain an integration task after physical TLS evidence exists.
Migration impact: none; no persisted format exists. If E chooses a differently
named public contract, the integrator can adapt it at composition or rename this
consumer-owned seam without changing actor behavior.
## Build wiring request
Add the `Adapters/TVTransport` library product to the iPhone target only. It depends
on the existing `RemoteCore` and `SamsungProtocol` products plus Apple Foundation,
Security and CryptoKit for the iOS URLSession adapter. Do not add it to watchOS.
The local package manifest exists only so workstream A can compile and run focused
tests before project-generator integration.
The pure Samsung event parser belongs to the already owned `SamsungProtocol` target;
no new shared domain type or command value is required.
## Test impact
Before merge, run the adapter package tests plus the root `WristivoKit` tests. The
integrator should then regenerate source membership and run both application builds.
Physical pairing, certificate behavior and token persistence remain explicitly
untested until the owner completes the manual hardware script.
+55
View File
@@ -0,0 +1,55 @@
# Wristivo physical pairing and TLS feasibility spike
Status: owner run pending. This procedure is evidence collection, not a compatibility claim.
Record the exact iPhone model/iOS build, TV model from Settings and firmware/Tizen
version, Wi-Fi topology, build commit, and whether a debugger is attached. The current
`QA85Q8FAAUXZN` model report is unverified until the TV label and Settings are checked.
Redact the TV address, pairing token, certificate details that identify the household,
and client lists before saving evidence. Keep raw captures outside git.
## Pair, deny and timeout
1. Put the iPhone and TV on the same LAN. Keep the existing TV remote available and
turn the TV on. Enter only the host name or address in the Wristivo setup screen.
2. Grant Local Network access when the setup flow explains why it is needed. Start
pairing explicitly. The candidate endpoint is TLS `wss` on port `8002`; do not try
port `8001` or a plaintext fallback.
3. Confirm that the TV prompt/device list identifies the controller as **Wristivo**.
Record the exact prompt text and firmware. Approve once, then issue one supported
direction from the iPhone and record that a socket submission is only a transport
receipt until the TV visibly responds.
4. Repeat with Deny. Confirm the app enters a recoverable denied state and sends no
command. Repeat with no TV approval until the 30-second pairing timeout; confirm
it does not become ready.
## TLS policy
First run with normal platform certificate and hostname validation. Record whether the
TV presents a publicly trusted chain, a self-signed certificate, or another failure;
capture only a redacted fingerprint and trust result. A failed trust check must remain
failed. Do not install a global ATS exception or accept-all challenge handler.
If the device presents a self-signed certificate and a product experiment is still
warranted, stop and obtain explicit user approval for that specific TV fingerprint.
Only a stored, per-TV leaf SHA-256 pin may be tested with the scoped policy; a pin
learned from the same unauthenticated first connection does not prove first-contact
identity. A changed certificate must require reapproval/re-pairing.
## Reconnect, revoke and lifecycle cases
After approval, terminate/relaunch the app and reboot the phone once. Verify a stored
token reconnects without pairing, including the locked/first-unlock behavior that the
chosen Keychain accessibility permits. Revoke the controller in the TV's device list,
then reconnect: the app must become unauthorized, delete the rejected token, and ask
for explicit pairing again. The app's Forget TV action must delete both token and trust
record; removing the controller on the TV is a separate TV-menu action.
Run a TV-off case, a LAN interruption, a selected-TV switch, and a phone suspend/reopen.
No command may be replayed or drained after reconnect. A command whose deadline expires
while disconnected must be reported unavailable/expired and discarded.
Record every offered directional, Select, Back, Home, playback and volume key separately.
Hide or disable any key without evidence. Do not test service-menu, factory, power or
purchase actions. Do not mark this TV or any Samsung model compatible from a simulator,
fake socket, unit test or successful WebSocket write alone.
+25
View File
@@ -0,0 +1,25 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "TVTransport",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "TVTransport", targets: ["TVTransport"])],
dependencies: [.package(path: "../../Packages/WristivoKit")],
targets: [
.target(
name: "TVTransport",
dependencies: [
.product(name: "RemoteCore", package: "WristivoKit"),
.product(name: "SamsungProtocol", package: "WristivoKit")
]
),
.testTarget(
name: "TVTransportTests",
dependencies: [
"TVTransport",
.product(name: "RemoteCore", package: "WristivoKit")
]
)
]
)
+11 -1
View File
@@ -1,3 +1,13 @@
# TVTransport
Planned adapter: iPhone-only paired local TV networking. No live implementation is present. Owner: workstream A in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
Workstream A implementation for iPhone-only paired local TV networking. The actor
owns pairing authorization, bounded event parsing, one-at-a-time command submission,
admission/deadline checks and generation cancellation. It never claims that a socket
write changed the TV. `SamsungEventParser` and `SamsungRemoteEncoder` remain pure
package seams; URLSession and TLS handling stay in this adapter.
The local `Package.swift` is a focused test harness. The integrator must add the
adapter to the iPhone target through the project generator, conform workstream E's
Keychain/trust repository to the injected seam in `INTEGRATION-PROPOSAL.md`, and keep
the adapter out of watchOS. Physical pairing and certificate behavior are untested
until the owner completes `MANUAL-PAIRING.md`.
@@ -0,0 +1,561 @@
import Foundation
import RemoteCore
import SamsungProtocol
/// The single phone-side owner of TV connection state and command admission.
/// A completed send reports transport submission only; it is not TV confirmation.
public actor TVConnectionActor: RemoteTransport, PhoneCommandCoordinating {
private enum ConnectionMode { case pair, reconnect }
private struct PendingSend {
let envelope: CommandEnvelope
let isWatch: Bool
let controlEpoch: UUID
let generation: UUID
let monotonicDeadline: TimeInterval
let continuation: CheckedContinuation<CommandReceipt, Never>
}
private let socketFactory: any TVWebSocketFactory
private let authorizationStore: any TVAuthorizationStore
private let clock: TVTransportClock
private let configuration: TVTransportConfiguration
private let parser: SamsungEventParser
private var state: TVConnectionState = .disconnected {
didSet { for observer in observers.values { observer.yield(state) } }
}
private var observers: [UUID: AsyncStream<TVConnectionState>.Continuation] = [:]
private var watchPeer: WatchPeerGeneration?
private var watchSession: UUID?
private var watchAdmission: CommandAdmission?
private var controlEpoch = UUID()
private var phoneSession = UUID()
private var phoneSequence: UInt64 = 0
private var activeWrite: UUID?
private var watchRequestRevision = UUID()
private var credentialMaintenance = false
private var receiveTask: Task<Void, Never>?
private var target: TelevisionTarget?
private var socket: (any TVWebSocket)?
private var admission: CommandAdmission?
private var generation = UUID()
private var queue: [PendingSend] = []
private var drainTask: Task<Void, Never>?
public init(
socketFactory: any TVWebSocketFactory,
authorizationStore: any TVAuthorizationStore,
clock: TVTransportClock = .system(),
configuration: TVTransportConfiguration = .init()
) {
self.socketFactory = socketFactory
self.authorizationStore = authorizationStore
self.clock = clock
self.configuration = configuration
self.parser = SamsungEventParser(maximumMessageBytes: configuration.maximumEventBytes)
}
public func connectionState() -> TVConnectionState { state }
public func stateUpdates() -> AsyncStream<TVConnectionState> {
let id = UUID()
let (stream, continuation) = AsyncStream<TVConnectionState>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[id] = continuation
continuation.yield(state)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(id) }
}
return stream
}
private func removeObserver(_ id: UUID) { observers[id] = nil }
public func watchBridgeDidActivate(peer: WatchPeerGeneration) async {
guard watchPeer != peer else { return }
watchPeer = peer
await invalidateWatchControl()
}
public func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async {
guard watchPeer == peer else { return }
watchPeer = nil
await invalidateWatchControl()
}
public func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer else { return .failure(.init(code: .sessionInvalidated)) }
let revision = UUID()
watchRequestRevision = revision
await invalidateWatchControl()
guard watchPeer == peer, watchRequestRevision == revision else { return .failure(.init(code: .sessionInvalidated)) }
guard case .ready(let id) = state, socket != nil else { return .failure(.init(code: .unavailable)) }
let session = UUID()
watchSession = session
watchAdmission = CommandAdmission(sessionID: session, televisionID: id)
return .success(.init(sessionID: session, televisionID: id, issuedAt: clock.wallNow()))
}
public func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionEnded, RelayFailure> {
do { try request.validate() } catch { return .failure(.init(code: .unsupportedSchema)) }
guard watchPeer == peer, watchSession == request.sessionID else { return .failure(.init(code: .sessionInvalidated)) }
await invalidateWatchControl()
return .success(.init(sessionID: request.sessionID))
}
public func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) async -> CommandReceipt {
guard watchPeer == peer, watchSession == envelope.sessionID else {
return .init(id: envelope.id, status: .rejected)
}
return await enqueue(envelope, isWatch: true)
}
public func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
guard let target else { return .init(id: UUID(), status: .unavailable) }
guard phoneSequence < UInt64.max else { return .init(id: UUID(), status: .rejected) }
phoneSequence += 1
let now = clock.wallNow()
return await send(.init(sessionID: phoneSession, televisionID: target.id,
sequence: phoneSequence, issuedAt: now, expiresAt: now.addingTimeInterval(0.8), command: command))
}
private func invalidateWatchControl() async {
controlEpoch = UUID()
watchSession = nil
watchAdmission = nil
// A write already submitted cannot be recalled. Close before another grant.
if activeWrite != nil { await disconnect() }
}
/// Starts an explicit first-pair attempt without sending a stored token.
@discardableResult
public func pair(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .pair)
}
/// Reconnects only when a stored token is available. It never falls back to pairing.
@discardableResult
public func connect(target newTarget: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
await establish(target: newTarget, sessionID: sessionID, mode: .reconnect)
}
/// Invalidates the current control session and closes its socket so an in-flight
/// old-session write cannot continue on a connection authorized for a new session.
/// Call `connect(target:sessionID:)` explicitly to establish the replacement.
public func invalidateSession() async {
await disconnect()
}
public func disconnect() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
target = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
/// Deletes the rejected/revoked pairing token while retaining an approved trust pin.
public func revokeAuthorization() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
guard let televisionID else { return }
do {
try await authorizationStore.deleteToken(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
/// Deletes both token and trust material for a complete forget-TV operation.
public func forgetTelevision() async {
guard !credentialMaintenance else { return }
credentialMaintenance = true
defer { credentialMaintenance = false }
let televisionID = target?.id
await disconnectKeepingTargetForDeletion()
if let televisionID {
do {
try await authorizationStore.deleteToken(for: televisionID)
try await authorizationStore.deleteTrust(for: televisionID)
} catch {
state = .unavailable(televisionID: televisionID, failure: .credentialStore)
}
}
// Forget removes the selected target as well as its authorization material.
target = nil
}
public func send(_ envelope: CommandEnvelope) async -> CommandReceipt {
await enqueue(envelope, isWatch: false)
}
private func enqueue(_ envelope: CommandEnvelope, isWatch: Bool) async -> CommandReceipt {
guard case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
socket != nil,
queue.count < configuration.maximumQueuedCommands else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let remaining = envelope.expiresAt.timeIntervalSince(clock.wallNow())
guard remaining > 0 else {
return CommandReceipt(id: envelope.id, status: .expired)
}
let commandGeneration = generation
let deadline = clock.monotonicNow() + remaining
return await withCheckedContinuation { continuation in
queue.append(PendingSend(
envelope: envelope,
isWatch: isWatch,
controlEpoch: controlEpoch,
generation: commandGeneration,
monotonicDeadline: deadline,
continuation: continuation
))
startDrainingIfNeeded(generation: commandGeneration)
}
}
private func establish(
target newTarget: TelevisionTarget,
sessionID: UUID,
mode: ConnectionMode
) async -> TVConnectionState {
guard !credentialMaintenance else { return .unavailable(televisionID: newTarget.id, failure: .credentialStore) }
let attempt = UUID()
generation = attempt
cancelCommandGeneration()
admission = nil
target = newTarget
let oldSocket = socket
socket = nil
state = .connecting(televisionID: newTarget.id)
await oldSocket?.close()
guard generation == attempt else { return state }
let stored: StoredTVAuthorization
do {
stored = try await authorizationStore.authorization(for: newTarget.id)
} catch {
guard generation == attempt else { return state }
state = .unavailable(televisionID: newTarget.id, failure: .credentialStore)
return state
}
guard generation == attempt else { return state }
let token: String?
switch mode {
case .pair:
token = nil
case .reconnect:
guard let existingToken = stored.token, !existingToken.isEmpty else {
state = .unauthorized(televisionID: newTarget.id)
return state
}
token = existingToken
}
let endpoint: URL
do {
endpoint = try SamsungRemoteEncoder.endpoint(host: newTarget.host, token: token)
} catch {
state = .unavailable(televisionID: newTarget.id, failure: .invalidTarget)
return state
}
let openedSocket: any TVWebSocket
do {
openedSocket = try await socketFactory.open(
endpoint: endpoint,
trust: stored.trust,
maximumMessageBytes: configuration.maximumEventBytes
)
} catch {
guard generation == attempt else { return state }
state = connectionFailureState(error, televisionID: newTarget.id)
return state
}
guard generation == attempt else {
await openedSocket.close()
return state
}
socket = openedSocket
state = .awaitingApproval(televisionID: newTarget.id)
let timeoutDeadline = clock.monotonicNow() + configuration.pairingTimeout
while generation == attempt {
let remaining = timeoutDeadline - clock.monotonicNow()
guard remaining > 0 else {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
let eventData: Data
do {
eventData = try await receive(from: openedSocket, timeout: remaining)
} catch is PairingTimeoutError {
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
} catch {
guard generation == attempt else {
await openedSocket.close()
return state
}
return await failConnection(connectionFailureState(error, televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
guard generation == attempt else {
await openedSocket.close()
return state
}
let event: SamsungRemoteEvent
do {
event = try parser.parse(eventData)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .malformedResponse), socket: openedSocket, attempt: attempt)
}
switch event {
case .unknown:
continue
case .connected(let returnedToken):
let usableToken = returnedToken ?? token
guard let usableToken, !usableToken.isEmpty else {
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
if let returnedToken {
do {
try await authorizationStore.saveToken(returnedToken, for: newTarget.id)
} catch {
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .credentialStore), socket: openedSocket, attempt: attempt)
}
}
guard generation == attempt else {
await openedSocket.close()
return state
}
phoneSession = sessionID
phoneSequence = 0
admission = CommandAdmission(sessionID: sessionID, televisionID: newTarget.id)
state = .ready(televisionID: newTarget.id)
receiveTask = Task { await self.monitor(openedSocket, attempt: attempt, televisionID: newTarget.id) }
return state
case .unauthorized:
switch mode {
case .reconnect:
try? await authorizationStore.deleteToken(for: newTarget.id)
return await failConnection(.unauthorized(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .pair:
return await failConnection(.denied(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
}
case .timedOut:
return await failConnection(.timedOut(televisionID: newTarget.id), socket: openedSocket, attempt: attempt)
case .error:
return await failConnection(.unavailable(televisionID: newTarget.id, failure: .socket), socket: openedSocket, attempt: attempt)
}
}
await openedSocket.close()
return state
}
private func startDrainingIfNeeded(generation commandGeneration: UUID) {
guard drainTask == nil else { return }
drainTask = Task { await self.drain(generation: commandGeneration) }
}
private func drain(generation commandGeneration: UUID) async {
while !Task.isCancelled,
generation == commandGeneration,
!queue.isEmpty {
let pending = queue.removeFirst()
guard pending.generation == commandGeneration else {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
continue
}
let receipt = await process(pending)
pending.continuation.resume(returning: receipt)
}
if generation == commandGeneration {
drainTask = nil
if !queue.isEmpty { startDrainingIfNeeded(generation: commandGeneration) }
}
}
private func process(_ pending: PendingSend) async -> CommandReceipt {
let envelope = pending.envelope
guard generation == pending.generation,
case .ready(let televisionID) = state,
televisionID == envelope.televisionID,
let liveSocket = socket,
var currentAdmission = (pending.isWatch ? watchAdmission : admission),
!pending.isWatch || pending.controlEpoch == controlEpoch else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
let decision = currentAdmission.admit(envelope, now: clock.wallNow())
if pending.isWatch { watchAdmission = currentAdmission } else { admission = currentAdmission }
switch decision {
case .rejected(.duplicate), .rejected(.outOfOrder):
return CommandReceipt(id: envelope.id, status: .duplicate)
case .rejected(.expired), .rejected(.stale):
return CommandReceipt(id: envelope.id, status: .expired)
case .rejected:
return CommandReceipt(id: envelope.id, status: .rejected)
case .accepted:
break
}
let frame: Data
do {
frame = try SamsungRemoteEncoder.clickFrame(for: envelope.command)
} catch {
return CommandReceipt(id: envelope.id, status: .rejected)
}
// Admission has been consumed. Recheck both wall and receiver-local monotonic
// deadlines immediately before the one allowed socket submission.
guard generation == pending.generation,
state == .ready(televisionID: televisionID),
clock.wallNow() < envelope.expiresAt,
clock.monotonicNow() < pending.monotonicDeadline,
!Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .expired)
}
do {
let writeID = UUID()
activeWrite = writeID
defer { if activeWrite == writeID { activeWrite = nil } }
try await sendBounded(frame, socket: liveSocket, timeout: max(0.001, pending.monotonicDeadline - clock.monotonicNow()))
guard generation == pending.generation, !Task.isCancelled else {
return CommandReceipt(id: envelope.id, status: .unavailable)
}
return CommandReceipt(id: envelope.id, status: .sentToTransport)
} catch {
if generation == pending.generation {
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: pending.generation)
}
return CommandReceipt(id: envelope.id, status: .unavailable)
}
}
private func cancelCommandGeneration() {
activeWrite = nil
receiveTask?.cancel()
receiveTask = nil
watchSession = nil
watchAdmission = nil
controlEpoch = UUID()
drainTask?.cancel()
drainTask = nil
let abandoned = queue
queue.removeAll(keepingCapacity: true)
for pending in abandoned {
pending.continuation.resume(returning: CommandReceipt(id: pending.envelope.id, status: .unavailable))
}
}
private func disconnectKeepingTargetForDeletion() async {
generation = UUID()
cancelCommandGeneration()
admission = nil
let oldSocket = socket
socket = nil
state = .disconnected
await oldSocket?.close()
}
private func failConnection(
_ failureState: TVConnectionState,
socket failedSocket: any TVWebSocket,
attempt: UUID
) async -> TVConnectionState {
if generation == attempt {
generation = UUID()
cancelCommandGeneration()
socket = nil
admission = nil
state = failureState
}
await failedSocket.close()
return state
}
private func monitor(_ liveSocket: any TVWebSocket, attempt: UUID, televisionID: UUID) async {
do {
while generation == attempt, !Task.isCancelled {
let data = try await liveSocket.receive()
guard generation == attempt, !Task.isCancelled else { return }
switch try parser.parse(data) {
case .connected, .unknown: continue
case .unauthorized:
credentialMaintenance = true
defer { credentialMaintenance = false }
_ = await failConnection(.unauthorized(televisionID: televisionID), socket: liveSocket, attempt: attempt)
try? await authorizationStore.deleteToken(for: televisionID)
return
case .timedOut, .error:
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
return
}
}
} catch {
guard generation == attempt else { return }
_ = await failConnection(.unavailable(televisionID: televisionID, failure: .socket), socket: liveSocket, attempt: attempt)
}
}
private func sendBounded(_ data: Data, socket: any TVWebSocket, timeout: TimeInterval) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await socket.send(data) }
group.addTask { try await Task.sleep(for: .seconds(timeout)); throw PairingTimeoutError() }
do {
_ = try await group.next()
group.cancelAll()
} catch {
group.cancelAll()
await socket.close()
throw error
}
}
}
private func connectionFailureState(_ error: Error, televisionID: UUID) -> TVConnectionState {
if let socketError = error as? TVSocketError, socketError == .untrustedCertificate {
return .untrusted(televisionID: televisionID)
}
return .unavailable(televisionID: televisionID, failure: .socket)
}
private struct PairingTimeoutError: Error {}
private func receive(from socket: any TVWebSocket, timeout: TimeInterval) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await socket.receive() }
group.addTask {
try await Task.sleep(for: .seconds(timeout))
throw PairingTimeoutError()
}
do {
guard let result = try await group.next() else { throw TVSocketError.closed }
group.cancelAll()
return result
} catch {
// Cancelling a Swift child task does not necessarily cancel a
// URLSessionWebSocketTask (or an injected fake) that is suspended
// in receive(). Close the socket before leaving the task group so
// the losing receive child cannot hold a pairing attempt open.
group.cancelAll()
await socket.close()
throw error
}
}
}
}
@@ -0,0 +1,122 @@
import Foundation
public struct TelevisionTarget: Sendable, Equatable {
public let id: UUID
public let host: String
public init(id: UUID, host: String) {
self.id = id
self.host = host
}
}
public enum TVTrustPolicy: Sendable, Equatable {
/// Require the platform's normal certificate and hostname validation.
case systemDefault
/// Accept only an exact leaf-certificate SHA-256 fingerprint that the user
/// previously approved for this television. The 32-byte value is not learned
/// or saved by the transport during the connection being authorized.
case pinnedLeafCertificateSHA256(Data)
}
public struct StoredTVAuthorization: Sendable, Equatable {
public let token: String?
public let trust: TVTrustPolicy
public init(token: String?, trust: TVTrustPolicy = .systemDefault) {
self.token = token
self.trust = trust
}
}
/// Consumer-owned persistence seam. The iPhone Keychain adapter from workstream E
/// supplies the implementation; the transport never caches credentials in plaintext.
public protocol TVAuthorizationStore: Sendable {
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization
func saveToken(_ token: String, for televisionID: UUID) async throws
func deleteToken(for televisionID: UUID) async throws
func deleteTrust(for televisionID: UUID) async throws
}
public enum TVSocketError: Error, Sendable, Equatable {
case untrustedCertificate
case unavailable
case closed
}
public protocol TVWebSocket: Sendable {
func receive() async throws -> Data
func send(_ data: Data) async throws
func close() async
}
public protocol TVWebSocketFactory: Sendable {
func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket
}
public enum TVConnectionFailure: Sendable, Equatable {
case invalidTarget
case missingToken
case malformedResponse
case credentialStore
case socket
}
public enum TVConnectionState: Sendable, Equatable {
case disconnected
case connecting(televisionID: UUID)
case awaitingApproval(televisionID: UUID)
case ready(televisionID: UUID)
case denied(televisionID: UUID)
case unauthorized(televisionID: UUID)
case untrusted(televisionID: UUID)
case timedOut(televisionID: UUID)
case unavailable(televisionID: UUID?, failure: TVConnectionFailure)
}
public struct TVTransportClock: Sendable {
public var wallNow: @Sendable () -> Date
public var monotonicNow: @Sendable () -> TimeInterval
public init(
wallNow: @escaping @Sendable () -> Date,
monotonicNow: @escaping @Sendable () -> TimeInterval
) {
self.wallNow = wallNow
self.monotonicNow = monotonicNow
}
public static func system() -> Self {
let clock = ContinuousClock()
let origin = clock.now
return Self(
wallNow: Date.init,
monotonicNow: {
let components = origin.duration(to: clock.now).components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
}
)
}
}
public struct TVTransportConfiguration: Sendable, Equatable {
public var pairingTimeout: TimeInterval
public var maximumQueuedCommands: Int
public var maximumEventBytes: Int
public init(
pairingTimeout: TimeInterval = 30,
maximumQueuedCommands: Int = 8,
maximumEventBytes: Int = 16 * 1024
) {
self.pairingTimeout = min(max(pairingTimeout, 0.001), 30)
self.maximumQueuedCommands = min(max(maximumQueuedCommands, 1), 32)
self.maximumEventBytes = min(max(maximumEventBytes, 256), 64 * 1024)
}
}
@@ -0,0 +1,154 @@
#if os(iOS)
import CryptoKit
import Foundation
import Security
public struct URLSessionSamsungSocketFactory: TVWebSocketFactory {
private let onUntrustedCertificate: (@Sendable (String, Data) -> Void)?
public init(onUntrustedCertificate: (@Sendable (String, Data) -> Void)? = nil) {
self.onUntrustedCertificate = onUntrustedCertificate
}
public func open(
endpoint: URL,
trust: TVTrustPolicy,
maximumMessageBytes: Int
) async throws -> any TVWebSocket {
guard endpoint.scheme == "wss", let host = endpoint.host else { throw TVSocketError.unavailable }
let delegate = ScopedTrustDelegate(policy: trust, host: host, observer: onUntrustedCertificate)
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = true
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
let task = session.webSocketTask(with: endpoint)
task.maximumMessageSize = maximumMessageBytes
task.resume()
return URLSessionSamsungSocket(session: session, task: task, delegate: delegate)
}
}
private final class URLSessionSamsungSocket: TVWebSocket, @unchecked Sendable {
private let session: URLSession
private let task: URLSessionWebSocketTask
private let delegate: ScopedTrustDelegate
init(session: URLSession, task: URLSessionWebSocketTask, delegate: ScopedTrustDelegate) {
self.session = session
self.task = task
self.delegate = delegate
}
func receive() async throws -> Data {
do {
switch try await task.receive() {
case .data(let data):
return data
case .string(let text):
return Data(text.utf8)
@unknown default:
throw TVSocketError.unavailable
}
} catch {
throw normalized(error)
}
}
func send(_ data: Data) async throws {
do {
guard let text = String(data: data, encoding: .utf8) else { throw TVSocketError.unavailable }
try await task.send(.string(text))
} catch {
throw normalized(error)
}
}
func close() async {
task.cancel(with: .goingAway, reason: nil)
session.invalidateAndCancel()
_ = delegate
}
private func normalized(_ error: Error) -> Error {
if delegate.rejectedTrust { return TVSocketError.untrustedCertificate }
let certificateCodes: Set<URLError.Code> = [
.serverCertificateHasBadDate,
.serverCertificateUntrusted,
.serverCertificateHasUnknownRoot,
.serverCertificateNotYetValid,
.clientCertificateRejected,
.clientCertificateRequired
]
if let urlError = error as? URLError, certificateCodes.contains(urlError.code) {
return TVSocketError.untrustedCertificate
}
if (error as? URLError)?.code == .cancelled { return TVSocketError.closed }
return TVSocketError.unavailable
}
}
private final class ScopedTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let policy: TVTrustPolicy
private let lock = NSLock()
private var trustWasRejected = false
var rejectedTrust: Bool { lock.withLock { trustWasRejected } }
private func rejectTrust() { lock.withLock { trustWasRejected = true } }
private let host: String
private let observer: (@Sendable (String, Data) -> Void)?
init(policy: TVTrustPolicy, host: String, observer: (@Sendable (String, Data) -> Void)?) {
self.policy = policy
self.host = host
self.observer = observer
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
guard challenge.protectionSpace.host.caseInsensitiveCompare(host) == .orderedSame,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(certificate) as Data))
switch policy {
case .systemDefault:
guard SecTrustEvaluateWithError(serverTrust, nil) else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
case .pinnedLeafCertificateSHA256(let expectedFingerprint):
guard expectedFingerprint.count == SHA256.byteCount,
let certificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let certificateData = SecCertificateCopyData(certificate) as Data
let presentedFingerprint = Data(SHA256.hash(data: certificateData))
guard presentedFingerprint == expectedFingerprint else {
rejectTrust()
observer?(host, fingerprint)
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: serverTrust))
}
}
}
#endif
@@ -0,0 +1,387 @@
import Foundation
import RemoteCore
import Testing
@testable import TVTransport
private let baseDate = Date(timeIntervalSince1970: 10_000)
private func envelope(
sessionID: UUID,
televisionID: UUID,
sequence: UInt64,
command: RemoteCommand,
id: UUID = UUID(),
issuedAt: Date = baseDate,
expiresAt: Date = baseDate.addingTimeInterval(0.8)
) -> CommandEnvelope {
CommandEnvelope(
sessionID: sessionID,
televisionID: televisionID,
sequence: sequence,
issuedAt: issuedAt,
expiresAt: expiresAt,
command: command,
id: id
)
}
@Test func explicitPairThenTokenReconnectAuthorizesBeforeReady() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let pairSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let reconnectSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [pairSocket, reconnectSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store)
let firstSession = UUID()
#expect(await transport.pair(target: tv, sessionID: firstSession) == .ready(televisionID: tv.id))
#expect(await store.savedToken(for: tv.id) == "redacted-token")
let firstEndpoint = try #require(await factory.endpoint(at: 0))
#expect(URLComponents(url: firstEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.contains(where: { $0.name == "token" }) == false)
await transport.disconnect()
let secondSession = UUID()
#expect(await transport.connect(target: tv, sessionID: secondSession) == .ready(televisionID: tv.id))
let secondEndpoint = try #require(await factory.endpoint(at: 1))
#expect(URLComponents(url: secondEndpoint, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "token" })?.value == "redacted-token")
}
@Test func pairingDenialTimeoutAndMissingInitialTokenFailClosed() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let deniedSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)])
let missingTokenSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let timeoutSocket = FakeSocket(events: [])
let factory = FakeSocketFactory(sockets: [deniedSocket, missingTokenSocket, timeoutSocket])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: factory, store: store, pairingTimeout: 0.01)
#expect(await transport.pair(target: tv, sessionID: UUID()) == .denied(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
#expect(await transport.pair(target: tv, sessionID: UUID()) == .timedOut(televisionID: tv.id))
}
@Test func sendsInOrderConsumesAdmissionAndExpiresQueuedWork() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let clock = TestClock(wall: baseDate, monotonic: 50)
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
blockedSendCount: 1
)
let factory = FakeSocketFactory(sockets: [socket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore(), clock: clock.value)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let first = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .right)
let second = envelope(sessionID: session, televisionID: tv.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await socket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
try? await Task.sleep(for: .milliseconds(10))
clock.advanceMonotonic(seconds: 1)
await socket.releaseBlockedSend()
#expect(await firstTask.value.status == .sentToTransport)
#expect(await secondTask.value.status == .expired)
#expect(await socket.sentKeyNames() == ["KEY_RIGHT"])
#expect(await transport.send(first).status == .duplicate)
}
@Test func failedWriteIsConsumedAndNeverRetried() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let session = UUID()
let socket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)],
failingSendCount: 1
)
let transport = makeTransport(
factory: FakeSocketFactory(sockets: [socket]),
store: FakeAuthorizationStore()
)
#expect(await transport.pair(target: tv, sessionID: session) == .ready(televisionID: tv.id))
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up)
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.send(command).status == .unavailable)
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}
@Test func targetChangeCancelsInflightAndQueuedOldGenerationCommands() async {
let firstTV = TelevisionTarget(id: UUID(), host: "first.synthetic.test")
let secondTV = TelevisionTarget(id: UUID(), host: "second.synthetic.test")
let firstSession = UUID()
let firstSocket = FakeSocket(
events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-one"}}"#.utf8)],
blockedSendCount: 1
)
let secondSocket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-two"}}"#.utf8)])
let factory = FakeSocketFactory(sockets: [firstSocket, secondSocket])
let transport = makeTransport(factory: factory, store: FakeAuthorizationStore())
#expect(await transport.pair(target: firstTV, sessionID: firstSession) == .ready(televisionID: firstTV.id))
let first = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 1, command: .right)
let second = envelope(sessionID: firstSession, televisionID: firstTV.id, sequence: 2, command: .left)
let firstTask = Task { await transport.send(first) }
await firstSocket.waitForSendCount(1)
let secondTask = Task { await transport.send(second) }
await Task.yield()
#expect(await transport.pair(target: secondTV, sessionID: UUID()) == .ready(televisionID: secondTV.id))
#expect(await firstTask.value.status == .unavailable)
#expect(await secondTask.value.status == .unavailable)
#expect(await firstSocket.sentKeyNames() == ["KEY_RIGHT"])
}
@Test func reconnectRequiresTokenAndRevocationDeletesIt() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: []), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .unauthorized(televisionID: tv.id))
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
await transport.revokeAuthorization()
#expect(await store.savedToken(for: tv.id) == nil)
}
@Test func forgetDeletesAuthorizationAndSelectedTarget() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let store = FakeAuthorizationStore()
await store.setAuthorization(.init(token: "redacted-token"), for: tv.id)
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
#expect(await transport.connect(target: tv, sessionID: UUID()) == .ready(televisionID: tv.id))
await transport.forgetTelevision()
#expect(await transport.connectionState() == .disconnected)
#expect(await store.savedToken(for: tv.id) == nil)
}
private func makeTransport(
factory: FakeSocketFactory,
store: FakeAuthorizationStore,
clock: TVTransportClock = TestClock(wall: baseDate, monotonic: 50).value,
pairingTimeout: TimeInterval = 1
) -> TVConnectionActor {
TVConnectionActor(
socketFactory: factory,
authorizationStore: store,
clock: clock,
configuration: .init(pairingTimeout: pairingTimeout)
)
}
private final class TestClock: @unchecked Sendable {
private let lock = NSLock()
private var wall: Date
private var monotonic: TimeInterval
init(wall: Date, monotonic: TimeInterval) {
self.wall = wall
self.monotonic = monotonic
}
var value: TVTransportClock {
TVTransportClock(
wallNow: { [self] in lock.withLock { wall } },
monotonicNow: { [self] in lock.withLock { monotonic } }
)
}
func advance(seconds: TimeInterval) {
lock.withLock {
wall = wall.addingTimeInterval(seconds)
monotonic += seconds
}
}
func advanceMonotonic(seconds: TimeInterval) {
lock.withLock { monotonic += seconds }
}
}
private actor FakeAuthorizationStore: TVAuthorizationStore {
private var values: [UUID: StoredTVAuthorization] = [:]
func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
values[televisionID] ?? StoredTVAuthorization(token: nil)
}
func saveToken(_ token: String, for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: token, trust: trust)
}
func deleteToken(for televisionID: UUID) async throws {
let trust = values[televisionID]?.trust ?? .systemDefault
values[televisionID] = StoredTVAuthorization(token: nil, trust: trust)
}
func deleteTrust(for televisionID: UUID) async throws {
let token = values[televisionID]?.token
values[televisionID] = StoredTVAuthorization(token: token, trust: .systemDefault)
}
func savedToken(for televisionID: UUID) -> String? { values[televisionID]?.token }
func setAuthorization(_ authorization: StoredTVAuthorization, for televisionID: UUID) {
values[televisionID] = authorization
}
}
private actor FakeSocketFactory: TVWebSocketFactory {
private var sockets: [FakeSocket]
private var endpoints: [URL] = []
init(sockets: [FakeSocket]) { self.sockets = sockets }
func open(endpoint: URL, trust: TVTrustPolicy, maximumMessageBytes: Int) async throws -> any TVWebSocket {
endpoints.append(endpoint)
guard !sockets.isEmpty else { throw TVSocketError.unavailable }
return sockets.removeFirst()
}
func endpoint(at index: Int) -> URL? {
endpoints.indices.contains(index) ? endpoints[index] : nil
}
}
private actor FakeSocket: TVWebSocket {
private var events: [Data]
private var receiveContinuation: CheckedContinuation<Data, Error>?
private var sendContinuation: CheckedContinuation<Void, Error>?
private var sent: [Data] = []
private var blockedSendCount: Int
private var failingSendCount: Int
private var closed = false
init(events: [Data], blockedSendCount: Int = 0, failingSendCount: Int = 0) {
self.events = events
self.blockedSendCount = blockedSendCount
self.failingSendCount = failingSendCount
}
func receive() async throws -> Data {
if closed { throw TVSocketError.closed }
if !events.isEmpty { return events.removeFirst() }
return try await withCheckedThrowingContinuation { receiveContinuation = $0 }
}
func send(_ data: Data) async throws {
if closed { throw TVSocketError.closed }
sent.append(data)
if failingSendCount > 0 {
failingSendCount -= 1
throw TVSocketError.unavailable
}
if blockedSendCount > 0 {
blockedSendCount -= 1
try await withCheckedThrowingContinuation { sendContinuation = $0 }
}
}
func close() async {
closed = true
receiveContinuation?.resume(throwing: TVSocketError.closed)
receiveContinuation = nil
sendContinuation?.resume(throwing: TVSocketError.closed)
sendContinuation = nil
}
func deliver(_ data: Data) {
if let pending = receiveContinuation {
receiveContinuation = nil
pending.resume(returning: data)
} else { events.append(data) }
}
func releaseBlockedSend() {
sendContinuation?.resume()
sendContinuation = nil
}
func waitForSendCount(_ count: Int) async {
while sent.count < count { await Task.yield() }
}
func sentKeyNames() -> [String] {
sent.compactMap { data in
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let parameters = object["params"] as? [String: String] else { return nil }
return parameters["DataOfCmd"]
}
}
}
@Test func watchNegotiationKeepsIdleSocketAndPhoneCommandsDoNotStealGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
#expect(await transport.sendPhoneCommand(.left).status == .sentToTransport)
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .sentToTransport)
#expect(await transport.submitWatchCommand(command, peer: peer).status == .duplicate)
#expect(await socket.sentKeyNames() == ["KEY_LEFT", "KEY_RIGHT"])
await transport.disconnect()
}
@Test func stalePeerAndStaleEndCannotInvalidateCurrentGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let oldPeer = WatchPeerGeneration(), peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: oldPeer)
let old = try await transport.beginWatchSession(.init(), peer: oldPeer).get()
await transport.watchBridgeDidActivate(peer: peer)
let current = try await transport.beginWatchSession(.init(), peer: peer).get()
await transport.watchBridgeDidDeactivate(peer: oldPeer)
#expect(await transport.endWatchSession(.init(sessionID: old.sessionID), peer: peer) == .failure(.init(code: .sessionInvalidated)))
#expect(await transport.submitWatchCommand(envelope(sessionID: old.sessionID, televisionID: tv.id, sequence: 1, command: .left), peer: oldPeer).status == .rejected)
#expect(await transport.submitWatchCommand(envelope(sessionID: current.sessionID, televisionID: tv.id, sequence: 1, command: .right), peer: peer).status == .sentToTransport)
await transport.disconnect()
}
@Test func rotatingGrantDuringWriteClosesSocketBeforeNewGrant() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
_ = await transport.pair(target: tv, sessionID: UUID())
let peer = WatchPeerGeneration()
await transport.watchBridgeDidActivate(peer: peer)
let grant = try await transport.beginWatchSession(.init(), peer: peer).get()
let command = envelope(sessionID: grant.sessionID, televisionID: tv.id, sequence: 1, command: .right)
let pending = Task { await transport.submitWatchCommand(command, peer: peer) }
await socket.waitForSendCount(1)
#expect(await transport.beginWatchSession(.init(), peer: peer) == .failure(.init(code: .unavailable)))
#expect(await pending.value.status == .unavailable)
#expect(await transport.connectionState() == .disconnected)
}
@Test func readyConnectionConsumesRevocationAndStopsCommands() async throws {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)])
let store = FakeAuthorizationStore()
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: store)
_ = await transport.pair(target: tv, sessionID: UUID())
await socket.deliver(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8))
for _ in 0..<1000 {
if await transport.connectionState() == .unauthorized(televisionID: tv.id) { break }
await Task.yield()
}
#expect(await transport.connectionState() == .unauthorized(televisionID: tv.id))
#expect(await transport.sendPhoneCommand(.left).status == .unavailable)
}
@Test func sendDeadlineClosesNonCompletingSocket() async {
let tv = TelevisionTarget(id: UUID(), host: "tv.synthetic.test")
let socket = FakeSocket(events: [Data(#"{"event":"ms.channel.connect","data":{"token":"redacted-token"}}"#.utf8)], blockedSendCount: 1)
let transport = makeTransport(factory: FakeSocketFactory(sockets: [socket]), store: FakeAuthorizationStore())
let session = UUID()
_ = await transport.pair(target: tv, sessionID: session)
let command = envelope(sessionID: session, televisionID: tv.id, sequence: 1, command: .up, expiresAt: baseDate.addingTimeInterval(0.02))
#expect(await transport.send(command).status == .unavailable)
#expect(await transport.connectionState() == .unavailable(televisionID: tv.id, failure: .socket))
#expect(await socket.sentKeyNames() == ["KEY_UP"])
}
@@ -0,0 +1,141 @@
> 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
design history; `docs/CONTRACTS.md` and RemoteCore/WatchSession.swift are the
authoritative final API. The original suggested signatures below are not the
implementation contract.
## Integration decisions
- Shared DTOs are implemented, including RelayFailure conforming to Error.
- RelayJSON centrally enforces millisecond dates and the 4096-byte payload bound.
- PhoneCommandCoordinating is an Actor protocol with explicit request values and
a local WatchPeerGeneration argument on every operation, plus activation and
deactivation callbacks. This resolves stale counterpart/lifecycle events.
- The bounded three-key container remains adapter-owned; RelayMessageKind freezes
its v1 routing vocabulary. Only A's single actor consumes command admission.
- An adjacent local Swift package provides Common/iPhone/Watch source and test
targets. Xcode links its platform products instead of duplicating source files
directly in the app targets. New B source files require no project regeneration.
- B may use a fake injected actor now. A's production conformance and physical
device behavior remain later integration gates, not blockers to B's coding.
## Original proposal (historical)
## Shared RemoteCore additions requested
Add typed, `Codable`, `Sendable`, `Equatable` control-plane values to
`RemoteCore`. Names are suggestions; the wire values and semantics need to be
frozen by integration before B implements them.
```swift
public struct BeginRemoteSessionRequest: Codable, Sendable, Equatable {
public let schemaVersion: Int
}
public struct RemoteSessionGrant: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
public let televisionID: UUID
public let issuedAt: Date
public let maximumCommandLifetimeMilliseconds: UInt64
}
public struct EndRemoteSessionRequest: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
}
public struct RemoteSessionEnded: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
}
public struct RelayFailure: Codable, Sendable, Equatable {
public enum Code: String, Codable, Sendable {
case unsupportedSchema
case malformedRequest
case noSelectedTelevision
case unavailable
case sessionInvalidated
}
public let schemaVersion: Int
public let code: Code
}
```
`maximumCommandLifetimeMilliseconds` must be positive and no greater than
`CommandAdmission.maximumLifetime * 1_000`. `issuedAt` is encoded as JSON
milliseconds since Unix epoch. A grant authorizes no command by itself and must
be bound by the phone actor to the currently activated paired WCSession and the
phone's currently selected TV.
The bridge container remains adapter-owned and has exactly three keys:
```text
schemaVersion: Int == 1
kind: String
payload: Data (maximum 4 KiB)
```
Freeze these v1 `kind` values: `beginSession`, `session`, `endSession`, `ended`,
`command`, `receipt`, and `error`. Every request receives one reply container.
Unsupported versions, unknown kinds, missing/extra or incorrectly typed keys,
oversized payloads, and structural decode failures fail closed with an `error`
reply. Semantic command rejection remains solely in the phone command actor.
## Injected phone actor seam requested
Integration/A should provide one actor conforming to a narrow protocol such as:
```swift
public protocol PhoneCommandCoordinating: Sendable {
func beginWatchSession() async -> Result<RemoteSessionGrant, RelayFailure>
func endWatchSession(_ request: EndRemoteSessionRequest) async
-> Result<RemoteSessionEnded, RelayFailure>
func submitWatchCommand(_ envelope: CommandEnvelope) async -> CommandReceipt
func watchBridgeDidDeactivate() async
}
```
That actor remains the only owner of the authorized session and
`CommandAdmission`. `WatchBridge` will retain only the injected protocol
reference on iPhone. It will not instantiate `CommandAdmission`, perform
semantic admission, select a TV, or issue session UUIDs.
Please confirm whether a changed paired-watch identity can be surfaced to the
actor as `watchBridgeDidDeactivate()` or needs an explicit opaque peer
generation parameter. No persistent hardware identifier should cross this seam.
## Project/test wiring requested
Update the integrator-owned project generator and generated project to:
- compile `Adapters/WatchBridge/Sources/Common` into both app targets;
- compile `Sources/iPhone` only into the iPhone target and `Sources/Watch` only
into the watchOS target;
- link `WatchConnectivity` through normal SDK imports (no third-party runtime);
- add adjacent fake-driven test targets for common, iPhone, and watch behavior;
- keep the adapter outside the pure algorithm package.
The test target must be able to inject a session facade rather than subclassing
or manufacturing `WCSession`. Planned coverage: reachable/unreachable delivery,
activation/deactivation, phone/watch reinstall or counterpart switch,
duplicate callback suppression, reply timeout, lost reply, invalid containers,
4 KiB payload boundary, millisecond date round trips, and application-context
sanitization.
## Migration and impact
This is additive to contract v1 but introduces the first control-plane wire
format. Existing demo apps remain demos until integration explicitly injects the
live adapter. Mixed installations fail closed on schema mismatch and expose an
update-required/unavailable UI state; they do not fall back to queued commands.
No permission string, background mode, credential path, analytics, raw motion,
or third-party dependency is requested. Physical iPhone/Watch testing remains
required for locked-phone wake, relaunch, counterpart switching, and actual
reachability behavior without Xcode attached.
+27
View File
@@ -0,0 +1,27 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "WatchBridge",
platforms: [.iOS(.v17), .watchOS(.v10), .macOS(.v14)],
products: [
.library(name: "WatchBridgeCommon", targets: ["WatchBridgeCommon"]),
.library(name: "WatchBridgePhone", targets: ["WatchBridgePhone"]),
.library(name: "WatchBridgeWatch", targets: ["WatchBridgeWatch"])
],
dependencies: [.package(path: "../../Packages/WristivoKit")],
targets: [
.target(name: "WatchBridgeCommon", dependencies: [
.product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/Common"),
.target(name: "WatchBridgePhone", dependencies: [
"WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/iPhone"),
.target(name: "WatchBridgeWatch", dependencies: [
"WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")
], path: "Sources/Watch"),
.testTarget(name: "WatchBridgeCommonTests", dependencies: ["WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/Common"),
.testTarget(name: "WatchBridgePhoneTests", dependencies: ["WatchBridgePhone", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/iPhone"),
.testTarget(name: "WatchBridgeWatchTests", dependencies: ["WatchBridgeWatch", "WatchBridgeCommon", .product(name: "RemoteCore", package: "WristivoKit")], path: "Tests/Watch")
]
)
+12 -1
View File
@@ -1,3 +1,14 @@
# WatchBridge
Planned adapter: versioned live WatchConnectivity relay. No live implementation is present. Owner: workstream B 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.
Versioned live WatchConnectivity relay, owned by workstream B. Integration prerequisites are now available; live WCSession behavior is still to be implemented.
- Shared DTOs, RelayJSON and PhoneCommandCoordinating: `Packages/WristivoKit/Sources/RemoteCore/WatchSession.swift`.
- Adjacent Swift package: `Adapters/WatchBridge/Package.swift`.
- Common source/test target: `Sources/Common` and `Tests/Common`.
- Phone source/test target: `Sources/iPhone` and `Tests/iPhone`.
- Watch source/test target: `Sources/Watch` and `Tests/Watch`.
- Xcode links WatchBridgePhone only to iPhone and WatchBridgeWatch only to watchOS; both depend on WatchBridgeCommon. Source files added under these package paths need no project regeneration.
Run `bash Scripts/check.sh` from the repository root for both packages' fake-driven tests; add `--apps` for unsigned native compilation. These are SwiftPM test targets, not hosted app UI tests. Adding actual WCSession code requires conditional imports/platform boundaries so fake-driven package tests continue to run on macOS. WCSession cannot run there as a substitute for iPhone/watch hardware.
Implement against an injected fake phone actor now; A's live transport is not a prerequisite for B's unit implementation. Keep one consuming CommandAdmission gate in A's phone actor. Use WatchPeerGeneration as a local activation epoch so stale callbacks cannot invalidate the current session. Read docs/CONTRACTS.md and docs/agents/WATCH-RELAY-HANDOFF.md before coding. No live connection is injected into the demo apps by this integration change.
@@ -0,0 +1,197 @@
import Foundation
import CoreFoundation
import RemoteCore
private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool {
String(cString: value.objCType) == "c"
}
private func relayNSNumberIsInteger(_ value: NSNumber) -> Bool {
["c", "s", "i", "l", "q", "C", "S", "I", "L", "Q"].contains(
String(cString: value.objCType))
}
/// Typed outgoing container seam. Workstream B owns WCSession dictionary parsing,
/// strict incoming key/type validation, routing and exactly-once replies.
public enum RelayContainerError: Error, Sendable, Equatable {
case missingKeys
case extraKeys
case invalidSchemaType
case unsupportedSchema(Int)
case invalidKind
case invalidPayload
case unexpectedKind(RelayMessageKind)
case malformedPayload
}
public struct RelayContainer: Sendable {
public let schemaVersion = RelayWire.schemaVersion
public let kind: RelayMessageKind
public let payload: Data
public init<Value: Encodable>(kind: RelayMessageKind, value: Value) throws {
self.kind = kind
self.payload = try RelayJSON.encode(value)
}
/// The exact WCSession dictionary shape. Parsing is structural only; the
/// caller must decode the payload for the expected request kind.
public init(dictionary: [String: Any]) throws {
let expected: Set<String> = ["schemaVersion", "kind", "payload"]
let actual = Set(dictionary.keys)
guard actual == expected else {
if actual.isSubset(of: expected) { throw RelayContainerError.missingKeys }
throw RelayContainerError.extraKeys
}
guard let version = dictionary["schemaVersion"] as? NSNumber,
CFGetTypeID(version) == CFNumberGetTypeID(),
relayNSNumberIsInteger(version),
version.intValue >= 0,
!relayNSNumberIsBoolean(version) else {
throw RelayContainerError.invalidSchemaType
}
let schema = version.intValue
guard schema == RelayWire.schemaVersion else {
throw RelayContainerError.unsupportedSchema(schema)
}
guard let rawKind = dictionary["kind"] as? String,
let kind = RelayMessageKind(rawValue: rawKind) else {
throw RelayContainerError.invalidKind
}
guard let payload = dictionary["payload"] as? Data else {
throw RelayContainerError.invalidPayload
}
guard payload.count <= RelayWire.maximumPayloadBytes else {
throw RelayContractError.payloadTooLarge
}
self.kind = kind
self.payload = payload
}
public func dictionary() -> [String: Any] {
["schemaVersion": schemaVersion, "kind": kind.rawValue, "payload": payload]
}
public func decode<Value: Decodable>(_ type: Value.Type,
expectedKind: RelayMessageKind) throws -> Value {
guard kind == expectedKind else { throw RelayContainerError.unexpectedKind(kind) }
do {
return try RelayJSON.decode(type, from: payload)
} catch {
throw RelayContainerError.malformedPayload
}
}
}
/// Only allowlisted, latest-state configuration belongs in application
/// context. Commands, tokens and executable work are intentionally absent.
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?, 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, "isConnectionReady": isConnectionReady]
if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString }
if let displayName { result["displayName"] = String(displayName.prefix(128)) }
return result
}
public init(dictionary: [String: Any]) throws {
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName", "isConnectionReady"]
guard Set(dictionary.keys).isSubset(of: allowed) else { throw RelayContainerError.extraKeys }
guard let version = dictionary["schemaVersion"] as? NSNumber,
relayNSNumberIsInteger(version),
!relayNSNumberIsBoolean(version),
version.intValue == RelayWire.schemaVersion else {
throw RelayContainerError.invalidSchemaType
}
if let raw = dictionary["selectedTelevisionID"] {
guard let string = raw as? String, UUID(uuidString: string) != nil else {
throw RelayContainerError.invalidPayload
}
self.selectedTelevisionID = UUID(uuidString: string)
} else {
self.selectedTelevisionID = nil
}
if let raw = dictionary["displayName"] {
guard let string = raw as? String, string.count <= 128 else {
throw RelayContainerError.invalidPayload
}
self.displayName = string
} 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
}
}
public enum RelayActivationState: Sendable, Equatable {
case notActivated, activating, activated, inactive, deactivated
}
/// A small injectable facade around WCSession. It deliberately exposes only
/// immediate messages and latest-state context, never transferUserInfo.
@MainActor
public protocol RelaySessionSending: AnyObject {
var activationState: RelayActivationState { get }
var isReachable: Bool { get }
func activate()
func sendMessage(_ message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void,
errorHandler: @escaping (Error) -> Void)
func updateApplicationContext(_ context: [String: Any]) throws
}
public final class RelayReplyOnce: @unchecked Sendable {
private let lock = NSLock()
private var completed = false
private let handler: ([String: Any]) -> Void
public init(_ handler: @escaping ([String: Any]) -> Void) { self.handler = handler }
public func reply(_ value: [String: Any]) {
lock.lock()
guard !completed else { lock.unlock(); return }
completed = true
lock.unlock()
handler(value)
}
}
/// WCSession delivers Foundation dictionaries and non-Sendable callbacks from
/// 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 {
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.container = try? RelayContainer(dictionary: message)
self.reply = RelayReplyOnce(replyHandler)
}
}
public func relayErrorReply(_ failure: RelayFailure) -> [String: Any] {
(try? RelayContainer(kind: .error, value: failure).dictionary()) ?? [
"schemaVersion": RelayWire.schemaVersion,
"kind": RelayMessageKind.error.rawValue,
"payload": Data()
]
}
@@ -0,0 +1,10 @@
import RemoteCore
import WatchBridgeCommon
/// Pure outgoing container construction: does not activate WCSession, grant a
/// session, queue a command, or transmit it. Live delivery belongs to B.
public enum WatchCommandPayload {
public static func container(for envelope: CommandEnvelope) throws -> RelayContainer {
try RelayContainer(kind: .command, value: envelope)
}
}
@@ -0,0 +1,191 @@
import Foundation
import RemoteCore
import WatchBridgeCommon
@MainActor
public final class WatchRelayBridge: NSObject {
public enum State: Sendable, Equatable {
case inactive
case activating
case unavailable
case ready(reachable: Bool)
case sessionNegotiating
case armed(sessionID: UUID, televisionID: UUID)
}
public private(set) var state: State = .inactive {
didSet { onStateChange?(state) }
}
public var onStateChange: ((State) -> Void)?
private let session: any RelaySessionSending
public private(set) var grant: RemoteSessionGrant?
private var generation = UUID()
private var nextSequence: UInt64 = 0
private var activationReady = false
private var pendingReplies: [UUID: RelayReplyOnce] = [:]
public init(session: any RelaySessionSending) {
self.session = session
super.init()
}
public func activate() {
guard !activationReady else { return }
state = .activating
session.activate()
}
public func activationDidComplete(error: Error?) {
guard error == nil else {
activationReady = false
state = .unavailable
return
}
activationReady = true
state = .ready(reachable: session.isReachable)
}
public func reachabilityDidChange() {
guard activationReady else { return }
if !session.isReachable { invalidatePending() }
if grant != nil { return }
state = .ready(reachable: session.isReachable)
}
public func didBecomeInactive() {
activationReady = false
invalidatePending()
state = .inactive
}
public func didDeactivate() {
activationReady = false
grant = nil
nextSequence = 0
invalidatePending()
state = .inactive
}
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()
guard let message = try? RelayContainer(kind: .beginSession, value: request).dictionary() else {
state = .unavailable
return
}
send(message: message, requestID: requestID) { [weak self] response in
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,
expectedKind: .session) else {
self.state = .unavailable
return
}
self.grant = received
self.nextSequence = 0
self.state = .armed(sessionID: received.sessionID,
televisionID: received.televisionID)
}
}
public func endSession() {
let oldGrant = grant
invalidatePending()
state = activationReady ? .ready(reachable: session.isReachable) : .inactive
guard activationReady, session.isReachable, let oldGrant,
let message = try? RelayContainer(kind: .endSession, value: EndRemoteSessionRequest(sessionID: oldGrant.sessionID)).dictionary() else { return }
// Locally revoke immediately; a delayed end reply cannot revoke a newer grant.
send(message: message, requestID: UUID()) { _ in }
}
public func sendCommandResult(_ command: RemoteCommand) async -> Result<CommandReceipt, RelayFailure> {
await withCheckedContinuation { continuation in
sendCommand(command) { continuation.resume(returning: $0) }
}
}
/// One command is one live sendMessage. A sequence is consumed before the
/// send; ambiguous timeout/failure is never replayed.
public func sendCommand(_ command: RemoteCommand, now: Date = Date(),
completion: @escaping @MainActor (Result<CommandReceipt, RelayFailure>) -> Void = { _ in }) {
guard activationReady, session.isReachable, let grant else {
state = .unavailable
completion(.failure(.init(code: .unavailable)))
return
}
guard nextSequence < UInt64.max else {
self.grant = nil
state = .unavailable
completion(.failure(.init(code: .sessionInvalidated)))
return
}
let sequence = nextSequence
nextSequence += 1
let lifetime = Double(grant.maximumCommandLifetimeMilliseconds) / 1_000
let envelope = CommandEnvelope(sessionID: grant.sessionID,
televisionID: grant.televisionID,
sequence: sequence,
issuedAt: now,
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, 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 { completion(.failure(.init(code: .unavailable))); return }
completion(.success(receipt))
// A receipt is command feedback, not evidence that the TV changed.
if receipt.status != .sentToTransport { self.invalidatePending(); self.state = .unavailable }
}
}
private func send(message: [String: Any], requestID: UUID,
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 }
response(value)
}
}
pendingReplies[requestID] = once
session.sendMessage(message, replyHandler: { value in
once.reply(value)
}, errorHandler: { _ in
once.reply(relayErrorReply(RelayFailure(code: .unavailable)))
})
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(1_000))
self?.pendingReplies[requestID]?.reply(relayErrorReply(RelayFailure(code: .unavailable)))
}
}
private func invalidatePending() {
generation = UUID()
let replies = Array(pendingReplies.values)
for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) }
grant = nil
nextSequence = 0
}
}
@@ -0,0 +1,86 @@
#if os(watchOS)
import Foundation
import RemoteCore
import WatchBridgeCommon
import WatchConnectivity
@MainActor
private final class WatchWCSessionSending: RelaySessionSending {
private let session: WCSession
init(session: WCSession) { self.session = session }
var activationState: RelayActivationState {
switch session.activationState {
case .notActivated: return .notActivated
case .inactive: return .inactive
case .activated: return .activated
@unknown default: return .inactive
}
}
var isReachable: Bool { session.isReachable }
func activate() { session.activate() }
func sendMessage(_ message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void,
errorHandler: @escaping (Error) -> Void) {
session.sendMessage(message, replyHandler: replyHandler, errorHandler: errorHandler)
}
func updateApplicationContext(_ context: [String: Any]) throws {
try session.updateApplicationContext(context)
}
}
/// Separate watchOS delegate. The app remains foreground/active for gesture
/// use; this delegate does not add a background mode or deferred command queue.
@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
bridge.activate()
}
nonisolated public func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
Task { @MainActor [weak self] in
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)
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.bridge.reachabilityDidChange() }
}
nonisolated public func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
// The watch is the request sender in v1; never execute a phone reply
// as a request. Complete unexpected inbound traffic exactly once.
RelayReplyOnce(replyHandler).reply(
relayErrorReply(RelayFailure(code: .malformedRequest)))
}
}
#endif
@@ -0,0 +1,211 @@
import Foundation
import RemoteCore
import WatchBridgeCommon
@MainActor
public final class PhoneRelayBridge: NSObject {
public enum State: Sendable, Equatable {
case inactive
case activating
case ready(reachable: Bool)
case unavailable
}
public private(set) var state: State = .inactive
private let session: any RelaySessionSending
private let coordinator: any PhoneCommandCoordinating
private var peer: WatchPeerGeneration?
private var activationReady = false
private var lifecycleTask: Task<Void, Never>?
private var pending: [UUID: RelayReplyOnce] = [:]
private var latestContext: RelayApplicationContext?
public func publishContext(_ context: RelayApplicationContext) {
latestContext = context
flushContext()
}
private func flushContext() {
guard session.activationState == .activated, let latestContext else { return }
if let dictionary = try? latestContext.dictionary() {
try? session.updateApplicationContext(dictionary)
}
}
public init(session: any RelaySessionSending,
dependency: PhoneRelayDependency) {
self.session = session
self.coordinator = dependency.coordinator
}
public func activate() {
guard peer == nil else { return }
state = .activating
activationReady = false
session.activate()
}
public func activationDidComplete(error: Error?) {
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)
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()
}
}
public func reachabilityDidChange() {
guard peer != nil, activationReady else { return }
state = .ready(reachable: session.isReachable)
}
public func didBecomeInactive() {
// iOS will normally follow with sessionDidDeactivate, but invalidate
// immediately so no command can slip through during counterpart swap.
didDeactivate()
}
public func didDeactivate() {
guard let oldPeer = peer else { return }
peer = nil
activationReady = false
state = .inactive
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
/// supplied reply exactly once, including malformed, unavailable and timed
/// out requests.
public func receive(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
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 {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
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, session.activationState == .activated, let currentPeer = peer else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let container: RelayContainer
do {
container = try RelayContainer(dictionary: message)
} catch {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
switch container.kind {
case .beginSession:
guard let request = try? container.decode(BeginRemoteSessionRequest.self,
expectedKind: .beginSession) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
let result = await ({
await self.coordinator.beginWatchSession(request, peer: currentPeer)
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
switch result {
case .success(let grant):
reply.reply(Self.reply(kind: .session, value: grant))
case .failure(let failure):
reply.reply(relayErrorReply(failure))
}
case .endSession:
guard let request = try? container.decode(EndRemoteSessionRequest.self,
expectedKind: .endSession) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
let result = await ({
await self.coordinator.endWatchSession(request, peer: currentPeer)
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
switch result {
case .success(let ended):
reply.reply(Self.reply(kind: .ended, value: ended))
case .failure(let failure):
reply.reply(relayErrorReply(failure))
}
case .command:
guard let envelope = try? container.decode(CommandEnvelope.self,
expectedKind: .command) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
let receipt = await ({
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
guard receipt.id == envelope.id else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
reply.reply(Self.reply(kind: .receipt, value: receipt))
case .session, .ended, .receipt, .error:
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
}
}
private static func reply<Value: Encodable>(kind: RelayMessageKind,
value: Value) -> [String: Any] {
(try? RelayContainer(kind: kind, value: value).dictionary()) ??
relayErrorReply(RelayFailure(code: .malformedRequest))
}
}
@@ -0,0 +1,11 @@
import RemoteCore
/// An injection seam, not a live relay. App composition supplies the same actor
/// used for phone commands; this holder creates no admission or session state.
public struct PhoneRelayDependency: Sendable {
public let coordinator: any PhoneCommandCoordinating
public init(coordinator: any PhoneCommandCoordinating) {
self.coordinator = coordinator
}
}
@@ -0,0 +1,87 @@
#if os(iOS)
import Foundation
import RemoteCore
import WatchBridgeCommon
import WatchConnectivity
@MainActor
private final class PhoneWCSessionSending: RelaySessionSending {
private let session: WCSession
init(session: WCSession) { self.session = session }
var activationState: RelayActivationState {
switch session.activationState {
case .notActivated: return .notActivated
case .inactive: return .inactive
case .activated: return .activated
@unknown default: return .inactive
}
}
var isReachable: Bool { session.isReachable }
func activate() { session.activate() }
func sendMessage(_ message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void,
errorHandler: @escaping (Error) -> Void) {
session.sendMessage(message, replyHandler: replyHandler, errorHandler: errorHandler)
}
func updateApplicationContext(_ context: [String: Any]) throws {
try session.updateApplicationContext(context)
}
}
/// iPhone-side WCSession delegate. All UI-facing state changes are hopped to
/// the main actor; semantic command admission remains in the injected actor.
@MainActor
public final class PhoneWCSessionDelegate: NSObject, WCSessionDelegate {
public let bridge: PhoneRelayBridge
private let session: WCSession
public init(coordinator: any PhoneCommandCoordinating,
session: WCSession = .default) {
self.session = session
self.bridge = PhoneRelayBridge(session: PhoneWCSessionSending(session: session),
dependency: PhoneRelayDependency(coordinator: coordinator))
super.init()
session.delegate = self
bridge.activate()
}
nonisolated public func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
Task { @MainActor [weak self] in self?.bridge.activationDidComplete(error: error) }
}
nonisolated public func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor [weak self] in self?.bridge.didBecomeInactive() }
}
nonisolated public func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor [weak self] in
guard let self else { return }
self.bridge.didDeactivate()
self.session.activate()
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.bridge.reachabilityDidChange() }
}
nonisolated public func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
Task { @MainActor [weak self, inbound] in
guard let self else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
self.bridge.receive(message: inbound.message,
replyHandler: inbound.reply.reply)
}
}
}
#endif
@@ -0,0 +1,82 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
@Test func payloadUsesUnixMillisecondsInBothDirections() throws {
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let envelope = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 1,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .right
)
let container = try RelayContainer(kind: .command, value: envelope)
let data = container.payload
#expect(container.schemaVersion == RelayWire.schemaVersion)
#expect(container.kind == .command)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect((object["issuedAt"] as? Double) == 1_700_000_000_125)
#expect((object["expiresAt"] as? Double) == 1_700_000_000_625)
#expect(try RelayJSON.decode(CommandEnvelope.self, from: data) == envelope)
}
@Test func outgoingContainerRejectsOversizedPayload() {
#expect(throws: RelayContractError.payloadTooLarge) {
try RelayContainer(kind: .error, value: String(repeating: "x", count: RelayWire.maximumPayloadBytes))
}
}
@Test func incomingContainerRequiresExactTypedShape() throws {
let payload = try RelayJSON.encode(BeginRemoteSessionRequest())
let valid: [String: Any] = [
"schemaVersion": 1,
"kind": "beginSession",
"payload": payload
]
#expect(try RelayContainer(dictionary: valid).kind == .beginSession)
#expect(throws: RelayContainerError.extraKeys) {
var extra = valid
extra["token"] = "never accepted"
_ = try RelayContainer(dictionary: extra)
}
#expect(throws: RelayContainerError.invalidSchemaType) {
var booleanVersion = valid
booleanVersion["schemaVersion"] = true
_ = try RelayContainer(dictionary: booleanVersion)
}
#expect(throws: RelayContainerError.invalidSchemaType) {
var floatingVersion = valid
floatingVersion["schemaVersion"] = 1.0
_ = try RelayContainer(dictionary: floatingVersion)
}
#expect(throws: RelayContainerError.invalidPayload) {
var wrongPayload = valid
wrongPayload["payload"] = "not bytes"
_ = try RelayContainer(dictionary: wrongPayload)
}
}
@Test func applicationContextContainsOnlyLatestStateAllowlist() throws {
let tv = UUID()
let context = try RelayApplicationContext(selectedTelevisionID: tv,
displayName: "Living room").dictionary()
#expect(context["selectedTelevisionID"] as? String == tv.uuidString)
#expect(context["displayName"] as? String == "Living room")
#expect(!context.keys.contains("token"))
#expect(!context.keys.contains("command"))
#expect(try RelayApplicationContext(dictionary: context).selectedTelevisionID == tv)
var executable = context
executable["command"] = "queued"
#expect(throws: RelayContainerError.extraKeys) {
_ = 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])
}
}
@@ -0,0 +1,19 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
import WatchBridgeWatch
@Test func watchPayloadPreservesCommandIdentityAndExpiry() throws {
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let envelope = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 42,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .left
)
let container = try WatchCommandPayload.container(for: envelope)
#expect(container.kind == .command)
let data = container.payload
#expect(try RelayJSON.decode(CommandEnvelope.self, from: data) == envelope)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect((object["expiresAt"] as? Double) == 1_700_000_000_625)
}
@@ -0,0 +1,150 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
import WatchBridgeWatch
@MainActor
private final class FakeWatchSession: RelaySessionSending {
var activationState: RelayActivationState = .notActivated
var isReachable = true
var sent: [[String: Any]] = []
var reply: (([String: Any]) -> Void)?
var error: ((Error) -> Void)?
var replyTwice = false
func activate() { activationState = .activated }
func sendMessage(_ message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void,
errorHandler: @escaping (Error) -> Void) {
sent.append(message)
reply = replyHandler
error = errorHandler
}
func updateApplicationContext(_ context: [String: Any]) throws {}
func answer(_ value: [String: Any]) {
reply?(value)
if replyTwice { reply?(value); error?(RelayTestError.failed) }
}
}
private enum RelayTestError: Error { case failed }
@MainActor
@Test func watchNegotiatesBeforeSendingCommandAndUsesMilliseconds() async throws {
let fake = FakeWatchSession()
let bridge = WatchRelayBridge(session: fake)
bridge.activate()
bridge.activationDidComplete(error: nil)
bridge.beginSession()
#expect(fake.sent.count == 1)
let request = try RelayContainer(dictionary: fake.sent[0])
#expect(request.kind == .beginSession)
let sessionID = UUID(), tvID = UUID()
let grant = RemoteSessionGrant(sessionID: sessionID, televisionID: tvID,
issuedAt: Date(timeIntervalSince1970: 1_700_000_000),
maximumCommandLifetimeMilliseconds: 500)
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
await Task.yield()
bridge.sendCommand(.right, now: Date(timeIntervalSince1970: 1_700_000_000))
#expect(fake.sent.count == 2)
let command = try RelayContainer(dictionary: fake.sent[1])
let envelope = try command.decode(CommandEnvelope.self, expectedKind: .command)
#expect(envelope.sessionID == sessionID)
#expect(envelope.televisionID == tvID)
#expect(envelope.sequence == 0)
#expect(envelope.expiresAt.timeIntervalSince1970 == 1_700_000_000.5)
}
@MainActor
@Test func watchDropsCommandsWhenUnreachableAndDuplicateRepliesCompleteOnce() async throws {
let fake = FakeWatchSession()
let bridge = WatchRelayBridge(session: fake)
bridge.activate()
bridge.activationDidComplete(error: nil)
fake.isReachable = false
bridge.beginSession()
#expect(fake.sent.isEmpty)
fake.isReachable = true
bridge.beginSession()
fake.replyTwice = true
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
await Task.yield()
#expect(bridge.state == .armed(sessionID: grant.sessionID, televisionID: grant.televisionID))
}
@MainActor
@Test func watchDeactivationDisarmsAndClearsGrant() 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()
bridge.didDeactivate()
#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)
}
@@ -0,0 +1,156 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgeCommon
import WatchBridgePhone
@MainActor
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 }
func sendMessage(_ message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void,
errorHandler: @escaping (Error) -> Void) {
sent.append(message)
reply = replyHandler
error = errorHandler
}
func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) }
func answer(_ value: [String: Any]) { reply?(value) }
}
private actor RecordingPhoneCoordinator: PhoneCommandCoordinating {
var activated: [WatchPeerGeneration] = []
var deactivated: [WatchPeerGeneration] = []
var commands: [CommandEnvelope] = []
let sessionID = UUID()
let televisionID = UUID()
func watchBridgeDidActivate(peer: WatchPeerGeneration) { activated.append(peer) }
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { deactivated.append(peer) }
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionGrant, RelayFailure> {
.success(RemoteSessionGrant(sessionID: sessionID, televisionID: televisionID,
issuedAt: Date(timeIntervalSince1970: 1_700_000_000)))
}
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionEnded, RelayFailure> {
.success(RemoteSessionEnded(sessionID: request.sessionID))
}
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt {
commands.append(envelope)
return CommandReceipt(id: envelope.id, status: .sentToTransport)
}
}
@MainActor
@Test func phoneWaitsForActorActivationAndForwardsCommandWithoutAdmission() async throws {
let session = FakePhoneSession()
let actor = RecordingPhoneCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor))
bridge.activate()
bridge.activationDidComplete(error: nil)
await Task.yield()
#expect({
if case .ready = bridge.state { return true }
return false
}())
let begin = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
bridge.receive(message: begin) { _ in }
await Task.yield()
#expect(session.sent.isEmpty)
let peerActivated = await actor.activated
#expect(peerActivated.count == 1)
bridge.receive(message: begin) { _ in }
await Task.yield()
#expect(session.sent.isEmpty)
}
@MainActor
@Test func phoneRejectsMalformedAndWrongDirectionRequests() async throws {
let session = FakePhoneSession()
let actor = RecordingPhoneCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor))
bridge.activate(); bridge.activationDidComplete(error: nil)
await Task.yield()
var replies = 0
bridge.receive(message: ["schemaVersion": true, "kind": "command", "payload": Data()]) { _ in
replies += 1
}
await Task.yield()
#expect(replies == 1)
}
private actor SuspendedCoordinator: PhoneCommandCoordinating {
var begins = 0
var active = false
var waiting: [CheckedContinuation<Result<RemoteSessionGrant, RelayFailure>, Never>] = []
func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true }
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false }
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
begins += 1
return await withCheckedContinuation { waiting.append($0) }
}
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result<RemoteSessionEnded, RelayFailure> { .failure(RelayFailure(code: .unavailable)) }
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { CommandReceipt(id: envelope.id, status: .sentToTransport) }
func release() { for continuation in waiting { continuation.resume(returning: .failure(RelayFailure(code: .unavailable))) }; waiting.removeAll() }
}
@MainActor
@Test func timeoutDoesNotWaitForUncooperativeActorAndBoundsOutstandingCalls() async throws {
let session = FakePhoneSession()
let coordinator = SuspendedCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
let message = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
var replies = 0
for _ in 0..<20 { bridge.receive(message: message) { _ in replies += 1 } }
try await Task.sleep(for: .milliseconds(1_150))
#expect(replies == 20)
#expect(await coordinator.begins == 16)
bridge.receive(message: message) { _ in replies += 1 }
#expect(replies == 21)
#expect(await coordinator.begins == 16)
await coordinator.release()
try await Task.sleep(for: .milliseconds(30))
#expect(replies == 21)
}
@MainActor
@Test func deactivationCompletesPendingRepliesBeforeActorReturns() async throws {
let session = FakePhoneSession()
let coordinator = SuspendedCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
var replies = 0
bridge.receive(message: try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()) { _ in replies += 1 }
try await Task.sleep(for: .milliseconds(30))
bridge.didDeactivate()
#expect(replies == 1)
await coordinator.release()
try await Task.sleep(for: .milliseconds(30))
#expect(replies == 1)
}
@MainActor
@Test func latestContextPublishedBeforeActivationIsFlushed() async throws {
let session = FakePhoneSession()
let coordinator = RecordingPhoneCoordinator()
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
let context = RelayApplicationContext(selectedTelevisionID: UUID(), displayName: "TV", isConnectionReady: true)
bridge.publishContext(context)
#expect(session.contexts.isEmpty)
bridge.activate(); bridge.activationDidComplete(error: nil)
try await Task.sleep(for: .milliseconds(30))
#expect(session.contexts.count == 1)
#expect(try RelayApplicationContext(dictionary: session.contexts[0]) == context)
}
@@ -0,0 +1,41 @@
import Foundation
import Testing
import RemoteCore
import WatchBridgePhone
private actor FakePhoneCoordinator: PhoneCommandCoordinating {
private(set) var receivedCommands: [CommandEnvelope] = []
private(set) var receivedPeers: [WatchPeerGeneration] = []
func watchBridgeDidActivate(peer: WatchPeerGeneration) {}
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) {}
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionGrant, RelayFailure> {
.failure(RelayFailure(code: .unavailable))
}
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
-> Result<RemoteSessionEnded, RelayFailure> {
.failure(RelayFailure(code: .sessionInvalidated))
}
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt {
receivedCommands.append(envelope)
receivedPeers.append(peer)
return CommandReceipt(id: envelope.id, status: .rejected)
}
}
@Test func phoneDependencyUsesInjectedActorAndPreservesItsDecision() async {
let actor = FakePhoneCoordinator()
let dependency = PhoneRelayDependency(coordinator: actor)
#expect(dependency.coordinator === actor)
let peer = WatchPeerGeneration()
let now = Date(timeIntervalSince1970: 1_700_000_000)
let command = CommandEnvelope(
sessionID: UUID(), televisionID: UUID(), sequence: 1,
issuedAt: now, expiresAt: now.addingTimeInterval(0.5), command: .right
)
let receipt = await dependency.coordinator.submitWatchCommand(command, peer: peer)
#expect(receipt == CommandReceipt(id: command.id, status: .rejected))
#expect(await actor.receivedCommands == [command])
#expect(await actor.receivedPeers == [peer])
}
+326 -22
View File
@@ -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)
}
}
+140
View File
@@ -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"
}
}
}
+38
View File
@@ -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?
}
+67
View File
@@ -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.
+360 -51
View File
@@ -1,67 +1,376 @@
import SwiftUI
import RemoteCore
/// Foundation UI: intentionally never opens a TV connection.
@MainActor
protocol PhoneExperienceService {
func requestLocalNetworkAccess() async -> Result<Void, PhoneExperienceError>
func pair(address: String, controllerName: String) async -> Result<PairedTelevision, PhoneExperienceError>
func send(_ command: RemoteCommand) async -> Result<PhoneCommandOutcome, PhoneExperienceError>
func forget() async -> Result<Void, PhoneExperienceError>
}
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<Void, PhoneExperienceError> {
scenario == .permissionDenied ? .failure(.localNetworkPermissionDenied) : .success(())
}
func pair(address: String, controllerName: String) async -> Result<PairedTelevision, PhoneExperienceError> {
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<PhoneCommandOutcome, PhoneExperienceError> {
scenario == .networkUnavailable ? .failure(.unavailable) : .success(.recordedInDemo)
}
func forget() async -> Result<Void, PhoneExperienceError> { .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)) }
+2 -1
View File
@@ -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) }
}
}
@@ -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<TimeInterval> = 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
}
}
@@ -0,0 +1,166 @@
import Foundation
/// Frozen v1 routing vocabulary. Dictionary parsing and request/reply routing
/// belong to WatchBridge; these constants are not an authorization mechanism.
public enum RelayMessageKind: String, Codable, Sendable, CaseIterable {
case beginSession, session, endSession, ended, command, receipt, error
}
public enum RelayWire {
public static let schemaVersion = 1
public static let maximumPayloadBytes = 4_096
public static let maximumCommandLifetimeMilliseconds: UInt64 = 1_000
}
public enum RelayContractError: Error, Sendable, Equatable {
case unsupportedSchema(Int)
case invalidLifetime
case invalidTimestamp
case payloadTooLarge
}
/// Control-plane structural invariants only. Command admission remains in the
/// phone actor and must never be consumed by a bridge decoder.
public protocol RelayControlPayload: Codable, Sendable {
func validate() throws
}
private func validateSchema(_ version: Int) throws {
guard version == RelayWire.schemaVersion else {
throw RelayContractError.unsupportedSchema(version)
}
}
public struct BeginRemoteSessionRequest: RelayControlPayload, Equatable {
public let schemaVersion: Int
public init(schemaVersion: Int = RelayWire.schemaVersion) {
self.schemaVersion = schemaVersion
}
public func validate() throws { try validateSchema(schemaVersion) }
}
public struct RemoteSessionGrant: RelayControlPayload, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
public let televisionID: UUID
public let issuedAt: Date
public let maximumCommandLifetimeMilliseconds: UInt64
public init(sessionID: UUID, televisionID: UUID, issuedAt: Date,
maximumCommandLifetimeMilliseconds: UInt64 = RelayWire.maximumCommandLifetimeMilliseconds,
schemaVersion: Int = RelayWire.schemaVersion) {
self.schemaVersion = schemaVersion
self.sessionID = sessionID
self.televisionID = televisionID
self.issuedAt = issuedAt
self.maximumCommandLifetimeMilliseconds = maximumCommandLifetimeMilliseconds
}
public func validate() throws {
try validateSchema(schemaVersion)
guard issuedAt.timeIntervalSince1970.isFinite else {
throw RelayContractError.invalidTimestamp
}
guard maximumCommandLifetimeMilliseconds > 0,
maximumCommandLifetimeMilliseconds <= RelayWire.maximumCommandLifetimeMilliseconds else {
throw RelayContractError.invalidLifetime
}
}
}
public struct EndRemoteSessionRequest: RelayControlPayload, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) {
self.schemaVersion = schemaVersion
self.sessionID = sessionID
}
public func validate() throws { try validateSchema(schemaVersion) }
}
public struct RemoteSessionEnded: RelayControlPayload, Equatable {
public let schemaVersion: Int
public let sessionID: UUID
public init(sessionID: UUID, schemaVersion: Int = RelayWire.schemaVersion) {
self.schemaVersion = schemaVersion
self.sessionID = sessionID
}
public func validate() throws { try validateSchema(schemaVersion) }
}
public struct RelayFailure: Error, RelayControlPayload, Equatable {
public enum Code: String, Codable, Sendable {
case unsupportedSchema, malformedRequest, noSelectedTelevision
case unavailable, sessionInvalidated
}
public let schemaVersion: Int
public let code: Code
public init(code: Code, schemaVersion: Int = RelayWire.schemaVersion) {
self.schemaVersion = schemaVersion
self.code = code
}
public func validate() throws { try validateSchema(schemaVersion) }
}
/// Every call creates a local coder, avoiding shared mutable coder concurrency.
/// Dates are numeric milliseconds since Unix epoch, never Foundation's default
/// reference-date seconds. A decoded command still requires actor admission.
public enum RelayJSON {
public static func encode<Value: Encodable>(_ value: Value) throws -> Data {
if let control = value as? any RelayControlPayload { try control.validate() }
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
let data = try encoder.encode(value)
guard data.count <= RelayWire.maximumPayloadBytes else {
throw RelayContractError.payloadTooLarge
}
return data
}
public static func decode<Value: Decodable>(_ type: Value.Type, from data: Data) throws -> Value {
guard data.count <= RelayWire.maximumPayloadBytes else {
throw RelayContractError.payloadTooLarge
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
let value = try decoder.decode(type, from: data)
if let control = value as? any RelayControlPayload { try control.validate() }
return value
}
}
/// Local activation generation, not a hardware identifier or wire value.
/// Create a fresh value each time the phone bridge activates a counterpart.
public struct WatchPeerGeneration: Sendable, Hashable {
public let id: UUID
public init(id: UUID = UUID()) { self.id = id }
}
/// A/integration implements this on the SAME actor that owns CommandAdmission
/// and TV dispatch. B injects it and can use a fake while A is implemented.
/// Never add a second consuming gate behind a forwarding implementation.
public protocol PhoneCommandCoordinating: Actor {
/// B serializes lifecycle events and awaits this before accepting requests.
/// Re-activating the same peer is idempotent; a new peer invalidates old
/// grants and pending work. This method must not pair or send a TV key.
func watchBridgeDidActivate(peer: WatchPeerGeneration) async
/// Validate the request and current peer, require a ready selected TV, and
/// issue a fresh session (invalidating any previous one for this peer).
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration)
async -> Result<RemoteSessionGrant, RelayFailure>
/// End only the named current session for this peer. Stale ends must not
/// invalidate a newer session; report sessionInvalidated instead.
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration)
async -> Result<RemoteSessionEnded, RelayFailure>
/// Sole semantic admission and TV dispatch boundary. Reject old peers and
/// recheck peer/session/TV/deadline after suspension before writing bytes.
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration)
async -> CommandReceipt
/// Ignore a stale peer's delayed deactivation. For the current peer,
/// invalidate sessions and pending commands; do not replay on activation.
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) async
}
@@ -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?
}
}
@@ -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)
}
}
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import RemoteCore
@Test func wireDatesUseUnixMillisecondsForGrantAndCommand() throws {
let session = UUID(), tv = UUID()
let issued = Date(timeIntervalSince1970: 1_700_000_000.125)
let grant = RemoteSessionGrant(sessionID: session, televisionID: tv, issuedAt: issued)
let data = try RelayJSON.encode(grant)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(object["issuedAt"] as? Double == 1_700_000_000_125)
#expect(try RelayJSON.decode(RemoteSessionGrant.self, from: data) == grant)
let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1,
issuedAt: issued, expiresAt: issued.addingTimeInterval(0.5), command: .right)
let commandData = try RelayJSON.encode(command)
let commandObject = try #require(JSONSerialization.jsonObject(with: commandData) as? [String: Any])
#expect(commandObject["expiresAt"] as? Double == 1_700_000_000_625)
#expect(try RelayJSON.decode(CommandEnvelope.self, from: commandData) == command)
}
@Test func controlPayloadsRoundTripAndFailureIsAnError() throws {
let session = UUID()
let begin = BeginRemoteSessionRequest()
let end = EndRemoteSessionRequest(sessionID: session)
let ended = RemoteSessionEnded(sessionID: session)
let failure = RelayFailure(code: .sessionInvalidated)
#expect(try RelayJSON.decode(BeginRemoteSessionRequest.self, from: RelayJSON.encode(begin)) == begin)
#expect(try RelayJSON.decode(EndRemoteSessionRequest.self, from: RelayJSON.encode(end)) == end)
#expect(try RelayJSON.decode(RemoteSessionEnded.self, from: RelayJSON.encode(ended)) == ended)
#expect(try RelayJSON.decode(RelayFailure.self, from: RelayJSON.encode(failure)) == failure)
let result: Result<RemoteSessionGrant, RelayFailure> = .failure(failure)
#expect(throws: failure) { try result.get() }
}
@Test func malformedOrUnsupportedControlPayloadsFailClosed() throws {
#expect(throws: RelayContractError.unsupportedSchema(2)) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":2}"#.utf8))
}
#expect(throws: (any Error).self) {
try RelayJSON.decode(EndRemoteSessionRequest.self, from: Data(#"{"schemaVersion":1,"sessionID":"bad"}"#.utf8))
}
#expect(throws: (any Error).self) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(#"{"schemaVersion":true}"#.utf8))
}
#expect(throws: RelayContractError.unsupportedSchema(9)) {
try RelayJSON.encode(RemoteSessionEnded(sessionID: UUID(), schemaVersion: 9))
}
}
@Test func invalidGrantLifetimeRejectedOnEncodeAndDecode() throws {
for lifetime: UInt64 in [0, 1_001, .max] {
let invalid = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(),
issuedAt: Date(timeIntervalSince1970: 1_000), maximumCommandLifetimeMilliseconds: lifetime)
#expect(throws: RelayContractError.invalidLifetime) { try RelayJSON.encode(invalid) }
// Bypass the validating encoder to simulate an untrusted peer.
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
let raw = try encoder.encode(invalid)
#expect(throws: RelayContractError.invalidLifetime) {
try RelayJSON.decode(RemoteSessionGrant.self, from: raw)
}
}
let invalidDate = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(),
issuedAt: Date(timeIntervalSince1970: .infinity))
#expect(throws: RelayContractError.invalidTimestamp) { try RelayJSON.encode(invalidDate) }
}
@Test func byteLimitIsCheckedBeforeDecodingAndAtEncoding() throws {
// JSON string quotes use 2 bytes. Exactly 4096 bytes is permitted.
let boundary = String(repeating: "a", count: RelayWire.maximumPayloadBytes - 2)
let encoded = try RelayJSON.encode(boundary)
#expect(encoded.count == RelayWire.maximumPayloadBytes)
#expect(try RelayJSON.decode(String.self, from: encoded) == boundary)
#expect(throws: RelayContractError.payloadTooLarge) { try RelayJSON.encode(boundary + "a") }
#expect(throws: RelayContractError.payloadTooLarge) {
try RelayJSON.decode(BeginRemoteSessionRequest.self, from: Data(repeating: 0, count: 4_097))
}
}
@Test func commandDecodeDoesNotConsumeOrReplaceAdmission() throws {
let session = UUID(), tv = UUID(), now = Date(timeIntervalSince1970: 1_000)
let command = CommandEnvelope(sessionID: session, televisionID: tv, sequence: 1,
issuedAt: now, expiresAt: now.addingTimeInterval(0.5), command: .left)
let data = try RelayJSON.encode(command)
var admission = CommandAdmission(sessionID: session, televisionID: tv)
#expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .accepted)
#expect(admission.admit(try RelayJSON.decode(CommandEnvelope.self, from: data), now: now) == .rejected(.duplicate))
}
@@ -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))
}
}
+4 -2
View File
@@ -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, a tested shared Swift package, an Xcode project, and implementation briefs for parallel agents. 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
```
+7 -5
View File
@@ -5,11 +5,13 @@ scratch=$(mktemp -d "${TMPDIR:-/private/tmp}/wristivo-check.XXXXXX")
trap 'rm -rf "$scratch"' EXIT
export CLANG_MODULE_CACHE_PATH="$scratch/clang-cache"
export SWIFTPM_MODULECACHE_OVERRIDE="$scratch/module-cache"
# This dependency-free package has no 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
# These local packages have no third-party dependencies or plugins. Disable nested SwiftPM sandboxing
# 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=()
+19
View File
@@ -25,6 +25,18 @@ package = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference)
package.relative_path = 'Packages/WristivoKit'
project.root_object.package_references << package
bridge_package = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference)
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)
@@ -63,12 +75,18 @@ add_sources(project, phone, 'Apps/iPhone/*.swift')
add_sources(project, watch, 'Apps/Watch/*.swift')
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'
@@ -77,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')
+263 -176
View File
@@ -7,41 +7,49 @@
objects = {
/* Begin PBXBuildFile section */
07891F143146642C0266FD875C601D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8EAD0E81A4073BB85995F21AC2B4AC6 /* Foundation.framework */; };
170DFF6AAFB8152BD51A4973BB5DF00F /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9D3CF76227B20B7C3540FB24BC876B3 /* Foundation.framework */; };
1B4C090099185A0D5A798AF87CDC8F23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; };
1B9C65E31D990E762267462A933553BA /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */; };
2F3BAA5EB973440A232134A3BD895BE1 /* RemoteCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8F92019D5A61504D315E161021F43F1A /* RemoteCore */; };
39CF36028D51F14FB3C052331BB95089 /* RemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */; };
3D66763F79D71C717673D84379DB1797 /* WristivoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */; };
41EC6157B4AF81E9C5964425A318DDD8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */; };
72715A7D6A205D60414F56FB18E0782C /* GestureCore in Frameworks */ = {isa = PBXBuildFile; productRef = 340123967C59555926E737A0F34AB1D8 /* GestureCore */; };
90D853831CC9607665EAA8B470A1FAFD /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; };
941586BDC5633D57DAEF324C5CAB5D14 /* SamsungProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */; };
A0F2F790688A596403FE392F949F865E /* CommandPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099286A1280A5A4F05B7D106924EF403 /* CommandPresentation.swift */; };
AFF041DC49AC1BAF0C719558A44B2955 /* WatchRemoteDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */; };
B774CEF1DAE7A9F591B141CD46AA6840 /* WristivoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4A082972BE30D2A9CA9B01AEDA159CD5 /* WristivoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D49E922B08FA5BB884D5F396CB911770 /* WristivoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */; };
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 */
2FD8B8B2F015FB57526A9D1F7D97014F /* PBXContainerItemProxy */ = {
C24B943398CBBE0C80BE10FEB2EEB2EE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A6B9AD07CCD6814B2B51DF0509865B11 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 92B9FFB5D4CDC0A46FD43667A67D7CDA;
remoteGlobalIDString = 021B5C7BA1AB0214281EFF292D2534DB;
remoteInfo = WristivoWatch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
1E513BDB1D02C7D7D28F78DF25F44A97 /* Embed Watch Content */ = {
F502729A53397FDE6D51E77DB05FEF56 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
B774CEF1DAE7A9F591B141CD46AA6840 /* WristivoWatch.app in Embed Watch Content */,
DA792A51BBB9BFFB0B920F6AE8945286 /* WristivoWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
@@ -50,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 = "<group>"; };
1CF531D616DF67256FF6B45F261F406A /* PhoneAppComposition.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PhoneAppComposition.swift; path = Apps/iPhone/PhoneAppComposition.swift; sourceTree = "<group>"; };
3D461BAF88CA54866E0E56A2DD436B4A /* RemoteDemoView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteDemoView.swift; path = Apps/iPhone/RemoteDemoView.swift; sourceTree = "<group>"; };
3E8D4E53699D4E49FA01E69284C27AF0 /* WatchRemoteDemoView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WatchRemoteDemoView.swift; path = Apps/Watch/WatchRemoteDemoView.swift; sourceTree = "<group>"; };
430827E671ADEF6F2A2CC46D934C0177 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Resources/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
@@ -57,29 +66,36 @@
72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = "<group>"; };
890F3EFC8807C413145F604429BF91F6 /* WristivoApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WristivoApp.swift; path = Apps/iPhone/WristivoApp.swift; sourceTree = "<group>"; };
96FEDE35530887CE7794CE846AE4AB84 /* WristivoWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WristivoWatchApp.swift; path = Apps/Watch/WristivoWatchApp.swift; sourceTree = "<group>"; };
9EB00680D6186AAB2100EFEB7ECF9783 /* LivePhoneView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LivePhoneView.swift; path = Apps/iPhone/LivePhoneView.swift; sourceTree = "<group>"; };
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 */
B88C11584DAE8E615A17FC886D69DCB5 /* Frameworks */ = {
9A0328506C66955C68928883584FC484 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
170DFF6AAFB8152BD51A4973BB5DF00F /* Foundation.framework in Frameworks */,
1B9C65E31D990E762267462A933553BA /* RemoteCore in Frameworks */,
941586BDC5633D57DAEF324C5CAB5D14 /* SamsungProtocol 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;
};
BA7EC3DC2B6177E7589C757502941595 /* Frameworks */ = {
B6BC727B014317D62607D9893E94C16A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
07891F143146642C0266FD875C601D6C /* Foundation.framework in Frameworks */,
2F3BAA5EB973440A232134A3BD895BE1 /* RemoteCore in Frameworks */,
72715A7D6A205D60414F56FB18E0782C /* GestureCore 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;
};
@@ -119,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 */,
@@ -138,36 +156,13 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1EE0B2EC50CDC1E93E13DDE8D3D0B85F /* Wristivo */ = {
021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 53BE8879069346EEFCBB651C0F32164D /* Build configuration list for PBXNativeTarget "Wristivo" */;
buildConfigurationList = E08F800FF2710E80C01363715E0E7460 /* Build configuration list for PBXNativeTarget "WristivoWatch" */;
buildPhases = (
3EF63CDCD6DED7DA7BAD8FBFF2490EF1 /* Sources */,
B88C11584DAE8E615A17FC886D69DCB5 /* Frameworks */,
B376C59BB378C28B89BD38291F2AF29F /* Resources */,
1E513BDB1D02C7D7D28F78DF25F44A97 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
B012FEF8A7F534D96D288D1D3E951AB1 /* PBXTargetDependency */,
);
name = Wristivo;
packageProductDependencies = (
17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */,
2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */,
);
productName = Wristivo;
productReference = A2F3C9F86650749EDA3E56A620D8ED43 /* Wristivo.app */;
productType = "com.apple.product-type.application";
};
92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 8603BA37D79B0E833C7EF148546C35FB /* Build configuration list for PBXNativeTarget "WristivoWatch" */;
buildPhases = (
57DA0310021CDADB3C00D1CAAB54F986 /* Sources */,
BA7EC3DC2B6177E7589C757502941595 /* Frameworks */,
7C2AD6858FE884341025B34CE43D029E /* Resources */,
297FEE65C9762D2C35AADE096BD7DC0D /* Sources */,
9A0328506C66955C68928883584FC484 /* Frameworks */,
A8500D0A0C0A200B2283E36BA000FA31 /* Resources */,
);
buildRules = (
);
@@ -175,13 +170,42 @@
);
name = WristivoWatch;
packageProductDependencies = (
8F92019D5A61504D315E161021F43F1A /* RemoteCore */,
340123967C59555926E737A0F34AB1D8 /* GestureCore */,
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 */
@@ -202,67 +226,74 @@
mainGroup = AA6CD4C83BB8EE679A0550AAAF6CA77D;
minimizedProjectReferenceProxies = 0;
packageReferences = (
80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */,
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 = (
1EE0B2EC50CDC1E93E13DDE8D3D0B85F /* Wristivo */,
92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */,
2359978616F0049FAAE3392CD7B62BAC /* Wristivo */,
021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
7C2AD6858FE884341025B34CE43D029E /* Resources */ = {
A8500D0A0C0A200B2283E36BA000FA31 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1B4C090099185A0D5A798AF87CDC8F23 /* PrivacyInfo.xcprivacy in Resources */,
763341B20334FB6F3E1A5A160BE4EACC /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B376C59BB378C28B89BD38291F2AF29F /* Resources */ = {
D912C772FCF20DFEEACCCB5892CF2A70 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
41EC6157B4AF81E9C5964425A318DDD8 /* PrivacyInfo.xcprivacy in Resources */,
9BD72C6A3AEFD7D9C0B2FF7CEE24C0A9 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3EF63CDCD6DED7DA7BAD8FBFF2490EF1 /* Sources */ = {
297FEE65C9762D2C35AADE096BD7DC0D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A0F2F790688A596403FE392F949F865E /* CommandPresentation.swift in Sources */,
39CF36028D51F14FB3C052331BB95089 /* RemoteDemoView.swift in Sources */,
3D66763F79D71C717673D84379DB1797 /* WristivoApp.swift in Sources */,
2589DC213E60F7600BF0C436CDA5A201 /* CommandPresentation.swift in Sources */,
8EEE5A0A19C98EEFEC8DBC74AE82BB1F /* WatchRemoteDemoView.swift in Sources */,
5E2DC3FB24B5F9350BFF6CC336B776D5 /* WristivoWatchApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
57DA0310021CDADB3C00D1CAAB54F986 /* Sources */ = {
8B4CDB0E36F0113815A7A847F175C7E3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
90D853831CC9607665EAA8B470A1FAFD /* CommandPresentation.swift in Sources */,
AFF041DC49AC1BAF0C719558A44B2955 /* WatchRemoteDemoView.swift in Sources */,
D49E922B08FA5BB884D5F396CB911770 /* 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 */
B012FEF8A7F534D96D288D1D3E951AB1 /* PBXTargetDependency */ = {
D40F97C4AB59EDDFB840293D32ACBDC6 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = WristivoWatch;
target = 92B9FFB5D4CDC0A46FD43667A67D7CDA /* WristivoWatch */;
targetProxy = 2FD8B8B2F015FB57526A9D1F7D97014F /* PBXContainerItemProxy */;
target = 021B5C7BA1AB0214281EFF292D2534DB /* WristivoWatch */;
targetProxy = C24B943398CBBE0C80BE10FEB2EEB2EE /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@@ -322,7 +353,7 @@
};
name = Release;
};
51D640C39FC1A3D291D46E18FEDC030A /* Debug */ = {
598823B10402AE1811A7F9BD64C3E9E4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
@@ -332,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)";
@@ -345,77 +378,6 @@
};
name = Debug;
};
553F6EB42092FB1108A50A2653994F7D /* 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_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;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
66C6C8170E97A44A5472C01194F2E1A0 /* 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;
};
69036077269CCDEE6A856173993766F6 /* 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;
};
7CF594D2A1A8B67A92FF51BEF2E85928 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 72EF8E42887FFEC5B4C2590143572AC2 /* Base.xcconfig */;
@@ -478,27 +440,84 @@
};
name = Debug;
};
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;
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;
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;
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;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
53BE8879069346EEFCBB651C0F32164D /* Build configuration list for PBXNativeTarget "Wristivo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
66C6C8170E97A44A5472C01194F2E1A0 /* Release */,
51D640C39FC1A3D291D46E18FEDC030A /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
8603BA37D79B0E833C7EF148546C35FB /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
553F6EB42092FB1108A50A2653994F7D /* Release */,
69036077269CCDEE6A856173993766F6 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
8CC8CEDC4982E5F11D3289DD1493CE12 /* Build configuration list for PBXProject "Wristivo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -508,36 +527,104 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DAB983D402EEB07761711034EDFB752E /* Build configuration list for PBXNativeTarget "Wristivo" */ = {
isa = XCConfigurationList;
buildConfigurations = (
80EDBFC744D81FC4D03D36A8A3830EE0 /* Release */,
598823B10402AE1811A7F9BD64C3E9E4 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E08F800FF2710E80C01363715E0E7460 /* Build configuration list for PBXNativeTarget "WristivoWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C76FED1BA41E91991F7AFDB4933D2309 /* Release */,
ACF4DD4FCB7A6DE481CB290113BAC91C /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */ = {
5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/WristivoKit;
};
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 */
17F7FFE0C5B5A43B2969CB383115F529 /* RemoteCore */ = {
0E02A167398F7B9444A964240446A2F1 /* TVTransport */ = {
isa = XCSwiftPackageProductDependency;
package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */;
productName = RemoteCore;
package = BFE41D2C320E50C462F22F5FEDD3115A /* XCLocalSwiftPackageReference "TVTransport" */;
productName = TVTransport;
};
2D9485594FC92EDB674203BAD168EDA4 /* SamsungProtocol */ = {
3C3315C9117F9BBC8BD3132FE6AD8BAB /* MotionCapture */ = {
isa = XCSwiftPackageProductDependency;
package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */;
productName = SamsungProtocol;
package = C0546D2DA5F55B16D1C355CA3EB03B56 /* XCLocalSwiftPackageReference "MotionCapture" */;
productName = MotionCapture;
};
340123967C59555926E737A0F34AB1D8 /* GestureCore */ = {
8B59743D623BC3D8F186FD3B820C6297 /* WatchBridgeWatch */ = {
isa = XCSwiftPackageProductDependency;
package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */;
package = 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */;
productName = WatchBridgeWatch;
};
9F6B7B14281EC69AA5E0EB5EA91BA14D /* WatchBridgePhone */ = {
isa = XCSwiftPackageProductDependency;
package = 57386EEA4988DA882D5A427050FD53AE /* XCLocalSwiftPackageReference "WatchBridge" */;
productName = WatchBridgePhone;
};
B2609C5AB45B5068ADCC82CD7DA1B244 /* GestureCore */ = {
isa = XCSwiftPackageProductDependency;
package = 5237B89A6F65701D5229E5D3EB6779A0 /* XCLocalSwiftPackageReference "WristivoKit" */;
productName = GestureCore;
};
8F92019D5A61504D315E161021F43F1A /* RemoteCore */ = {
BB680AC5AD94E2E1204C6737D7FBA374 /* RemoteCore */ = {
isa = XCSwiftPackageProductDependency;
package = 80E94E305196F000AA7B9F9E06E8D44B /* XCLocalSwiftPackageReference "WristivoKit" */;
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 */;
@@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EE0B2EC50CDC1E93E13DDE8D3D0B85F"
BlueprintIdentifier = "2359978616F0049FAAE3392CD7B62BAC"
BuildableName = "Wristivo.app"
BlueprintName = "Wristivo"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -30,7 +30,7 @@
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EE0B2EC50CDC1E93E13DDE8D3D0B85F"
BlueprintIdentifier = "2359978616F0049FAAE3392CD7B62BAC"
BuildableName = "Wristivo.app"
BlueprintName = "Wristivo"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -53,7 +53,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EE0B2EC50CDC1E93E13DDE8D3D0B85F"
BlueprintIdentifier = "2359978616F0049FAAE3392CD7B62BAC"
BuildableName = "Wristivo.app"
BlueprintName = "Wristivo"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -70,7 +70,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EE0B2EC50CDC1E93E13DDE8D3D0B85F"
BlueprintIdentifier = "2359978616F0049FAAE3392CD7B62BAC"
BuildableName = "Wristivo.app"
BlueprintName = "Wristivo"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92B9FFB5D4CDC0A46FD43667A67D7CDA"
BlueprintIdentifier = "021B5C7BA1AB0214281EFF292D2534DB"
BuildableName = "WristivoWatch.app"
BlueprintName = "WristivoWatch"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -30,7 +30,7 @@
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92B9FFB5D4CDC0A46FD43667A67D7CDA"
BlueprintIdentifier = "021B5C7BA1AB0214281EFF292D2534DB"
BuildableName = "WristivoWatch.app"
BlueprintName = "WristivoWatch"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -53,7 +53,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92B9FFB5D4CDC0A46FD43667A67D7CDA"
BlueprintIdentifier = "021B5C7BA1AB0214281EFF292D2534DB"
BuildableName = "WristivoWatch.app"
BlueprintName = "WristivoWatch"
ReferencedContainer = "container:Wristivo.xcodeproj">
@@ -70,7 +70,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92B9FFB5D4CDC0A46FD43667A67D7CDA"
BlueprintIdentifier = "021B5C7BA1AB0214281EFF292D2534DB"
BuildableName = "WristivoWatch.app"
BlueprintName = "WristivoWatch"
ReferencedContainer = "container:Wristivo.xcodeproj">
+11 -9
View File
@@ -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 | Planned |
| 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.
+18 -5
View File
@@ -1,14 +1,16 @@
# Command contract v1
Source of truth: `Packages/WristivoKit/Sources/RemoteCore`. The integration owner maintains these types. This document defines production adapter behavior in addition to the foundation types; the scaffold does not yet implement the relay/session 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. Proposed control-plane operations: `beginSession` returns v1, sessionID, televisionID and expiry policy; `endSession` invalidates that exact session; a new phone process, TV switch, re-pair, bridge deactivation or app stop invalidates old authorization. Add typed control-plane DTOs under integration ownership before transport implementation. Starting a session must never issue a TV key.
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.
Use `RelayJSON.encode(_:)` and `RelayJSON.decode(_:from:)` for all bridge payloads. They enforce a 4096-byte payload limit and the millisecond date strategy. Control DTO invariants are checked on encode and decode; direct Codable use bypasses that validation. The phone actor must still validate control requests from local callers. Command decoding performs no semantic admission and consumes no duplicate/sequence state. A grant is not a command; its issue date does not extend command lifetime. Apply the existing one-second command deadline independently to each command.
Sequence starts at 0 or 1 and strictly increases within a session; all senders in a given session must share one counter. At UInt64 exhaustion start a new session rather than wrapping. The receiver rejects wrong target/session/version, invalid/non-finite times, future timestamps beyond 250 ms, messages over one second old, expiry in the past, expiry preceding issue, or a declared lifetime longer than one second. No arbitrary clock-skew widening: report need to resync/start a new session. Test actual paired-device skew. Adapter also maintains a receiver-local monotonic deadline so wall-clock changes cannot extend pending work.
@@ -18,14 +20,25 @@ Validate decoded values at every trust boundary; Codable construction alone does
`CommandReceipt` correlates the envelope ID. Status is sentToTransport, rejected, expired, duplicate or unavailable. A successful send only means the adapter completed the socket write; Samsung may provide no per-key execution acknowledgement. Never display "TV confirmed" based only on that receipt. Timeout after possible delivery is uncertain; ask the user to issue a new action if needed rather than replaying.
Planned bridge container: `["schemaVersion": 1, "kind": "command", "payload": Data]` with bounded JSON payload (4 KiB maximum), delivered using sendMessage/replyHandler. Validate container types and payload size before decoding. Reply uses the same versioned container with kind `receipt`. Exactly one reply per incoming request, including errors. No passwords/tokens/raw motion in these messages. Test Codable round trips, invalid payloads, schema mismatches and restart cases.
Frozen bridge container: exactly `["schemaVersion": 1, "kind": String, "payload": Data]`, with bounded JSON payload (4 KiB maximum), delivered using sendMessage/replyHandler. `RelayMessageKind` freezes beginSession/session, endSession/ended, command/receipt, and error. Validate exact container keys/types and payload size before decoding. Boolean NSNumber is not an integer version; reject it explicitly. Unknown kinds, extra/missing container keys, inappropriate request/reply direction, unsupported container versions and malformed payloads fail closed with an error reply. DTO fields may evolve only under the compatibility policy below. Exactly one reply per incoming request, including errors. The request's WCSession reply closure supplies correlation for control requests; command receipts additionally match CommandEnvelope.id. Never execute a reply payload as a request. No passwords/tokens/raw motion in these messages.
## Adapter interfaces
Existing `RemoteTransport.send(_ envelope: CommandEnvelope) async -> CommandReceipt` is the command sink. The sender has no implicit retry guarantee. Platform agents propose small lifecycle protocols for connection, session acquisition, token repository and motion source through the integrator; do not hide setup inside `send` or start pairing in response to a gesture.
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.
`PhoneCommandCoordinating: Actor` is now the injected phone seam. A/integration implements it on the same actor that owns CommandAdmission and TV dispatch, alongside RemoteTransport if useful. B does not implement a second authoritative actor. Its methods are `watchBridgeDidActivate(peer:)`, `beginWatchSession(_:peer:)`, `endWatchSession(_:peer:)`, `submitWatchCommand(_:peer:)` and `watchBridgeDidDeactivate(peer:)`. A fake conforming actor is sufficient for B's independent implementation/tests; the live A actor is required only for end-to-end integration.
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 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.
+29
View File
@@ -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.
+6 -6
View File
@@ -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.
+16
View File
@@ -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.
+4 -1
View File
@@ -21,6 +21,8 @@ The coordinating engineer is the integration owner. Agents implement focused bra
Package paths in the table mean `Packages/WristivoKit/Sources/<module>` and `Tests/<module>Tests`. A new adapter needs tests in an adjacent Tests directory; integrator creates target membership before the implementation branch starts. QA may run acceptance planning immediately, but must not edit another agent's tests or report planned cases as passed.
**B prerequisites are now established.** Follow [the Watch relay handoff](WATCH-RELAY-HANDOFF.md): frozen session DTOs/codec/actor protocol live in RemoteCore/WatchSession.swift; the adjacent WatchBridge package provides three source/test targets and its platform products are linked to the apps. B works in a separate worktree, uses a fake actor until A is integrated, and does not need to take over the integration role. Adapter Package.swift files, dependency changes and Xcode project wiring remain integrator-owned; new Swift source/test files within existing target paths are B-owned and automatically included.
## A — TV transport prompt
> Implement an iPhone-only TV connection actor and pure bounded event parser. Use SamsungRemoteEncoder without copying a community implementation. Support explicit connect/pair/deny/timeout/revoke/disconnect, authorize before ready, serialize commands, consume admission once, recheck deadlines before sends, and cancel old-generation operations on TV/session changes. Integrate injected token/trust storage from E. No blanket TLS trust, arbitrary key API, silent plaintext fallback or replay on reconnect. Use synthetic redacted event fixtures for malformed/oversize/unknown/missing-token responses and fake socket tests for deadline, ordering and cancellation. Deliver a manual physical pairing script showing the name Wristivo, reconnect and revoke. Do not mark the TV compatible until the owner runs it. Begin with a hardware feasibility spike for the candidate endpoint and TLS policy before expanding UI dependencies.
@@ -57,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
+28
View File
@@ -0,0 +1,28 @@
# Watch relay — integration prerequisites ready
Scope: shared seams and compile/test membership. This is not a completed relay or a hardware verification.
## Instructions for B
Use the dedicated `feat/watch-relay` worktree prepared by the integrating engineer. Do not switch the original TV-transport checkout or edit shared contracts/project files. The integration commit in this worktree supplies all prerequisites below. A branch name alone does not isolate work: separate working directories are required when multiple agents run.
1. Read RemoteCore/WatchSession.swift and docs/CONTRACTS.md for exact types and semantics. The historical proposal's method signatures have been superseded.
2. Implement only Adapters/WatchBridge sources/tests. All three target memberships already exist and discover new Swift files automatically. The package manifest and Xcode generator remain integration-owned; ask only for an actual dependency/target change, not ordinary new source files.
3. Use PhoneCommandCoordinating through a fake actor while A implements production conformance. B owns WCSession activation, facades, live delivery, bounded containers, routing, timeout and reply-once handling. A/integration owns the sole authorized session/admission/TV-dispatch actor.
4. Use RelayJSON for all payloads; no default JSON date coders. Control DTO validation does not replace command admission. Match command receipt IDs and expected reply kinds. A timeout or an old-generation reply never retries an action or re-arms input.
5. Establish and capture a fresh local WatchPeerGeneration for each phone bridge activation. Deliver lifecycle changes in order and await activation before forwarding requests. Pass that peer on every actor call. Never accept a peer ID from wire data or put it in application context.
6. Keep OS APIs behind injectable facades and platform guards. Test fake behavior on macOS without subclassing WCSession; compile real delegates on each native SDK. Do not start WCSession or change the demo app behavior at module import.
## Required B tests
Exactly-once completion on success/error/timeout races; stale callback after deactivate/reactivate; ignored delayed begin grant/end acknowledgement; unreachable and unactivated session; wrong kind/version/types/extra keys; Bool-as-version rejection; 4096/4097-byte boundary; malformed/unknown payload; receipt ID mismatch; bounded pending requests; expiry while waiting; no replay after reconnect; application-context allowlist excludes tokens, raw motion and commands. Validate phone/watch counterpart replacement and reinstall recovery with fakes first.
Run `bash Scripts/check.sh`. The integrator also runs both app builds before merge. Hardware testing of locked-phone wake, relaunch, actual reachability and detached-debugger lifecycle remains unperformed and mandatory before feature completion. Do not block fake-driven implementation on access to a TV.
## Responsibilities for A/integration
Implement PhoneCommandCoordinating on the single existing/new TV command actor, validate caller and peer lifecycle, require a ready selected TV for a grant, and make stale end/deactivation harmless. Test actor reentrancy and recheck all state before socket writes. Do not forward an admitted command into another consuming admission gate. The storage/TLS proposal from A is outside this relay-prerequisite patch and remains separate integration work.
## Delivery
Return a branch/commit, focused test evidence, explicit native compilation status, documented errors and remaining hardware gates. Do not push main. The user is handling remote pushes; this prerequisite change does not push anything.
@@ -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.
@@ -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.