Files
openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift
Mariano 738b011624 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
2026-02-20 16:39:13 +00:00

231 lines
7.6 KiB
Swift

import Foundation
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 {
private struct PersistedState: Codable {
var title: String
var body: String
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"
private let defaults: UserDefaults
var title = "OpenClaw"
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) {
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.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,
risk: message.risk)
}
}
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
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() {
guard let updatedAt = self.updatedAt else { return }
let state = PersistedState(
title: self.title,
body: self.body,
transport: self.transport,
updatedAt: updatedAt,
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)
}
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 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
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(self.mapHapticRisk(risk))
}
}