iOS/watch: add actionable watch approvals and quick replies (#21996)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 3c2a01f903
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-20 16:39:13 +00:00
committed by GitHub
parent 8e4f6c0384
commit 738b011624
10 changed files with 598 additions and 31 deletions

View File

@@ -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<String>()
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

View File

@@ -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 {}

View File

@@ -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) {}
}