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 private var grant: RemoteSessionGrant? 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, grant == nil else { 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 activationReady, session.isReachable, grant == nil else { state = .unavailable return } 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 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() { 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) } } /// 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()) { guard activationReady, session.isReachable, let grant else { state = .unavailable return } guard nextSequence < UInt64.max else { self.grant = nil state = .unavailable 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 { return } send(message: message, requestID: UUID()) { [weak self] response in guard let self, let container = try? RelayContainer(dictionary: response), container.kind == .receipt, let receipt = try? container.decode(CommandReceipt.self, expectedKind: .receipt), receipt.id == envelope.id else { return } // A receipt is command feedback, not evidence that the TV changed. if receipt.status == .unavailable { self.state = .unavailable } } } private func send(message: [String: Any], requestID: UUID, response: @escaping @MainActor ([String: Any]) -> Void) { guard pendingReplies.count < 32 else { state = .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) } } 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() { let replies = pendingReplies.values pendingReplies.removeAll() for reply in replies { reply.reply(relayErrorReply(RelayFailure(code: .sessionInvalidated))) } grant = nil nextSequence = 0 } }