| 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<Void, Never>] = [] |
| 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)") |
| } |
| |
| 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) {} |
| } |
|
|