Implement WatchConnectivity relay bridge

This commit is contained in:
2026-09-18 09:59:38 +04:00
parent 9b87299bf5
commit 3e787543c0
8 changed files with 913 additions and 0 deletions
@@ -0,0 +1,179 @@
import Foundation
import RemoteCore
import WatchBridgeCommon
@MainActor
public final class PhoneRelayBridge: NSObject {
public enum State: Sendable, Equatable {
case inactive
case activating
case ready(reachable: Bool)
case unavailable
}
public private(set) var state: State = .inactive
private let session: any RelaySessionSending
private let coordinator: any PhoneCommandCoordinating
private var peer: WatchPeerGeneration?
private var activationReady = false
public init(session: any RelaySessionSending,
dependency: PhoneRelayDependency) {
self.session = session
self.coordinator = dependency.coordinator
}
public func activate() {
guard peer == nil else { return }
state = .activating
activationReady = false
session.activate()
}
public func activationDidComplete(error: Error?) {
guard error == nil else {
state = .unavailable
activationReady = false
return
}
let newPeer = WatchPeerGeneration()
peer = newPeer
activationReady = false
state = .ready(reachable: session.isReachable)
Task { [weak self] in
await self?.coordinator.watchBridgeDidActivate(peer: newPeer)
guard let self, self.peer == newPeer else { return }
self.activationReady = true
}
}
public func reachabilityDidChange() {
guard peer != nil, activationReady else { return }
state = .ready(reachable: session.isReachable)
}
public func didBecomeInactive() {
// iOS will normally follow with sessionDidDeactivate, but invalidate
// immediately so no command can slip through during counterpart swap.
didDeactivate()
}
public func didDeactivate() {
guard let oldPeer = peer else { return }
peer = nil
activationReady = false
state = .inactive
Task { await coordinator.watchBridgeDidDeactivate(peer: oldPeer) }
}
/// Entry point for the native WCSession delegate. Every path completes the
/// supplied reply exactly once, including malformed, unavailable and timed
/// out requests.
public func receive(message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
let reply = RelayReplyOnce(replyHandler)
Task { @MainActor [weak self] in
guard let self else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
await self.route(message: message, reply: reply)
}
}
private func route(message: [String: Any], reply: RelayReplyOnce) async {
guard activationReady, let currentPeer = peer else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
let container: RelayContainer
do {
container = try RelayContainer(dictionary: message)
} catch {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
switch container.kind {
case .beginSession:
guard let request = try? container.decode(BeginRemoteSessionRequest.self,
expectedKind: .beginSession) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
await self.coordinator.beginWatchSession(request, peer: currentPeer)
}) else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
switch result {
case .success(let grant):
reply.reply(Self.reply(kind: .session, value: grant))
case .failure(let failure):
reply.reply(relayErrorReply(failure))
}
case .endSession:
guard let request = try? container.decode(EndRemoteSessionRequest.self,
expectedKind: .endSession) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let result = await withTimeout({
await self.coordinator.endWatchSession(request, peer: currentPeer)
}) else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
switch result {
case .success(let ended):
reply.reply(Self.reply(kind: .ended, value: ended))
case .failure(let failure):
reply.reply(relayErrorReply(failure))
}
case .command:
guard let envelope = try? container.decode(CommandEnvelope.self,
expectedKind: .command) else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
guard let receipt = await withTimeout({
await self.coordinator.submitWatchCommand(envelope, peer: currentPeer)
}) else {
reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
guard receipt.id == envelope.id else {
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
return
}
reply.reply(Self.reply(kind: .receipt, value: receipt))
case .session, .ended, .receipt, .error:
reply.reply(relayErrorReply(RelayFailure(code: .malformedRequest)))
}
}
private static func reply<Value: Encodable>(kind: RelayMessageKind,
value: Value) -> [String: Any] {
(try? RelayContainer(kind: kind, value: value).dictionary()) ??
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
}
}
}
@@ -0,0 +1,87 @@
#if os(iOS)
import Foundation
import RemoteCore
import WatchBridgeCommon
import WatchConnectivity
@MainActor
private final class PhoneWCSessionSending: 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)
}
}
/// iPhone-side WCSession delegate. All UI-facing state changes are hopped to
/// the main actor; semantic command admission remains in the injected actor.
@MainActor
public final class PhoneWCSessionDelegate: NSObject, WCSessionDelegate {
public let bridge: PhoneRelayBridge
private let session: WCSession
public init(coordinator: any PhoneCommandCoordinating,
session: WCSession = .default) {
self.session = session
self.bridge = PhoneRelayBridge(session: PhoneWCSessionSending(session: session),
dependency: PhoneRelayDependency(coordinator: coordinator))
super.init()
session.delegate = self
bridge.activate()
}
nonisolated public func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
Task { @MainActor [weak self] in self?.bridge.activationDidComplete(error: error) }
}
nonisolated public func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor [weak self] in self?.bridge.didBecomeInactive() }
}
nonisolated public func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor [weak self] in
guard let self else { return }
self.bridge.didDeactivate()
self.session.activate()
}
}
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) {
let inbound = RelayInboundMessage(message: message, replyHandler: replyHandler)
Task { @MainActor [weak self, inbound] in
guard let self else {
inbound.reply.reply(relayErrorReply(RelayFailure(code: .unavailable)))
return
}
self.bridge.receive(message: inbound.message,
replyHandler: inbound.reply.reply)
}
}
}
#endif