mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
iOS Watch: bridge mirrored notification actions into quick replies (#22123)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 401fbe8a7a
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:
@@ -1617,6 +1617,15 @@ private extension NodeAppModel {
|
||||
let result = try await self.watchMessagingService.sendNotification(
|
||||
id: req.id,
|
||||
params: params)
|
||||
if result.queuedForDelivery || !result.deliveredImmediately {
|
||||
let invokeID = req.id
|
||||
Task { @MainActor in
|
||||
await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
|
||||
invokeID: invokeID,
|
||||
params: params,
|
||||
sendResult: result)
|
||||
}
|
||||
}
|
||||
let payload = OpenClawWatchNotifyPayload(
|
||||
deliveredImmediately: result.deliveredImmediately,
|
||||
queuedForDelivery: result.queuedForDelivery,
|
||||
@@ -2550,6 +2559,12 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async {
|
||||
await self.handleWatchQuickReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension NodeAppModel {
|
||||
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import os
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate {
|
||||
private struct PendingWatchPromptAction {
|
||||
var promptId: String?
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
|
||||
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
|
||||
weak var appModel: NodeAppModel? {
|
||||
didSet {
|
||||
guard let model = self.appModel, let token = self.pendingAPNsDeviceToken else { return }
|
||||
self.pendingAPNsDeviceToken = nil
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
guard let model = self.appModel else { return }
|
||||
if let token = self.pendingAPNsDeviceToken {
|
||||
self.pendingAPNsDeviceToken = nil
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
}
|
||||
}
|
||||
if !self.pendingWatchPromptActions.isEmpty {
|
||||
let pending = self.pendingWatchPromptActions
|
||||
self.pendingWatchPromptActions.removeAll()
|
||||
Task { @MainActor in
|
||||
for action in pending {
|
||||
await model.handleMirroredWatchPromptAction(
|
||||
promptId: action.promptId,
|
||||
actionId: action.actionId,
|
||||
actionLabel: action.actionLabel,
|
||||
sessionKey: action.sessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +53,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate {
|
||||
) -> Bool
|
||||
{
|
||||
self.registerBackgroundWakeRefreshTask()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
application.registerForRemoteNotifications()
|
||||
return true
|
||||
}
|
||||
@@ -118,6 +146,305 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate {
|
||||
"Background wake refresh finished applied=\(applied, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func isWatchPromptNotification(_ userInfo: [AnyHashable: Any]) -> Bool {
|
||||
(userInfo[WatchPromptNotificationBridge.typeKey] as? String) == WatchPromptNotificationBridge.typeValue
|
||||
}
|
||||
|
||||
private static func parseWatchPromptAction(
|
||||
from response: UNNotificationResponse) -> PendingWatchPromptAction?
|
||||
{
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
guard Self.isWatchPromptNotification(userInfo) else { return nil }
|
||||
|
||||
let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String
|
||||
let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String
|
||||
|
||||
switch response.actionIdentifier {
|
||||
case WatchPromptNotificationBridge.actionPrimaryIdentifier:
|
||||
let actionId = (userInfo[WatchPromptNotificationBridge.actionPrimaryIDKey] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !actionId.isEmpty else { return nil }
|
||||
let actionLabel = userInfo[WatchPromptNotificationBridge.actionPrimaryLabelKey] as? String
|
||||
return PendingWatchPromptAction(
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey)
|
||||
case WatchPromptNotificationBridge.actionSecondaryIdentifier:
|
||||
let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !actionId.isEmpty else { return nil }
|
||||
let actionLabel = userInfo[WatchPromptNotificationBridge.actionSecondaryLabelKey] as? String
|
||||
return PendingWatchPromptAction(
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
|
||||
guard let appModel = self.appModel else {
|
||||
self.pendingWatchPromptActions.append(action)
|
||||
return
|
||||
}
|
||||
await appModel.handleMirroredWatchPromptAction(
|
||||
promptId: action.promptId,
|
||||
actionId: action.actionId,
|
||||
actionLabel: action.actionLabel,
|
||||
sessionKey: action.sessionKey)
|
||||
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
||||
{
|
||||
let userInfo = notification.request.content.userInfo
|
||||
if Self.isWatchPromptNotification(userInfo) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
return
|
||||
}
|
||||
completionHandler([])
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void)
|
||||
{
|
||||
guard let action = Self.parseWatchPromptAction(from: response) else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
await self.routeWatchPromptAction(action)
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchPromptNotificationBridge {
|
||||
static let typeKey = "openclaw.type"
|
||||
static let typeValue = "watch.prompt"
|
||||
static let promptIDKey = "openclaw.watch.promptId"
|
||||
static let sessionKeyKey = "openclaw.watch.sessionKey"
|
||||
static let actionPrimaryIDKey = "openclaw.watch.action.primary.id"
|
||||
static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label"
|
||||
static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id"
|
||||
static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label"
|
||||
static let actionPrimaryIdentifier = "openclaw.watch.action.primary"
|
||||
static let actionSecondaryIdentifier = "openclaw.watch.action.secondary"
|
||||
static let categoryPrefix = "openclaw.watch.prompt.category."
|
||||
|
||||
@MainActor
|
||||
static func scheduleMirroredWatchPromptNotificationIfNeeded(
|
||||
invokeID: String,
|
||||
params: OpenClawWatchNotifyParams,
|
||||
sendResult: WatchNotificationSendResult) async
|
||||
{
|
||||
guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return }
|
||||
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty || !body.isEmpty else { return }
|
||||
guard await self.requestNotificationAuthorizationIfNeeded() else { return }
|
||||
|
||||
let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in
|
||||
let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !id.isEmpty, !label.isEmpty else { return nil }
|
||||
return OpenClawWatchAction(id: id, label: label, style: action.style)
|
||||
}
|
||||
let primaryAction = normalizedActions.first
|
||||
let secondaryAction = normalizedActions.dropFirst().first
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
var categoryIdentifier = ""
|
||||
if let primaryAction {
|
||||
let categoryID = "\(self.categoryPrefix)\(invokeID)"
|
||||
let category = UNNotificationCategory(
|
||||
identifier: categoryID,
|
||||
actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction),
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
await self.upsertNotificationCategory(category, center: center)
|
||||
categoryIdentifier = categoryID
|
||||
}
|
||||
|
||||
var userInfo: [AnyHashable: Any] = [
|
||||
self.typeKey: self.typeValue,
|
||||
]
|
||||
if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty {
|
||||
userInfo[self.promptIDKey] = promptId
|
||||
}
|
||||
if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty {
|
||||
userInfo[self.sessionKeyKey] = sessionKey
|
||||
}
|
||||
if let primaryAction {
|
||||
userInfo[self.actionPrimaryIDKey] = primaryAction.id
|
||||
userInfo[self.actionPrimaryLabelKey] = primaryAction.label
|
||||
}
|
||||
if let secondaryAction {
|
||||
userInfo[self.actionSecondaryIDKey] = secondaryAction.id
|
||||
userInfo[self.actionSecondaryLabelKey] = secondaryAction.label
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title.isEmpty ? "OpenClaw" : title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.userInfo = userInfo
|
||||
if !categoryIdentifier.isEmpty {
|
||||
content.categoryIdentifier = categoryIdentifier
|
||||
}
|
||||
if #available(iOS 15.0, *) {
|
||||
switch params.priority ?? .active {
|
||||
case .passive:
|
||||
content.interruptionLevel = .passive
|
||||
case .timeSensitive:
|
||||
content.interruptionLevel = .timeSensitive
|
||||
case .active:
|
||||
content.interruptionLevel = .active
|
||||
}
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "watch.prompt.\(invokeID)",
|
||||
content: content,
|
||||
trigger: nil)
|
||||
try? await self.addNotificationRequest(request, center: center)
|
||||
}
|
||||
|
||||
private static func categoryActions(
|
||||
primaryAction: OpenClawWatchAction,
|
||||
secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction]
|
||||
{
|
||||
var actions: [UNNotificationAction] = [
|
||||
UNNotificationAction(
|
||||
identifier: self.actionPrimaryIdentifier,
|
||||
title: primaryAction.label,
|
||||
options: self.notificationActionOptions(style: primaryAction.style))
|
||||
]
|
||||
if let secondaryAction {
|
||||
actions.append(
|
||||
UNNotificationAction(
|
||||
identifier: self.actionSecondaryIdentifier,
|
||||
title: secondaryAction.label,
|
||||
options: self.notificationActionOptions(style: secondaryAction.style)))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions {
|
||||
switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "destructive":
|
||||
return [.destructive]
|
||||
case "foreground":
|
||||
// For mirrored watch actions, keep handling in background when possible.
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestNotificationAuthorizationIfNeeded() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = await self.notificationAuthorizationStatus(center: center)
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .notDetermined:
|
||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
if !granted { return false }
|
||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||
return self.isAuthorizationStatusAllowed(updatedStatus)
|
||||
case .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .denied, .notDetermined:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func upsertNotificationCategory(
|
||||
_ category: UNNotificationCategory,
|
||||
center: UNUserNotificationCenter) async
|
||||
{
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationCategories { categories in
|
||||
var updated = categories
|
||||
updated.update(with: category)
|
||||
center.setNotificationCategories(updated)
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func handleMirroredWatchPromptAction(
|
||||
promptId: String?,
|
||||
actionId: String,
|
||||
actionLabel: String?,
|
||||
sessionKey: String?) async
|
||||
{
|
||||
let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedActionID.isEmpty else { return }
|
||||
|
||||
let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let event = WatchQuickReplyEvent(
|
||||
replyId: UUID().uuidString,
|
||||
promptId: (normalizedPromptID?.isEmpty == false) ? normalizedPromptID! : "unknown",
|
||||
actionId: normalizedActionID,
|
||||
actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil,
|
||||
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
|
||||
note: "source=ios.notification",
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
transport: "ios.notification")
|
||||
await self._bridgeConsumeMirroredWatchReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
|
||||
Reference in New Issue
Block a user