Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "PhoneControl",
|
||||
platforms: [.iOS(.v17), .macOS(.v14)],
|
||||
products: [.library(name: "PhoneControl", targets: ["PhoneControl"])],
|
||||
dependencies: [
|
||||
.package(path: "../../Packages/WristivoKit"),
|
||||
.package(path: "../TVTransport"), .package(path: "../SecureStorage")
|
||||
],
|
||||
targets: [
|
||||
.target(name: "PhoneControl", dependencies: [
|
||||
.product(name: "RemoteCore", package: "WristivoKit"),
|
||||
.product(name: "SamsungProtocol", package: "WristivoKit"),
|
||||
"TVTransport", "SecureStorage"
|
||||
]),
|
||||
.testTarget(name: "PhoneControlTests", dependencies: ["PhoneControl"])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
# PhoneControl
|
||||
|
||||
Integrator-owned orchestration for the iPhone. `LivePhoneController` is main-actor UI state with injected TV actor, security repository and selected-TV store. It never implements command admission or watch grant issuance. `KeychainSelectedTelevisionStore` keeps a stable opaque TV UUID and address in device-only Keychain storage, separate from credentials.
|
||||
|
||||
Pairing is explicit. Restore/reconnect only uses an existing token. Failed metadata reads/writes do not start pairing; changing the address requires forgetting the old target. Forget disconnects first and retains the identity if deleting credentials fails. Trust failure only offers a certificate candidate; explicit approval stores a pin and requires a separate pairing action. Demo mode disconnects live transport.
|
||||
|
||||
Tests inject actors and stores; they do not access the real Keychain or network. Native composition lives in Apps/iPhone/PhoneAppComposition.swift. Device lock/relaunch and Keychain accessibility remain physical gates.
|
||||
@@ -0,0 +1,225 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
import SamsungProtocol
|
||||
import SecureStorage
|
||||
import TVTransport
|
||||
|
||||
public protocol PhoneTVControlling: Actor {
|
||||
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
|
||||
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState
|
||||
func disconnect() async
|
||||
func stateUpdates() async -> AsyncStream<TVConnectionState>
|
||||
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt
|
||||
}
|
||||
|
||||
extension TVConnectionActor: PhoneTVControlling { }
|
||||
|
||||
public protocol PhoneAuthorizationManaging: Sendable {
|
||||
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws
|
||||
func forgetTelevision(_ televisionID: UUID) async throws
|
||||
}
|
||||
|
||||
extension KeychainTVAuthorizationStore: PhoneAuthorizationManaging { }
|
||||
|
||||
public enum PhoneControlError: Error, Equatable, Sendable {
|
||||
case invalidAddress, forgetFirst, storageUnavailable
|
||||
}
|
||||
|
||||
public struct CertificateCandidate: Equatable, Sendable, Identifiable {
|
||||
public let id: UUID
|
||||
public let host: String
|
||||
public let sha256: Data
|
||||
public var fingerprint: String { sha256.map { String(format: "%02X", $0) }.joined(separator: ":") }
|
||||
}
|
||||
|
||||
/// Composition state only. Authorization, admission and TV writes belong to the
|
||||
/// injected TV actor; this object never retries a command or creates a watch grant.
|
||||
@MainActor
|
||||
public final class LivePhoneController: ObservableObject {
|
||||
@Published public private(set) var television: SelectedTelevision?
|
||||
@Published public private(set) var connection: TVConnectionState = .disconnected
|
||||
@Published public private(set) var certificate: CertificateCandidate?
|
||||
@Published public private(set) var isBusy = false
|
||||
@Published public private(set) var notice: String?
|
||||
@Published public private(set) var error: String?
|
||||
@Published public private(set) var isDemo = false
|
||||
|
||||
public var onSelectionChanged: ((SelectedTelevision?, Bool) -> Void)?
|
||||
public var isReady: Bool {
|
||||
guard !isDemo, let television else { return false }
|
||||
return connection == .ready(televisionID: television.id)
|
||||
}
|
||||
|
||||
private let transport: any PhoneTVControlling
|
||||
private let authorization: any PhoneAuthorizationManaging
|
||||
private let selection: any SelectedTelevisionStoring
|
||||
private var observation: Task<Void, Never>?
|
||||
private var restored = false
|
||||
private var acceptingCertificate = false
|
||||
|
||||
public init(transport: any PhoneTVControlling, authorization: any PhoneAuthorizationManaging,
|
||||
selection: any SelectedTelevisionStoring) {
|
||||
self.transport = transport
|
||||
self.authorization = authorization
|
||||
self.selection = selection
|
||||
}
|
||||
|
||||
deinit { observation?.cancel() }
|
||||
|
||||
public func start() async {
|
||||
guard !isBusy, !isDemo, !restored else { return }
|
||||
observeConnection()
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
television = try await selection.load()
|
||||
restored = true
|
||||
publish()
|
||||
if let television {
|
||||
acceptingCertificate = true
|
||||
defer { acceptingCertificate = false }
|
||||
_ = try SamsungRemoteEncoder.endpoint(host: television.host)
|
||||
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
|
||||
publish()
|
||||
}
|
||||
} catch {
|
||||
self.error = "Saved TV information could not be read. Unlock the iPhone and try again."
|
||||
}
|
||||
}
|
||||
|
||||
public func pair(address: String) async {
|
||||
guard !isBusy, !isDemo else { return }
|
||||
clearFeedback()
|
||||
let host = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard (try? SamsungRemoteEncoder.endpoint(host: host)) != nil else {
|
||||
error = "Enter a bare TV address or hostname from its network settings, without a URL or port."
|
||||
return
|
||||
}
|
||||
isBusy = true
|
||||
defer { isBusy = false; acceptingCertificate = false }
|
||||
do {
|
||||
// Load before writing: a failed/locked read must not orphan an existing identity.
|
||||
if !restored { television = try await selection.load(); restored = true }
|
||||
if let existing = television, existing.host != host {
|
||||
error = "Forget the saved TV before selecting a different address."
|
||||
return
|
||||
}
|
||||
let selected = television ?? SelectedTelevision(host: host)
|
||||
try await selection.save(selected)
|
||||
television = selected
|
||||
certificate = nil
|
||||
acceptingCertificate = true
|
||||
observeConnection()
|
||||
connection = .connecting(televisionID: selected.id)
|
||||
publish()
|
||||
connection = await transport.pair(target: .init(id: selected.id, host: host), sessionID: UUID())
|
||||
publish()
|
||||
} catch {
|
||||
self.error = "TV information could not be saved securely. Unlock the iPhone and try again. No pairing was started."
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnect() async {
|
||||
guard !isBusy, !isDemo else { return }
|
||||
if !restored { await start(); return }
|
||||
guard let television else { return }
|
||||
isBusy = true
|
||||
clearFeedback()
|
||||
certificate = nil
|
||||
acceptingCertificate = true
|
||||
defer { isBusy = false; acceptingCertificate = false }
|
||||
connection = .connecting(televisionID: television.id)
|
||||
publish()
|
||||
connection = await transport.connect(target: .init(id: television.id, host: television.host), sessionID: UUID())
|
||||
publish()
|
||||
}
|
||||
|
||||
/// A rejected TLS challenge can only produce a review candidate, never trust.
|
||||
public func receiveUntrustedCertificate(host: String, sha256: Data) {
|
||||
guard !isDemo, acceptingCertificate, let television,
|
||||
normalized(host) == normalized(television.host), sha256.count == 32 else { return }
|
||||
certificate = CertificateCandidate(id: television.id, host: television.host, sha256: sha256)
|
||||
}
|
||||
|
||||
public func approveCertificate(_ candidate: CertificateCandidate) async {
|
||||
guard !isBusy, !isDemo, candidate == certificate, candidate.id == television?.id else { return }
|
||||
isBusy = true
|
||||
clearFeedback()
|
||||
defer { isBusy = false }
|
||||
await transport.disconnect()
|
||||
connection = .disconnected
|
||||
publish()
|
||||
do {
|
||||
try await authorization.approveCertificate(sha256: candidate.sha256, for: candidate.id, approvedAt: Date())
|
||||
certificate = nil
|
||||
notice = "Certificate saved for this TV. Tap Pair / pair again, then approve Wristivo on the TV."
|
||||
} catch { self.error = "Certificate approval could not be saved. Unlock the iPhone and try again." }
|
||||
}
|
||||
|
||||
public func send(_ command: RemoteCommand) async {
|
||||
guard !isBusy, isReady else { return }
|
||||
isBusy = true
|
||||
clearFeedback()
|
||||
defer { isBusy = false }
|
||||
let receipt = await transport.sendPhoneCommand(command)
|
||||
switch receipt.status {
|
||||
case .sentToTransport: notice = "Sent to TV transport. The TV's response is not confirmed."
|
||||
case .unavailable: error = "Delivery is uncertain. Check the TV before issuing a new action. Nothing will be replayed."
|
||||
case .expired: error = "The command expired and was not sent."
|
||||
case .rejected, .duplicate: error = "The command was rejected. Reconnect before trying a new action."
|
||||
}
|
||||
}
|
||||
|
||||
public func forget() async {
|
||||
guard !isBusy, !isDemo else { return }
|
||||
isBusy = true
|
||||
clearFeedback()
|
||||
defer { isBusy = false }
|
||||
await transport.disconnect()
|
||||
connection = .disconnected
|
||||
certificate = nil
|
||||
publish()
|
||||
do {
|
||||
let saved: SelectedTelevision?
|
||||
if let television { saved = television } else { saved = try await selection.load() }
|
||||
if let saved { try await authorization.forgetTelevision(saved.id) }
|
||||
// Keep metadata if secret deletion fails so the user can retry deletion.
|
||||
try await selection.delete()
|
||||
television = nil
|
||||
restored = true
|
||||
notice = "Saved TV access and certificate approval were removed from this iPhone."
|
||||
publish()
|
||||
} catch { self.error = "The saved TV could not be completely removed. Unlock the iPhone and retry Forget TV." }
|
||||
}
|
||||
|
||||
public func setDemo(_ enabled: Bool) async {
|
||||
guard !isBusy, enabled != isDemo else { return }
|
||||
isBusy = true
|
||||
await transport.disconnect()
|
||||
connection = .disconnected
|
||||
certificate = nil
|
||||
isDemo = enabled
|
||||
clearFeedback()
|
||||
publish()
|
||||
isBusy = false
|
||||
if !enabled { await reconnect() }
|
||||
}
|
||||
|
||||
private func observeConnection() {
|
||||
guard observation == nil else { return }
|
||||
let transport = transport
|
||||
observation = Task { [weak self] in
|
||||
let stream = await transport.stateUpdates()
|
||||
for await state in stream {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.connection = state
|
||||
self.publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() { onSelectionChanged?(isDemo ? nil : television, isReady) }
|
||||
private func clearFeedback() { notice = nil; error = nil }
|
||||
private func normalized(_ host: String) -> String { host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import SecureStorage
|
||||
|
||||
public struct SelectedTelevision: Codable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
public let host: String
|
||||
|
||||
public init(id: UUID = UUID(), name: String = "My TV", host: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.host = host
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SelectedTelevisionStoring: Sendable {
|
||||
func load() async throws -> SelectedTelevision?
|
||||
func save(_ television: SelectedTelevision) async throws
|
||||
func delete() async throws
|
||||
}
|
||||
|
||||
/// Device-only metadata keeps the opaque identity stable across app launches.
|
||||
/// This separate Keychain item contains no TV token or certificate.
|
||||
public actor KeychainSelectedTelevisionStore: SelectedTelevisionStoring {
|
||||
private let client: any KeychainClient
|
||||
private let service = "com.wristivo.selected-television"
|
||||
private let account = "selected"
|
||||
|
||||
public init(client: any KeychainClient = SystemKeychainClient()) { self.client = client }
|
||||
|
||||
public func load() async throws -> SelectedTelevision? {
|
||||
do {
|
||||
let data = try client.read(service: service, account: account)
|
||||
guard data.count <= 4096 else { throw PhoneControlError.storageUnavailable }
|
||||
return try JSONDecoder().decode(SelectedTelevision.self, from: data)
|
||||
} catch KeychainError.notFound { return nil }
|
||||
}
|
||||
|
||||
public func save(_ television: SelectedTelevision) async throws {
|
||||
let data = try JSONEncoder().encode(television)
|
||||
do { try client.update(service: service, account: account, data: data) }
|
||||
catch KeychainError.notFound {
|
||||
try client.add(service: service, account: account, data: data,
|
||||
accessibility: .afterFirstUnlockThisDeviceOnly)
|
||||
}
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
do { try client.delete(service: service, account: account) }
|
||||
catch KeychainError.notFound { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
import TVTransport
|
||||
@testable import PhoneControl
|
||||
|
||||
private actor FakeTV: PhoneTVControlling {
|
||||
var paired: [TelevisionTarget] = []
|
||||
var connected: [TelevisionTarget] = []
|
||||
var commands: [RemoteCommand] = []
|
||||
var disconnects = 0
|
||||
var pairHook: (@Sendable () async -> Void)?
|
||||
func setPairHook(_ hook: @escaping @Sendable () async -> Void) { pairHook = hook }
|
||||
var status: CommandReceipt.Status = .sentToTransport
|
||||
func pair(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
|
||||
paired.append(target)
|
||||
if let pairHook { await pairHook(); return .untrusted(televisionID: target.id) }
|
||||
return .ready(televisionID: target.id)
|
||||
}
|
||||
func connect(target: TelevisionTarget, sessionID: UUID) async -> TVConnectionState {
|
||||
connected.append(target); return .ready(televisionID: target.id)
|
||||
}
|
||||
func disconnect() async { disconnects += 1 }
|
||||
func stateUpdates() async -> AsyncStream<TVConnectionState> { AsyncStream { $0.finish() } }
|
||||
func sendPhoneCommand(_ command: RemoteCommand) async -> CommandReceipt {
|
||||
commands.append(command); return .init(id: UUID(), status: status)
|
||||
}
|
||||
func failWrites() { status = .unavailable }
|
||||
}
|
||||
|
||||
private actor MemorySelection: SelectedTelevisionStoring {
|
||||
var value: SelectedTelevision?
|
||||
var failSave = false
|
||||
init(_ value: SelectedTelevision? = nil, failSave: Bool = false) { self.value = value; self.failSave = failSave }
|
||||
func load() async throws -> SelectedTelevision? { value }
|
||||
func save(_ television: SelectedTelevision) async throws {
|
||||
if failSave { throw PhoneControlError.storageUnavailable }; value = television
|
||||
}
|
||||
func delete() async throws { value = nil }
|
||||
}
|
||||
|
||||
private actor MemoryAuthorization: PhoneAuthorizationManaging {
|
||||
var forgotten: [UUID] = []
|
||||
var approvals = 0
|
||||
let failDelete: Bool
|
||||
init(failDelete: Bool = false) { self.failDelete = failDelete }
|
||||
func approveCertificate(sha256: Data, for televisionID: UUID, approvedAt: Date) async throws { approvals += 1 }
|
||||
func forgetTelevision(_ televisionID: UUID) async throws {
|
||||
if failDelete { throw PhoneControlError.storageUnavailable }; forgotten.append(televisionID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PhoneControlTests {
|
||||
@Test func restorationUsesSavedIdentityAndNeverPairs() async {
|
||||
let selected = SelectedTelevision(host: "tv.local")
|
||||
let tv = FakeTV()
|
||||
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(selected))
|
||||
await controller.start()
|
||||
#expect(controller.television == selected)
|
||||
#expect(controller.isReady)
|
||||
#expect(await tv.paired.isEmpty)
|
||||
#expect(await tv.connected.map(\.id) == [selected.id])
|
||||
await controller.pair(address: selected.host)
|
||||
#expect(await tv.paired.map(\.id) == [selected.id])
|
||||
}
|
||||
|
||||
@Test func storageFailurePreventsPairingAndDifferentTargetRequiresForget() async {
|
||||
let tv = FakeTV()
|
||||
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(failSave: true))
|
||||
await controller.pair(address: "tv.local")
|
||||
#expect(await tv.paired.isEmpty)
|
||||
#expect(controller.error != nil)
|
||||
let existing = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "old.local")))
|
||||
await existing.pair(address: "new.local")
|
||||
#expect(await tv.paired.isEmpty)
|
||||
#expect(existing.television?.host == "old.local")
|
||||
}
|
||||
|
||||
@Test func partialForgetKeepsIdentityForDeletionRetryAndDisconnects() async throws {
|
||||
let selected = SelectedTelevision(host: "tv.local")
|
||||
let store = MemorySelection(selected)
|
||||
let tv = FakeTV()
|
||||
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(failDelete: true), selection: store)
|
||||
await controller.start()
|
||||
await controller.forget()
|
||||
#expect(!controller.isReady)
|
||||
#expect(controller.error != nil)
|
||||
#expect(try await store.load() == selected)
|
||||
#expect(await tv.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func forgetDeletesSecurityAndSelection() async throws {
|
||||
let selected = SelectedTelevision(host: "tv.local")
|
||||
let store = MemorySelection(selected)
|
||||
let auth = MemoryAuthorization()
|
||||
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: store)
|
||||
await controller.start(); await controller.forget()
|
||||
#expect(controller.television == nil)
|
||||
#expect(try await store.load() == nil)
|
||||
#expect(await auth.forgotten == [selected.id])
|
||||
}
|
||||
|
||||
@Test func ambiguousWriteIsNotReplayedAndDemoDisconnectsLiveRoute() async {
|
||||
let tv = FakeTV()
|
||||
await tv.failWrites()
|
||||
let controller = LivePhoneController(transport: tv, authorization: MemoryAuthorization(), selection: MemorySelection(.init(host: "tv.local")))
|
||||
await controller.start(); await controller.send(.right)
|
||||
#expect(await tv.commands == [.right])
|
||||
#expect(controller.error?.contains("uncertain") == true)
|
||||
await controller.setDemo(true); await controller.send(.left)
|
||||
#expect(controller.isDemo && !controller.isReady)
|
||||
#expect(await tv.commands == [.right])
|
||||
#expect(await tv.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func certificateRequiresExplicitApprovalAndDoesNotRetryPairing() async throws {
|
||||
let auth = MemoryAuthorization()
|
||||
let tv = FakeTV()
|
||||
let controller = LivePhoneController(transport: tv, authorization: auth, selection: MemorySelection())
|
||||
await tv.setPairHook {
|
||||
await controller.receiveUntrustedCertificate(host: "other.local", sha256: Data(repeating: 1, count: 32))
|
||||
await controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 2, count: 32))
|
||||
}
|
||||
await controller.pair(address: "tv.local")
|
||||
let candidate = try #require(controller.certificate)
|
||||
#expect(candidate.sha256 == Data(repeating: 2, count: 32))
|
||||
#expect(await auth.approvals == 0)
|
||||
await controller.approveCertificate(candidate)
|
||||
#expect(await auth.approvals == 1)
|
||||
#expect(await tv.paired.count == 1)
|
||||
#expect(!controller.isReady)
|
||||
#expect(controller.certificate == nil)
|
||||
await controller.approveCertificate(candidate)
|
||||
#expect(await auth.approvals == 1)
|
||||
}
|
||||
|
||||
@Test func unsolicitedCertificateIsNotOfferedOrApproved() async {
|
||||
let auth = MemoryAuthorization()
|
||||
let controller = LivePhoneController(transport: FakeTV(), authorization: auth, selection: MemorySelection(.init(host: "tv.local")))
|
||||
await controller.start()
|
||||
controller.receiveUntrustedCertificate(host: "tv.local", sha256: Data(repeating: 1, count: 32))
|
||||
#expect(controller.certificate == nil)
|
||||
#expect(await auth.approvals == 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user