From 57083e4220e3a5884e5bf016ef3af556c20cbddb Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:37:41 +0000 Subject: [PATCH] iOS: add Apple Watch companion message MVP (#20054) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 720791ae6b4f74792dc028a74d2bab2777552887 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../Gateway/GatewayConnectionController.swift | 13 ++ apps/ios/Sources/Model/NodeAppModel.swift | 63 +++++++ .../Services/NodeServiceProtocols.swift | 23 +++ .../Services/WatchMessagingService.swift | 176 ++++++++++++++++++ apps/ios/Tests/NodeAppModelInvokeTests.swift | 123 ++++++++++++ apps/ios/WatchApp/Info.plist | 28 +++ apps/ios/WatchExtension/Info.plist | 32 ++++ .../Sources/OpenClawWatchApp.swift | 20 ++ .../Sources/WatchConnectivityReceiver.swift | 92 +++++++++ .../Sources/WatchInboxStore.swift | 124 ++++++++++++ .../Sources/WatchInboxView.swift | 27 +++ apps/ios/project.yml | 50 +++++ .../Sources/OpenClawKit/Capabilities.swift | 1 + .../Sources/OpenClawKit/WatchCommands.swift | 52 ++++++ src/agents/tools/cron-tool.test-helpers.ts | 7 +- 16 files changed, 831 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Sources/Services/WatchMessagingService.swift create mode 100644 apps/ios/WatchApp/Info.plist create mode 100644 apps/ios/WatchExtension/Info.plist create mode 100644 apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift create mode 100644 apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift create mode 100644 apps/ios/WatchExtension/Sources/WatchInboxStore.swift create mode 100644 apps/ios/WatchExtension/Sources/WatchInboxView.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e139f9f2..fa240adb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. - Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. ### Fixes diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 132b32d364..92abd996b7 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -729,6 +729,9 @@ final class GatewayConnectionController { if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } caps.append(OpenClawCapability.device.rawValue) + if WatchMessagingService.isSupportedOnDevice() { + caps.append(OpenClawCapability.watch.rawValue) + } caps.append(OpenClawCapability.photos.rawValue) caps.append(OpenClawCapability.contacts.rawValue) caps.append(OpenClawCapability.calendar.rawValue) @@ -772,6 +775,10 @@ final class GatewayConnectionController { commands.append(OpenClawDeviceCommand.status.rawValue) commands.append(OpenClawDeviceCommand.info.rawValue) } + if caps.contains(OpenClawCapability.watch.rawValue) { + commands.append(OpenClawWatchCommand.status.rawValue) + commands.append(OpenClawWatchCommand.notify.rawValue) + } if caps.contains(OpenClawCapability.photos.rawValue) { commands.append(OpenClawPhotosCommand.latest.rawValue) } @@ -822,6 +829,12 @@ final class GatewayConnectionController { permissions["motion"] = motionStatus == .authorized || pedometerStatus == .authorized + let watchStatus = WatchMessagingService.currentStatusSnapshot() + permissions["watchSupported"] = watchStatus.supported + permissions["watchPaired"] = watchStatus.paired + permissions["watchAppInstalled"] = watchStatus.appInstalled + permissions["watchReachable"] = watchStatus.reachable + return permissions } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 7b843d154a..6efe148d45 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -113,6 +113,7 @@ final class NodeAppModel { private let calendarService: any CalendarServicing private let remindersService: any RemindersServicing private let motionService: any MotionServicing + private let watchMessagingService: any WatchMessagingServicing var lastAutoA2uiURL: String? private var pttVoiceWakeSuspended = false private var talkVoiceWakeSuspended = false @@ -147,6 +148,7 @@ final class NodeAppModel { calendarService: any CalendarServicing = CalendarService(), remindersService: any RemindersServicing = RemindersService(), motionService: any MotionServicing = MotionService(), + watchMessagingService: any WatchMessagingServicing = WatchMessagingService(), talkMode: TalkModeManager = TalkModeManager()) { self.screen = screen @@ -160,6 +162,7 @@ final class NodeAppModel { self.calendarService = calendarService self.remindersService = remindersService self.motionService = motionService + self.watchMessagingService = watchMessagingService self.talkMode = talkMode GatewayDiagnostics.bootstrap() @@ -1430,6 +1433,14 @@ private extension NodeAppModel { return try await self.handleDeviceInvoke(req) } + register([ + OpenClawWatchCommand.status.rawValue, + OpenClawWatchCommand.notify.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleWatchInvoke(req) + } + register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } return try await self.handlePhotosInvoke(req) @@ -1480,6 +1491,58 @@ private extension NodeAppModel { return NodeCapabilityRouter(handlers: handlers) } + func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawWatchCommand.status.rawValue: + let status = await self.watchMessagingService.status() + let payload = OpenClawWatchStatusPayload( + supported: status.supported, + paired: status.paired, + appInstalled: status.appInstalled, + reachable: status.reachable, + activationState: status.activationState) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawWatchCommand.notify.rawValue: + let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty && body.isEmpty { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "INVALID_REQUEST: empty watch notification")) + } + do { + let result = try await self.watchMessagingService.sendNotification( + id: req.id, + title: title, + body: body, + priority: params.priority) + let payload = OpenClawWatchNotifyPayload( + deliveredImmediately: result.deliveredImmediately, + queuedForDelivery: result.queuedForDelivery, + transport: result.transport) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } catch { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: error.localizedDescription)) + } + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 5ed6f8cfd8..6f882e82a1 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -65,6 +65,29 @@ protocol MotionServicing: Sendable { func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload } +struct WatchMessagingStatus: Sendable, Equatable { + var supported: Bool + var paired: Bool + var appInstalled: Bool + var reachable: Bool + var activationState: String +} + +struct WatchNotificationSendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String +} + +protocol WatchMessagingServicing: AnyObject, Sendable { + func status() async -> WatchMessagingStatus + func sendNotification( + id: String, + title: String, + body: String, + priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult +} + extension CameraController: CameraServicing {} extension ScreenRecordService: ScreenRecordingServicing {} extension LocationService: LocationServicing {} diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift new file mode 100644 index 0000000000..8332fb5882 --- /dev/null +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -0,0 +1,176 @@ +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) {} +} diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 3041439399..f5f40fc8b7 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,6 +29,39 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +@MainActor +private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { + var currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: true, + activationState: "activated") + var nextSendResult = WatchNotificationSendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage") + var sendError: Error? + var lastSent: (id: String, title: String, body: String, priority: OpenClawNotificationPriority?)? + + func status() async -> WatchMessagingStatus { + self.currentStatus + } + + func sendNotification( + id: String, + title: String, + body: String, + priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult + { + self.lastSent = (id: id, title: title, body: body, priority: priority) + if let sendError = self.sendError { + throw sendError + } + return self.nextSendResult + } +} + @Suite(.serialized) struct NodeAppModelInvokeTests { @Test @MainActor func decodeParamsFailsWithoutJSON() { #expect(throws: Error.self) { @@ -156,6 +189,96 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(res.error?.code == .invalidRequest) } + @Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws { + let watchService = MockWatchMessagingService() + watchService.currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: false, + activationState: "inactive") + let appModel = NodeAppModel(watchMessagingService: watchService) + let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData) + #expect(payload.supported == true) + #expect(payload.reachable == false) + #expect(payload.activationState == "inactive") + } + + @Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws { + let watchService = MockWatchMessagingService() + watchService.nextSendResult = WatchNotificationSendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo") + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "OpenClaw", + body: "Meeting with Peter is at 4pm", + priority: .timeSensitive) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.title == "OpenClaw") + #expect(watchService.lastSent?.body == "Meeting with Peter is at 4pm") + #expect(watchService.lastSent?.priority == .timeSensitive) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) + #expect(payload.deliveredImmediately == false) + #expect(payload.queuedForDelivery == true) + #expect(payload.transport == "transferUserInfo") + } + + @Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: " ", body: "\n") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-empty", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .invalidRequest) + #expect(watchService.lastSent == nil) + } + + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { + let watchService = MockWatchMessagingService() + watchService.sendError = NSError( + domain: "watch", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"]) + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-fail", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .unavailable) + #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) + } + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { let appModel = NodeAppModel() let url = URL(string: "openclaw://agent?message=hello")! diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist new file mode 100644 index 0000000000..7fcab097ca --- /dev/null +++ b/apps/ios/WatchApp/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.2.16 + CFBundleVersion + 20260216 + WKCompanionAppBundleIdentifier + ai.openclaw.ios + WKWatchKitApp + + + diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist new file mode 100644 index 0000000000..a145333bd1 --- /dev/null +++ b/apps/ios/WatchExtension/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleShortVersionString + 2026.2.16 + CFBundleVersion + 20260216 + NSExtension + + NSExtensionAttributes + + WKAppBundleIdentifier + ai.openclaw.ios.watchkitapp + + NSExtensionPointIdentifier + com.apple.watchkit + + + diff --git a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift new file mode 100644 index 0000000000..6084f57444 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct OpenClawWatchApp: App { + @State private var inboxStore = WatchInboxStore() + @State private var receiver: WatchConnectivityReceiver? + + var body: some Scene { + WindowGroup { + WatchInboxView(store: self.inboxStore) + .task { + if self.receiver == nil { + let receiver = WatchConnectivityReceiver(store: self.inboxStore) + receiver.activate() + self.receiver = receiver + } + } + } + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift new file mode 100644 index 0000000000..9a128049c3 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -0,0 +1,92 @@ +import Foundation +import WatchConnectivity + +final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { + private let store: WatchInboxStore + private let session: WCSession? + + init(store: WatchInboxStore) { + self.store = store + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + } + + func activate() { + guard let session = self.session else { return } + 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 { + func session( + _: WCSession, + activationDidCompleteWith _: WCSessionActivationState, + error _: (any Error)?) + {} + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(message) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + } + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let incoming = Self.parseNotificationPayload(message) else { + replyHandler(["ok": false]) + return + } + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + replyHandler(["ok": true]) + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(userInfo) else { return } + Task { @MainActor in + 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(message: incoming, transport: "applicationContext") + } + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift new file mode 100644 index 0000000000..0a715f16b6 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift @@ -0,0 +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? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.restorePersistedState() + Task { + await self.ensureNotificationAuthorization() + } + } + + 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 } + + 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) + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxView.swift b/apps/ios/WatchExtension/Sources/WatchInboxView.swift new file mode 100644 index 0000000000..c5ea9a9f53 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchInboxView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct WatchInboxView: View { + @Bindable var store: WatchInboxStore + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(store.title) + .font(.headline) + .lineLimit(2) + + Text(store.body) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + + if let updatedAt = store.updatedAt { + Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index cb23cd0dc2..b3dce3028c 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -37,6 +37,7 @@ targets: dependencies: - target: OpenClawShareExtension embed: true + - target: OpenClawWatchApp - package: OpenClawKit - package: OpenClawKit product: OpenClawChatUI @@ -140,6 +141,55 @@ targets: info: path: ShareExtension/Info.plist + OpenClawWatchApp: + type: application.watchapp2 + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchApp + dependencies: + - target: OpenClawWatchExtension + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp + info: + path: WatchApp/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.16" + CFBundleVersion: "20260216" + WKCompanionAppBundleIdentifier: ai.openclaw.ios + WKWatchKitApp: true + + OpenClawWatchExtension: + type: watchkit2-extension + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchExtension/Sources + dependencies: + - sdk: WatchConnectivity.framework + - sdk: UserNotifications.framework + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.watchkitapp.extension + info: + path: WatchExtension/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.16" + CFBundleVersion: "20260216" + NSExtension: + NSExtensionAttributes: + WKAppBundleIdentifier: ai.openclaw.ios.watchkitapp + NSExtensionPointIdentifier: com.apple.watchkit + OpenClawTests: type: bundle.unit-test platform: iOS diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift index d5c5e3c439..49f9efe996 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift @@ -7,6 +7,7 @@ public enum OpenClawCapability: String, Codable, Sendable { case voiceWake case location case device + case watch case photos case contacts case calendar diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift new file mode 100644 index 0000000000..814efe68a8 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -0,0 +1,52 @@ +import Foundation + +public enum OpenClawWatchCommand: String, Codable, Sendable { + case status = "watch.status" + case notify = "watch.notify" +} + +public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable { + public var supported: Bool + public var paired: Bool + public var appInstalled: Bool + public var reachable: Bool + public var activationState: String + + public init( + supported: Bool, + paired: Bool, + appInstalled: Bool, + reachable: Bool, + activationState: String) + { + self.supported = supported + self.paired = paired + self.appInstalled = appInstalled + self.reachable = reachable + self.activationState = activationState + } +} + +public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { + public var title: String + public var body: String + public var priority: OpenClawNotificationPriority? + + public init(title: String, body: String, priority: OpenClawNotificationPriority? = nil) { + self.title = title + self.body = body + self.priority = priority + } +} + +public struct OpenClawWatchNotifyPayload: Codable, Sendable, Equatable { + public var deliveredImmediately: Bool + public var queuedForDelivery: Bool + public var transport: String + + public init(deliveredImmediately: Bool, queuedForDelivery: Bool, transport: String) { + self.deliveredImmediately = deliveredImmediately + self.queuedForDelivery = queuedForDelivery + self.transport = transport + } +} diff --git a/src/agents/tools/cron-tool.test-helpers.ts b/src/agents/tools/cron-tool.test-helpers.ts index 9125c8a86d..9045636864 100644 --- a/src/agents/tools/cron-tool.test-helpers.ts +++ b/src/agents/tools/cron-tool.test-helpers.ts @@ -1,6 +1,11 @@ import { vi } from "vitest"; -export const callGatewayMock = vi.fn(); +type GatewayMockFn = ((opts: unknown) => unknown) & { + mockReset: () => void; + mockResolvedValue: (value: unknown) => void; +}; + +export const callGatewayMock = vi.fn() as GatewayMockFn; vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts),