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
@@ -1,4 +1,5 @@
import Foundation
import CoreFoundation
import RemoteCore
private func relayNSNumberIsBoolean(_ value: NSNumber) -> Bool {
@@ -88,22 +89,24 @@ public struct RelayApplicationContext: Sendable, Equatable {
public let schemaVersion: Int
public let selectedTelevisionID: UUID?
public let displayName: String?
public let isConnectionReady: Bool
public init(selectedTelevisionID: UUID?, displayName: String?) {
public init(selectedTelevisionID: UUID?, displayName: String?, isConnectionReady: Bool = false) {
self.schemaVersion = RelayWire.schemaVersion
self.selectedTelevisionID = selectedTelevisionID
self.displayName = displayName
self.isConnectionReady = isConnectionReady && selectedTelevisionID != nil
}
public func dictionary() throws -> [String: Any] {
var result: [String: Any] = ["schemaVersion": schemaVersion]
var result: [String: Any] = ["schemaVersion": schemaVersion, "isConnectionReady": isConnectionReady]
if let selectedTelevisionID { result["selectedTelevisionID"] = selectedTelevisionID.uuidString }
if let displayName { result["displayName"] = String(displayName.prefix(128)) }
return result
}
public init(dictionary: [String: Any]) throws {
let allowed: Set<String> = ["schemaVersion", "selectedTelevisionID", "displayName"]
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),
@@ -127,6 +130,12 @@ public struct RelayApplicationContext: Sendable, Equatable {
} else {
self.displayName = nil
}
if let raw = dictionary["isConnectionReady"] {
guard let number = raw as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
throw RelayContainerError.invalidPayload
}
self.isConnectionReady = number.boolValue && selectedTelevisionID != nil
} else { self.isConnectionReady = false }
self.schemaVersion = version.intValue
}
}
@@ -168,12 +177,13 @@ public final class RelayReplyOnce: @unchecked Sendable {
/// a delegate context. This explicit boundary lets the native delegate hop to
/// the main actor without pretending the dictionary itself is Sendable.
public final class RelayInboundMessage: @unchecked Sendable {
public let message: [String: Any]
private let container: RelayContainer?
public var message: [String: Any] { container?.dictionary() ?? [:] }
public let reply: RelayReplyOnce
public init(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
self.message = message
self.container = try? RelayContainer(dictionary: message)
self.reply = RelayReplyOnce(replyHandler)
}
}
@@ -19,7 +19,8 @@ public final class WatchRelayBridge: NSObject {
public var onStateChange: ((State) -> Void)?
private let session: any RelaySessionSending
private var grant: RemoteSessionGrant?
public private(set) var grant: RemoteSessionGrant?
private var generation = UUID()
private var nextSequence: UInt64 = 0
private var activationReady = false
private var pendingReplies: [UUID: RelayReplyOnce] = [:]
@@ -46,7 +47,9 @@ public final class WatchRelayBridge: NSObject {
}
public func reachabilityDidChange() {
guard activationReady, grant == nil else { return }
guard activationReady else { return }
if !session.isReachable { invalidatePending() }
if grant != nil { return }
state = .ready(reachable: session.isReachable)
}
@@ -65,10 +68,13 @@ public final class WatchRelayBridge: NSObject {
}
public func beginSession() {
guard grant == nil, state != .sessionNegotiating else { return }
guard activationReady, session.isReachable, grant == nil else {
state = .unavailable
return
}
invalidatePending()
let requestGeneration = generation
state = .sessionNegotiating
let requestID = UUID()
let request = BeginRemoteSessionRequest()
@@ -77,7 +83,7 @@ public final class WatchRelayBridge: NSObject {
return
}
send(message: message, requestID: requestID) { [weak self] response in
guard let self else { return }
guard let self, self.generation == requestGeneration, self.session.isReachable else { return }
guard let container = try? RelayContainer(dictionary: response),
container.kind == .session,
let received = try? container.decode(RemoteSessionGrant.self,
@@ -93,33 +99,34 @@ public final class WatchRelayBridge: NSObject {
}
public func endSession() {
guard activationReady, let grant else { return }
let request = EndRemoteSessionRequest(sessionID: grant.sessionID)
guard let message = try? RelayContainer(kind: .endSession, value: request).dictionary() else {
self.grant = nil
state = .ready(reachable: session.isReachable)
return
}
let requestID = UUID()
send(message: message, requestID: requestID) { [weak self] response in
guard let self else { return }
guard self.grant?.sessionID == grant.sessionID else { return }
self.grant = nil
self.nextSequence = 0
self.state = .ready(reachable: self.session.isReachable)
let oldGrant = grant
invalidatePending()
state = activationReady ? .ready(reachable: session.isReachable) : .inactive
guard activationReady, session.isReachable, let oldGrant,
let message = try? RelayContainer(kind: .endSession, value: EndRemoteSessionRequest(sessionID: oldGrant.sessionID)).dictionary() else { return }
// Locally revoke immediately; a delayed end reply cannot revoke a newer grant.
send(message: message, requestID: UUID()) { _ in }
}
public func sendCommandResult(_ command: RemoteCommand) async -> Result<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()) {
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
@@ -132,17 +139,20 @@ public final class WatchRelayBridge: NSObject {
expiresAt: now.addingTimeInterval(lifetime),
command: command)
guard let message = try? RelayContainer(kind: .command, value: envelope).dictionary() else {
completion(.failure(.init(code: .malformedRequest)))
return
}
let requestGeneration = generation
send(message: message, requestID: UUID()) { [weak self] response in
guard let self,
guard let self, self.generation == requestGeneration,
let container = try? RelayContainer(dictionary: response),
container.kind == .receipt,
let receipt = try? container.decode(CommandReceipt.self,
expectedKind: .receipt),
receipt.id == envelope.id else { return }
receipt.id == envelope.id else { completion(.failure(.init(code: .unavailable))); return }
completion(.success(receipt))
// A receipt is command feedback, not evidence that the TV changed.
if receipt.status == .unavailable { self.state = .unavailable }
if receipt.status != .sentToTransport { self.invalidatePending(); self.state = .unavailable }
}
}
@@ -150,12 +160,12 @@ public final class WatchRelayBridge: NSObject {
response: @escaping @MainActor ([String: Any]) -> Void) {
guard pendingReplies.count < 32 else {
state = .unavailable
response(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let once = RelayReplyOnce { [weak self] value in
Task { @MainActor [weak self] in
guard let self, self.pendingReplies.removeValue(forKey: requestID) != nil else { return }
guard self.activationReady else { return }
response(value)
}
}
@@ -172,8 +182,8 @@ public final class WatchRelayBridge: NSObject {
}
private func invalidatePending() {
let replies = pendingReplies.values
pendingReplies.removeAll()
generation = UUID()
let replies = Array(pendingReplies.values)
for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) }
grant = nil
nextSequence = 0
@@ -33,8 +33,12 @@ private final class WatchWCSessionSending: RelaySessionSending {
@MainActor
public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
public let bridge: WatchRelayBridge
public var onContext: ((RelayApplicationContext) -> Void)?
public private(set) var latestContext: RelayApplicationContext?
private let session: WCSession
public init(session: WCSession = .default) {
self.session = session
bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session))
super.init()
session.delegate = self
@@ -48,6 +52,21 @@ public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
guard let self else { return }
if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() }
self.bridge.activationDidComplete(error: error)
self.receiveContext(self.session.receivedApplicationContext)
}
}
private func receiveContext(_ dictionary: [String: Any]) {
guard let context = try? RelayApplicationContext(dictionary: dictionary) else { return }
latestContext = context
onContext?(context)
}
nonisolated public func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
guard let context = try? RelayApplicationContext(dictionary: applicationContext) else { return }
Task { @MainActor [weak self] in
self?.latestContext = context
self?.onContext?(context)
}
}
@@ -16,6 +16,21 @@ public final class PhoneRelayBridge: NSObject {
private let coordinator: any PhoneCommandCoordinating
private var peer: WatchPeerGeneration?
private var activationReady = false
private var lifecycleTask: Task<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) {
@@ -31,19 +46,24 @@ public final class PhoneRelayBridge: NSObject {
}
public func activationDidComplete(error: Error?) {
guard error == nil else {
guard error == nil, session.activationState == .activated else {
didDeactivate()
state = .unavailable
activationReady = false
return
}
guard peer == nil else { flushContext(); return }
let newPeer = WatchPeerGeneration()
peer = newPeer
activationReady = false
state = .ready(reachable: session.isReachable)
Task { [weak self] in
let prior = lifecycleTask
lifecycleTask = Task { [weak self] in
await prior?.value
await self?.coordinator.watchBridgeDidActivate(peer: newPeer)
guard let self, self.peer == newPeer else { return }
self.activationReady = true
self.flushContext()
}
}
@@ -63,7 +83,12 @@ public final class PhoneRelayBridge: NSObject {
peer = nil
activationReady = false
state = .inactive
Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) }
for reply in pending.values { reply.reply(relayErrorReply(RelayFailure(code: .unavailable))) }
let prior = lifecycleTask
lifecycleTask = Task {
await prior?.value
await coordinator.watchBridgeDidDeactivate(peer: oldPeer)
}
}
/// Entry point for the native WCSession delegate. Every path completes the
@@ -71,18 +96,36 @@ public final class PhoneRelayBridge: NSObject {
/// out requests.
public func receive(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
let reply = RelayReplyOnce(replyHandler)
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
guard pending.count < 16 else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let receivedPeer = peer
let id = UUID()
pending[id] = inbound.reply
Task { @MainActor [weak self] in
guard let self else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
await self.route(message: message, reply: reply)
guard self.peer == receivedPeer, receivedPeer != nil else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
self.pending.removeValue(forKey: id)
return
}
await self.route(message: inbound.message, reply: inbound.reply)
self.pending.removeValue(forKey: id)
}
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, let reply = self.pending[id] else { return }
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
}
}
private func route(message: [String: Any], reply: RelayReplyOnce) async {
guard activationReady, let currentPeer = peer else {
guard activationReady, session.activationState == .activated, let currentPeer = peer else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -101,9 +144,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
let result = await ({
await self.coordinator.beginWatchSession(request, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -120,9 +164,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
let result = await ({
await self.coordinator.endWatchSession(request, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -139,9 +184,10 @@ public final class PhoneRelayBridge: NSObject {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let receipt = await withTimeout({
let receipt = await ({
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
}) else {
})()
guard peer == currentPeer, activationReady else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
@@ -162,18 +208,4 @@ public final class PhoneRelayBridge: NSObject {
relayErrorReply(RelayFailure(code: .malformedRequest))
}
private func withTimeout<Value: Sendable>(
_ operation: @escaping @Sendable () async -> Value
) async -> Value? {
await withTaskGroup(of: Value?.self) { group in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(for: .milliseconds(1_000))
return nil
}
let result = await group.next() ?? nil
group.cancelAll()
return result
}
}
}