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