diff --git a/CHANGELOG.md b/CHANGELOG.md index cd534d80efc..903123adb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky. - macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky. - Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky. +- 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. - CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. - 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. diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index a18e3d58ba9..e86fb75629c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -43,6 +43,7 @@ final class NodeAppModel { private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") + private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") enum CameraHUDKind { case photo case recording @@ -109,6 +110,8 @@ final class NodeAppModel { private var backgroundReconnectSuppressed = false private var backgroundReconnectLeaseUntil: Date? private var lastSignificantLocationWakeAt: Date? + private var queuedWatchReplies: [WatchQuickReplyEvent] = [] + private var seenWatchReplyIds = Set() private var gatewayConnected = false private var operatorConnected = false @@ -155,6 +158,11 @@ final class NodeAppModel { self.talkMode = talkMode self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) GatewayDiagnostics.bootstrap() + self.watchMessagingService.setReplyHandler { [weak self] event in + Task { @MainActor in + await self?.handleWatchQuickReply(event) + } + } self.voiceWake.configure { [weak self] cmd in guard let self else { return } @@ -1608,9 +1616,7 @@ private extension NodeAppModel { do { let result = try await self.watchMessagingService.sendNotification( id: req.id, - title: title, - body: body, - priority: params.priority) + params: params) let payload = OpenClawWatchNotifyPayload( deliveredImmediately: result.deliveredImmediately, queuedForDelivery: result.queuedForDelivery, @@ -2255,6 +2261,90 @@ extension NodeAppModel { /// Back-compat hook retained for older gateway-connect flows. func onNodeGatewayConnected() async { await self.registerAPNsTokenIfNeeded() + await self.flushQueuedWatchRepliesIfConnected() + } + + private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { + let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) + if replyId.isEmpty || actionId.isEmpty { + self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") + return + } + + if self.seenWatchReplyIds.contains(replyId) { + self.watchReplyLogger.debug( + "watch reply deduped replyId=\(replyId, privacy: .public)") + return + } + self.seenWatchReplyIds.insert(replyId) + + if await !self.isGatewayConnected() { + self.queuedWatchReplies.append(event) + self.watchReplyLogger.info( + "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") + return + } + + await self.forwardWatchReplyToAgent(event) + } + + private func flushQueuedWatchRepliesIfConnected() async { + guard await self.isGatewayConnected() else { return } + guard !self.queuedWatchReplies.isEmpty else { return } + + let pending = self.queuedWatchReplies + self.queuedWatchReplies.removeAll() + for event in pending { + await self.forwardWatchReplyToAgent(event) + } + } + + private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async { + let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey + let message = Self.makeWatchReplyAgentMessage(event) + let link = AgentDeepLink( + message: message, + sessionKey: effectiveSessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: event.replyId) + do { + try await self.sendAgentRequest(link: link) + self.watchReplyLogger.info( + "watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)") + self.openChatRequestID &+= 1 + } catch { + self.watchReplyLogger.error( + "watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.queuedWatchReplies.insert(event, at: 0) + } + } + + private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String { + let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines) + let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId + var lines: [String] = [] + lines.append("Watch reply: \(summary)") + lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)") + lines.append("actionId=\(event.actionId)") + lines.append("replyId=\(event.replyId)") + if !transport.isEmpty { + lines.append("transport=\(transport)") + } + if let sentAtMs = event.sentAtMs { + lines.append("sentAtMs=\(sentAtMs)") + } + if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + lines.append("note=\(note)") + } + return lines.joined(separator: "\n") } func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { @@ -2497,5 +2587,9 @@ extension NodeAppModel { func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) { self.applyTalkModeSync(enabled: enabled, phase: phase) } + + func _test_queuedWatchReplyCount() -> Int { + self.queuedWatchReplies.count + } } #endif diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 6f882e82a11..27ee7cc2776 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -73,6 +73,17 @@ struct WatchMessagingStatus: Sendable, Equatable { var activationState: String } +struct WatchQuickReplyEvent: Sendable, Equatable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int? + var transport: String +} + struct WatchNotificationSendResult: Sendable, Equatable { var deliveredImmediately: Bool var queuedForDelivery: Bool @@ -81,11 +92,10 @@ struct WatchNotificationSendResult: Sendable, Equatable { protocol WatchMessagingServicing: AnyObject, Sendable { func status() async -> WatchMessagingStatus + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) func sendNotification( id: String, - title: String, - body: String, - priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult } extension CameraController: CameraServicing {} diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift index 8332fb5882d..3511a06c2db 100644 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -23,6 +23,8 @@ enum WatchMessagingError: LocalizedError { final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable { private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") private let session: WCSession? + private let replyHandlerLock = NSLock() + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? override init() { if WCSession.isSupported() { @@ -67,11 +69,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked return Self.status(for: session) } + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandlerLock.lock() + self.replyHandler = handler + self.replyHandlerLock.unlock() + } + func sendNotification( id: String, - title: String, - body: String, - priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { await self.ensureActivated() guard let session = self.session else { @@ -82,14 +88,44 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked guard snapshot.paired else { throw WatchMessagingError.notPaired } guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled } - let payload: [String: Any] = [ + var payload: [String: Any] = [ "type": "watch.notify", "id": id, - "title": title, - "body": body, - "priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, + "title": params.title, + "body": params.body, + "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, "sentAtMs": Int(Date().timeIntervalSince1970 * 1000), ] + if let promptId = Self.nonEmpty(params.promptId) { + payload["promptId"] = promptId + } + if let sessionKey = Self.nonEmpty(params.sessionKey) { + payload["sessionKey"] = sessionKey + } + if let kind = Self.nonEmpty(params.kind) { + payload["kind"] = kind + } + if let details = Self.nonEmpty(params.details) { + payload["details"] = details + } + if let expiresAtMs = params.expiresAtMs { + payload["expiresAtMs"] = expiresAtMs + } + if let risk = params.risk { + payload["risk"] = risk.rawValue + } + if let actions = params.actions, !actions.isEmpty { + payload["actions"] = actions.map { action in + var encoded: [String: Any] = [ + "id": action.id, + "label": action.label, + ] + if let style = Self.nonEmpty(action.style) { + encoded["style"] = style + } + return encoded + } + } if snapshot.reachable { do { @@ -120,6 +156,47 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked } } + private func emitReply(_ event: WatchQuickReplyEvent) { + let handler: ((WatchQuickReplyEvent) -> Void)? + self.replyHandlerLock.lock() + handler = self.replyHandler + self.replyHandlerLock.unlock() + handler?(event) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func parseQuickReplyPayload( + _ payload: [String: Any], + transport: String) -> WatchQuickReplyEvent? + { + guard (payload["type"] as? String) == "watch.reply" else { + return nil + } + guard let actionId = nonEmpty(payload["actionId"] as? String) else { + return nil + } + let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" + let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString + let actionLabel = nonEmpty(payload["actionLabel"] as? String) + let sessionKey = nonEmpty(payload["sessionKey"] as? String) + let note = nonEmpty(payload["note"] as? String) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + + return WatchQuickReplyEvent( + replyId: replyId, + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey, + note: note, + sentAtMs: sentAtMs, + transport: transport) + } + private func ensureActivated() async { guard let session = self.session else { return } if session.activationState == .activated { return } @@ -172,5 +249,32 @@ extension WatchMessagingService: WCSessionDelegate { session.activate() } + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + return + } + self.emitReply(event) + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + replyHandler(["ok": false, "error": "unsupported_payload"]) + return + } + replyHandler(["ok": true]) + self.emitReply(event) + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { + return + } + self.emitReply(event) + } + func sessionReachabilityDidChange(_ session: WCSession) {} } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 403c08f5c73..3d015afae84 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -42,24 +42,28 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck queuedForDelivery: false, transport: "sendMessage") var sendError: Error? - var lastSent: (id: String, title: String, body: String, priority: OpenClawNotificationPriority?)? + var lastSent: (id: String, params: OpenClawWatchNotifyParams)? + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? 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) + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandler = handler + } + + func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { + self.lastSent = (id: id, params: params) if let sendError = self.sendError { throw sendError } return self.nextSendResult } + + func emitReply(_ event: WatchQuickReplyEvent) { + self.replyHandler?(event) + } } @Suite(.serialized) struct NodeAppModelInvokeTests { @@ -243,9 +247,9 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck 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) + #expect(watchService.lastSent?.params.title == "OpenClaw") + #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") + #expect(watchService.lastSent?.params.priority == .timeSensitive) let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) @@ -292,6 +296,22 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) } + @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + watchService.emitReply( + WatchQuickReplyEvent( + replyId: "reply-offline-1", + promptId: "prompt-1", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "ios", + note: nil, + sentAtMs: 1234, + transport: "transferUserInfo")) + #expect(appModel._test_queuedWatchReplyCount() == 1) + } + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { let appModel = NodeAppModel() let url = URL(string: "openclaw://agent?message=hello")! diff --git a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift index 6084f574442..4c123c49f16 100644 --- a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift +++ b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift @@ -7,7 +7,15 @@ struct OpenClawWatchApp: App { var body: some Scene { WindowGroup { - WatchInboxView(store: self.inboxStore) + WatchInboxView(store: self.inboxStore) { action in + guard let receiver = self.receiver else { return } + let draft = self.inboxStore.makeReplyDraft(action: action) + self.inboxStore.markReplySending(actionLabel: action.label) + Task { @MainActor in + let result = await receiver.sendReply(draft) + self.inboxStore.markReplyResult(result, actionLabel: action.label) + } + } .task { if self.receiver == nil { let receiver = WatchConnectivityReceiver(store: self.inboxStore) diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift index fd0d84cc55c..da1c3c379a3 100644 --- a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -1,6 +1,23 @@ import Foundation import WatchConnectivity +struct WatchReplyDraft: Sendable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int +} + +struct WatchReplySendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String + var errorMessage: String? +} + final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { private let store: WatchInboxStore private let session: WCSession? @@ -21,6 +38,114 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { session.activate() } + 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) + } + } + + func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult { + await self.ensureActivated() + guard let session = self.session else { + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: false, + transport: "none", + errorMessage: "watch session unavailable") + } + + var payload: [String: Any] = [ + "type": "watch.reply", + "replyId": draft.replyId, + "promptId": draft.promptId, + "actionId": draft.actionId, + "sentAtMs": draft.sentAtMs, + ] + if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !actionLabel.isEmpty + { + payload["actionLabel"] = actionLabel + } + if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionKey.isEmpty + { + payload["sessionKey"] = sessionKey + } + if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + payload["note"] = note + } + + if session.isReachable { + do { + try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload, replyHandler: { _ in + continuation.resume() + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + return WatchReplySendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage", + errorMessage: nil) + } catch { + // Fall through to queued delivery below. + } + } + + _ = session.transferUserInfo(payload) + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo", + errorMessage: nil) + } + + private static func normalizeObject(_ value: Any) -> [String: Any]? { + if let object = value as? [String: Any] { + return object + } + if let object = value as? [AnyHashable: Any] { + var normalized: [String: Any] = [:] + normalized.reserveCapacity(object.count) + for (key, item) in object { + guard let stringKey = key as? String else { + continue + } + normalized[stringKey] = item + } + return normalized + } + return nil + } + + private static func parseActions(_ value: Any?) -> [WatchPromptAction] { + guard let raw = value as? [Any] else { + return [] + } + return raw.compactMap { item in + guard let obj = Self.normalizeObject(item) else { + return nil + } + let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !id.isEmpty, !label.isEmpty else { + return nil + } + let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchPromptAction(id: id, label: label, style: style) + } + } + private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? { guard let type = payload["type"] as? String, type == "watch.notify" else { return nil @@ -38,12 +163,31 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { let id = (payload["id"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let promptId = (payload["promptId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sessionKey = (payload["sessionKey"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let kind = (payload["kind"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let details = (payload["details"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue + let risk = (payload["risk"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let actions = Self.parseActions(payload["actions"]) return WatchNotifyMessage( id: id, title: title, body: body, - sentAtMs: sentAtMs) + sentAtMs: sentAtMs, + promptId: promptId, + sessionKey: sessionKey, + kind: kind, + details: details, + expiresAtMs: expiresAtMs, + risk: risk, + actions: actions) } } diff --git a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift index 0a715f16b63..2ac1d75d6e1 100644 --- a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift @@ -3,11 +3,24 @@ import Observation import UserNotifications import WatchKit +struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable { + var id: String + var label: String + var style: String? +} + struct WatchNotifyMessage: Sendable { var id: String? var title: String var body: String var sentAtMs: Int? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] } @MainActor @Observable final class WatchInboxStore { @@ -17,6 +30,15 @@ struct WatchNotifyMessage: Sendable { var transport: String var updatedAt: Date var lastDeliveryKey: String? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction]? + var replyStatusText: String? + var replyStatusAt: Date? } private static let persistedStateKey = "watch.inbox.state.v1" @@ -26,6 +48,16 @@ struct WatchNotifyMessage: Sendable { var body = "Waiting for messages from your iPhone." var transport = "none" var updatedAt: Date? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] = [] + var replyStatusText: String? + var replyStatusAt: Date? + var isReplySending = false private var lastDeliveryKey: String? init(defaults: UserDefaults = .standard) { @@ -51,14 +83,25 @@ struct WatchNotifyMessage: Sendable { self.body = message.body self.transport = transport self.updatedAt = Date() + self.promptId = message.promptId + self.sessionKey = message.sessionKey + self.kind = message.kind + self.details = message.details + self.expiresAtMs = message.expiresAtMs + self.risk = message.risk + self.actions = message.actions self.lastDeliveryKey = deliveryKey + self.replyStatusText = nil + self.replyStatusAt = nil + self.isReplySending = false self.persistState() Task { await self.postLocalNotification( identifier: deliveryKey, title: normalizedTitle, - body: message.body) + body: message.body, + risk: message.risk) } } @@ -74,6 +117,15 @@ struct WatchNotifyMessage: Sendable { self.transport = state.transport self.updatedAt = state.updatedAt self.lastDeliveryKey = state.lastDeliveryKey + self.promptId = state.promptId + self.sessionKey = state.sessionKey + self.kind = state.kind + self.details = state.details + self.expiresAtMs = state.expiresAtMs + self.risk = state.risk + self.actions = state.actions ?? [] + self.replyStatusText = state.replyStatusText + self.replyStatusAt = state.replyStatusAt } private func persistState() { @@ -83,7 +135,16 @@ struct WatchNotifyMessage: Sendable { body: self.body, transport: self.transport, updatedAt: updatedAt, - lastDeliveryKey: self.lastDeliveryKey) + lastDeliveryKey: self.lastDeliveryKey, + promptId: self.promptId, + sessionKey: self.sessionKey, + kind: self.kind, + details: self.details, + expiresAtMs: self.expiresAtMs, + risk: self.risk, + actions: self.actions, + replyStatusText: self.replyStatusText, + replyStatusAt: self.replyStatusAt) guard let data = try? JSONEncoder().encode(state) else { return } self.defaults.set(data, forKey: Self.persistedStateKey) } @@ -106,7 +167,52 @@ struct WatchNotifyMessage: Sendable { } } - private func postLocalNotification(identifier: String, title: String, body: String) async { + private func mapHapticRisk(_ risk: String?) -> WKHapticType { + switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "high": + return .failure + case "medium": + return .notification + default: + return .click + } + } + + func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft { + let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchReplyDraft( + replyId: UUID().uuidString, + promptId: (prompt?.isEmpty == false) ? prompt! : "unknown", + actionId: action.id, + actionLabel: action.label, + sessionKey: self.sessionKey, + note: nil, + sentAtMs: Int(Date().timeIntervalSince1970 * 1000)) + } + + func markReplySending(actionLabel: String) { + self.isReplySending = true + self.replyStatusText = "Sending \(actionLabel)…" + self.replyStatusAt = Date() + self.persistState() + } + + func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) { + self.isReplySending = false + if let errorMessage = result.errorMessage, !errorMessage.isEmpty { + self.replyStatusText = "Failed: \(errorMessage)" + } else if result.deliveredImmediately { + self.replyStatusText = "\(actionLabel): sent" + } else if result.queuedForDelivery { + self.replyStatusText = "\(actionLabel): queued" + } else { + self.replyStatusText = "\(actionLabel): sent" + } + self.replyStatusAt = Date() + self.persistState() + } + + private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async { let content = UNMutableNotificationContent() content.title = title content.body = body @@ -119,6 +225,6 @@ struct WatchNotifyMessage: Sendable { trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) _ = try? await UNUserNotificationCenter.current().add(request) - WKInterfaceDevice.current().play(.notification) + WKInterfaceDevice.current().play(self.mapHapticRisk(risk)) } } diff --git a/apps/ios/WatchExtension/Sources/WatchInboxView.swift b/apps/ios/WatchExtension/Sources/WatchInboxView.swift index c5ea9a9f534..c6f944a949e 100644 --- a/apps/ios/WatchExtension/Sources/WatchInboxView.swift +++ b/apps/ios/WatchExtension/Sources/WatchInboxView.swift @@ -2,6 +2,18 @@ import SwiftUI struct WatchInboxView: View { @Bindable var store: WatchInboxStore + var onAction: ((WatchPromptAction) -> Void)? + + private func role(for action: WatchPromptAction) -> ButtonRole? { + switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return .destructive + case "cancel": + return .cancel + default: + return nil + } + } var body: some View { ScrollView { @@ -14,6 +26,31 @@ struct WatchInboxView: View { .font(.body) .fixedSize(horizontal: false, vertical: true) + if let details = store.details, !details.isEmpty { + Text(details) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !store.actions.isEmpty { + ForEach(store.actions) { action in + Button(role: self.role(for: action)) { + self.onAction?(action) + } label: { + Text(action.label) + .frame(maxWidth: .infinity) + } + .disabled(store.isReplySending) + } + } + + if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty { + Text(replyStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + if let updatedAt = store.updatedAt { Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))") .font(.footnote) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift index 814efe68a88..0bd6990710c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -5,6 +5,24 @@ public enum OpenClawWatchCommand: String, Codable, Sendable { case notify = "watch.notify" } +public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable { + case low + case medium + case high +} + +public struct OpenClawWatchAction: Codable, Sendable, Equatable { + public var id: String + public var label: String + public var style: String? + + public init(id: String, label: String, style: String? = nil) { + self.id = id + self.label = label + self.style = style + } +} + public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable { public var supported: Bool public var paired: Bool @@ -31,11 +49,36 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { public var title: String public var body: String public var priority: OpenClawNotificationPriority? + public var promptId: String? + public var sessionKey: String? + public var kind: String? + public var details: String? + public var expiresAtMs: Int? + public var risk: OpenClawWatchRisk? + public var actions: [OpenClawWatchAction]? - public init(title: String, body: String, priority: OpenClawNotificationPriority? = nil) { + public init( + title: String, + body: String, + priority: OpenClawNotificationPriority? = nil, + promptId: String? = nil, + sessionKey: String? = nil, + kind: String? = nil, + details: String? = nil, + expiresAtMs: Int? = nil, + risk: OpenClawWatchRisk? = nil, + actions: [OpenClawWatchAction]? = nil) + { self.title = title self.body = body self.priority = priority + self.promptId = promptId + self.sessionKey = sessionKey + self.kind = kind + self.details = details + self.expiresAtMs = expiresAtMs + self.risk = risk + self.actions = actions } }