From 5003b7b2dd28cebf22d7c633298c8356629fd9ec Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Wed, 18 Feb 2026 11:46:34 +0000 Subject: [PATCH] iOS: fix watch companion delivery and watchOS target --- .../Sources/WatchConnectivityReceiver.swift | 36 +++++- .../Sources/WatchInboxStore.swift | 121 ++++++++++++++++-- 2 files changed, 140 insertions(+), 17 deletions(-) diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift index 8c97bc4c8a0..e3ed450ff0c 100644 --- a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -1,7 +1,7 @@ import Foundation import WatchConnectivity -final class WatchConnectivityReceiver: NSObject { +final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { private let store: WatchInboxStore private let session: WCSession? @@ -20,6 +20,31 @@ final class WatchConnectivityReceiver: NSObject { session.delegate = self session.activate() } + + private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? { + guard let type = payload["type"] as? String, type == "watch.notify" else { + return nil + } + + let title = (payload["title"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (payload["body"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard title.isEmpty == false || body.isEmpty == false else { + return nil + } + + let id = (payload["id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + + return WatchNotifyMessage( + id: id, + title: title, + body: body, + sentAtMs: sentAtMs) + } } extension WatchConnectivityReceiver: WCSessionDelegate { @@ -30,20 +55,23 @@ extension WatchConnectivityReceiver: WCSessionDelegate { {} func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(message) else { return } Task { @MainActor in - self.store.consume(payload: message, transport: "sendMessage") + self.store.consume(message: incoming, transport: "sendMessage") } } func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(userInfo) else { return } Task { @MainActor in - self.store.consume(payload: userInfo, transport: "transferUserInfo") + self.store.consume(message: incoming, transport: "transferUserInfo") } } func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(applicationContext) else { return } Task { @MainActor in - self.store.consume(payload: applicationContext, transport: "applicationContext") + self.store.consume(message: incoming, transport: "applicationContext") } } } diff --git a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift index 2b01e383415..0a715f16b63 100644 --- a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift @@ -1,29 +1,124 @@ import Foundation import Observation +import UserNotifications +import WatchKit + +struct WatchNotifyMessage: Sendable { + var id: String? + var title: String + var body: String + var sentAtMs: Int? +} @MainActor @Observable final class WatchInboxStore { + private struct PersistedState: Codable { + var title: String + var body: String + var transport: String + var updatedAt: Date + var lastDeliveryKey: String? + } + + private static let persistedStateKey = "watch.inbox.state.v1" + private let defaults: UserDefaults + var title = "OpenClaw" var body = "Waiting for messages from your iPhone." var transport = "none" var updatedAt: Date? + private var lastDeliveryKey: String? - func consume(payload: [String: Any], transport: String) { - guard let type = payload["type"] as? String, type == "watch.notify" else { - return + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.restorePersistedState() + Task { + await self.ensureNotificationAuthorization() } + } - let titleValue = (payload["title"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let bodyValue = (payload["body"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + func consume(message: WatchNotifyMessage, transport: String) { + let messageID = message.id? + .trimmingCharacters(in: .whitespacesAndNewlines) + let deliveryKey = self.deliveryKey( + messageID: messageID, + title: message.title, + body: message.body, + sentAtMs: message.sentAtMs) + guard deliveryKey != self.lastDeliveryKey else { return } - guard titleValue.isEmpty == false || bodyValue.isEmpty == false else { - return - } - - self.title = titleValue.isEmpty ? "OpenClaw" : titleValue - self.body = bodyValue + let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title + self.title = normalizedTitle + self.body = message.body self.transport = transport self.updatedAt = Date() + self.lastDeliveryKey = deliveryKey + self.persistState() + + Task { + await self.postLocalNotification( + identifier: deliveryKey, + title: normalizedTitle, + body: message.body) + } + } + + private func restorePersistedState() { + guard let data = self.defaults.data(forKey: Self.persistedStateKey), + let state = try? JSONDecoder().decode(PersistedState.self, from: data) + else { + return + } + + self.title = state.title + self.body = state.body + self.transport = state.transport + self.updatedAt = state.updatedAt + self.lastDeliveryKey = state.lastDeliveryKey + } + + private func persistState() { + guard let updatedAt = self.updatedAt else { return } + let state = PersistedState( + title: self.title, + body: self.body, + transport: self.transport, + updatedAt: updatedAt, + lastDeliveryKey: self.lastDeliveryKey) + guard let data = try? JSONEncoder().encode(state) else { return } + self.defaults.set(data, forKey: Self.persistedStateKey) + } + + private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String { + if let messageID, messageID.isEmpty == false { + return "id:\(messageID)" + } + return "content:\(title)|\(body)|\(sentAtMs ?? 0)" + } + + private func ensureNotificationAuthorization() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .notDetermined: + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + default: + break + } + } + + private func postLocalNotification(identifier: String, title: String, body: String) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.threadIdentifier = "openclaw-watch" + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) + + _ = try? await UNUserNotificationCenter.current().add(request) + WKInterfaceDevice.current().play(.notification) } }