Implement WatchConnectivity relay bridge
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#if os(watchOS)
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
import WatchBridgeCommon
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
private final class WatchWCSessionSending: RelaySessionSending {
|
||||
private let session: WCSession
|
||||
init(session: WCSession) { self.session = session }
|
||||
var activationState: RelayActivationState {
|
||||
switch session.activationState {
|
||||
case .notActivated: return .notActivated
|
||||
case .inactive: return .inactive
|
||||
case .activated: return .activated
|
||||
@unknown default: return .inactive
|
||||
}
|
||||
}
|
||||
var isReachable: Bool { session.isReachable }
|
||||
func activate() { session.activate() }
|
||||
func sendMessage(_ message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void,
|
||||
errorHandler: @escaping (Error) -> Void) {
|
||||
session.sendMessage(message, replyHandler: replyHandler, errorHandler: errorHandler)
|
||||
}
|
||||
func updateApplicationContext(_ context: [String: Any]) throws {
|
||||
try session.updateApplicationContext(context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate watchOS delegate. The app remains foreground/active for gesture
|
||||
/// use; this delegate does not add a background mode or deferred command queue.
|
||||
@MainActor
|
||||
public final class WatchWCSessionDelegate: NSObject, WCSessionDelegate {
|
||||
public let bridge: WatchRelayBridge
|
||||
|
||||
public init(session: WCSession = .default) {
|
||||
bridge = WatchRelayBridge(session: WatchWCSessionSending(session: session))
|
||||
super.init()
|
||||
session.delegate = self
|
||||
bridge.activate()
|
||||
}
|
||||
|
||||
nonisolated public func session(_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if case .inactive = self.bridge.state { } else { self.bridge.didDeactivate() }
|
||||
self.bridge.activationDidComplete(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
Task { @MainActor [weak self] in self?.bridge.reachabilityDidChange() }
|
||||
}
|
||||
|
||||
nonisolated public func session(_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
// The watch is the request sender in v1; never execute a phone reply
|
||||
// as a request. Complete unexpected inbound traffic exactly once.
|
||||
RelayReplyOnce(replyHandler).reply(
|
||||
relayErrorReply(RelayFailure(code: .malformedRequest)))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user