import Foundation import OSLog @preconcurrency import WatchConnectivity private struct WatchConnectivityTransportCallbacks { var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)? var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)? var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)? } private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws { // WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly // nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the // Objective-C callback boundary and trap on the reply callback executor check. try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation) in session.sendMessage( payload, replyHandler: { _ in continuation.resume(returning: ()) }, errorHandler: { error in continuation.resume(throwing: error) } ) } } final class WatchConnectivityTransport: NSObject, @unchecked Sendable { nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") private let session: WCSession? private let callbacksLock = NSLock() private var callbacks = WatchConnectivityTransportCallbacks() override init() { if WCSession.isSupported() { self.session = WCSession.default } else { self.session = nil } super.init() if let session = self.session { session.delegate = self session.activate() } } nonisolated static func isSupportedOnDevice() -> Bool { WCSession.isSupported() } nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus { guard WCSession.isSupported() else { return WatchMessagingStatus( supported: false, paired: false, appInstalled: false, reachable: false, activationState: "unsupported") } return self.status(for: WCSession.default) } func status() async -> WatchMessagingStatus { await self.ensureActivated() return self.currentStatusSnapshot() } func currentStatusSnapshot() -> WatchMessagingStatus { guard let session = self.session else { return WatchMessagingStatus( supported: false, paired: false, appInstalled: false, reachable: false, activationState: "unsupported") } return Self.status(for: session) } func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) { self.updateCallbacks { $0.statusUpdateHandler = handler } } func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { self.updateCallbacks { $0.replyHandler = handler } } func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) { self.updateCallbacks { $0.execApprovalResolveHandler = handler } } func setExecApprovalSnapshotRequestHandler( _ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?) { self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler } } func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult { await self.ensureActivated() let session = try self.requireReadySession() if session.isReachable { do { try await sendReachableWatchMessage(payload, with: session) return WatchNotificationSendResult( deliveredImmediately: true, queuedForDelivery: false, transport: "sendMessage") } catch { Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)") } } _ = session.transferUserInfo(payload) return WatchNotificationSendResult( deliveredImmediately: false, queuedForDelivery: true, transport: "transferUserInfo") } func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult { await self.ensureActivated() let session = try self.requireReadySession() if session.isReachable { do { try await sendReachableWatchMessage(payload, with: session) return WatchNotificationSendResult( deliveredImmediately: true, queuedForDelivery: false, transport: "sendMessage") } catch { Self.logger.error( "watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)") } } do { try session.updateApplicationContext(payload) return WatchNotificationSendResult( deliveredImmediately: false, queuedForDelivery: true, transport: "applicationContext") } catch { Self.logger.error( "watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)") _ = session.transferUserInfo(payload) return WatchNotificationSendResult( deliveredImmediately: false, queuedForDelivery: true, transport: "transferUserInfo") } } private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) { self.callbacksLock.lock() defer { self.callbacksLock.unlock() } update(&self.callbacks) } private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks { self.callbacksLock.lock() defer { self.callbacksLock.unlock() } return self.callbacks } private func requireReadySession() throws -> WCSession { guard let session = self.session else { throw WatchMessagingError.unsupported } let snapshot = Self.status(for: session) guard snapshot.paired else { throw WatchMessagingError.notPaired } guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled } return session } private func ensureActivated() async { guard let session = self.session else { return } if session.activationState == .activated { return } session.activate() for _ in 0..<8 { if session.activationState == .activated { return } try? await Task.sleep(nanoseconds: 100_000_000) } } private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) { guard let handler = self.callbacksSnapshot().statusUpdateHandler else { return } Task { @MainActor in handler(snapshot) } } private func emitReply(_ event: WatchQuickReplyEvent) { guard let handler = self.callbacksSnapshot().replyHandler else { return } Task { @MainActor in handler(event) } } private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) { guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else { return } Task { @MainActor in handler(event) } } private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) { guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else { return } Task { @MainActor in handler(event) } } nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus { WatchMessagingStatus( supported: true, paired: session.isPaired, appInstalled: session.isWatchAppInstalled, reachable: session.isReachable, activationState: self.activationStateLabel(session.activationState)) } nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String { switch state { case .notActivated: "notActivated" case .inactive: "inactive" case .activated: "activated" @unknown default: "unknown" } } } extension WatchConnectivityTransport: WCSessionDelegate { func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { GatewayDiagnostics.log( "watch messaging: activation complete state=\(Self.activationStateLabel(activationState)) error=\(error?.localizedDescription ?? "none")") if let error { Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)") } else { Self.logger.debug( "watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") } self.emitStatusUpdate(Self.status(for: session)) } func sessionDidBecomeInactive(_: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating") session.activate() self.emitStatusUpdate(Self.status(for: session)) } func session(_: WCSession, didReceiveMessage message: [String: Any]) { let type = (message["type"] as? String) ?? "unknown" GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)") if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") { self.emitReply(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload( message, transport: "sendMessage") { self.emitExecApprovalResolve(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload( message, transport: "sendMessage") { self.emitExecApprovalSnapshotRequest(event) } } func session( _: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { let type = (message["type"] as? String) ?? "unknown" GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)") if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") { replyHandler(["ok": true]) self.emitReply(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload( message, transport: "sendMessage") { replyHandler(["ok": true]) self.emitExecApprovalResolve(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload( message, transport: "sendMessage") { replyHandler(["ok": true]) self.emitExecApprovalSnapshotRequest(event) return } replyHandler(["ok": false, "error": "unsupported_payload"]) } func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { let type = (userInfo["type"] as? String) ?? "unknown" GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)") if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload( userInfo, transport: "transferUserInfo") { self.emitReply(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload( userInfo, transport: "transferUserInfo") { self.emitExecApprovalResolve(event) return } if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload( userInfo, transport: "transferUserInfo") { self.emitExecApprovalSnapshotRequest(event) } } func sessionReachabilityDidChange(_ session: WCSession) { GatewayDiagnostics.log( "watch messaging: reachability changed reachable=\(session.isReachable) paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)") self.emitStatusUpdate(Self.status(for: session)) } }