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