Files
openclaw/apps/ios/Sources/Services/WatchMessagingService.swift
Mariano 57083e4220 iOS: add Apple Watch companion message MVP (#20054)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 720791ae6b
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-02-18 13:37:41 +00:00

177 lines
5.6 KiB
Swift

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"
}
}
}
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession?
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()
}
}
static func isSupportedOnDevice() -> Bool {
WCSession.isSupported()
}
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 sendNotification(
id: String,
title: String,
body: String,
priority: OpenClawNotificationPriority?) 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 }
let payload: [String: Any] = [
"type": "watch.notify",
"id": id,
"title": title,
"body": body,
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
]
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 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 static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
paired: session.isPaired,
appInstalled: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: activationStateLabel(session.activationState))
}
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state {
case .notActivated:
"notActivated"
case .inactive:
"inactive"
case .activated:
"activated"
@unknown default:
"unknown"
}
}
}
extension WatchMessagingService: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
if let error {
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
return
}
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func sessionReachabilityDidChange(_ session: WCSession) {}
}