Files
wristivo/Adapters/WatchBridge/Sources/Watch/WatchRelayBridge.swift
T

182 lines
6.6 KiB
Swift

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
}
}