import Foundation import OpenClawKit import OSLog @preconcurrency import WatchConnectivity enum WatchMessagingError: LocalizedError { case unsupported case notPaired case watchAppNotInstalled var errorDescription: String? { switch self { case .unsupported: "WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device" case .notPaired: "WATCH_UNAVAILABLE: no paired Apple Watch" case .watchAppNotInstalled: "WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed" } } } @MainActor final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing { nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") private let session: WCSession? private var pendingActivationContinuations: [CheckedContinuation] = [] private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? 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") } let session = WCSession.default return status(for: session) } func status() async -> WatchMessagingStatus { await self.ensureActivated() guard let session = self.session else { return WatchMessagingStatus( supported: false, paired: false, appInstalled: false, reachable: false, activationState: "unsupported") } return Self.status(for: session) } func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { self.replyHandler = handler } func sendNotification( id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { await self.ensureActivated() 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 } var payload: [String: Any] = [ "type": "watch.notify", "id": id, "title": params.title, "body": params.body, "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, "sentAtMs": Int(Date().timeIntervalSince1970 * 1000), ] if let promptId = Self.nonEmpty(params.promptId) { payload["promptId"] = promptId } if let sessionKey = Self.nonEmpty(params.sessionKey) { payload["sessionKey"] = sessionKey } if let kind = Self.nonEmpty(params.kind) { payload["kind"] = kind } if let details = Self.nonEmpty(params.details) { payload["details"] = details } if let expiresAtMs = params.expiresAtMs { payload["expiresAtMs"] = expiresAtMs } if let risk = params.risk { payload["risk"] = risk.rawValue } if let actions = params.actions, !actions.isEmpty { payload["actions"] = actions.map { action in var encoded: [String: Any] = [ "id": action.id, "label": action.label, ] if let style = Self.nonEmpty(action.style) { encoded["style"] = style } return encoded } } if snapshot.reachable { do { try await self.sendReachableMessage(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") } private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws { try await withCheckedThrowingContinuation { continuation in session.sendMessage( payload, replyHandler: { _ in continuation.resume() }, errorHandler: { error in continuation.resume(throwing: error) } ) } } private func emitReply(_ event: WatchQuickReplyEvent) { self.replyHandler?(event) } nonisolated private static func nonEmpty(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } nonisolated private static func parseQuickReplyPayload( _ payload: [String: Any], transport: String) -> WatchQuickReplyEvent? { guard (payload["type"] as? String) == "watch.reply" else { return nil } guard let actionId = nonEmpty(payload["actionId"] as? String) else { return nil } let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let actionLabel = nonEmpty(payload["actionLabel"] as? String) let sessionKey = nonEmpty(payload["sessionKey"] as? String) let note = nonEmpty(payload["note"] as? String) let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue return WatchQuickReplyEvent( replyId: replyId, promptId: promptId, actionId: actionId, actionLabel: actionLabel, sessionKey: sessionKey, note: note, sentAtMs: sentAtMs, transport: transport) } private func ensureActivated() async { guard let session = self.session else { return } if session.activationState == .activated { return } session.activate() await withCheckedContinuation { continuation in self.pendingActivationContinuations.append(continuation) } } nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus { WatchMessagingStatus( supported: true, paired: session.isPaired, appInstalled: session.isWatchAppInstalled, reachable: session.isReachable, activationState: 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 WatchMessagingService: WCSessionDelegate { nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { 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)") } // Always resume all waiters so callers never hang, even on error. Task { @MainActor in let waiters = self.pendingActivationContinuations self.pendingActivationContinuations.removeAll() for continuation in waiters { continuation.resume() } } } nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() } nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) { guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { return } Task { @MainActor in self.emitReply(event) } } nonisolated func session( _: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { replyHandler(["ok": false, "error": "unsupported_payload"]) return } replyHandler(["ok": true]) Task { @MainActor in self.emitReply(event) } } nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { return } Task { @MainActor in self.emitReply(event) } } nonisolated func sessionReachabilityDidChange(_ session: WCSession) {} }