Skip to content

Commit 57083e4

Browse files
authored
iOS: add Apple Watch companion message MVP (#20054)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 720791a Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent e71e9a5 commit 57083e4

16 files changed

Lines changed: 831 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- 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.
910
- 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.
1011

1112
### Fixes

apps/ios/Sources/Gateway/GatewayConnectionController.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,9 @@ final class GatewayConnectionController {
729729
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
730730

731731
caps.append(OpenClawCapability.device.rawValue)
732+
if WatchMessagingService.isSupportedOnDevice() {
733+
caps.append(OpenClawCapability.watch.rawValue)
734+
}
732735
caps.append(OpenClawCapability.photos.rawValue)
733736
caps.append(OpenClawCapability.contacts.rawValue)
734737
caps.append(OpenClawCapability.calendar.rawValue)
@@ -772,6 +775,10 @@ final class GatewayConnectionController {
772775
commands.append(OpenClawDeviceCommand.status.rawValue)
773776
commands.append(OpenClawDeviceCommand.info.rawValue)
774777
}
778+
if caps.contains(OpenClawCapability.watch.rawValue) {
779+
commands.append(OpenClawWatchCommand.status.rawValue)
780+
commands.append(OpenClawWatchCommand.notify.rawValue)
781+
}
775782
if caps.contains(OpenClawCapability.photos.rawValue) {
776783
commands.append(OpenClawPhotosCommand.latest.rawValue)
777784
}
@@ -822,6 +829,12 @@ final class GatewayConnectionController {
822829
permissions["motion"] =
823830
motionStatus == .authorized || pedometerStatus == .authorized
824831

832+
let watchStatus = WatchMessagingService.currentStatusSnapshot()
833+
permissions["watchSupported"] = watchStatus.supported
834+
permissions["watchPaired"] = watchStatus.paired
835+
permissions["watchAppInstalled"] = watchStatus.appInstalled
836+
permissions["watchReachable"] = watchStatus.reachable
837+
825838
return permissions
826839
}
827840

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ final class NodeAppModel {
113113
private let calendarService: any CalendarServicing
114114
private let remindersService: any RemindersServicing
115115
private let motionService: any MotionServicing
116+
private let watchMessagingService: any WatchMessagingServicing
116117
var lastAutoA2uiURL: String?
117118
private var pttVoiceWakeSuspended = false
118119
private var talkVoiceWakeSuspended = false
@@ -147,6 +148,7 @@ final class NodeAppModel {
147148
calendarService: any CalendarServicing = CalendarService(),
148149
remindersService: any RemindersServicing = RemindersService(),
149150
motionService: any MotionServicing = MotionService(),
151+
watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
150152
talkMode: TalkModeManager = TalkModeManager())
151153
{
152154
self.screen = screen
@@ -160,6 +162,7 @@ final class NodeAppModel {
160162
self.calendarService = calendarService
161163
self.remindersService = remindersService
162164
self.motionService = motionService
165+
self.watchMessagingService = watchMessagingService
163166
self.talkMode = talkMode
164167
GatewayDiagnostics.bootstrap()
165168

@@ -1430,6 +1433,14 @@ private extension NodeAppModel {
14301433
return try await self.handleDeviceInvoke(req)
14311434
}
14321435

1436+
register([
1437+
OpenClawWatchCommand.status.rawValue,
1438+
OpenClawWatchCommand.notify.rawValue,
1439+
]) { [weak self] req in
1440+
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
1441+
return try await self.handleWatchInvoke(req)
1442+
}
1443+
14331444
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
14341445
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
14351446
return try await self.handlePhotosInvoke(req)
@@ -1480,6 +1491,58 @@ private extension NodeAppModel {
14801491
return NodeCapabilityRouter(handlers: handlers)
14811492
}
14821493

1494+
func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
1495+
switch req.command {
1496+
case OpenClawWatchCommand.status.rawValue:
1497+
let status = await self.watchMessagingService.status()
1498+
let payload = OpenClawWatchStatusPayload(
1499+
supported: status.supported,
1500+
paired: status.paired,
1501+
appInstalled: status.appInstalled,
1502+
reachable: status.reachable,
1503+
activationState: status.activationState)
1504+
let json = try Self.encodePayload(payload)
1505+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
1506+
case OpenClawWatchCommand.notify.rawValue:
1507+
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
1508+
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
1509+
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
1510+
if title.isEmpty && body.isEmpty {
1511+
return BridgeInvokeResponse(
1512+
id: req.id,
1513+
ok: false,
1514+
error: OpenClawNodeError(
1515+
code: .invalidRequest,
1516+
message: "INVALID_REQUEST: empty watch notification"))
1517+
}
1518+
do {
1519+
let result = try await self.watchMessagingService.sendNotification(
1520+
id: req.id,
1521+
title: title,
1522+
body: body,
1523+
priority: params.priority)
1524+
let payload = OpenClawWatchNotifyPayload(
1525+
deliveredImmediately: result.deliveredImmediately,
1526+
queuedForDelivery: result.queuedForDelivery,
1527+
transport: result.transport)
1528+
let json = try Self.encodePayload(payload)
1529+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
1530+
} catch {
1531+
return BridgeInvokeResponse(
1532+
id: req.id,
1533+
ok: false,
1534+
error: OpenClawNodeError(
1535+
code: .unavailable,
1536+
message: error.localizedDescription))
1537+
}
1538+
default:
1539+
return BridgeInvokeResponse(
1540+
id: req.id,
1541+
ok: false,
1542+
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
1543+
}
1544+
}
1545+
14831546
func locationMode() -> OpenClawLocationMode {
14841547
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
14851548
return OpenClawLocationMode(rawValue: raw) ?? .off

apps/ios/Sources/Services/NodeServiceProtocols.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ protocol MotionServicing: Sendable {
6565
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
6666
}
6767

68+
struct WatchMessagingStatus: Sendable, Equatable {
69+
var supported: Bool
70+
var paired: Bool
71+
var appInstalled: Bool
72+
var reachable: Bool
73+
var activationState: String
74+
}
75+
76+
struct WatchNotificationSendResult: Sendable, Equatable {
77+
var deliveredImmediately: Bool
78+
var queuedForDelivery: Bool
79+
var transport: String
80+
}
81+
82+
protocol WatchMessagingServicing: AnyObject, Sendable {
83+
func status() async -> WatchMessagingStatus
84+
func sendNotification(
85+
id: String,
86+
title: String,
87+
body: String,
88+
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
89+
}
90+
6891
extension CameraController: CameraServicing {}
6992
extension ScreenRecordService: ScreenRecordingServicing {}
7093
extension LocationService: LocationServicing {}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import Foundation
2+
import OpenClawKit
3+
import OSLog
4+
@preconcurrency import WatchConnectivity
5+
6+
enum WatchMessagingError: LocalizedError {
7+
case unsupported
8+
case notPaired
9+
case watchAppNotInstalled
10+
11+
var errorDescription: String? {
12+
switch self {
13+
case .unsupported:
14+
"WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device"
15+
case .notPaired:
16+
"WATCH_UNAVAILABLE: no paired Apple Watch"
17+
case .watchAppNotInstalled:
18+
"WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed"
19+
}
20+
}
21+
}
22+
23+
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
24+
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
25+
private let session: WCSession?
26+
27+
override init() {
28+
if WCSession.isSupported() {
29+
self.session = WCSession.default
30+
} else {
31+
self.session = nil
32+
}
33+
super.init()
34+
if let session = self.session {
35+
session.delegate = self
36+
session.activate()
37+
}
38+
}
39+
40+
static func isSupportedOnDevice() -> Bool {
41+
WCSession.isSupported()
42+
}
43+
44+
static func currentStatusSnapshot() -> WatchMessagingStatus {
45+
guard WCSession.isSupported() else {
46+
return WatchMessagingStatus(
47+
supported: false,
48+
paired: false,
49+
appInstalled: false,
50+
reachable: false,
51+
activationState: "unsupported")
52+
}
53+
let session = WCSession.default
54+
return status(for: session)
55+
}
56+
57+
func status() async -> WatchMessagingStatus {
58+
await self.ensureActivated()
59+
guard let session = self.session else {
60+
return WatchMessagingStatus(
61+
supported: false,
62+
paired: false,
63+
appInstalled: false,
64+
reachable: false,
65+
activationState: "unsupported")
66+
}
67+
return Self.status(for: session)
68+
}
69+
70+
func sendNotification(
71+
id: String,
72+
title: String,
73+
body: String,
74+
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
75+
{
76+
await self.ensureActivated()
77+
guard let session = self.session else {
78+
throw WatchMessagingError.unsupported
79+
}
80+
81+
let snapshot = Self.status(for: session)
82+
guard snapshot.paired else { throw WatchMessagingError.notPaired }
83+
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
84+
85+
let payload: [String: Any] = [
86+
"type": "watch.notify",
87+
"id": id,
88+
"title": title,
89+
"body": body,
90+
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
91+
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
92+
]
93+
94+
if snapshot.reachable {
95+
do {
96+
try await self.sendReachableMessage(payload, with: session)
97+
return WatchNotificationSendResult(
98+
deliveredImmediately: true,
99+
queuedForDelivery: false,
100+
transport: "sendMessage")
101+
} catch {
102+
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
103+
}
104+
}
105+
106+
_ = session.transferUserInfo(payload)
107+
return WatchNotificationSendResult(
108+
deliveredImmediately: false,
109+
queuedForDelivery: true,
110+
transport: "transferUserInfo")
111+
}
112+
113+
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
114+
try await withCheckedThrowingContinuation { continuation in
115+
session.sendMessage(payload, replyHandler: { _ in
116+
continuation.resume()
117+
}, errorHandler: { error in
118+
continuation.resume(throwing: error)
119+
})
120+
}
121+
}
122+
123+
private func ensureActivated() async {
124+
guard let session = self.session else { return }
125+
if session.activationState == .activated { return }
126+
session.activate()
127+
for _ in 0..<8 {
128+
if session.activationState == .activated { return }
129+
try? await Task.sleep(nanoseconds: 100_000_000)
130+
}
131+
}
132+
133+
private static func status(for session: WCSession) -> WatchMessagingStatus {
134+
WatchMessagingStatus(
135+
supported: true,
136+
paired: session.isPaired,
137+
appInstalled: session.isWatchAppInstalled,
138+
reachable: session.isReachable,
139+
activationState: activationStateLabel(session.activationState))
140+
}
141+
142+
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
143+
switch state {
144+
case .notActivated:
145+
"notActivated"
146+
case .inactive:
147+
"inactive"
148+
case .activated:
149+
"activated"
150+
@unknown default:
151+
"unknown"
152+
}
153+
}
154+
}
155+
156+
extension WatchMessagingService: WCSessionDelegate {
157+
func session(
158+
_ session: WCSession,
159+
activationDidCompleteWith activationState: WCSessionActivationState,
160+
error: (any Error)?)
161+
{
162+
if let error {
163+
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
164+
return
165+
}
166+
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
167+
}
168+
169+
func sessionDidBecomeInactive(_ session: WCSession) {}
170+
171+
func sessionDidDeactivate(_ session: WCSession) {
172+
session.activate()
173+
}
174+
175+
func sessionReachabilityDidChange(_ session: WCSession) {}
176+
}

0 commit comments

Comments
 (0)