Skip to content

Commit 3c2a01f

Browse files
committed
iOS/watch: add actionable watch approvals and quick replies #21996 thanks @mbelinky
1 parent 8e4f6c0 commit 3c2a01f

File tree

10 files changed

+598
-31
lines changed

10 files changed

+598
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
5151
- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
5252
- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
53+
- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.
5354
- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford.
5455
- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc.
5556

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ final class NodeAppModel {
4343
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
4444
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
4545
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
46+
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
4647
enum CameraHUDKind {
4748
case photo
4849
case recording
@@ -109,6 +110,8 @@ final class NodeAppModel {
109110
private var backgroundReconnectSuppressed = false
110111
private var backgroundReconnectLeaseUntil: Date?
111112
private var lastSignificantLocationWakeAt: Date?
113+
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
114+
private var seenWatchReplyIds = Set<String>()
112115

113116
private var gatewayConnected = false
114117
private var operatorConnected = false
@@ -155,6 +158,11 @@ final class NodeAppModel {
155158
self.talkMode = talkMode
156159
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
157160
GatewayDiagnostics.bootstrap()
161+
self.watchMessagingService.setReplyHandler { [weak self] event in
162+
Task { @MainActor in
163+
await self?.handleWatchQuickReply(event)
164+
}
165+
}
158166

159167
self.voiceWake.configure { [weak self] cmd in
160168
guard let self else { return }
@@ -1608,9 +1616,7 @@ private extension NodeAppModel {
16081616
do {
16091617
let result = try await self.watchMessagingService.sendNotification(
16101618
id: req.id,
1611-
title: title,
1612-
body: body,
1613-
priority: params.priority)
1619+
params: params)
16141620
let payload = OpenClawWatchNotifyPayload(
16151621
deliveredImmediately: result.deliveredImmediately,
16161622
queuedForDelivery: result.queuedForDelivery,
@@ -2255,6 +2261,90 @@ extension NodeAppModel {
22552261
/// Back-compat hook retained for older gateway-connect flows.
22562262
func onNodeGatewayConnected() async {
22572263
await self.registerAPNsTokenIfNeeded()
2264+
await self.flushQueuedWatchRepliesIfConnected()
2265+
}
2266+
2267+
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
2268+
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
2269+
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
2270+
if replyId.isEmpty || actionId.isEmpty {
2271+
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
2272+
return
2273+
}
2274+
2275+
if self.seenWatchReplyIds.contains(replyId) {
2276+
self.watchReplyLogger.debug(
2277+
"watch reply deduped replyId=\(replyId, privacy: .public)")
2278+
return
2279+
}
2280+
self.seenWatchReplyIds.insert(replyId)
2281+
2282+
if await !self.isGatewayConnected() {
2283+
self.queuedWatchReplies.append(event)
2284+
self.watchReplyLogger.info(
2285+
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
2286+
return
2287+
}
2288+
2289+
await self.forwardWatchReplyToAgent(event)
2290+
}
2291+
2292+
private func flushQueuedWatchRepliesIfConnected() async {
2293+
guard await self.isGatewayConnected() else { return }
2294+
guard !self.queuedWatchReplies.isEmpty else { return }
2295+
2296+
let pending = self.queuedWatchReplies
2297+
self.queuedWatchReplies.removeAll()
2298+
for event in pending {
2299+
await self.forwardWatchReplyToAgent(event)
2300+
}
2301+
}
2302+
2303+
private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async {
2304+
let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
2305+
let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey
2306+
let message = Self.makeWatchReplyAgentMessage(event)
2307+
let link = AgentDeepLink(
2308+
message: message,
2309+
sessionKey: effectiveSessionKey,
2310+
thinking: "low",
2311+
deliver: false,
2312+
to: nil,
2313+
channel: nil,
2314+
timeoutSeconds: nil,
2315+
key: event.replyId)
2316+
do {
2317+
try await self.sendAgentRequest(link: link)
2318+
self.watchReplyLogger.info(
2319+
"watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)")
2320+
self.openChatRequestID &+= 1
2321+
} catch {
2322+
self.watchReplyLogger.error(
2323+
"watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
2324+
self.queuedWatchReplies.insert(event, at: 0)
2325+
}
2326+
}
2327+
2328+
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
2329+
let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
2330+
let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines)
2331+
let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines)
2332+
let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId
2333+
var lines: [String] = []
2334+
lines.append("Watch reply: \(summary)")
2335+
lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)")
2336+
lines.append("actionId=\(event.actionId)")
2337+
lines.append("replyId=\(event.replyId)")
2338+
if !transport.isEmpty {
2339+
lines.append("transport=\(transport)")
2340+
}
2341+
if let sentAtMs = event.sentAtMs {
2342+
lines.append("sentAtMs=\(sentAtMs)")
2343+
}
2344+
if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
2345+
lines.append("note=\(note)")
2346+
}
2347+
return lines.joined(separator: "\n")
22582348
}
22592349

22602350
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
@@ -2497,5 +2587,9 @@ extension NodeAppModel {
24972587
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
24982588
self.applyTalkModeSync(enabled: enabled, phase: phase)
24992589
}
2590+
2591+
func _test_queuedWatchReplyCount() -> Int {
2592+
self.queuedWatchReplies.count
2593+
}
25002594
}
25012595
#endif

apps/ios/Sources/Services/NodeServiceProtocols.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ struct WatchMessagingStatus: Sendable, Equatable {
7373
var activationState: String
7474
}
7575

76+
struct WatchQuickReplyEvent: Sendable, Equatable {
77+
var replyId: String
78+
var promptId: String
79+
var actionId: String
80+
var actionLabel: String?
81+
var sessionKey: String?
82+
var note: String?
83+
var sentAtMs: Int?
84+
var transport: String
85+
}
86+
7687
struct WatchNotificationSendResult: Sendable, Equatable {
7788
var deliveredImmediately: Bool
7889
var queuedForDelivery: Bool
@@ -81,11 +92,10 @@ struct WatchNotificationSendResult: Sendable, Equatable {
8192

8293
protocol WatchMessagingServicing: AnyObject, Sendable {
8394
func status() async -> WatchMessagingStatus
95+
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
8496
func sendNotification(
8597
id: String,
86-
title: String,
87-
body: String,
88-
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
98+
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
8999
}
90100

91101
extension CameraController: CameraServicing {}

apps/ios/Sources/Services/WatchMessagingService.swift

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ enum WatchMessagingError: LocalizedError {
2323
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
2424
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
2525
private let session: WCSession?
26+
private let replyHandlerLock = NSLock()
27+
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
2628

2729
override init() {
2830
if WCSession.isSupported() {
@@ -67,11 +69,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
6769
return Self.status(for: session)
6870
}
6971

72+
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
73+
self.replyHandlerLock.lock()
74+
self.replyHandler = handler
75+
self.replyHandlerLock.unlock()
76+
}
77+
7078
func sendNotification(
7179
id: String,
72-
title: String,
73-
body: String,
74-
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
80+
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
7581
{
7682
await self.ensureActivated()
7783
guard let session = self.session else {
@@ -82,14 +88,44 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
8288
guard snapshot.paired else { throw WatchMessagingError.notPaired }
8389
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
8490

85-
let payload: [String: Any] = [
91+
var payload: [String: Any] = [
8692
"type": "watch.notify",
8793
"id": id,
88-
"title": title,
89-
"body": body,
90-
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
94+
"title": params.title,
95+
"body": params.body,
96+
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
9197
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
9298
]
99+
if let promptId = Self.nonEmpty(params.promptId) {
100+
payload["promptId"] = promptId
101+
}
102+
if let sessionKey = Self.nonEmpty(params.sessionKey) {
103+
payload["sessionKey"] = sessionKey
104+
}
105+
if let kind = Self.nonEmpty(params.kind) {
106+
payload["kind"] = kind
107+
}
108+
if let details = Self.nonEmpty(params.details) {
109+
payload["details"] = details
110+
}
111+
if let expiresAtMs = params.expiresAtMs {
112+
payload["expiresAtMs"] = expiresAtMs
113+
}
114+
if let risk = params.risk {
115+
payload["risk"] = risk.rawValue
116+
}
117+
if let actions = params.actions, !actions.isEmpty {
118+
payload["actions"] = actions.map { action in
119+
var encoded: [String: Any] = [
120+
"id": action.id,
121+
"label": action.label,
122+
]
123+
if let style = Self.nonEmpty(action.style) {
124+
encoded["style"] = style
125+
}
126+
return encoded
127+
}
128+
}
93129

94130
if snapshot.reachable {
95131
do {
@@ -120,6 +156,47 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
120156
}
121157
}
122158

159+
private func emitReply(_ event: WatchQuickReplyEvent) {
160+
let handler: ((WatchQuickReplyEvent) -> Void)?
161+
self.replyHandlerLock.lock()
162+
handler = self.replyHandler
163+
self.replyHandlerLock.unlock()
164+
handler?(event)
165+
}
166+
167+
private static func nonEmpty(_ value: String?) -> String? {
168+
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
169+
return trimmed.isEmpty ? nil : trimmed
170+
}
171+
172+
private static func parseQuickReplyPayload(
173+
_ payload: [String: Any],
174+
transport: String) -> WatchQuickReplyEvent?
175+
{
176+
guard (payload["type"] as? String) == "watch.reply" else {
177+
return nil
178+
}
179+
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
180+
return nil
181+
}
182+
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
183+
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
184+
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
185+
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
186+
let note = nonEmpty(payload["note"] as? String)
187+
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
188+
189+
return WatchQuickReplyEvent(
190+
replyId: replyId,
191+
promptId: promptId,
192+
actionId: actionId,
193+
actionLabel: actionLabel,
194+
sessionKey: sessionKey,
195+
note: note,
196+
sentAtMs: sentAtMs,
197+
transport: transport)
198+
}
199+
123200
private func ensureActivated() async {
124201
guard let session = self.session else { return }
125202
if session.activationState == .activated { return }
@@ -172,5 +249,32 @@ extension WatchMessagingService: WCSessionDelegate {
172249
session.activate()
173250
}
174251

252+
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
253+
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
254+
return
255+
}
256+
self.emitReply(event)
257+
}
258+
259+
func session(
260+
_: WCSession,
261+
didReceiveMessage message: [String: Any],
262+
replyHandler: @escaping ([String: Any]) -> Void)
263+
{
264+
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
265+
replyHandler(["ok": false, "error": "unsupported_payload"])
266+
return
267+
}
268+
replyHandler(["ok": true])
269+
self.emitReply(event)
270+
}
271+
272+
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
273+
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
274+
return
275+
}
276+
self.emitReply(event)
277+
}
278+
175279
func sessionReachabilityDidChange(_ session: WCSession) {}
176280
}

0 commit comments

Comments
 (0)