Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
@@ -0,0 +1,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 { }
}
}